commit 6ee031045bdd3d2bf2f3c43c66bb6f6dee25626a
Author: Aleksandr Yakovlev
Date: Sun Jun 2 12:37:06 2024 +0600
Initial commit - monorepo
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..57301af
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+application/config/config.local.php
+application/logs
+uploads
+application/tmp
+application/plugins/admin
+application/plugins/
+application/install
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..a2b8e91
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "framework"]
+ path = framework
+ url = https://github.com/livestreet/livestreet-framework.git
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..7ebd5d0
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,48 @@
+language: php
+
+php:
+ - 5.3
+ - 5.4
+
+before_script:
+ # export virtual display
+ - export DISPLAY=:99
+
+ # change application working folders for write access
+ - chmod -R 0777 ./tmp
+ - chmod -R 0777 ./uploads
+ - chmod -R 0777 ./templates/cache/
+ - chmod -R 0777 ./templates/compiled/
+ - cp ./install/*.sql tests/fixtures/sql/
+ - rm -rf ./install
+
+ # install required PHP stuff
+ - sudo apt-get update
+ - sudo apt-get install php5 php5-cli php5-mysql php5-mcrypt php5-xsl php5-xdebug php-apc php5-gd php5-curl php5-intl mysql-server mysql-client
+
+ # launch apache, MySQL and PHPUnit Selenium installers
+ - ./tests/travis/apache_setup.sh
+ - ./tests/travis/mysql_setup.sh
+ - mysql -u root -e "USE social_test; SHOW TABLES;" | wc -l
+ - cp ./config/config.test.php.dist config/config.test.php
+ - cp ./config/config.test.php.dist config/config.local.php
+ - sudo sed -i s/sql-mode/#sql-mode/ /etc/mysql/my.cnf
+ - sudo /etc/init.d/mysql restart
+ - sleep 5
+
+ # get HTML of main page (for debug)
+
+ - firefox --version
+ - wget -S http://livestreet.test -O /dev/null
+
+ # start virtual display
+ - sh -e /etc/init.d/xvfb start
+ - sleep 5
+
+ # download and launch Selenium
+ - wget -O /tmp/selenium-server-standalone.jar http://selenium.dev.stfalcon.com/selenium-server-standalone-latest.jar
+ - java -jar /tmp/selenium-server-standalone.jar > /dev/null &
+ - sleep 5
+
+
+script: HTTP_APP_ENV=test php tests/behat/behat.phar -c tests/behat/behat.yml
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b1aa378
--- /dev/null
+++ b/README.md
@@ -0,0 +1,15 @@
+Исходный код ifhub.ru
+
+На основе [LiveStreet CMS](http://livestreetcms.ru) 2.0.1.dev
+
+### Лицензия
+
+LiveStreet - open-source проект под лицензией [GPL-2.0](http://opensource.org/licenses/GPL-2.0).
+
+
+
+Readme
+-------------
+
+* [English README](https://github.com/livestreet/livestreet/blob/master/Readme.EN.txt)
+* [Russian README](https://github.com/livestreet/livestreet/blob/master/Readme.RU.txt)
diff --git a/application/classes/.htaccess b/application/classes/.htaccess
new file mode 100644
index 0000000..2859d7f
--- /dev/null
+++ b/application/classes/.htaccess
@@ -0,0 +1,2 @@
+Order Deny,Allow
+Deny from all
\ No newline at end of file
diff --git a/application/classes/actions/ActionAdmin.class.php b/application/classes/actions/ActionAdmin.class.php
new file mode 100644
index 0000000..e20b3d9
--- /dev/null
+++ b/application/classes/actions/ActionAdmin.class.php
@@ -0,0 +1,171 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки УРЛа вида /admin/
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionAdmin extends Action
+{
+ /**
+ * Текущий пользователь
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+ /**
+ * Главное меню
+ *
+ * @var string
+ */
+ protected $sMenuHeadItemSelect = 'admin';
+
+ /**
+ * Инициализация
+ *
+ * @return string
+ */
+ public function Init()
+ {
+ /**
+ * Если нет прав доступа - перекидываем на 404 страницу
+ */
+ if (!$this->User_IsAuthorization() or !$oUserCurrent = $this->User_GetUserCurrent() or !$oUserCurrent->isAdministrator()) {
+ return parent::EventNotFound();
+ }
+ $this->SetDefaultEvent('index');
+
+ $this->oUserCurrent = $oUserCurrent;
+ }
+
+ /**
+ * Регистрация евентов
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEvent('index', 'EventIndex');
+ $this->AddEvent('plugins', 'EventPlugins');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Отображение главной страницы админки
+ */
+ protected function EventIndex()
+ {
+ /**
+ * Определяем доступность установки расширенной админ-панели
+ */
+ $aPluginsAll = func_list_plugins(true);
+ if (in_array('admin', $aPluginsAll)) {
+ $this->Viewer_Assign('availableAdminPlugin', true);
+ }
+ }
+
+ /**
+ * Страница со списком плагинов
+ *
+ */
+ protected function EventPlugins()
+ {
+ $this->sMenuHeadItemSelect = 'plugins';
+ /**
+ * Получаем название плагина и действие
+ */
+ if ($sPlugin = getRequestStr('plugin', null, 'get') and $sAction = getRequestStr('action', null, 'get')) {
+ return $this->SubmitManagePlugin($sPlugin, $sAction);
+ }
+ /**
+ * Получаем список блогов
+ */
+ $aPlugins = $this->PluginManager_GetPluginsItems(array('order' => 'name'));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('plugins', $aPlugins);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('admin.plugins.title'));
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('plugins');
+ }
+
+ /**
+ * Активация\деактивация плагина
+ *
+ * @param string $sPlugin Имя плагина
+ * @param string $sAction Действие
+ */
+ protected function SubmitManagePlugin($sPlugin, $sAction)
+ {
+ $this->Security_ValidateSendForm();
+ if (!in_array($sAction, array('activate', 'deactivate', 'remove', 'apply_update'))) {
+ $this->Message_AddError($this->Lang_Get('admin.plugins.notices.unknown_action'), $this->Lang_Get('common.error.error'),
+ true);
+ Router::Location(Router::GetPath('admin/plugins'));
+ }
+ $bResult = false;
+ /**
+ * Активируем\деактивируем плагин
+ */
+ if ($sAction == 'activate') {
+ $bResult = $this->PluginManager_ActivatePlugin($sPlugin);
+ } elseif ($sAction == 'deactivate') {
+ $bResult = $this->PluginManager_DeactivatePlugin($sPlugin);
+ } elseif ($sAction == 'remove') {
+ $bResult = $this->PluginManager_RemovePlugin($sPlugin);
+ } elseif ($sAction == 'apply_update') {
+ $this->PluginManager_ApplyPluginUpdate($sPlugin);
+ $bResult = true;
+ }
+ if ($bResult) {
+ $this->Message_AddNotice($this->Lang_Get('admin.plugins.notices.action_ok'), $this->Lang_Get('common.attention'),
+ true);
+ } else {
+ if (!($aMessages = $this->Message_GetErrorSession()) or !count($aMessages)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'), true);
+ }
+ }
+ /**
+ * Возвращаем на страницу управления плагинами
+ */
+ Router::Location(Router::GetPath('admin') . 'plugins/');
+ }
+
+ /**
+ * Выполняется при завершении работы экшена
+ *
+ */
+ public function EventShutdown()
+ {
+ /**
+ * Загружаем в шаблон необходимые переменные
+ */
+ $this->Viewer_Assign('sMenuHeadItemSelect', $this->sMenuHeadItemSelect);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionAjax.class.php b/application/classes/actions/ActionAjax.class.php
new file mode 100644
index 0000000..1abb923
--- /dev/null
+++ b/application/classes/actions/ActionAjax.class.php
@@ -0,0 +1,2122 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки ajax запросов
+ * Ответ отдает в JSON фомате
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionAjax extends Action
+{
+ /**
+ * Текущий пользователь
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+
+ /**
+ * Инициализация
+ */
+ public function Init()
+ {
+ /**
+ * Устанавливаем формат ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Получаем текущего пользователя
+ */
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Регистрация евентов
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEventPreg('/^vote$/i', '/^comment$/', 'EventVoteComment');
+ $this->AddEventPreg('/^vote$/i', '/^topic$/', 'EventVoteTopic');
+ $this->AddEventPreg('/^vote$/i', '/^get$/', '/^info$/', '/^topic$/', 'EventVoteGetInfoTopic');
+
+ $this->AddEventPreg('/^favourite$/i', '/^save-tags/', 'EventFavouriteSaveTags');
+ $this->AddEventPreg('/^favourite$/i', '/^topic$/', 'EventFavouriteTopic');
+ $this->AddEventPreg('/^favourite$/i', '/^comment$/', 'EventFavouriteComment');
+ $this->AddEventPreg('/^favourite$/i', '/^talk$/', 'EventFavouriteTalk');
+
+ $this->AddEventPreg('/^stream$/i', '/^comment$/', 'EventStreamComment');
+ $this->AddEventPreg('/^stream$/i', '/^topic$/', 'EventStreamTopic');
+
+ $this->AddEventPreg('/^blogs$/i', '/^top$/', 'EventBlogsTop');
+ $this->AddEventPreg('/^blogs$/i', '/^self$/', 'EventBlogsSelf');
+ $this->AddEventPreg('/^blogs$/i', '/^join$/', 'EventBlogsJoin');
+ $this->AddEventPreg('/^blogs$/i', '/^get-by-category$/', 'EventBlogsGetByCategory');
+
+ $this->AddEventPreg('/^preview$/i', '/^text$/', 'EventPreviewText');
+
+ $this->AddEventPreg('/^autocompleter$/i', '/^tag$/', 'EventAutocompleterTag');
+ $this->AddEventPreg('/^autocompleter$/i', '/^user$/', 'EventAutocompleterUser');
+
+ $this->AddEventPreg('/^comment$/i', '/^delete$/', 'EventCommentDelete');
+ $this->AddEventPreg('/^comment$/i', '/^load$/', 'EventCommentLoad');
+ $this->AddEventPreg('/^comment$/i', '/^update$/', 'EventCommentUpdate');
+
+ $this->AddEventPreg('/^geo$/i', '/^get/', '/^regions$/', 'EventGeoGetRegions');
+ $this->AddEventPreg('/^geo$/i', '/^get/', '/^cities$/', 'EventGeoGetCities');
+
+ $this->AddEventPreg('/^media$/i', '/^upload$/', '/^$/', 'EventMediaUpload');
+ $this->AddEventPreg('/^media$/i', '/^upload-link$/', '/^$/', 'EventMediaUploadLink');
+ $this->AddEventPreg('/^media$/i', '/^generate-target-tmp$/', '/^$/', 'EventMediaGenerateTargetTmp');
+ $this->AddEventPreg('/^media$/i', '/^submit-insert$/', '/^$/', 'EventMediaSubmitInsert');
+ $this->AddEventPreg('/^media$/i', '/^submit-create-photoset$/', '/^$/', 'EventMediaSubmitCreatePhotoset');
+ $this->AddEventPreg('/^media$/i', '/^load-gallery$/', '/^$/', 'EventMediaLoadGallery');
+ $this->AddEventPreg('/^media$/i', '/^remove-file$/', '/^$/', 'EventMediaRemoveFile');
+ $this->AddEventPreg('/^media$/i', '/^create-preview-file$/', '/^$/', 'EventMediaCreatePreviewFile');
+ $this->AddEventPreg('/^media$/i', '/^remove-preview-file$/', '/^$/', 'EventMediaRemovePreviewFile');
+ $this->AddEventPreg('/^media$/i', '/^remove-target$/', '/^$/', 'EventMediaRemoveTarget');
+ $this->AddEventPreg('/^media$/i', '/^load-preview-items$/', '/^$/', 'EventMediaLoadPreviewItems');
+ $this->AddEventPreg('/^media$/i', '/^save-data-file$/', '/^$/', 'EventMediaSaveDataFile');
+
+ $this->AddEventPreg('/^property$/i', '/^tags$/', '/^autocompleter$/', '/^$/', 'EventPropertyTagsAutocompleter');
+
+ $this->AddEventPreg('/^captcha$/i', '/^$/', 'EventCaptcha');
+ $this->AddEventPreg('/^captcha$/i', '/^validate$/', '/^$/', 'EventCaptchaValidate');
+
+ $this->AddEventPreg('/^poll$/i', '/^modal-create$/', '/^$/', 'EventPollModalCreate');
+ $this->AddEventPreg('/^poll$/i', '/^modal-update/', '/^$/', 'EventPollModalUpdate');
+ $this->AddEventPreg('/^poll$/i', '/^create$/', '/^$/', 'EventPollCreate');
+ $this->AddEventPreg('/^poll$/i', '/^update$/', '/^$/', 'EventPollUpdate');
+ $this->AddEventPreg('/^poll$/i', '/^remove$/', '/^$/', 'EventPollRemove');
+ $this->AddEventPreg('/^poll$/i', '/^vote$/', '/^$/', 'EventPollVote');
+
+ $this->AddEvent('modal-friend-list', 'EventModalFriendList');
+ $this->AddEventPreg('/^modal$/i', '/^image-crop$/', '/^$/', 'EventModalImageCrop');
+
+ /**
+ * Стена
+ */
+
+ // Добавление поста/комментария
+ $this->AddEventPreg('/^wall$/i', '/^add$/', 'EventWallAdd');
+ // Удаление поста/комментария
+ $this->AddEventPreg('/^wall$/i', '/^remove$/', 'EventWallRemove');
+ // Подгрузка постов
+ $this->AddEventPreg('/^wall$/i', '/^load$/', 'EventWallLoad');
+ // Подгрузка комментариев
+ $this->AddEventPreg('/^wall$/i', '/^load-comments$/', 'EventWallLoadComments');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Показывает модальное окно с друзьями
+ */
+ protected function EventModalFriendList()
+ {
+ if (!$this->oUserCurrent) {
+ return parent::EventNotFound();
+ }
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+
+ // Получаем переменные
+ $bSelectable = (bool)getRequest('selectable');
+
+ // Получаем список друзей
+ $aUsersFriend = $this->User_GetUsersFriend($this->oUserCurrent->getId());
+
+ if ($aUsersFriend['collection']) {
+ $oViewer->Assign('users', $aUsersFriend['collection'], true);
+ }
+
+ $oViewer->Assign('selectable', $bSelectable, true);
+
+ $this->Viewer_AssignAjax('sText', $oViewer->Fetch("component@user.modal.user-list"));
+ }
+
+ /**
+ * Показывает модальное окно с функцией кропа изображения
+ */
+ protected function EventModalImageCrop()
+ {
+ $oViewer = $this->Viewer_GetLocalViewer();
+
+ $oViewer->Assign('usePreview', (bool)getRequest('use_preview'), true);
+ $oViewer->Assign('image', getRequestStr('image_src'), true);
+ $oViewer->Assign('originalWidth', (int)getRequest('original_width'), true);
+ $oViewer->Assign('originalHeight', (int)getRequest('original_height'), true);
+ $oViewer->Assign('width', (int)getRequest('width'), true);
+ $oViewer->Assign('height', (int)getRequest('height'), true);
+ $oViewer->Assign('title', getRequestStr('title'), true);
+ $oViewer->Assign('desc', getRequestStr('desc'), true);
+
+ $this->Viewer_AssignAjax('sText', $oViewer->Fetch("component@crop.crop"));
+ }
+
+ protected function EventPollVote()
+ {
+ if (!$oPoll = $this->Poll_GetPollById(getRequestStr('id'))) {
+ return $this->EventErrorDebug();
+ }
+
+ if (!$this->oUserCurrent and !$oPoll->getIsGuestAllow()) {
+ return $this->EventErrorDebug();
+ }
+
+ /**
+ * Истекло время голосования?
+ */
+ if (!$oPoll->isAllowVote()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('poll.notices.error_not_allow_vote'));
+ return;
+ }
+ /**
+ * Пользователь уже голосовал?
+ */
+ if ($this->Poll_CheckUserAlreadyVote($oPoll, $this->oUserCurrent)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('poll.notices.error_already_vote'));
+ return;
+ }
+
+ $aAnswerIds = array();
+ $aAnswerItems = array();
+ if (!getRequest('abstain')) {
+ /**
+ * Проверяем варианты ответов
+ */
+ if (!$aAnswer = (array)getRequest('answers')) {
+ $this->Message_AddErrorSingle($this->Lang_Get('poll.notices.error_no_answers'));
+ return;
+ }
+
+ foreach ($aAnswer as $iAnswerId) {
+ if (!is_numeric($iAnswerId)) {
+ return $this->EventErrorDebug();
+ }
+ $aAnswerIds[] = $iAnswerId;
+ }
+ /**
+ * Корректность ID вариантов
+ */
+ $aAnswerItems = $this->Poll_GetAnswerItemsByFilter(array(
+ 'id in' => $aAnswerIds,
+ 'poll_id' => $oPoll->getId()
+ ));
+ if (count($aAnswerItems) != count($aAnswerIds)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Ограничение на максимальное число ответов
+ */
+ if (count($aAnswerIds) > $oPoll->getCountAnswerMax()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('poll.notices.error_answers_max', array('count' => $oPoll->getCountAnswerMax())));
+ return;
+ }
+ }
+
+ /**
+ * Голосуем
+ */
+ $oVote = Engine::GetEntity('ModulePoll_EntityVote');
+ $oVote->setPollId($oPoll->getId());
+ $oVote->setPoll($oPoll); // для быстродействия/оптимизации
+ $oVote->setUserId($this->oUserCurrent ? $this->oUserCurrent->getId() : null);
+ $oVote->setGuestKey($this->oUserCurrent ? null : func_generator(32));
+ $oVote->setAnswers($aAnswerIds);
+ $oVote->setAnswersObject($aAnswerItems); // передаем для быстродействия, чтобы не запрашивать варианты еще раз после сохранения голоса
+ if ($oVote->Add()) {
+ /**
+ * Устанавливаем куку
+ */
+ if ($oVote->getGuestKey()) {
+ $this->Session_SetCookie($this->Poll_GetCookieVoteName($oPoll), $oVote->getGuestKey(), time() + 60 * 60 * 24 * 90);
+ }
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('poll', $oPoll);
+ $this->Viewer_AssignAjax('sText', $oViewer->Fetch("component@poll.result"));
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ protected function EventPollCreate()
+ {
+ if (!$this->oUserCurrent) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Создаем
+ */
+ $oPoll = Engine::GetEntity('ModulePoll_EntityPoll');
+ $oPoll->_setValidateScenario('create');
+ $oPoll->_setDataSafe(getRequest('poll'));
+ $oPoll->setAnswersRaw(getRequest('answers'));
+ $oPoll->setTargetRaw(getRequest('target'));
+ $oPoll->setUserId($this->oUserCurrent->getId());
+
+ if ($oPoll->_Validate()) {
+ if ($oPoll->Add()) {
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('poll', $oPoll);
+ $this->Viewer_AssignAjax('item', $oViewer->Fetch("component@poll.manage.item"));
+ return true;
+ } else {
+ $this->Message_AddError($this->Lang_Get('common.error.save'), $this->Lang_Get('common.error.error'));
+ }
+ } else {
+ $this->Message_AddError($oPoll->_getValidateError(), $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ protected function EventPollUpdate()
+ {
+ if (!$this->oUserCurrent) {
+ return $this->EventErrorDebug();
+ }
+
+ if (!$oPoll = $this->Poll_GetPollById(getRequestStr('poll_id'))) {
+ return $this->EventErrorDebug();
+ }
+
+ /**
+ * Проверяем корректность target'а
+ */
+ if ($oPoll->getTargetId()) {
+ if (!$this->Poll_CheckTarget($oPoll->getTargetType(), $oPoll->getTargetId())) {
+ return $this->EventErrorDebug();
+ }
+ } else {
+ $sTarget = isset($_REQUEST['target']['tmp']) ? $_REQUEST['target']['tmp'] : '';
+ if (!$this->Poll_IsAllowTargetType($oPoll->getTargetType()) or $oPoll->getTargetTmp() != $sTarget) {
+ return $this->EventErrorDebug();
+ }
+ }
+ /**
+ * Обновляем
+ */
+ $oPoll->_setValidateScenario('update');
+ $oPoll->_setDataSafe(getRequest('poll'));
+ $oPoll->setAnswersRaw(getRequest('answers'));
+
+ if ($oPoll->_Validate()) {
+ if ($oPoll->Update()) {
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('poll', $oPoll);
+ $this->Viewer_AssignAjax('item', $oViewer->Fetch("component@poll.manage.item"));
+ $this->Viewer_AssignAjax('id', $oPoll->getId());
+ return true;
+ } else {
+ $this->Message_AddError($this->Lang_Get('common.error.save'), $this->Lang_Get('common.error.error'));
+ }
+ } else {
+ $this->Message_AddError($oPoll->_getValidateError(), $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ protected function EventPollRemove()
+ {
+ if (!$this->oUserCurrent) {
+ return $this->EventErrorDebug();
+ }
+
+ if (!$oPoll = $this->Poll_GetPollById(getRequestStr('id'))) {
+ return $this->EventErrorDebug();
+ }
+
+ /**
+ * Проверяем корректность target'а
+ */
+ if ($oPoll->getTargetId()) {
+ if (!$this->Poll_CheckTarget($oPoll->getTargetType(), $oPoll->getTargetId())) {
+ return $this->EventErrorDebug();
+ }
+ } else {
+ if (!$this->Poll_IsAllowTargetType($oPoll->getTargetType()) or $oPoll->getTargetTmp() != getRequestStr('tmp')) {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ if (!$oPoll->isAllowRemove()) {
+ $this->Message_AddError($this->Lang_Get('poll.notices.error_not_allow_remove'));
+ return;
+ }
+
+ /**
+ * Удаляем
+ */
+ if ($oPoll->Delete()) {
+ return true;
+ } else {
+ $this->Message_AddError($this->Lang_Get('common.error.save'), $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ protected function EventPollModalCreate()
+ {
+ if (!$this->oUserCurrent) {
+ return $this->EventErrorDebug();
+ }
+
+ /**
+ * Проверяем корректность target'а
+ */
+ $sTargetType = getRequestStr('target_type');
+ $sTargetId = getRequestStr('target_id');
+
+ $sTargetTmp = $this->Session_GetCookie('poll_target_tmp_' . $sTargetType) ? $this->Session_GetCookie('poll_target_tmp_' . $sTargetType) : getRequestStr('target_tmp');
+ if ($sTargetId) {
+ $sTargetTmp = null;
+ if (!$this->Poll_CheckTarget($sTargetType, $sTargetId)) {
+ return $this->EventErrorDebug();
+ }
+ } else {
+ $sTargetId = null;
+ if (!$this->Poll_IsAllowTargetType($sTargetType)) {
+ return $this->EventErrorDebug();
+ }
+ if (!$sTargetTmp) {
+ $sTargetTmp = func_generator();
+ $this->Session_SetCookie('poll_target_tmp_' . $sTargetType, $sTargetTmp, time() + 24 * 3600);
+ }
+ }
+
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('sTargetType', $sTargetType);
+ $oViewer->Assign('sTargetId', $sTargetId);
+ $oViewer->Assign('sTargetTmp', $sTargetTmp);
+ $this->Viewer_AssignAjax('sText', $oViewer->Fetch("component@poll.modal.create"));
+ }
+
+ protected function EventPollModalUpdate()
+ {
+ if (!$this->oUserCurrent) {
+ return $this->EventErrorDebug();
+ }
+
+ if (!$oPoll = $this->Poll_GetPollById(getRequestStr('id'))) {
+ return $this->EventErrorDebug();
+ }
+
+ /**
+ * Проверяем корректность target'а
+ */
+ if ($oPoll->getTargetId()) {
+ if (!$this->Poll_CheckTarget($oPoll->getTargetType(), $oPoll->getTargetId())) {
+ return $this->EventErrorDebug();
+ }
+ } else {
+ if (!$this->Poll_IsAllowTargetType($oPoll->getTargetType()) or $oPoll->getTargetTmp() != getRequestStr('target_tmp')) {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('poll', $oPoll);
+ $oViewer->Assign('sTargetTmp', getRequestStr('target_tmp'));
+ $this->Viewer_AssignAjax('sText', $oViewer->Fetch("component@poll.modal.create"));
+ }
+
+ /**
+ * Отображение каптчи
+ */
+ protected function EventCaptcha()
+ {
+ $this->Viewer_SetResponseAjax(null);
+ /**
+ * Подключаем каптчу
+ */
+ require_once(Config::Get('path.framework.libs_vendor.server') . '/kcaptcha/kcaptcha.php');
+ /**
+ * Определяем уникальное название (возможность нескольких каптч на одной странице)
+ */
+ $sName = '';
+ if (isset($_GET['name']) and is_string($_GET['name']) and $_GET['name']) {
+ $sName = $_GET['name'];
+ }
+ /**
+ * Генерируем каптчу и сохраняем код в сессию
+ */
+ $oCaptcha = new KCAPTCHA();
+ $this->Session_Set('captcha_keystring' . ($sName ? '_' . $sName : ''), $oCaptcha->getKeyString());
+ $this->SetTemplate(false);
+ }
+
+ /**
+ * Ajax валидация каптчи
+ *
+ * @apiParam {String} name Уникальное название каптчи
+ * @apiParam {String} captcha Значение каптчи для проверки
+ */
+ protected function EventCaptchaValidate()
+ {
+ $sName = isset($_REQUEST['name']) ? $_REQUEST['name'] : '';
+ $sValue = isset($_REQUEST['captcha']) ? $_REQUEST['captcha'] : '';
+
+ $sCaptchaValidateType = func_camelize('captcha_' . Config::Get('sys.captcha.type'));
+ if (!$this->Validate_Validate($sCaptchaValidateType, $sValue, array('name' => $sName))) {
+ $aErrors = $this->Validate_GetErrors();
+ $this->Viewer_AssignAjax('errors', array('captcha' => array(reset($aErrors))));
+ }
+ }
+
+ protected function EventPropertyTagsAutocompleter()
+ {
+ /**
+ * Первые буквы тега переданы?
+ */
+ if (!($sValue = getRequest('value', null, 'post')) or !is_string($sValue)) {
+ return;
+ }
+ $aItems = array();
+ /**
+ * Формируем список тегов
+ */
+ $aTags = $this->Property_GetPropertyTagsByLike($sValue, getRequestStr('property_id'), 10);
+ foreach ($aTags as $oTag) {
+ $aItems[] = $oTag->getText();
+ }
+ /**
+ * Передаем результат в ajax ответ
+ */
+ $this->Viewer_AssignAjax('aItems', $aItems);
+ }
+
+ protected function EventMediaUploadLink()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * URL передали?
+ */
+ if (!($sUrl = getRequestStr('url'))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Необходимо выполнить загрузку файла
+ */
+ if (getRequest('upload')) {
+ /**
+ * Проверяем корректность target'а
+ */
+ $sTargetType = getRequestStr('target_type');
+ $sTargetId = getRequestStr('target_id');
+
+ $sTargetTmp = $this->Session_GetCookie('media_target_tmp_' . $sTargetType) ? $this->Session_GetCookie('media_target_tmp_' . $sTargetType) : getRequestStr('target_tmp');
+ if ($sTargetId) {
+ $sTargetTmp = null;
+ if (true !== $res = $this->Media_CheckTarget($sTargetType, $sTargetId,
+ ModuleMedia::TYPE_CHECK_ALLOW_ADD,
+ array('user' => $this->oUserCurrent))
+ ) {
+ $this->Message_AddError(is_string($res) ? $res : $this->Lang_Get('common.error.system.base'),
+ $this->Lang_Get('common.error.error'));
+ return false;
+ }
+ } else {
+ $sTargetId = null;
+ if (!$sTargetTmp) {
+ return $this->EventErrorDebug();
+ }
+ if (true !== $res = $this->Media_CheckTarget($sTargetType, null, ModuleMedia::TYPE_CHECK_ALLOW_ADD,
+ array('user' => $this->oUserCurrent), $sTargetTmp)
+ ) {
+ $this->Message_AddError(is_string($res) ? $res : $this->Lang_Get('common.error.system.base'),
+ $this->Lang_Get('common.error.error'));
+ return false;
+ }
+ }
+
+ /**
+ * Выполняем загрузку файла
+ */
+ if ($mResult = $this->Media_UploadUrl($sUrl, $sTargetType, $sTargetId, $sTargetTmp) and is_object($mResult)
+ ) {
+ $aParams = array(
+ 'align' => getRequestStr('align'),
+ 'title' => getRequestStr('title')
+ );
+ $this->Viewer_AssignAjax('sText', $this->Media_BuildCodeForEditor($mResult, $aParams));
+ } else {
+ $this->Message_AddError(is_string($mResult) ? $mResult : $this->Lang_Get('common.error.system.base'),
+ $this->Lang_Get('common.error.error'));
+ }
+ } else {
+ /**
+ * Формируем параметры для билдера HTML
+ */
+ $aParams = array(
+ 'align' => getRequestStr('align'),
+ 'title' => getRequestStr('title'),
+ 'image_url' => $sUrl
+ );
+ $this->Viewer_AssignAjax('sText', $this->Media_BuildImageCodeForEditor($aParams));
+ }
+ }
+
+ protected function EventMediaSaveDataFile()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $aAllowData = array('title');
+ $sName = getRequestStr('name');
+ $sValue = getRequestStr('value');
+ if (!in_array($sName, $aAllowData)) {
+ return $this->EventErrorDebug();
+ }
+ $sId = getRequestStr('id');
+ if (!$oMedia = $this->Media_GetMediaById($sId)) {
+ return $this->EventErrorDebug();
+ }
+ if (true === $res = $this->Media_CheckTarget($oMedia->getTargetType(), null,
+ ModuleMedia::TYPE_CHECK_ALLOW_UPDATE, array('media' => $oMedia, 'user' => $this->oUserCurrent))
+ ) {
+ $oMedia->setDataOne($sName, $sValue);
+ $oMedia->Update();
+ } else {
+ $this->Message_AddErrorSingle(is_string($res) ? $res : $this->Lang_Get('common.error.system.base'));
+ }
+ }
+
+ protected function EventMediaRemoveFile()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $sId = getRequestStr('id');
+ if (!$oMedia = $this->Media_GetMediaById($sId)) {
+ return $this->EventErrorDebug();
+ }
+ if (true === $res = $this->Media_CheckTarget($oMedia->getTargetType(), null,
+ ModuleMedia::TYPE_CHECK_ALLOW_REMOVE, array('media' => $oMedia, 'user' => $this->oUserCurrent))
+ ) {
+ $oMedia->Delete();
+ } else {
+ $this->Message_AddErrorSingle(is_string($res) ? $res : $this->Lang_Get('common.error.system.base'));
+ }
+ }
+
+ protected function EventMediaCreatePreviewFile()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $sId = getRequestStr('id');
+ if (!$oMedia = $this->Media_GetMediaById($sId)) {
+ return $this->EventErrorDebug();
+ }
+
+ $sTargetType = getRequestStr('target_type');
+ $sTargetId = getRequestStr('target_id') ?: null;
+ $sTargetTmp = getRequestStr('target_tmp') ?: null;
+
+ /**
+ * Доступ к файлу медиа
+ */
+ if (!$this->Media_GetAllowMediaItemsById(array($oMedia->getId()))) {
+ return $this->EventErrorDebug();
+ }
+
+ /**
+ * Проверяем доступ к этому объекту медиа
+ */
+ if (true !== $res = $this->Media_CheckTarget($sTargetType, $sTargetId,
+ ModuleMedia::TYPE_CHECK_ALLOW_PREVIEW, array('media' => $oMedia, 'user' => $this->oUserCurrent))
+ ) {
+ $this->Message_AddErrorSingle(is_string($res) ? $res : $this->Lang_Get('common.error.system.base'));
+ }
+
+ /**
+ * Получаем объект связи
+ */
+ $aFilter = array('media_id' => $oMedia->getId(), 'target_type' => $sTargetType);
+ if ($sTargetTmp) {
+ $aFilter['target_tmp'] = $sTargetTmp;
+ } else {
+ $aFilter['target_id'] = $sTargetId;
+ }
+ if (!$oTarget = $this->Media_GetTargetByFilter($aFilter)) {
+ /**
+ * Попытка добавить в качестве превью ранее загруженный файл для другого объекта
+ * Делаем новую связь медиа с текущим объектом
+ */
+ if (!($oTarget = $this->Media_AttachMediaToTarget($oMedia, $sTargetType, $sTargetId, $sTargetTmp))) {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ /**
+ * Удаляем все текущие превью
+ */
+ $this->Media_RemoveAllPreviewByTarget($oTarget->getTargetType(), $oTarget->getTargetId(), $oTarget->getTargetTmp());
+
+ if (true === $res2 = $this->Media_CreateFilePreview($oMedia, $oTarget)) {
+ $this->Viewer_AssignAjax('bUnsetOther', true);
+ } else {
+ $this->Message_AddErrorSingle(is_string($res2) ? $res2 : $this->Lang_Get('common.error.system.base'));
+ }
+ }
+
+ protected function EventMediaRemovePreviewFile()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $sId = getRequestStr('id');
+ if (!$oMedia = $this->Media_GetMediaById($sId)) {
+ return $this->EventErrorDebug();
+ }
+
+ $sTargetType = getRequestStr('target_type');
+ $sTargetId = getRequestStr('target_id');
+ $sTargetTmp = getRequestStr('target_tmp');
+
+ /**
+ * Получаем объект связи
+ */
+ $aFilter = array('media_id' => $oMedia->getId(), 'target_type' => $sTargetType);
+ if ($sTargetTmp) {
+ $aFilter['target_tmp'] = $sTargetTmp;
+ } else {
+ $aFilter['target_id'] = $sTargetId;
+ }
+ if (!$oTarget = $this->Media_GetTargetByFilter($aFilter)) {
+ return $this->EventErrorDebug();
+ }
+ if (!$oTarget->getIsPreview()) {
+ return $this->EventErrorDebug();
+ }
+
+
+ /**
+ * Проверяем доступ к этому медиа
+ */
+ if (true === $res = $this->Media_CheckTarget($oTarget->getTargetType(), $oTarget->getTargetId(),
+ ModuleMedia::TYPE_CHECK_ALLOW_PREVIEW, array('media' => $oMedia, 'user' => $this->oUserCurrent))
+ ) {
+ /**
+ * Удаляем превью
+ */
+ $this->Media_RemoveFilePreview($oMedia, $oTarget);
+ } else {
+ $this->Message_AddErrorSingle(is_string($res) ? $res : $this->Lang_Get('common.error.system.base'));
+ }
+ }
+
+ protected function EventMediaRemoveTarget()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $sId = getRequestStr('id');
+ if (!$oMedia = $this->Media_GetMediaById($sId)) {
+ return $this->EventErrorDebug();
+ }
+
+ $sTargetType = getRequestStr('target_type');
+ $sTargetId = getRequestStr('target_id');
+ $sTargetTmp = getRequestStr('target_tmp');
+
+ /**
+ * Получаем объект связи
+ */
+ $aFilter = array('media_id' => $oMedia->getId(), 'target_type' => $sTargetType);
+ if ($sTargetTmp) {
+ $aFilter['target_tmp'] = $sTargetTmp;
+ } else {
+ $aFilter['target_id'] = $sTargetId;
+ }
+ if (!$oTarget = $this->Media_GetTargetByFilter($aFilter)) {
+ return $this->EventErrorDebug();
+ }
+ $oTarget->Delete();
+
+ $this->Viewer_AssignAjax('bRemoveResult', $oTarget->Delete());
+
+ }
+
+ protected function EventMediaLoadGallery()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+
+ $sType = getRequestStr('target_type');
+ $sId = getRequestStr('target_id');
+ $sTmp = getRequestStr('target_tmp');
+ $iPage = (int)getRequestStr('page');
+ $iPage = $iPage < 1 ? 1 : $iPage;
+
+ $aMediaItems = array();
+ if ($sType) {
+ /**
+ * Получаем медиа для конкретного объекта
+ */
+ if ($sId) {
+ if (!$this->Media_CheckTarget($sType, $sId, ModuleMedia::TYPE_CHECK_ALLOW_VIEW_LIST)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.not_access'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $aMediaItems = $this->Media_GetMediaByTarget($sType, $sId);
+ } elseif ($sTmp) {
+ $aMediaItems = $this->Media_GetMediaByTargetTmp($sTmp, $this->oUserCurrent->getId());
+ }
+ } else {
+ /**
+ * Получаем все медиа, созданные пользователем без учета временных
+ */
+ $aResult = $this->Media_GetMediaItemsByFilter(array(
+ 'user_id' => $this->oUserCurrent->getId(),
+ 'mt.target_tmp' => null,
+ '#page' => array($iPage, 20),
+ '#join' => array(
+ 'LEFT JOIN ' . Config::Get('db.table.media_target') . ' mt ON ( t.id = mt.media_id and mt.target_tmp IS NOT NULL ) ' => array(),
+ ),
+ '#group' => 'id',
+ '#order' => array('id' => 'desc')
+ ));
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, 20, Config::Get('pagination.pages.count'), null);
+ $aMediaItems = $aResult['collection'];
+ $this->Viewer_AssignAjax('pagination', $aPaging);
+ }
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $sTemplate = '';
+ foreach ($aMediaItems as $oMediaItem) {
+ $oViewer->Assign('oMediaItem', $oMediaItem);
+ $sTemplate .= $oViewer->Fetch('component@uploader.file');
+ }
+ $this->Viewer_AssignAjax('html', $sTemplate);
+ $this->Viewer_AssignAjax('count_loaded', count($aMediaItems));
+ $this->Viewer_AssignAjax('page', count($aMediaItems) > 0 ? $iPage + 1 : $iPage);
+ }
+
+ protected function EventMediaLoadPreviewItems()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+
+ $sType = getRequestStr('target_type');
+ $sId = getRequestStr('target_id');
+ $sTmp = getRequestStr('target_tmp');
+
+ $aFilter = array(
+ 'target_type' => $sType,
+ 'is_preview' => 1,
+ );
+ if ($sId) {
+ $aFilter['target_id'] = $sId;
+ } else {
+ $aFilter['target_tmp'] = $sTmp;
+ }
+ $aTargetItems = $this->Media_GetTargetItemsByFilter($aFilter);
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('imagePreviewItems', $aTargetItems);
+ $this->Viewer_AssignAjax('sTemplatePreview', $oViewer->Fetch('component@field.image-ajax-items'));
+ }
+
+ protected function EventMediaSubmitInsert()
+ {
+ $aIds = array(0);
+ foreach ((array)getRequest('ids') as $iId) {
+ $aIds[] = (int)$iId;
+ }
+
+ if (!($aMediaItems = $this->Media_GetAllowMediaItemsById($aIds))) {
+ $this->Message_AddError($this->Lang_Get('media.error.need_choose_items'));
+ return false;
+ }
+
+ $aParams = array(
+ 'align' => getRequestStr('align'),
+ 'size' => getRequestStr('size'),
+ 'relative_web' => true
+ );
+ /**
+ * Если изображений несколько, то генерируем идентификатор группы для лайтбокса
+ */
+ if (count($aMediaItems) > 1) {
+ $aParams['lbx_group'] = rand(1, 100);
+ }
+
+ $sTextResult = '';
+ foreach ($aMediaItems as $oMedia) {
+ $sTextResult .= $this->Media_BuildCodeForEditor($oMedia, $aParams) . "\r\n";
+ }
+ $this->Viewer_AssignAjax('sTextResult', $sTextResult);
+ }
+
+ protected function EventMediaSubmitCreatePhotoset()
+ {
+ $aMediaItems = $this->Media_GetAllowMediaItemsById(getRequest('ids'));
+ if (!$aMediaItems) {
+ $this->Message_AddError($this->Lang_Get('media.error.need_choose_items'));
+ return false;
+ }
+
+ $aItems = array();
+ foreach ($aMediaItems as $oMedia) {
+ $aItems[] = $oMedia->getId();
+ }
+
+ $sTextResult = 'Viewer_AssignAjax('sTextResult', $sTextResult);
+ }
+
+ protected function EventMediaGenerateTargetTmp()
+ {
+ $sType = getRequestStr('type');
+ if ($this->Media_IsAllowTargetType($sType)) {
+ $sTmp = func_generator();
+ $this->Session_SetCookie('media_target_tmp_' . $sType, $sTmp, time() + 24 * 3600);
+ $this->Viewer_AssignAjax('sTmpKey', $sTmp);
+ }
+ }
+
+ protected function EventMediaUpload()
+ {
+ if (getRequest('is_iframe')) {
+ $this->Viewer_SetResponseAjax('jsonIframe', false);
+ } else {
+ $this->Viewer_SetResponseAjax('json');
+ }
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Файл был загружен?
+ */
+ if (!isset($_FILES['filedata']['tmp_name'])) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверяем корректность target'а
+ */
+ $sTargetType = getRequestStr('target_type');
+ $sTargetId = getRequestStr('target_id');
+
+ $sTargetTmp = $this->Session_GetCookie('media_target_tmp_' . $sTargetType) ? $this->Session_GetCookie('media_target_tmp_' . $sTargetType) : getRequestStr('target_tmp');
+ if ($sTargetId) {
+ $sTargetTmp = null;
+ if (true !== $res = $this->Media_CheckTarget($sTargetType, $sTargetId, ModuleMedia::TYPE_CHECK_ALLOW_ADD,
+ array('user' => $this->oUserCurrent))
+ ) {
+ $this->Message_AddError(is_string($res) ? $res : $this->Lang_Get('common.error.system.base'),
+ $this->Lang_Get('common.error.error'));
+ return false;
+ }
+ } else {
+ $sTargetId = null;
+ if (!$sTargetTmp) {
+ return $this->EventErrorDebug();
+ }
+ if (true !== $res = $this->Media_CheckTarget($sTargetType, null, ModuleMedia::TYPE_CHECK_ALLOW_ADD,
+ array('user' => $this->oUserCurrent), $sTargetTmp)
+ ) {
+ $this->Message_AddError(is_string($res) ? $res : $this->Lang_Get('common.error.system.base'),
+ $this->Lang_Get('common.error.error'));
+ return false;
+ }
+ }
+
+ /**
+ * TODO: необходима проверка на максимальное общее количество файлов, на максимальный размер файла
+ * Эти настройки можно хранить в конфиге: module.media.type.topic.max_file_count=30 и т.п.
+ */
+
+ /**
+ * Выполняем загрузку файла
+ */
+ if ($mResult = $this->Media_Upload($_FILES['filedata'], $sTargetType, $sTargetId,
+ $sTargetTmp) and is_object($mResult)
+ ) {
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('oMediaItem', $mResult);
+
+ $sTemplateFile = $oViewer->Fetch('component@uploader.file');
+
+ $this->Viewer_AssignAjax('sTemplateFile', $sTemplateFile);
+ } else {
+ $this->Message_AddError(is_string($mResult) ? $mResult : $this->Lang_Get('common.error.system.base'),
+ $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ /**
+ * Получение информации о голосовании за топик
+ */
+ protected function EventVoteGetInfoTopic()
+ {
+ if (!($oTopic = $this->Topic_GetTopicById(getRequestStr('iTargetId', null, 'post')))) {
+ return $this->EventErrorDebug();
+ }
+
+ if (!$oTopic->getVote() && ($this->oUserCurrent && $oTopic->getUserId() != $this->oUserCurrent->getId()) && (strtotime($oTopic->getDatePublish()) + Config::Get('acl.vote.topic.limit_time') > time())) {
+ return $this->EventErrorDebug();
+ }
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+
+ $oViewer->Assign('target', $oTopic, true);
+ $oViewer->Assign('oUserCurrent', $this->oUserCurrent);
+
+ $this->Viewer_AssignAjax('sText', $oViewer->Fetch("component@vote.info"));
+ }
+
+ /**
+ * Получение списка регионов по стране
+ */
+ protected function EventGeoGetRegions()
+ {
+ $iCountryId = getRequestStr('country');
+ $iLimit = 200;
+ if (is_numeric(getRequest('limit')) and getRequest('limit') > 0) {
+ $iLimit = getRequest('limit');
+ }
+ /**
+ * Находим страну
+ */
+ if (!($oCountry = $this->Geo_GetGeoObject('country', $iCountryId))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Получаем список регионов
+ */
+ if ($sTargetType = getRequestStr('target_type') and $this->Geo_IsAllowTargetType($sTargetType)) {
+ $aRegions = $this->Geo_GetRegionsUsedByTargetType($oCountry->getId(), $sTargetType);
+ } else {
+ $aRegions = $this->Geo_GetRegions(array('country_id' => $oCountry->getId()), array('sort' => 'asc'), 1,
+ $iLimit);
+ $aRegions = $aRegions['collection'];
+ }
+ /**
+ * Формируем ответ
+ */
+ $aReturn = array();
+ foreach ($aRegions as $oObject) {
+ $aReturn[] = array(
+ 'id' => $oObject->getId(),
+ 'name' => $oObject->getName(),
+ );
+ }
+ /**
+ * Устанавливаем переменные для ajax ответа
+ */
+ $this->Viewer_AssignAjax('aRegions', $aReturn);
+ }
+
+ /**
+ * Получение списка городов по региону
+ */
+ protected function EventGeoGetCities()
+ {
+ $iRegionId = getRequestStr('region');
+ $iLimit = 500;
+ if (is_numeric(getRequest('limit')) and getRequest('limit') > 0) {
+ $iLimit = getRequest('limit');
+ }
+ /**
+ * Находим регион
+ */
+ if (!($oRegion = $this->Geo_GetGeoObject('region', $iRegionId))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Получаем города
+ */
+ if ($sTargetType = getRequestStr('target_type') and $this->Geo_IsAllowTargetType($sTargetType)) {
+ $aCities = $this->Geo_GetCitiesUsedByTargetType($oRegion->getId(), $sTargetType);
+ } else {
+ $aCities = $this->Geo_GetCities(array('region_id' => $oRegion->getId()), array('sort' => 'asc'), 1,
+ $iLimit);
+ $aCities = $aCities['collection'];
+ }
+ /**
+ * Формируем ответ
+ */
+ $aReturn = array();
+ foreach ($aCities as $oObject) {
+ $aReturn[] = array(
+ 'id' => $oObject->getId(),
+ 'name' => $oObject->getName(),
+ );
+ }
+ /**
+ * Устанавливаем переменные для ajax ответа
+ */
+ $this->Viewer_AssignAjax('aCities', $aReturn);
+ }
+
+ /**
+ * Голосование за комментарий
+ *
+ */
+ protected function EventVoteComment()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Комментарий существует?
+ */
+ if (!($oComment = $this->Comment_GetCommentById(getRequestStr('iTargetId', null, 'post')))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверка типа комментария
+ */
+ if (!in_array($oComment->getTargetType(), (array)Config::Get('module.comment.vote_target_allow'))) {
+ return $this->EventErrorDebug();
+ }
+ if ($oComment->getTargetType() == 'topic') {
+ /**
+ * Проверяем права на просмотр топика
+ */
+ if (!$this->ACL_IsAllowShowTopic($oComment->getTarget(), $this->oUserCurrent)) {
+ return parent::EventNotFound();
+ }
+ }
+ /**
+ * Пользователь имеет право голоса?
+ */
+ if (!$this->ACL_CanVoteComment($this->oUserCurrent, $oComment)) {
+ $this->Message_AddErrorSingle($this->Rbac_GetMsgLast());
+ return;
+ }
+ /**
+ * Как именно голосует пользователь
+ */
+ $iValue = getRequestStr('value', null, 'post');
+ if (!in_array($iValue, array('1', '-1'))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Голосуем
+ */
+ $oTopicCommentVote = Engine::GetEntity('Vote');
+ $oTopicCommentVote->setTargetId($oComment->getId());
+ $oTopicCommentVote->setTargetType('comment');
+ $oTopicCommentVote->setVoterId($this->oUserCurrent->getId());
+ $oTopicCommentVote->setDirection($iValue);
+ $oTopicCommentVote->setDate(date("Y-m-d H:i:s"));
+ $iVal = (float)$this->Rating_VoteComment($this->oUserCurrent, $oComment, $iValue);
+ $oTopicCommentVote->setValue($iVal);
+
+ $oComment->setCountVote($oComment->getCountVote() + 1);
+ $this->Hook_Run("vote_{$oTopicCommentVote->getTargetType()}_before",
+ array('oTarget' => $oComment, 'oVote' => $oTopicCommentVote, 'iValue' => $iValue));
+ if ($this->Vote_AddVote($oTopicCommentVote) and $this->Comment_UpdateComment($oComment)) {
+ $this->Hook_Run("vote_{$oTopicCommentVote->getTargetType()}_after",
+ array('oTarget' => $oComment, 'oVote' => $oTopicCommentVote, 'iValue' => $iValue));
+
+ $this->Message_AddNoticeSingle($this->Lang_Get('vote.notices.success'), $this->Lang_Get('common.attention'));
+ $this->Viewer_AssignAjax('iRating', $oComment->getRating());
+ /**
+ * Добавляем событие в ленту
+ */
+ $this->Stream_Write($oTopicCommentVote->getVoterId(), 'vote_comment_' . $oComment->getTargetType(),
+ $oComment->getId());
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ /**
+ * Голосование за топик
+ *
+ */
+ protected function EventVoteTopic()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Топик существует?
+ */
+ if (!($oTopic = $this->Topic_GetTopicById(getRequestStr('iTargetId', null, 'post')))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверяем права на просмотр топика
+ */
+ if (!$this->ACL_IsAllowShowTopic($oTopic, $this->oUserCurrent)) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Как проголосовал пользователь
+ */
+ $iValue = getRequestStr('value', null, 'post');
+ if (!in_array($iValue, array('1', '-1', '0'))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Права на голосование
+ */
+ if (!$this->ACL_CanVoteTopic($this->oUserCurrent, $oTopic, $iValue)) {
+ $this->Message_AddErrorSingle($this->Rbac_GetMsgLast());
+ return;
+ }
+ /**
+ * Голосуем
+ */
+ $oTopicVote = Engine::GetEntity('Vote');
+ $oTopicVote->setTargetId($oTopic->getId());
+ $oTopicVote->setTargetType('topic');
+ $oTopicVote->setVoterId($this->oUserCurrent->getId());
+ $oTopicVote->setDirection($iValue);
+ $oTopicVote->setDate(date("Y-m-d H:i:s"));
+ $iVal = 0;
+ if ($iValue != 0) {
+ $iVal = (float)$this->Rating_VoteTopic($this->oUserCurrent, $oTopic, $iValue);
+ }
+ $oTopicVote->setValue($iVal);
+ $oTopic->setCountVote($oTopic->getCountVote() + 1);
+ if ($iValue == 1) {
+ $oTopic->setCountVoteUp($oTopic->getCountVoteUp() + 1);
+ } elseif ($iValue == -1) {
+ $oTopic->setCountVoteDown($oTopic->getCountVoteDown() + 1);
+ } elseif ($iValue == 0) {
+ $oTopic->setCountVoteAbstain($oTopic->getCountVoteAbstain() + 1);
+ }
+ $this->Hook_Run("vote_{$oTopicVote->getTargetType()}_before",
+ array('oTarget' => $oTopic, 'oVote' => $oTopicVote, 'iValue' => $iValue));
+ if ($this->Vote_AddVote($oTopicVote) and $this->Topic_UpdateTopic($oTopic)) {
+ $this->Hook_Run("vote_{$oTopicVote->getTargetType()}_after",
+ array('oTarget' => $oTopic, 'oVote' => $oTopicVote, 'iValue' => $iValue));
+ if ($iValue) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('vote.notices.success'), $this->Lang_Get('common.attention'));
+ } else {
+ $this->Message_AddNoticeSingle($this->Lang_Get('vote.notices.success_abstain'),
+ $this->Lang_Get('common.attention'));
+ }
+ $this->Viewer_AssignAjax('iRating', $oTopic->getRating());
+ /**
+ * Добавляем событие в ленту
+ */
+ $this->Stream_write($oTopicVote->getVoterId(), 'vote_topic', $oTopic->getId());
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ }
+
+ /**
+ * Сохраняет теги для избранного
+ *
+ */
+ protected function EventFavouriteSaveTags()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Объект уже должен быть в избранном
+ */
+ if ($oFavourite = $this->Favourite_GetFavourite(getRequestStr('target_id'), getRequestStr('target_type'),
+ $this->oUserCurrent->getId())
+ ) {
+ /**
+ * Обрабатываем теги
+ */
+ $aTags = explode(',', trim(getRequestStr('tags'), "\r\n\t\0\x0B ."));
+ $aTagsNew = array();
+ $aTagsNewLow = array();
+ $aTagsReturn = array();
+ foreach ($aTags as $sTag) {
+ $sTag = trim($sTag);
+ if (func_check($sTag, 'text', 2, 50) and !in_array(mb_strtolower($sTag, 'UTF-8'), $aTagsNewLow)) {
+ $sTagEsc = htmlspecialchars($sTag);
+ $aTagsNew[] = $sTag;
+ $aTagsReturn[] = array(
+ 'tag' => $sTagEsc,
+ 'url' => $this->oUserCurrent->getUserWebPath() . 'favourites/' . $oFavourite->getTargetType() . 's/tag/' . $sTagEsc . '/',
+ // костыль для URL с множественным числом
+ );
+ $aTagsNewLow[] = mb_strtolower($sTag, 'UTF-8');
+ }
+ }
+ if (!count($aTagsNew)) {
+ $oFavourite->setTags('');
+ } else {
+ $oFavourite->setTags(join(',', $aTagsNew));
+ }
+ $this->Viewer_AssignAjax('tags', $aTagsReturn);
+ $this->Favourite_UpdateFavourite($oFavourite);
+ return;
+ }
+ return $this->EventErrorDebug();
+ }
+
+ /**
+ * Обработка избранного - топик
+ *
+ */
+ protected function EventFavouriteTopic()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Можно только добавить или удалить из избранного
+ */
+ $iType = getRequestStr('type', null, 'post');
+ if (!in_array($iType, array('1', '0'))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Топик существует?
+ */
+ if (!($oTopic = $this->Topic_GetTopicById(getRequestStr('iTargetId', null, 'post')))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Есть доступ к комментариям этого топика? Закрытый блог?
+ */
+ if (!$this->ACL_IsAllowShowBlog($oTopic->getBlog(), $this->oUserCurrent)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Пропускаем топик из черновиков
+ */
+ if (!$oTopic->getPublish()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('topic.add.notices.error_favourite_draft'),
+ $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Топик уже в избранном?
+ */
+ $oFavouriteTopic = $this->Topic_GetFavouriteTopic($oTopic->getId(), $this->oUserCurrent->getId());
+ if (!$oFavouriteTopic and $iType) {
+ $oFavouriteTopicNew = Engine::GetEntity('Favourite',
+ array(
+ 'target_id' => $oTopic->getId(),
+ 'user_id' => $this->oUserCurrent->getId(),
+ 'target_type' => 'topic',
+ 'target_publish' => $oTopic->getPublish()
+ )
+ );
+ $oTopic->setCountFavourite($oTopic->getCountFavourite() + 1);
+ if ($this->Topic_AddFavouriteTopic($oFavouriteTopicNew) and $this->Topic_UpdateTopic($oTopic)) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('favourite.notices.add_success'),
+ $this->Lang_Get('common.attention'));
+ $this->Viewer_AssignAjax('bState', true);
+ $this->Viewer_AssignAjax('iCount', $oTopic->getCountFavourite());
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+ if (!$oFavouriteTopic and !$iType) {
+ $this->Message_AddErrorSingle($this->Lang_Get('favourite.notices.already_removed'),
+ $this->Lang_Get('common.error.error'));
+ return;
+ }
+ if ($oFavouriteTopic and $iType) {
+ $this->Message_AddErrorSingle($this->Lang_Get('favourite.notices.already_added'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ if ($oFavouriteTopic and !$iType) {
+ $oTopic->setCountFavourite($oTopic->getCountFavourite() - 1);
+ if ($this->Topic_DeleteFavouriteTopic($oFavouriteTopic) and $this->Topic_UpdateTopic($oTopic)) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('favourite.notices.remove_success'),
+ $this->Lang_Get('common.attention'));
+ $this->Viewer_AssignAjax('bState', false);
+ $this->Viewer_AssignAjax('iCount', $oTopic->getCountFavourite());
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+ }
+
+ /**
+ * Обработка избранного - комментарий
+ *
+ */
+ protected function EventFavouriteComment()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Можно только добавить или удалить из избранного
+ */
+ $iType = getRequestStr('type', null, 'post');
+ if (!in_array($iType, array('1', '0'))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Комментарий существует?
+ */
+ if (!($oComment = $this->Comment_GetCommentById(getRequestStr('iTargetId', null, 'post')))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Есть права?
+ */
+ if (!$this->ACL_IsAllowFavouriteComment($oComment, $this->oUserCurrent)) {
+ $this->Message_AddErrorSingle($this->Rbac_GetMsgLast());
+ return;
+ }
+ /**
+ * Комментарий уже в избранном?
+ */
+ $oFavouriteComment = $this->Comment_GetFavouriteComment($oComment->getId(), $this->oUserCurrent->getId());
+ if (!$oFavouriteComment and $iType) {
+ $oFavouriteCommentNew = Engine::GetEntity('Favourite',
+ array(
+ 'target_id' => $oComment->getId(),
+ 'target_type' => 'comment',
+ 'user_id' => $this->oUserCurrent->getId(),
+ 'target_publish' => $oComment->getPublish()
+ )
+ );
+ $oComment->setCountFavourite($oComment->getCountFavourite() + 1);
+ if ($this->Comment_AddFavouriteComment($oFavouriteCommentNew) and $this->Comment_UpdateComment($oComment)) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('favourite.notices.add_success'),
+ $this->Lang_Get('common.attention'));
+ $this->Viewer_AssignAjax('bState', true);
+ $this->Viewer_AssignAjax('iCount', $oComment->getCountFavourite());
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+ if (!$oFavouriteComment and !$iType) {
+ return $this->EventErrorDebug();
+ }
+ if ($oFavouriteComment and $iType) {
+ return $this->EventErrorDebug();
+ }
+ if ($oFavouriteComment and !$iType) {
+ $oComment->setCountFavourite($oComment->getCountFavourite() - 1);
+ if ($this->Comment_DeleteFavouriteComment($oFavouriteComment) and $this->Comment_UpdateComment($oComment)) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('favourite.notices.remove_success'),
+ $this->Lang_Get('common.attention'));
+ $this->Viewer_AssignAjax('bState', false);
+ $this->Viewer_AssignAjax('iCount', $oComment->getCountFavourite());
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+ }
+
+ /**
+ * Обработка избранного - личное сообщение
+ *
+ */
+ protected function EventFavouriteTalk()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Можно только добавить или удалить из избранного
+ */
+ $iType = getRequestStr('type', null, 'post');
+ if (!in_array($iType, array('1', '0'))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Сообщение существует?
+ */
+ if (!($oTalk = $this->Talk_GetTalkById(getRequestStr('iTargetId', null, 'post')))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Есть доступ?
+ */
+ if (!($oTalkUser = $this->Talk_GetTalkUser($oTalk->getId(), $this->oUserCurrent->getId()))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Сообщение уже в избранном?
+ */
+ $oFavouriteTalk = $this->Talk_GetFavouriteTalk($oTalk->getId(), $this->oUserCurrent->getId());
+ if (!$oFavouriteTalk and $iType) {
+ $oFavouriteTalkNew = Engine::GetEntity('Favourite',
+ array(
+ 'target_id' => $oTalk->getId(),
+ 'target_type' => 'talk',
+ 'user_id' => $this->oUserCurrent->getId(),
+ 'target_publish' => '1'
+ )
+ );
+ if ($this->Talk_AddFavouriteTalk($oFavouriteTalkNew)) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('favourite.notices.add_success'),
+ $this->Lang_Get('common.attention'));
+ $this->Viewer_AssignAjax('bState', true);
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ // Этого письма нет в вашем избранном
+ if (!$oFavouriteTalk and !$iType) {
+ return $this->EventErrorDebug();
+ }
+
+ // Это письмо уже есть в вашем избранном
+ if ($oFavouriteTalk and $iType) {
+ return $this->EventErrorDebug();
+ }
+
+ if ($oFavouriteTalk and !$iType) {
+ if ($this->Talk_DeleteFavouriteTalk($oFavouriteTalk)) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('favourite.notices.remove_success'),
+ $this->Lang_Get('common.attention'));
+ $this->Viewer_AssignAjax('bState', false);
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ }
+
+ /**
+ * Обработка получения последних комментов
+ * Используется в блоке "Прямой эфир"
+ *
+ */
+ protected function EventStreamComment()
+ {
+ if ($aComments = $this->Comment_GetCommentsOnline('topic', Config::Get('block.stream.row'))) {
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('comments', $aComments, true);
+ $sTextResult = $oViewer->Fetch("component@activity.recent-comments");
+ $this->Viewer_AssignAjax('sText', $sTextResult);
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('activity.block_recent.comments_empty'),
+ $this->Lang_Get('common.attention'));
+ return;
+ }
+ }
+
+ /**
+ * Обработка получения последних топиков
+ * Используется в блоке "Прямой эфир"
+ *
+ */
+ protected function EventStreamTopic()
+ {
+ if ($oTopics = $this->Topic_GetTopicsLast(Config::Get('block.stream.row'))) {
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('topics', $oTopics, true);
+ $sTextResult = $oViewer->Fetch("component@activity.recent-topics");
+ $this->Viewer_AssignAjax('sText', $sTextResult);
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('activity.block_recent.topics_empty'),
+ $this->Lang_Get('common.attention'));
+ return;
+ }
+ }
+
+ /**
+ * Обработка получения TOP блогов
+ * Используется в блоке "TOP блогов"
+ *
+ */
+ protected function EventBlogsTop()
+ {
+ /**
+ * Получаем список блогов и формируем ответ
+ */
+ if ($aResult = $this->Blog_GetBlogsRating(1, Config::Get('block.blogs.row'))) {
+ $aBlogs = $aResult['collection'];
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('aBlogs', $aBlogs);
+ $sTextResult = $oViewer->Fetch("component@blog.top");
+ $this->Viewer_AssignAjax('sText', $sTextResult);
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ }
+
+ /**
+ * Обработка получения своих блогов
+ * Используется в блоке "TOP блогов"
+ *
+ */
+ protected function EventBlogsSelf()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Получаем список блогов и формируем ответ
+ */
+ if ($aBlogs = $this->Blog_GetBlogsRatingSelf($this->oUserCurrent->getId(), Config::Get('block.blogs.row'))) {
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('aBlogs', $aBlogs);
+ $sTextResult = $oViewer->Fetch("component@blog.top");
+ $this->Viewer_AssignAjax('sText', $sTextResult);
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('blog.blocks.blogs.self_empty'), $this->Lang_Get('common.attention'));
+ return;
+ }
+ }
+
+ /**
+ * Обработка получения подключенных блогов
+ * Используется в блоке "TOP блогов"
+ *
+ */
+ protected function EventBlogsJoin()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Получаем список блогов и формируем ответ
+ */
+ if ($aBlogs = $this->Blog_GetBlogsRatingJoin($this->oUserCurrent->getId(), Config::Get('block.blogs.row'))) {
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('aBlogs', $aBlogs);
+ $sTextResult = $oViewer->Fetch("component@blog.top");
+ $this->Viewer_AssignAjax('sText', $sTextResult);
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('blog.blocks.blogs.joined_empty'), $this->Lang_Get('common.attention'));
+ return;
+ }
+ }
+
+ /**
+ * Загружает список блогов конкретной категории
+ */
+ protected function EventBlogsGetByCategory()
+ {
+ if (!($oCategory = $this->Category_GetCategoryById(getRequestStr('id')))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Список ID блогов по категории
+ */
+ $aBlogIds = $this->Blog_GetTargetIdsByCategory($oCategory, 1, 1000, true);
+ /**
+ * Формируем фильтр для получения списка блогов
+ */
+ $aFilter = array(
+ 'exclude_type' => 'personal',
+ 'id' => $aBlogIds ? $aBlogIds : array(0)
+ );
+ /**
+ * Получаем список блогов(все по фильтру)
+ */
+ $aResult = $this->Blog_GetBlogsByFilter($aFilter, array('blog_title' => 'asc'), 1, PHP_INT_MAX);
+ $aBlogs = $aResult['collection'];
+ /**
+ * Получаем список блогов и формируем ответ
+ */
+ if ($aBlogs) {
+ $aResult = array();
+ foreach ($aBlogs as $oBlog) {
+ $aResult[] = array(
+ 'id' => $oBlog->getId(),
+ 'title' => htmlspecialchars($oBlog->getTitle()),
+ 'type' => $oBlog->getType(),
+ 'rating' => $oBlog->getRating(),
+ 'url' => $oBlog->getUrl(),
+ 'url_full' => $oBlog->getUrlFull(),
+ );
+ }
+ $this->Viewer_AssignAjax('aBlogs', $aResult);
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('blog.blocks.navigator.empty'), $this->Lang_Get('common.attention'));
+ return;
+ }
+ }
+
+ /**
+ * Предпросмотр текста
+ *
+ */
+ protected function EventPreviewText()
+ {
+ $sText = getRequestStr('text', null, 'post');
+ $bSave = getRequest('save', null, 'post');
+ /**
+ * Экранировать или нет HTML теги
+ */
+ if ($bSave) {
+ $sTextResult = htmlspecialchars($sText);
+ } else {
+ $sTextResult = $this->Text_Parser($sText);
+ }
+ /**
+ * Передаем результат в ajax ответ
+ */
+ $this->Viewer_AssignAjax('sText', $sTextResult);
+ }
+
+ /**
+ * Автоподставновка тегов
+ *
+ */
+ protected function EventAutocompleterTag()
+ {
+ /**
+ * Первые буквы тега переданы?
+ */
+ if (!($sValue = getRequest('value', null, 'post')) or !is_string($sValue)) {
+ return;
+ }
+ $aItems = array();
+ /**
+ * Формируем список тегов
+ */
+ $aTags = $this->Topic_GetTopicTagsByLike($sValue, 10);
+ foreach ($aTags as $oTag) {
+ $aItems[] = $oTag->getText();
+ }
+ /**
+ * Передаем результат в ajax ответ
+ */
+ $this->Viewer_AssignAjax('aItems', $aItems);
+ }
+
+ /**
+ * Автоподставновка пользователей
+ *
+ */
+ protected function EventAutocompleterUser()
+ {
+ /**
+ * Первые буквы логина переданы?
+ */
+ if (!($sValue = getRequest('value', null, 'post')) or !is_string($sValue)) {
+ return;
+ }
+ $bReturnExtended = getRequest('extended') ?: false;
+ $aItems = array();
+ /**
+ * Формируем список пользователей
+ */
+ $aUsers = $this->User_GetUsersByLoginLike($sValue, 10);
+ foreach ($aUsers as $oUser) {
+ if ($bReturnExtended) {
+ $aItems[] = array(
+ 'value' => $oUser->getId(),
+ 'label' => $oUser->getDisplayName(),
+ );
+ } else {
+ $aItems[] = $oUser->getDisplayName();
+ }
+ }
+ /**
+ * Передаем результат в ajax ответ
+ */
+ $this->Viewer_AssignAjax('aItems', $aItems);
+ }
+
+ /**
+ * Удаление/восстановление комментария
+ *
+ */
+ protected function EventCommentDelete()
+ {
+ /**
+ * Есть права на удаление комментария?
+ */
+ if (!$this->ACL_CanDeleteComment($this->oUserCurrent)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.not_access'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Комментарий существует?
+ */
+ $idComment = getRequestStr('comment_id', null, 'post');
+ if (!($oComment = $this->Comment_GetCommentById($idComment))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Устанавливаем пометку о том, что комментарий удален
+ */
+ $oComment->setDelete(($oComment->getDelete() + 1) % 2);
+ $this->Hook_Run('comment_delete_before', array('oComment' => $oComment));
+ if (!$this->Comment_UpdateCommentStatus($oComment)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $this->Hook_Run('comment_delete_after', array('oComment' => $oComment));
+ /**
+ * Формируем текст ответа
+ */
+ if ($bState = (bool)$oComment->getDelete()) {
+ $sMsg = $this->Lang_Get('common.success.remove');
+ $sTextToggle = $this->Lang_Get('comments.comment.restore');
+ } else {
+ $sMsg = $this->Lang_Get('comments.notices.success_restore');
+ $sTextToggle = $this->Lang_Get('common.remove');
+ }
+ /**
+ * Обновление события в ленте активности
+ */
+ $this->Stream_write($oComment->getUserId(), 'add_comment', $oComment->getId(), !$oComment->getDelete());
+ /**
+ * Показываем сообщение и передаем переменные в ajax ответ
+ */
+ $this->Message_AddNoticeSingle($sMsg, $this->Lang_Get('common.attention'));
+ $this->Viewer_AssignAjax('state', $bState);
+ $this->Viewer_AssignAjax('toggle_text', $sTextToggle);
+ }
+
+ /**
+ * Загрузка данных комментария для редактировоания
+ *
+ */
+ protected function EventCommentLoad()
+ {
+ /**
+ * Комментарий существует?
+ */
+ $idComment = getRequestStr('comment_id', null, 'post');
+ if (!($oComment = $this->Comment_GetCommentById($idComment))) {
+ return $this->EventErrorDebug();
+ }
+ if (!$oComment->isAllowEdit()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.not_access'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $sText = $oComment->getTextSource() ? $oComment->getTextSource() : $oComment->getText();
+ $this->Viewer_AssignAjax('text', $sText);
+ }
+
+ /**
+ * Редактирование комментария
+ *
+ */
+ protected function EventCommentUpdate()
+ {
+ /**
+ * Комментарий существует?
+ */
+ $idComment = getRequestStr('reply', null, 'post');
+ if (!($oComment = $this->Comment_GetCommentById($idComment))) {
+ return $this->EventErrorDebug();
+ }
+ if (!$oComment->isAllowEdit()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.not_access'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+
+ $sText = getRequestStr('comment_text');
+ /**
+ * Проверяем текст комментария
+ */
+ if (!$this->Validate_Validate('string', $sText, array('min' => 2, 'max' => 10000, 'allowEmpty' => false))) {
+ $this->Message_AddErrorSingle($this->Lang_Get('topic.comments.notices.error_text'),
+ $this->Lang_Get('common.error.error'));
+ return;
+ }
+
+ $oComment->setText($this->Text_Parser($sText));
+ $oComment->setTextSource($sText);
+ $oComment->setDateEdit(date('Y-m-d H:i:s'));
+ $oComment->setCountEdit($oComment->getCountEdit() + 1);
+
+ if ($this->Comment_UpdateComment($oComment)) {
+ $oViewerLocal = $this->Viewer_GetLocalViewer();
+ $oViewerLocal->Assign('oUserCurrent', $this->oUserCurrent);
+ $oViewerLocal->Assign('oneComment', true, true);
+ $oViewerLocal->Assign('useEdit', true, true);
+
+ if ($oComment->getTargetType() == 'topic') {
+ $oViewerLocal->Assign('useFavourite', true, true);
+ $oViewerLocal->Assign('useVote', true, true);
+ }
+
+ $oViewerLocal->Assign('comment', $oComment, true);
+ $sHtml = $oViewerLocal->Fetch($this->Comment_GetTemplateCommentByTarget($oComment->getTargetId(),
+ $oComment->getTargetType()));
+ $this->Viewer_AssignAjax('html', $sHtml);
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ /**
+ * Проверка корректности профиля
+ */
+ protected function WallCheckUserProfile()
+ {
+ if (!($this->oUserProfile = $this->User_GetUserById((int)getRequestStr('user_id')))) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Добавление записи на стену
+ */
+ public function EventWallAdd()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$this->oUserCurrent) {
+ return $this->EventErrorDebug();
+ }
+
+ if (!$this->WallCheckUserProfile()) {
+ return $this->EventErrorDebug();
+ }
+
+ // Создаем запись
+ $oWall = Engine::GetEntity('Wall');
+
+ $oWall->_setValidateScenario('add');
+ $oWall->setWallUserId($this->oUserProfile->getId());
+ $oWall->setUserId($this->oUserCurrent->getId());
+ $oWall->setText(getRequestStr('text'));
+ $oWall->setPid(getRequestStr('pid'));
+
+ $this->Hook_Run('wall_add_validate_before', array('oWall' => $oWall));
+
+ if ($oWall->_Validate()) {
+ // Экранируем текст и добавляем запись в БД
+ $oWall->setText($this->Text_Parser($oWall->getText()));
+ $this->Hook_Run('wall_add_before', array('oWall' => $oWall));
+
+ if ($this->Wall_AddWall($oWall)) {
+ $this->Hook_Run('wall_add_after', array('oWall' => $oWall));
+
+ // Отправляем уведомления
+ if ($oWall->getWallUserId() != $oWall->getUserId()) {
+ $this->Wall_SendNotifyWallNew($oWall, $this->oUserCurrent);
+ }
+
+ if ($oWallParent = $oWall->GetPidWall() and $oWallParent->getUserId() != $oWall->getUserId()) {
+ $this->Wall_SendNotifyWallReply($oWallParent, $oWall, $this->oUserCurrent);
+ }
+
+ // Добавляем событие в ленту
+ $this->Stream_Write($oWall->getUserId(), 'add_wall', $oWall->getId());
+ } else {
+ $this->Message_AddError($this->Lang_Get('common.error.add'), $this->Lang_Get('common.error.error'));
+ }
+ } else {
+ $this->Message_AddError($oWall->_getValidateError(), $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ /**
+ * Удаление записи со стены
+ */
+ public function EventWallRemove()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$this->oUserCurrent) {
+ return $this->EventErrorDebug();
+ }
+
+ if (!$this->WallCheckUserProfile()) {
+ return $this->EventErrorDebug();
+ }
+
+ // Получаем запись
+ if (!($oWall = $this->Wall_GetWallById(getRequestStr('id')))) {
+ return $this->EventErrorDebug();
+ }
+
+ // Если разрешено удаление - удаляем
+ if ($oWall->isAllowDelete()) {
+ $this->Wall_DeleteWall($oWall);
+ return;
+ }
+
+ return $this->EventErrorDebug();
+ }
+
+ /**
+ * Ajax подгрузка сообщений стены
+ */
+ public function EventWallLoad()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$this->WallCheckUserProfile()) {
+ return $this->EventErrorDebug();
+ }
+
+ // Формируем фильтр для запроса к БД
+ $aFilter = array(
+ 'wall_user_id' => $this->oUserProfile->getId(),
+ 'pid' => null
+ );
+
+ if (is_numeric(getRequest('last_id'))) {
+ $aFilter['id_less'] = getRequest('last_id');
+ } else {
+ if (is_numeric(getRequest('first_id'))) {
+ $aFilter['id_more'] = getRequest('first_id');
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ // Получаем сообщения и формируем ответ
+ $aWall = $this->Wall_GetWall($aFilter, array('id' => 'desc'), 1, Config::Get('module.wall.per_page'));
+
+ $this->Viewer_Assign('posts', $aWall['collection'], true);
+ $this->Viewer_Assign('oUserCurrent',
+ $this->oUserCurrent); // хак, т.к. к этому моменту текущий юзер не загружен в шаблон
+
+ $this->Viewer_AssignAjax('html', $this->Viewer_Fetch('component@wall.posts'));
+ $this->Viewer_AssignAjax('count_loaded', count($aWall['collection']));
+
+ if (count($aWall['collection'])) {
+ $this->Viewer_AssignAjax('last_id', end($aWall['collection'])->getId());
+ }
+ }
+
+ /**
+ * Подгрузка комментариев
+ */
+ public function EventWallLoadComments()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$this->WallCheckUserProfile()) {
+ return $this->EventErrorDebug();
+ }
+
+ if (!($oWall = $this->Wall_GetWallById(getRequestStr('target_id'))) or $oWall->getPid()) {
+ return $this->EventErrorDebug();
+ }
+
+ // Формируем фильтр для запроса к БД
+ $aFilter = array(
+ 'wall_user_id' => $this->oUserProfile->getId(),
+ 'pid' => $oWall->getId()
+ );
+
+ if (is_numeric(getRequest('last_id'))) {
+ $aFilter['id_less'] = getRequest('last_id');
+ } else {
+ if (is_numeric(getRequest('first_id'))) {
+ $aFilter['id_more'] = getRequest('first_id');
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ // Получаем сообщения и формируем ответ
+ // Необходимо вернуть все ответы, но ставим "разумное" ограничение
+ $aWall = $this->Wall_GetWall($aFilter, array('id' => 'asc'), 1, 300);
+
+ // Передаем переменные
+ $this->Viewer_Assign('comments', $aWall['collection'], true);
+ $this->Viewer_Assign('oUserCurrent',
+ $this->oUserCurrent); // хак, т.к. к этому моменту текущий юзер не загружен в шаблон
+
+ $this->Viewer_AssignAjax('html', $this->Viewer_Fetch('component@wall.comments'));
+ $this->Viewer_AssignAjax('count_loaded', count($aWall['collection']));
+
+ if (count($aWall['collection'])) {
+ $this->Viewer_AssignAjax('last_id', end($aWall['collection'])->getId());
+ }
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionArchive.class.php b/application/classes/actions/ActionArchive.class.php
new file mode 100644
index 0000000..a6c5a25
--- /dev/null
+++ b/application/classes/actions/ActionArchive.class.php
@@ -0,0 +1,55 @@
+SetDefaultEvent('index');
+ }
+
+ /**
+ * Регистрируем евенты
+ *
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEvent('index', 'EventIndex');
+ $this->AddEvent('wiki', 'EventIfwiki');
+ }
+
+ /**
+ * Вывод списка архивов
+ *
+ */
+ protected function EventIndex()
+ {
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle('Архивы');
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Архивы вики
+ */
+ protected function EventIfwiki()
+ {
+ $this->Viewer_AddHtmlTitle('Архивы IFWiki');
+ $files = array_slice(scandir('./wikidump'), 2);
+ $this->SetTemplateAction('archive');
+ $this->Viewer_Assign('files', $files);
+ }
+}
diff --git a/application/classes/actions/ActionAuth.class.php b/application/classes/actions/ActionAuth.class.php
new file mode 100644
index 0000000..4e924fb
--- /dev/null
+++ b/application/classes/actions/ActionAuth.class.php
@@ -0,0 +1,624 @@
+
+ *
+ */
+
+/**
+ * Обрабатывает авторизацию/регистрацию
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionAuth extends Action
+{
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ /**
+ * Если включены инвайты то перенаправляем на страницу регистрации по инвайтам
+ */
+ if (!$this->User_IsAuthorization() and Config::Get('general.reg.invite') and in_array(Router::GetActionEvent(),
+ array('register', 'ajax-register')) and !$this->CheckInviteRegister()
+ ) {
+ return Router::Action('auth', 'invite');
+ }
+ /**
+ * Устанавливаем дефолтный евент
+ */
+ $this->SetDefaultEvent('login');
+ /**
+ * Отключаем отображение статистики выполнения
+ */
+ Router::SetIsShowStats(false);
+ }
+
+ /**
+ * Регистрируем евенты
+ *
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEvent('login', 'EventLogin');
+ $this->AddEvent('logout', 'EventLogout');
+ $this->AddEvent('password-reset', 'EventPasswordReset');
+ $this->AddEvent('register', 'EventRegister');
+ $this->AddEvent('register-confirm', 'EventRegisterConfirm');
+ $this->AddEvent('activate', 'EventActivate');
+ $this->AddEvent('reactivation', 'EventReactivation');
+ $this->AddEvent('invite', 'EventInvite');
+ $this->AddEventPreg('/^referral$/i', '/^[\w\-\_]{1,200}$/i', 'EventReferral');
+
+ $this->AddEvent('ajax-login', 'EventAjaxLogin');
+ $this->AddEvent('ajax-password-reset', 'EventAjaxPasswordReset');
+ $this->AddEvent('ajax-validate-fields', 'EventAjaxValidateFields');
+ $this->AddEvent('ajax-validate-login', 'EventAjaxValidateLogin');
+ $this->AddEvent('ajax-validate-email', 'EventAjaxValidateEmail');
+ $this->AddEvent('ajax-register', 'EventAjaxRegister');
+ $this->AddEvent('ajax-reactivation', 'EventAjaxReactivation');
+ }
+
+ /**
+ * Ajax авторизация
+ */
+ protected function EventAjaxLogin()
+ {
+ /**
+ * Устанвливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Логин и пароль являются строками?
+ */
+ if (!is_string(getRequest('login')) or !is_string(getRequest('password'))) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'));
+ return;
+ }
+ /**
+ * Проверяем есть ли такой юзер по логину
+ */
+ if ((func_check(getRequest('login'),
+ 'mail') and $oUser = $this->User_GetUserByMail(getRequest('login'))) or $oUser = $this->User_GetUserByLogin(getRequest('login'))
+ ) {
+ /**
+ * Выбираем сценарий валидации
+ */
+ $oUser->_setValidateScenario('signIn');
+ /**
+ * Заполняем поля (данные)
+ */
+ $oUser->setCaptcha(getRequestStr('captcha'));
+ /**
+ * Запускаем валидацию
+ */
+ if ($oUser->_Validate()) {
+ /**
+ * Сверяем хеши паролей и проверяем активен ли юзер
+ */
+ if ($this->User_VerifyAccessAuth($oUser) and $oUser->verifyPassword(getRequest('password'))) {
+ if (!$oUser->getActivate()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('auth.login.notices.error_not_activated',
+ array('reactivation_path' => Router::GetPath('auth/reactivation'))));
+ return;
+ }
+ $bRemember = getRequest('remember', false) ? true : false;
+ /**
+ * Убиваем каптчу
+ */
+ $this->Session_Drop('captcha_keystring_user_auth');
+ /**
+ * Авторизуем
+ */
+ $this->User_Authorization($oUser, $bRemember);
+ /**
+ * Определяем редирект
+ */
+ $sUrl = Config::Get('module.user.redirect_after_login');
+ if (getRequestStr('return-path')) {
+ $sUrl = getRequestStr('return-path');
+ }
+ $this->Viewer_AssignAjax('sUrlRedirect', $sUrl ? $sUrl : Router::GetPath('/'));
+ return;
+ }
+ } else {
+ /**
+ * Получаем ошибки
+ */
+ $this->Viewer_AssignAjax('errors', $oUser->_getValidateErrors());
+ $this->Message_AddErrorSingle(null);
+ return;
+ }
+ }
+ $this->Message_AddErrorSingle($this->Lang_Get('auth.login.notices.error_login'));
+ }
+
+ /**
+ * Обрабатываем процесс залогинивания
+ * По факту только отображение шаблона, дальше вступает в дело Ajax
+ *
+ */
+ protected function EventLogin()
+ {
+ /**
+ * Если уже авторизирован
+ */
+ if ($this->User_IsAuthorization()) {
+ Router::Location(Router::GetPath('/'));
+ }
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('auth.login.title'));
+ }
+
+ /**
+ * Обрабатываем процесс разлогинивания
+ *
+ */
+ protected function EventLogout()
+ {
+ $this->Security_ValidateSendForm();
+ if ($this->User_GetUserCurrent()) {
+ $this->User_Logout();
+ }
+ Router::LocationAction('/');
+ }
+
+ /**
+ * Ajax запрос на восстановление пароля
+ */
+ protected function EventAjaxPasswordReset()
+ {
+ /**
+ * Устанвливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Пользователь с таким емайлом существует?
+ */
+ if ((func_check(getRequestStr('mail'), 'mail') and $oUser = $this->User_GetUserByMail(getRequestStr('mail')))) {
+ /**
+ * Формируем и отправляем ссылку на смену пароля
+ */
+ $oReminder = Engine::GetEntity('User_Reminder');
+ $oReminder->setCode(func_generator(32));
+ $oReminder->setDateAdd(date("Y-m-d H:i:s"));
+ $oReminder->setDateExpire(date("Y-m-d H:i:s", time() + 60 * 60 * 24 * 7));
+ $oReminder->setDateUsed(null);
+ $oReminder->setIsUsed(0);
+ $oReminder->setUserId($oUser->getId());
+ if ($this->User_AddReminder($oReminder)) {
+ $this->Message_AddNotice($this->Lang_Get('auth.reset.notices.success_send_link'));
+ $this->User_SendNotifyReminderCode($oUser, $oReminder);
+ return;
+ }
+ }
+ $this->Message_AddError($this->Lang_Get('auth.notices.error_bad_email'), $this->Lang_Get('common.error.error'));
+ }
+
+ /**
+ * Обработка напоминания пароля, подтверждение смены пароля
+ *
+ */
+ protected function EventPasswordReset()
+ {
+ if ($this->User_IsAuthorization()) {
+ Router::LocationAction('/');
+ }
+ $this->SetTemplateAction('reset');
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('auth.reset.title'));
+ /**
+ * Проверка кода на восстановление пароля и генерация нового пароля
+ */
+ if (func_check($this->GetParam(0), 'md5')) {
+ /**
+ * Проверка кода подтверждения
+ */
+ if ($oReminder = $this->User_GetReminderByCode($this->GetParam(0))) {
+ if (!$oReminder->getIsUsed() and strtotime($oReminder->getDateExpire()) > time() and $oUser = $this->User_GetUserById($oReminder->getUserId())) {
+ $sNewPassword = func_generator(7);
+ $oUser->setPassword($this->User_MakeHashPassword($sNewPassword));
+ if ($this->User_Update($oUser)) {
+ $oReminder->setDateUsed(date("Y-m-d H:i:s"));
+ $oReminder->setIsUsed(1);
+ $this->User_UpdateReminder($oReminder);
+ $this->User_SendNotifyReminderPassword($oUser, $sNewPassword);
+ $this->SetTemplateAction('reset_confirm');
+ return;
+ }
+ }
+ }
+ $this->Message_AddErrorSingle($this->Lang_Get('auth.reset.alerts.error_bad_code'),
+ $this->Lang_Get('common.error.error'));
+ return Router::Action('error');
+ }
+ }
+
+ /**
+ * Ajax валидация форму регистрации
+ */
+ protected function EventAjaxValidateFields()
+ {
+ $this->ValidateFields(getRequest('fields'));
+ }
+
+ /**
+ * Ajax валидация логина
+ */
+ protected function EventAjaxValidateLogin()
+ {
+ $this->ValidateFields(array(array('field' => 'login', 'value' => getRequest('login'))));
+ }
+
+ /**
+ * Ajax валидация емэйла
+ */
+ protected function EventAjaxValidateEmail()
+ {
+ $this->ValidateFields(array(array('field' => 'mail', 'value' => getRequest('mail'))));
+ }
+
+ /**
+ * Ajax валидация форму регистрации
+ */
+ protected function ValidateFields($aFields)
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Создаем объект пользователя и устанавливаем сценарий валидации
+ */
+ $oUser = Engine::GetEntity('ModuleUser_EntityUser');
+ $oUser->_setValidateScenario('registration');
+ /**
+ * Пробегаем по переданным полям/значениям и валидируем их каждое в отдельности
+ */
+ if (is_array($aFields)) {
+ foreach ($aFields as $aField) {
+ if (isset($aField['field']) and isset($aField['value'])) {
+ $this->Hook_Run('registration_validate_field', array('aField' => &$aField, 'oUser' => $oUser));
+
+ $sField = $aField['field'];
+ $sValue = $aField['value'];
+ /**
+ * Список полей для валидации
+ */
+ switch ($sField) {
+ case 'login':
+ $oUser->setLogin($sValue);
+ break;
+ case 'mail':
+ $oUser->setMail($sValue);
+ break;
+ case 'captcha':
+ $oUser->setCaptcha($sValue);
+ break;
+ case 'password':
+ $oUser->setPassword($sValue);
+ break;
+ case 'password_confirm':
+ $oUser->setPasswordConfirm($sValue);
+ $oUser->setPassword(isset($aField['params']['password']) ? $aField['params']['password'] : null);
+ break;
+ default:
+ continue 2;
+ break;
+ }
+ /**
+ * Валидируем поле
+ */
+ $oUser->_Validate(array($sField), false);
+ }
+ }
+ }
+ /**
+ * Возникли ошибки?
+ */
+ if ($oUser->_hasValidateErrors()) {
+ /**
+ * Получаем ошибки
+ */
+ $this->Viewer_AssignAjax('errors', $oUser->_getValidateErrors());
+ }
+ }
+
+ /**
+ * Обработка Ajax регистрации
+ */
+ protected function EventAjaxRegister()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Создаем объект пользователя и устанавливаем сценарий валидации
+ */
+ $oUser = Engine::GetEntity('ModuleUser_EntityUser');
+ $oUser->_setValidateScenario('registration');
+ /**
+ * Заполняем поля (данные)
+ */
+ $oUser->setLogin(getRequestStr('login'));
+ $oUser->setMail(getRequestStr('mail'));
+ $oUser->setPassword(getRequestStr('password'));
+ $oUser->setPasswordConfirm(getRequestStr('password_confirm'));
+ $oUser->setCaptcha(getRequestStr('captcha'));
+ $oUser->setDateRegister(date("Y-m-d H:i:s"));
+ $oUser->setIpRegister(func_getIp());
+ /**
+ * Если используется активация, то генерим код активации
+ */
+ if (Config::Get('general.reg.activation')) {
+ $oUser->setActivate(0);
+ $oUser->setActivateKey(md5(func_generator() . time()));
+ } else {
+ $oUser->setActivate(1);
+ $oUser->setActivateKey(null);
+ }
+ $this->Hook_Run('registration_validate_before', array('oUser' => $oUser));
+ /**
+ * Запускаем валидацию
+ */
+ if ($oUser->_Validate()) {
+ $this->Hook_Run('registration_validate_after', array('oUser' => $oUser));
+ $oUser->setPassword($this->User_MakeHashPassword($oUser->getPassword()));
+ if ($this->User_Add($oUser)) {
+ $this->Hook_Run('registration_after', array('oUser' => $oUser));
+ /**
+ * Убиваем каптчу
+ */
+ $this->Session_Drop('captcha_keystring_user_signup');
+ /**
+ * Подписываем пользователя на дефолтные события в ленте активности
+ */
+ $this->Stream_switchUserEventDefaultTypes($oUser->getId());
+ /**
+ * Если юзер зарегистрировался по приглашению то обновляем инвайт
+ */
+ if ($sCode = $this->GetInviteRegister()) {
+ $this->Invite_UseCode($sCode, $oUser);
+ }
+ /**
+ * Если стоит регистрация с активацией то проводим её
+ */
+ if (Config::Get('general.reg.activation')) {
+ /**
+ * Отправляем на мыло письмо о подтверждении регистрации
+ */
+ $this->User_SendNotifyRegistrationActivate($oUser, getRequestStr('password'));
+ $this->Viewer_AssignAjax('sUrlRedirect', Router::GetPath('auth/register-confirm'));
+ } else {
+ $this->User_SendNotifyRegistration($oUser, getRequestStr('password'));
+ $oUser = $this->User_GetUserById($oUser->getId());
+ /**
+ * Сразу авторизуем
+ */
+ $this->User_Authorization($oUser, false);
+ $this->DropInviteRegister();
+ /**
+ * Определяем URL для редиректа после авторизации
+ */
+ $sUrl = Config::Get('module.user.redirect_after_registration');
+ if (getRequestStr('return-path')) {
+ $sUrl = getRequestStr('return-path');
+ }
+ $this->Viewer_AssignAjax('sUrlRedirect', $sUrl ? $sUrl : Router::GetPath('/'));
+ $this->Message_AddNoticeSingle($this->Lang_Get('auth.registration.notices.success'));
+ }
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'));
+ return;
+ }
+ } else {
+ /**
+ * Получаем ошибки
+ */
+ $this->Viewer_AssignAjax('errors', $oUser->_getValidateErrors());
+ $this->Message_AddErrorSingle(null);
+ }
+ }
+
+ /**
+ * Показывает страничку регистрации
+ * Просто вывод шаблона
+ */
+ protected function EventRegister()
+ {
+ if ($this->User_IsAuthorization()) {
+ Router::LocationAction('/');
+ }
+ }
+
+ /**
+ * Обработка реферального кода
+ */
+ protected function EventReferral()
+ {
+ if ($this->User_IsAuthorization()) {
+ Router::LocationAction('/');
+ }
+ /**
+ * Смотрим наличие реферального кода и сохраняем его в сессию
+ */
+ if ($sCode = $this->GetParam(0)) {
+ if ($iType = $this->Invite_GetInviteTypeByCode($sCode)) {
+ if (!Config::Get('general.reg.invite') or $iType != ModuleInvite::INVITE_TYPE_REFERRAL) {
+ $this->Session_Set('invite_code', $sCode);
+ }
+ }
+ }
+ Router::LocationAction('auth/register');
+ }
+
+ /**
+ * Обрабатывает активацию аккаунта
+ */
+ protected function EventActivate()
+ {
+ if ($this->User_IsAuthorization()) {
+ Router::LocationAction('/');
+ }
+ $bError = false;
+ /**
+ * Проверяет передан ли код активации
+ */
+ $sActivateKey = $this->GetParam(0);
+ if (!func_check($sActivateKey, 'md5')) {
+ $bError = true;
+ }
+ /**
+ * Проверяет верный ли код активации
+ */
+ if (!($oUser = $this->User_GetUserByActivateKey($sActivateKey))) {
+ $bError = true;
+ }
+ /**
+ *
+ */
+ if ($oUser and $oUser->getActivate()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('auth.registration.notices.error_reactivate'),
+ $this->Lang_Get('common.error.error'));
+ return Router::Action('error');
+ }
+ /**
+ * Если что то не то
+ */
+ if ($bError) {
+ $this->Message_AddErrorSingle($this->Lang_Get('auth.registration.notices.error_code'),
+ $this->Lang_Get('common.error.error'));
+ return Router::Action('error');
+ }
+ /**
+ * Активируем
+ */
+ $oUser->setActivate(1);
+ $oUser->setDateActivate(date("Y-m-d H:i:s"));
+ /**
+ * Сохраняем юзера
+ */
+ if ($this->User_Update($oUser)) {
+ $this->User_Authorization($oUser, false);
+ $this->DropInviteRegister();
+ return;
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'));
+ return Router::Action('error');
+ }
+ }
+
+ /**
+ * Повторный запрос активации
+ */
+ protected function EventReactivation()
+ {
+ if ($this->User_IsAuthorization()) {
+ Router::LocationAction('/');
+ }
+
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('auth.reactivation.title'));
+ }
+
+ /**
+ * Ajax повторной активации
+ */
+ protected function EventAjaxReactivation()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ if ((func_check(getRequestStr('mail'), 'mail') and $oUser = $this->User_GetUserByMail(getRequestStr('mail')))) {
+ if ($oUser->getActivate()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('auth.registration.notices.error_reactivate'));
+ return;
+ } else {
+ $oUser->setActivateKey(md5(func_generator() . time()));
+ if ($this->User_Update($oUser)) {
+ $this->Message_AddNotice($this->Lang_Get('auth.reactivation.notices.success'));
+ $this->User_SendNotifyReactivationCode($oUser);
+ return;
+ }
+ }
+ }
+
+ $this->Message_AddErrorSingle($this->Lang_Get('auth.notices.error_bad_email'));
+ }
+
+ /**
+ * Просто выводит шаблон для подтверждения регистрации
+ *
+ */
+ protected function EventRegisterConfirm()
+ {
+ $this->SetTemplateAction('confirm');
+ }
+
+ protected function EventInvite()
+ {
+ if ($this->User_IsAuthorization()) {
+ Router::LocationAction('/');
+ }
+ $this->SetTemplateAction('invite');
+
+ if (isPost()) {
+ /**
+ * Проверяем валидность кода
+ */
+ if ($this->Invite_CheckCode(getRequestStr('invite_code'), ModuleInvite::INVITE_TYPE_CODE)) {
+ Router::Location($this->Invite_GetReferralLink(null, getRequestStr('invite_code')));
+ } else {
+ $this->Message_AddError($this->Lang_Get('auth.invite.alerts.error_code'), $this->Lang_Get('common.error.error'));
+ }
+ }
+ }
+
+ /**
+ * Пытается ли юзер зарегистрироваться с помощью кода приглашения
+ *
+ * @return bool
+ */
+ protected function CheckInviteRegister()
+ {
+ if ($this->GetInviteRegister()) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Вожвращает код приглашения из сессии
+ *
+ * @return string
+ */
+ protected function GetInviteRegister()
+ {
+ return $this->Session_Get('invite_code');
+ }
+
+ /**
+ * Удаляет код приглашения из сессии
+ */
+ protected function DropInviteRegister()
+ {
+ $this->Session_Drop('invite_code');
+ }
+}
diff --git a/application/classes/actions/ActionBlog.class.php b/application/classes/actions/ActionBlog.class.php
new file mode 100644
index 0000000..ee8359b
--- /dev/null
+++ b/application/classes/actions/ActionBlog.class.php
@@ -0,0 +1,2246 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки URL'ов вида /blog/
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionBlog extends Action
+{
+ /**
+ * Главное меню
+ *
+ * @var string
+ */
+ protected $sMenuHeadItemSelect = 'blog';
+ /**
+ * Какое меню активно
+ *
+ * @var string
+ */
+ protected $sMenuItemSelect = 'blog';
+ /**
+ * Какое подменю активно
+ *
+ * @var string
+ */
+ protected $sMenuSubItemSelect = 'good';
+ /**
+ * УРЛ блога который подставляется в меню
+ *
+ * @var string
+ */
+ protected $sMenuSubBlogUrl;
+ /**
+ * Текущий пользователь
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+ /**
+ * Число новых топиков в коллективных блогах
+ *
+ * @var int
+ */
+ protected $iCountTopicsCollectiveNew = 0;
+ /**
+ * Число новых топиков в персональных блогах
+ *
+ * @var int
+ */
+ protected $iCountTopicsPersonalNew = 0;
+ /**
+ * Число новых топиков в конкретном блоге
+ *
+ * @var int
+ */
+ protected $iCountTopicsBlogNew = 0;
+ /**
+ * Общее число новых топиков
+ *
+ * @var int
+ */
+ protected $iCountTopicsNew = 0;
+ /**
+ * Число новых топиков в выбранном разделе
+ *
+ * @var int
+ */
+ protected $iCountTopicsSubNew = 0;
+ /**
+ * URL-префикс для навигации по топикам
+ *
+ * @var string
+ */
+ protected $sNavTopicsSubUrl = '';
+ /**
+ * Список URL с котрыми запрещено создавать блог
+ *
+ * @var array
+ */
+ protected $aBadBlogUrl = array(
+ 'new',
+ 'good',
+ 'bad',
+ 'discussed',
+ 'top',
+ 'edit',
+ 'add',
+ 'admin',
+ 'delete',
+ 'invite',
+ 'ajaxaddcomment',
+ 'ajaxaddbloginvite',
+ 'ajaxresponsecomment',
+ 'ajaxrebloginvite',
+ 'ajaxbloginfo',
+ 'ajaxblogjoin',
+ 'ajax',
+ '_show_topic_url',
+ );
+
+ /**
+ * Инизиализация экшена
+ *
+ */
+ public function Init()
+ {
+ /**
+ * Устанавливаем евент по дефолту, т.е. будем показывать хорошие топики из коллективных блогов
+ */
+ $this->SetDefaultEvent('good');
+ $this->sMenuSubBlogUrl = Router::GetPath('blog');
+ /**
+ * Достаём текущего пользователя
+ */
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ /**
+ * Подсчитываем новые топики
+ */
+ $this->iCountTopicsCollectiveNew = $this->Topic_GetCountTopicsCollectiveNew();
+ $this->iCountTopicsPersonalNew = $this->Topic_GetCountTopicsPersonalNew();
+ $this->iCountTopicsBlogNew = $this->iCountTopicsCollectiveNew;
+ $this->iCountTopicsNew = $this->iCountTopicsCollectiveNew + $this->iCountTopicsPersonalNew;
+ $this->iCountTopicsSubNew = $this->iCountTopicsCollectiveNew;
+ $this->sNavTopicsSubUrl = Router::GetPath('blog');
+ /**
+ * Загружаем в шаблон JS текстовки
+ */
+ $this->Lang_AddLangJs(array(
+ 'blog.join.join',
+ 'blog.join.leave'
+ ));
+ }
+
+ /**
+ * Регистрируем евенты, по сути определяем УРЛы вида /blog/.../
+ *
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEventPreg('/^good$/i', '/^(page([1-9]\d{0,5}))?$/i', array('EventTopics', 'topics'));
+ $this->AddEvent('good', array('EventTopics', 'topics'));
+ $this->AddEventPreg('/^bad$/i', '/^(page([1-9]\d{0,5}))?$/i', array('EventTopics', 'topics'));
+ $this->AddEventPreg('/^new$/i', '/^(page([1-9]\d{0,5}))?$/i', array('EventTopics', 'topics'));
+ $this->AddEventPreg('/^newall$/i', '/^(page([1-9]\d{0,5}))?$/i', array('EventTopics', 'topics'));
+ $this->AddEventPreg('/^discussed$/i', '/^(page([1-9]\d{0,5}))?$/i', array('EventTopics', 'topics'));
+ $this->AddEventPreg('/^top$/i', '/^(page([1-9]\d{0,5}))?$/i', array('EventTopics', 'topics'));
+
+ $this->AddEvent('add', 'EventAddBlog');
+ $this->AddEvent('edit', 'EventEditBlog');
+ $this->AddEvent('delete', 'EventDeleteBlog');
+ $this->AddEventPreg('/^admin$/i', '/^\d+$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventAdminBlog');
+ $this->AddEvent('invite', 'EventInviteBlog');
+
+ $this->AddEvent('ajaxaddcomment', 'AjaxAddComment');
+ $this->AddEvent('ajaxresponsecomment', 'AjaxResponseComment');
+ $this->AddEvent('ajaxaddbloginvite', 'AjaxAddBlogInvite');
+ $this->AddEvent('ajaxrebloginvite', 'AjaxReBlogInvite');
+ $this->AddEvent('ajaxremovebloginvite', 'AjaxRemoveBlogInvite');
+ $this->AddEvent('ajaxbloginfo', 'AjaxBlogInfo');
+ $this->AddEvent('ajaxblogjoin', 'AjaxBlogJoin');
+ $this->AddEventPreg('/^ajax$/i', '/^upload-avatar$/i', '/^$/i', 'EventAjaxUploadAvatar');
+ $this->AddEventPreg('/^ajax$/i', '/^crop-avatar$/i', '/^$/i', 'EventAjaxCropAvatar');
+ $this->AddEventPreg('/^ajax$/i', '/^crop-cancel-avatar$/i', '/^$/i', 'EventAjaxCropCancelAvatar');
+ $this->AddEventPreg('/^ajax$/i', '/^remove-avatar$/i', '/^$/i', 'EventAjaxRemoveAvatar');
+ $this->AddEventPreg('/^ajax$/i', '/^modal-crop-avatar$/i', '/^$/i', 'EventAjaxModalCropAvatar');
+
+ $this->AddEventPreg('/^_show_topic_url$/i', '/^$/i', 'EventInternalShowTopicByUrl');
+ $this->AddEventPreg('/^(\d+)\.html$/i', '/^$/i', array('EventShowTopic', 'topic'));
+
+ $this->AddEventPreg('/^[\w\-\_]+$/i', '/^(page([1-9]\d{0,5}))?$/i', array('EventShowBlog', 'blog'));
+ $this->AddEventPreg('/^[\w\-\_]+$/i', '/^bad$/i', '/^(page([1-9]\d{0,5}))?$/i', array('EventShowBlog', 'blog'));
+ $this->AddEventPreg('/^[\w\-\_]+$/i', '/^new$/i', '/^(page([1-9]\d{0,5}))?$/i', array('EventShowBlog', 'blog'));
+ $this->AddEventPreg('/^[\w\-\_]+$/i', '/^newall$/i', '/^(page([1-9]\d{0,5}))?$/i',
+ array('EventShowBlog', 'blog'));
+ $this->AddEventPreg('/^[\w\-\_]+$/i', '/^discussed$/i', '/^(page([1-9]\d{0,5}))?$/i',
+ array('EventShowBlog', 'blog'));
+ $this->AddEventPreg('/^[\w\-\_]+$/i', '/^top$/i', '/^(page([1-9]\d{0,5}))?$/i', array('EventShowBlog', 'blog'));
+
+ $this->AddEventPreg('/^[\w\-\_]+$/i', '/^users$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventShowUsers');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Добавление нового блога
+ *
+ */
+ protected function EventAddBlog()
+ {
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('blog.add.title'));
+ /**
+ * Меню
+ */
+ $this->sMenuSubItemSelect = 'add';
+ $this->sMenuItemSelect = 'blog';
+ /**
+ * Проверяем авторизован ли пользователь
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.not_access'), $this->Lang_Get('common.error.error'));
+ return Router::Action('error');
+ }
+ /**
+ * Проверяем права на создание блога
+ */
+ if (!$this->ACL_CanCreateBlog($this->oUserCurrent)) {
+ $this->Message_AddErrorSingle($this->Rbac_GetMsgLast());
+ return Router::Action('error');
+ }
+ $this->Hook_Run('blog_add_show');
+ /**
+ * Прогружаем категории блогов
+ */
+ $aCategories = $this->Blog_GetCategoriesTree();
+ $this->Viewer_Assign('blogCategories', $aCategories);
+ /**
+ * Создаем объект блога
+ */
+ $oBlog = Engine::GetEntity('Blog');
+ /**
+ * Запускаем проверку корректности ввода полей при добалении блога.
+ * Дополнительно проверяем, что был отправлен POST запрос.
+ */
+ if (!$this->checkBlogFields($oBlog)) {
+ return false;
+ }
+ /**
+ * Если всё ок то пытаемся создать блог
+ */
+ $oBlog->setOwnerId($this->oUserCurrent->getId());
+ $oBlog->setTitle(strip_tags(getRequestStr('blog_title')));
+ /**
+ * Парсим текст на предмет разных ХТМЛ тегов
+ */
+ $sText = $this->Text_Parser(getRequestStr('blog_description'));
+ $oBlog->setDescription($sText);
+ $oBlog->setType(getRequestStr('blog_type'));
+ $oBlog->setDateAdd(date("Y-m-d H:i:s"));
+ $oBlog->setLimitRatingTopic(getRequestStr('blog_limit_rating_topic'));
+ $oBlog->setUrl(getRequestStr('blog_url'));
+ if ($this->oUserCurrent->isAdministrator()) {
+ $oBlog->setSkipIndex(getRequest('blog_skip_index') ? 1 : 0);
+ } else {
+ $oBlog->setSkipIndex(0);
+ }
+ $oBlog->setAvatar(null);
+ /**
+ * Создаём блог
+ */
+ $this->Hook_Run('blog_add_before', array('oBlog' => $oBlog));
+ if ($this->Blog_AddBlog($oBlog)) {
+ $this->Hook_Run('blog_add_after', array('oBlog' => $oBlog));
+ /**
+ * Сохраняем категории
+ */
+ if (Config::Get('module.blog.category_allow') and ($this->oUserCurrent->isAdministrator() or !Config::Get('module.blog.category_only_admin'))) {
+ $oBlog->category->CallbackAfterSave();
+ }
+ /**
+ * Получаем блог, это для получение полного пути блога, если он в будущем будет зависит от других сущностей(компании, юзер и т.п.)
+ */
+ $oBlog = $this->Blog_GetBlogById($oBlog->getId());
+ /**
+ * Фиксируем ID у media файлов
+ */
+ $this->Media_ReplaceTargetTmpById('blog', $oBlog->getId());
+ /**
+ * Добавляем событие в ленту
+ */
+ $this->Stream_write($oBlog->getOwnerId(), 'add_blog', $oBlog->getId());
+ Router::Location($oBlog->getUrlFull());
+ } else {
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ /**
+ * Редактирование блога
+ *
+ */
+ protected function EventEditBlog()
+ {
+ /**
+ * Меню
+ */
+ $this->sMenuSubItemSelect = '';
+ $this->sMenuItemSelect = 'profile';
+ /**
+ * Проверяем передан ли в УРЛе номер блога
+ */
+ $sBlogId = $this->GetParam(0);
+ if (!$oBlog = $this->Blog_GetBlogById($sBlogId)) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Проверяем тип блога
+ */
+ if ($oBlog->getType() == 'personal') {
+ return parent::EventNotFound();
+ }
+ /**
+ * Проверям авторизован ли пользователь
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.not_access'), $this->Lang_Get('common.error.error'));
+ return Router::Action('error');
+ }
+ /**
+ * Проверка на право редактировать блог
+ */
+ if (!$this->ACL_IsAllowEditBlog($oBlog, $this->oUserCurrent)) {
+ return parent::EventNotFound();
+ }
+
+ $this->Hook_Run('blog_edit_show', array('oBlog' => $oBlog));
+ /**
+ * Прогружаем категории блогов
+ */
+ $aCategories = $this->Blog_GetCategoriesTree();
+ $this->Viewer_Assign('blogCategories', $aCategories);
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($oBlog->getTitle());
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('common.edit'));
+
+ $this->Viewer_Assign('blogEdit', $oBlog);
+ /**
+ * Устанавливаем шалон для вывода
+ */
+ $this->SetTemplateAction('add');
+ /**
+ * Если нажали кнопку "Сохранить"
+ */
+ if (isPost()) {
+ /**
+ * Запускаем проверку корректности ввода полей при редактировании блога
+ */
+ if (!$this->checkBlogFields($oBlog)) {
+ return false;
+ }
+ $oBlog->setTitle(strip_tags(getRequestStr('blog_title')));
+ /**
+ * Парсим описание блога на предмет ХТМЛ тегов
+ */
+ $sText = $this->Text_Parser(getRequestStr('blog_description'));
+ $oBlog->setDescription($sText);
+ /**
+ * Сбрасываем кеш, если поменяли тип блога
+ * Нужна доработка, т.к. в этом блоге могут быть топики других юзеров
+ */
+ if ($oBlog->getType() != getRequestStr('blog_type')) {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("topic_update_user_{$oBlog->getOwnerId()}"));
+ }
+ $oBlog->setType(getRequestStr('blog_type'));
+ $oBlog->setLimitRatingTopic(getRequestStr('blog_limit_rating_topic'));
+ if ($this->oUserCurrent->isAdministrator()) {
+ $oBlog->setUrl(getRequestStr('blog_url')); // разрешаем смену URL блога только админу
+ $oBlog->setSkipIndex(getRequest('blog_skip_index') ? 1 : 0);
+ }
+ /**
+ * Обновляем блог
+ */
+ $this->Hook_Run('blog_edit_before', array('oBlog' => $oBlog));
+ if ($this->Blog_UpdateBlog($oBlog)) {
+ $this->Hook_Run('blog_edit_after', array('oBlog' => $oBlog));
+ /**
+ * Сохраняем категории
+ */
+ if (Config::Get('module.blog.category_allow') and ($this->oUserCurrent->isAdministrator() or !Config::Get('module.blog.category_only_admin'))) {
+ $oBlog->category->CallbackAfterSave();
+ }
+
+ Router::Location($oBlog->getUrlFull());
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ return Router::Action('error');
+ }
+ } else {
+ /**
+ * Загружаем данные в форму редактирования блога
+ */
+ $_REQUEST['blog_title'] = $oBlog->getTitle();
+ $_REQUEST['blog_url'] = $oBlog->getUrl();
+ $_REQUEST['blog_skip_index'] = $oBlog->getSkipIndex();
+ $_REQUEST['blog_type'] = $oBlog->getType();
+ $_REQUEST['blog_description'] = $oBlog->getDescription();
+ $_REQUEST['blog_limit_rating_topic'] = $oBlog->getLimitRatingTopic();
+ $_REQUEST['blog_id'] = $oBlog->getId();
+ }
+ }
+
+ /**
+ * Управление пользователями блога
+ *
+ */
+ protected function EventAdminBlog()
+ {
+ /**
+ * Меню
+ */
+ $this->sMenuItemSelect = 'admin';
+ $this->sMenuSubItemSelect = '';
+ /**
+ * Проверяем передан ли в УРЛе номер блога
+ */
+ $sBlogId = $this->GetParam(0);
+ if (!$oBlog = $this->Blog_GetBlogById($sBlogId)) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Проверям авторизован ли пользователь
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.not_access'), $this->Lang_Get('common.error.error'));
+ return Router::Action('error');
+ }
+ /**
+ * Проверка на право управлением пользователями блога
+ */
+ if (!$this->ACL_IsAllowAdminBlog($oBlog, $this->oUserCurrent)) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Обрабатываем сохранение формы
+ */
+ if (isPost()) {
+ $this->Security_ValidateSendForm();
+
+ $aUserRank = getRequest('user_rank', array());
+ if (!is_array($aUserRank)) {
+ $aUserRank = array();
+ }
+ foreach ($aUserRank as $sUserId => $sRank) {
+ $sRank = (string)$sRank;
+ if (!($oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $sUserId))) {
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ break;
+ }
+ /**
+ * Увеличиваем число читателей блога
+ */
+ if (in_array($sRank, array(
+ 'administrator',
+ 'moderator',
+ 'reader'
+ )) and $oBlogUser->getUserRole() == ModuleBlog::BLOG_USER_ROLE_BAN
+ ) {
+ $oBlog->setCountUser($oBlog->getCountUser() + 1);
+ }
+
+ switch ($sRank) {
+ case 'administrator':
+ $oBlogUser->setUserRole(ModuleBlog::BLOG_USER_ROLE_ADMINISTRATOR);
+ break;
+ case 'moderator':
+ $oBlogUser->setUserRole(ModuleBlog::BLOG_USER_ROLE_MODERATOR);
+ break;
+ case 'reader':
+ $oBlogUser->setUserRole(ModuleBlog::BLOG_USER_ROLE_USER);
+ break;
+ case 'ban':
+ if ($oBlogUser->getUserRole() != ModuleBlog::BLOG_USER_ROLE_BAN) {
+ $oBlog->setCountUser($oBlog->getCountUser() - 1);
+ }
+ $oBlogUser->setUserRole(ModuleBlog::BLOG_USER_ROLE_BAN);
+ break;
+ default:
+ $oBlogUser->setUserRole(ModuleBlog::BLOG_USER_ROLE_GUEST);
+ }
+ $this->Blog_UpdateRelationBlogUser($oBlogUser);
+ $this->Message_AddNoticeSingle($this->Lang_Get('blog.admin.alerts.submit_success'));
+ }
+ $this->Blog_UpdateBlog($oBlog);
+ }
+ /**
+ * Текущая страница
+ */
+ $iPage = $this->GetParamEventMatch(1, 2) ? $this->GetParamEventMatch(1, 2) : 1;
+ /**
+ * Получаем список подписчиков блога
+ */
+ $aResult = $this->Blog_GetBlogUsersByBlogId(
+ $oBlog->getId(),
+ array(
+ ModuleBlog::BLOG_USER_ROLE_BAN,
+ ModuleBlog::BLOG_USER_ROLE_USER,
+ ModuleBlog::BLOG_USER_ROLE_MODERATOR,
+ ModuleBlog::BLOG_USER_ROLE_ADMINISTRATOR
+ ), $iPage, Config::Get('module.blog.users_per_page')
+ );
+ $aBlogUsers = $aResult['collection'];
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.blog.users_per_page'),
+ Config::Get('pagination.pages.count'), Router::GetPath('blog') . "admin/{$oBlog->getId()}");
+ $this->Viewer_Assign('pagination', $aPaging);
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($oBlog->getTitle());
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('blog.admin.title'));
+
+ $this->Viewer_Assign('blogEdit', $oBlog);
+ $this->Viewer_Assign('blogUsers', $aBlogUsers);
+ /**
+ * Устанавливаем шалон для вывода
+ */
+ $this->SetTemplateAction('admin');
+ /**
+ * Если блог закрытый, получаем приглашенных
+ * и добавляем блок-форму для приглашения
+ */
+ if ($oBlog->getType() == 'close') {
+ $aBlogUsersInvited = $this->Blog_GetBlogUsersByBlogId($oBlog->getId(), ModuleBlog::BLOG_USER_ROLE_INVITE,
+ null);
+ $this->Viewer_Assign('blogUsersInvited', $aBlogUsersInvited['collection']);
+ $this->Viewer_AddBlock('right', 'component@blog.block.invite');
+ }
+ }
+
+ /**
+ * Проверка полей блога
+ *
+ * @param ModuleBlog_EntityBlog|null $oBlog
+ * @return bool
+ */
+ protected function checkBlogFields($oBlog = null)
+ {
+ /**
+ * Проверяем только если была отправлена форма с данными (методом POST)
+ */
+ if (!isPost()) {
+ $_REQUEST['blog_limit_rating_topic'] = 0;
+ return false;
+ }
+ $this->Security_ValidateSendForm();
+
+ $bOk = true;
+ /**
+ * Проверяем есть ли название блога
+ */
+ if (!func_check(getRequestStr('blog_title'), 'text', 2, 200)) {
+ $this->Message_AddError($this->Lang_Get('blog.add.fields.title.error'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ } else {
+ /**
+ * Проверяем есть ли уже блог с таким названием
+ */
+ if ($oBlogExists = $this->Blog_GetBlogByTitle(getRequestStr('blog_title'))) {
+ if (!$oBlog or $oBlog->getId() != $oBlogExists->getId()) {
+ $this->Message_AddError($this->Lang_Get('blog.add.fields.title.error_unique'),
+ $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ }
+ }
+
+ /**
+ * Проверяем есть ли URL блога, с заменой всех пробельных символов на "_"
+ */
+ if (!$oBlog or !$oBlog->getId() or $this->oUserCurrent->isAdministrator()) {
+ $blogUrl = preg_replace("/\s+/", '_', getRequestStr('blog_url'));
+ $_REQUEST['blog_url'] = $blogUrl;
+ if (!func_check(getRequestStr('blog_url'), 'login', 2, 50)) {
+ $this->Message_AddError($this->Lang_Get('blog.add.fields.url.error'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ }
+ /**
+ * Проверяем на счет плохих УРЛов
+ */
+ if (in_array(getRequestStr('blog_url'), $this->aBadBlogUrl)) {
+ $this->Message_AddError($this->Lang_Get('blog.add.fields.url.error_badword') . ' ' . join(',',
+ $this->aBadBlogUrl), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ /**
+ * Проверяем есть ли уже блог с таким URL
+ */
+ if ($oBlogExists = $this->Blog_GetBlogByUrl(getRequestStr('blog_url'))) {
+ if (!$oBlog or $oBlog->getId() != $oBlogExists->getId()) {
+ $this->Message_AddError($this->Lang_Get('blog.add.fields.url.error_unique'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ }
+ /**
+ * Проверяем есть ли описание блога
+ */
+ if (!func_check(getRequestStr('blog_description'), 'text', 10, 3000)) {
+ $this->Message_AddError($this->Lang_Get('blog.add.fields.description.error'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ /**
+ * Проверяем доступные типы блога для создания
+ */
+ if (!$this->Blog_IsAllowBlogType(getRequestStr('blog_type'))) {
+ $this->Message_AddError($this->Lang_Get('blog.add.fields.type.error'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ /**
+ * Преобразуем ограничение по рейтингу в число
+ */
+ if (!func_check(getRequestStr('blog_limit_rating_topic'), 'float')) {
+ $this->Message_AddError($this->Lang_Get('blog.add.fields.rating.error'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ /**
+ * Проверяем категорию блога
+ */
+ if (Config::Get('module.blog.category_allow')) {
+ if (true !== ($mRes = $oBlog->category->ValidateCategoriesCheck(getRequest('category')))) {
+ $this->Message_AddError($mRes, $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ }
+
+ /**
+ * Выполнение хуков
+ */
+ $this->Hook_Run('check_blog_fields', array('bOk' => &$bOk));
+ return $bOk;
+ }
+
+ /**
+ * Показ всех топиков
+ *
+ */
+ protected function EventTopics()
+ {
+ $sPeriod = 1; // по дефолту 1 день
+ if (in_array(getRequestStr('period'), array(1, 7, 30, 'all'))) {
+ $sPeriod = getRequestStr('period');
+ }
+ $sShowType = $this->sCurrentEvent;
+ if (!in_array($sShowType, array('discussed', 'top'))) {
+ $sPeriod = 'all';
+ }
+ /**
+ * Меню
+ */
+ $this->sMenuSubItemSelect = $sShowType == 'newall' ? 'new' : $sShowType;
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(0, 2) ? $this->GetParamEventMatch(0, 2) : 1;
+ if ($iPage == 1 and !getRequest('period')) {
+ $this->Viewer_SetHtmlCanonical(Router::GetPath('blog') . $sShowType . '/');
+ }
+ /**
+ * Получаем список топиков
+ */
+ $aResult = $this->Topic_GetTopicsCollective($iPage, Config::Get('module.topic.per_page'), $sShowType,
+ $sPeriod == 'all' ? null : $sPeriod * 60 * 60 * 24);
+ /**
+ * Если нет топиков за 1 день, то показываем за неделю (7)
+ */
+ if (in_array($sShowType,
+ array('discussed', 'top')) and !$aResult['count'] and $iPage == 1 and !getRequest('period')
+ ) {
+ $sPeriod = 7;
+ $aResult = $this->Topic_GetTopicsCollective($iPage, Config::Get('module.topic.per_page'), $sShowType,
+ $sPeriod == 'all' ? null : $sPeriod * 60 * 60 * 24);
+ }
+ $aTopics = $aResult['collection'];
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('topics_list_show', array('aTopics' => $aTopics));
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), Router::GetPath('blog') . $sShowType,
+ in_array($sShowType, array('discussed', 'top')) ? array('period' => $sPeriod) : array());
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('blog_show', array('sShowType' => $sShowType));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('topics', $aTopics);
+ $this->Viewer_Assign('paging', $aPaging);
+ if (in_array($sShowType, array('discussed', 'top'))) {
+ $this->Viewer_Assign('periodSelectCurrent', $sPeriod);
+ $this->Viewer_Assign('periodSelectRoot', Router::GetPath('blog') . $sShowType . '/');
+ }
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('index');
+ }
+
+
+ /**
+ * Обработка ЧПУ топика
+ */
+ protected function EventInternalShowTopicByUrl()
+ {
+ $sTopicUrl = Config::Get('module.topic._router_topic_original_url');
+ /**
+ * Проверяем ключ
+ */
+ if (is_null($sTopicUrl)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверяем корректность URL топика
+ * Сначала нужно получить сам топик по ID или уникальному полю Slug (транслитерированный заголовок)
+ * Смотрим наличие ID или Slug в маске топика
+ */
+ $sUrlEscape = preg_quote(trim(Config::Get('module.topic.url'), '/ '));
+ $aMask = array_map(function ($sItem) {
+ return "({$sItem})";
+ }, Config::Get('module.topic.url_preg'));
+ $sPreg = strtr($sUrlEscape, $aMask);
+ if (preg_match('@^' . $sPreg . '$@iu', $sTopicUrl, $aMatch)) {
+ $aRuleRequire = array();
+ if (preg_match_all('#%(\w+)%#', $sUrlEscape, $aMatch2)) {
+ foreach ($aMatch2[1] as $k => $sFind) {
+ if (in_array($sFind, array('id', 'title'))) {
+ if (isset($aMatch[$k + 1])) {
+ $aRuleRequire[$sFind] = $aMatch[$k + 1];
+ }
+ }
+ }
+ }
+ /**
+ * Не удалось найти обязательные поля - запускаем обработку дальше по цепочке
+ */
+ if (!$aRuleRequire) {
+ return Router::Action($sTopicUrl);
+ }
+
+ $oTopic = null;
+ /**
+ * Ищем топик
+ */
+ if (isset($aRuleRequire['id'])) {
+ $oTopic = $this->Topic_GetTopicById($aRuleRequire['id']);
+ } elseif (isset($aRuleRequire['title'])) {
+ $oTopic = $this->Topic_GetTopicBySlug($aRuleRequire['title']);
+ }
+ if (!$oTopic) {
+ return Router::Action($sTopicUrl);
+ }
+ /**
+ * Проверяем корректность URL топика
+ */
+ if ($oTopic->getUrl(false) != $sTopicUrl) {
+ Router::Location($oTopic->getUrl());
+ }
+ /**
+ * Направляем на стандартную обработку топика
+ */
+ return Router::Action('blog', "{$oTopic->getId()}.html");
+ }
+ /**
+ * Запускаем обработку дальше по цепочке
+ */
+ return Router::Action($sTopicUrl);
+ }
+
+ /**
+ * Показ топика
+ *
+ */
+ protected function EventShowTopic()
+ {
+ $iTopicId = $this->GetEventMatch(1);
+ $this->sMenuItemSelect = 'blog';
+ $this->sMenuSubItemSelect = '';
+ /**
+ * Проверяем есть ли такой топик
+ */
+ if (!($oTopic = $this->Topic_GetTopicById($iTopicId))) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Проверяем права на просмотр топика
+ */
+ if (!$this->ACL_IsAllowShowTopic($oTopic, $this->oUserCurrent)) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Достаём комменты к топику
+ */
+ if (!Config::Get('module.comment.nested_page_reverse') and Config::Get('module.comment.use_nested') and Config::Get('module.comment.nested_per_page')) {
+ $iPageDef = ceil($this->Comment_GetCountCommentsRootByTargetId($oTopic->getId(),
+ 'topic') / Config::Get('module.comment.nested_per_page'));
+ } else {
+ $iPageDef = 1;
+ }
+ $iPage = getRequest('cmtpage', 0) ? (int)getRequest('cmtpage', 0) : $iPageDef;
+ $aReturn = $this->Comment_GetCommentsByTargetId($oTopic->getId(), 'topic', $iPage,
+ Config::Get('module.comment.nested_per_page'));
+ $iMaxIdComment = $aReturn['iMaxIdComment'];
+ $aComments = $aReturn['comments'];
+ /**
+ * Если используется постраничность для комментариев - формируем ее
+ */
+ if (Config::Get('module.comment.use_nested') and Config::Get('module.comment.nested_per_page')) {
+ $aPaging = $this->Viewer_MakePaging($aReturn['count'], $iPage,
+ Config::Get('module.comment.nested_per_page'), Config::Get('pagination.pages.count'), '');
+ $this->Viewer_Assign('pagingComments', $aPaging);
+ }
+ /**
+ * Отмечаем дату прочтения топика
+ */
+ if ($this->oUserCurrent) {
+ $oTopicRead = Engine::GetEntity('Topic_TopicRead');
+ $oTopicRead->setTopicId($oTopic->getId());
+ $oTopicRead->setUserId($this->oUserCurrent->getId());
+ $oTopicRead->setCommentCountLast($oTopic->getCountComment());
+ $oTopicRead->setCommentIdLast($iMaxIdComment);
+ $oTopicRead->setDateRead(date("Y-m-d H:i:s"));
+ $this->Topic_SetTopicRead($oTopicRead);
+ }
+ /**
+ * Выставляем SEO данные
+ */
+ $sTextSeo = strip_tags($oTopic->getText());
+ $this->Viewer_SetHtmlDescription(func_text_words($sTextSeo, Config::Get('seo.description_words_count')));
+ $this->Viewer_SetHtmlKeywords($oTopic->getTags());
+ $this->Viewer_SetHtmlCanonical($oTopic->getUrl());
+ /**
+ * Open Graph
+ */
+ $this->Viewer_SetOpenGraphProperty('og:type', 'article');
+ $this->Viewer_SetOpenGraphProperty('og:title', $oTopic->getTitle());
+ $this->Viewer_SetOpenGraphProperty('og:description', $this->Viewer_GetHtmlDescription());
+ $this->Viewer_SetOpenGraphProperty('og:url', $oTopic->getUrl());
+ $this->Viewer_SetOpenGraphProperty('article:author', $oTopic->getUser()->getUserWebPath());
+ $this->Viewer_SetOpenGraphProperty('article:published_time', date('c', strtotime($oTopic->getDatePublish())));
+ if ($sImage = $oTopic->getPreviewImageWebPath(Config::Get('module.topic.default_preview_size'))) {
+ $this->Viewer_SetOpenGraphProperty('og:image', $sImage);
+ }
+ if ($aTags = $oTopic->getTagsArray()) {
+ $this->Viewer_SetOpenGraphProperty('article:tag', $aTags);
+ }
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('topic_show', array("oTopic" => $oTopic));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('topic', $oTopic);
+ $this->Viewer_Assign('comments', $aComments);
+ $this->Viewer_Assign('lastCommentId', $iMaxIdComment);
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($oTopic->getBlog()->getTitle());
+ $this->Viewer_AddHtmlTitle($oTopic->getTitle());
+ $this->Viewer_SetHtmlRssAlternate(Router::GetPath('rss') . 'comments/' . $oTopic->getId() . '/',
+ $oTopic->getTitle());
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('topic');
+ }
+
+ /**
+ * Страница со списком читателей блога
+ *
+ */
+ protected function EventShowUsers()
+ {
+ $sBlogUrl = $this->sCurrentEvent;
+ /**
+ * Проверяем есть ли блог с таким УРЛ
+ */
+ if (!($oBlog = $this->Blog_GetBlogByUrl($sBlogUrl))) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Меню
+ */
+ $this->sMenuSubItemSelect = '';
+ $this->sMenuSubBlogUrl = $oBlog->getUrlFull();
+ /**
+ * Текущая страница
+ */
+ $iPage = $this->GetParamEventMatch(1, 2) ? $this->GetParamEventMatch(1, 2) : 1;
+ $aBlogUsersResult = $this->Blog_GetBlogUsersByBlogId($oBlog->getId(), ModuleBlog::BLOG_USER_ROLE_USER, $iPage,
+ Config::Get('module.blog.users_per_page'));
+ $aBlogUsers = $aBlogUsersResult['collection'];
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aBlogUsersResult['count'], $iPage,
+ Config::Get('module.blog.users_per_page'), Config::Get('pagination.pages.count'),
+ $oBlog->getUrlFull() . 'users');
+ $this->Viewer_Assign('paging', $aPaging);
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('blog_collective_show_users', array('oBlog' => $oBlog));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('blogUsers', $aBlogUsers);
+ $this->Viewer_Assign('countBlogUsers', $aBlogUsersResult['count']);
+ $this->Viewer_Assign('blog', $oBlog);
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($oBlog->getTitle());
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('users');
+ }
+
+ /**
+ * Вывод топиков из определенного блога
+ *
+ */
+ protected function EventShowBlog()
+ {
+ $sPeriod = 1; // по дефолту 1 день
+ if (in_array(getRequestStr('period'), array(1, 7, 30, 'all'))) {
+ $sPeriod = getRequestStr('period');
+ }
+ $sBlogUrl = $this->sCurrentEvent;
+ $sShowType = in_array($this->GetParamEventMatch(0, 0),
+ array('bad', 'new', 'newall', 'discussed', 'top')) ? $this->GetParamEventMatch(0, 0) : 'good';
+ if (!in_array($sShowType, array('discussed', 'top'))) {
+ $sPeriod = 'all';
+ }
+ /**
+ * Проверяем есть ли блог с таким УРЛ
+ */
+ if (!($oBlog = $this->Blog_GetBlogByUrl($sBlogUrl))) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Определяем права на отображение закрытого блога
+ */
+ if ($oBlog->getType() == 'close'
+ and (!$this->oUserCurrent
+ or !in_array(
+ $oBlog->getId(),
+ $this->Blog_GetAccessibleBlogsByUser($this->oUserCurrent)
+ )
+ )
+ ) {
+ $bPrivateBlog = true;
+ } else {
+ $bPrivateBlog = false;
+ }
+ /**
+ * Меню
+ */
+ $this->sMenuSubItemSelect = $sShowType == 'newall' ? 'new' : $sShowType;
+ $this->sNavTopicsSubUrl = $oBlog->getUrlFull();
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(($sShowType == 'good') ? 0 : 1,
+ 2) ? $this->GetParamEventMatch(($sShowType == 'good') ? 0 : 1, 2) : 1;
+ if ($iPage == 1 and !getRequest('period') and in_array($sShowType, array('discussed', 'top'))) {
+ $this->Viewer_SetHtmlCanonical($oBlog->getUrlFull() . $sShowType . '/');
+ }
+
+ if (!$bPrivateBlog) {
+ /**
+ * Получаем список топиков
+ */
+ $aResult = $this->Topic_GetTopicsByBlog($oBlog, $iPage, Config::Get('module.topic.per_page'), $sShowType,
+ $sPeriod == 'all' ? null : $sPeriod * 60 * 60 * 24);
+ /**
+ * Если нет топиков за 1 день, то показываем за неделю (7)
+ */
+ if (in_array($sShowType,
+ array('discussed', 'top')) and !$aResult['count'] and $iPage == 1 and !getRequest('period')
+ ) {
+ $sPeriod = 7;
+ $aResult = $this->Topic_GetTopicsByBlog($oBlog, $iPage, Config::Get('module.topic.per_page'),
+ $sShowType, $sPeriod == 'all' ? null : $sPeriod * 60 * 60 * 24);
+ }
+ $aTopics = $aResult['collection'];
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = ($sShowType == 'good')
+ ? $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), rtrim($oBlog->getUrlFull(), '/'))
+ : $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), $oBlog->getUrlFull() . $sShowType,
+ array('period' => $sPeriod));
+ /**
+ * Получаем число новых топиков в текущем блоге
+ */
+ $this->iCountTopicsSubNew = $this->Topic_GetCountTopicsByBlogNew($oBlog);
+
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('topics', $aTopics);
+ if (in_array($sShowType, array('discussed', 'top'))) {
+ $this->Viewer_Assign('periodSelectCurrent', $sPeriod);
+ $this->Viewer_Assign('periodSelectRoot', $oBlog->getUrlFull() . $sShowType . '/');
+ }
+ }
+ /**
+ * Выставляем SEO данные
+ */
+ $sTextSeo = strip_tags($oBlog->getDescription());
+ $this->Viewer_SetHtmlDescription(func_text_words($sTextSeo, Config::Get('seo.description_words_count')));
+ /**
+ * Получаем список юзеров блога
+ */
+ $aBlogUsersResult = $this->Blog_GetBlogUsersByBlogId($oBlog->getId(), ModuleBlog::BLOG_USER_ROLE_USER, 1,
+ Config::Get('module.blog.users_per_page'));
+ $aBlogUsers = $aBlogUsersResult['collection'];
+ $aBlogModeratorsResult = $this->Blog_GetBlogUsersByBlogId($oBlog->getId(),
+ ModuleBlog::BLOG_USER_ROLE_MODERATOR);
+ $aBlogModerators = $aBlogModeratorsResult['collection'];
+ $aBlogAdministratorsResult = $this->Blog_GetBlogUsersByBlogId($oBlog->getId(),
+ ModuleBlog::BLOG_USER_ROLE_ADMINISTRATOR);
+ $aBlogAdministrators = $aBlogAdministratorsResult['collection'];
+
+ if ($this->oUserCurrent) {
+ /**
+ * Для админов проекта получаем список блогов и передаем их во вьювер
+ */
+ if ($this->oUserCurrent->isAdministrator()) {
+ $aBlogs = $this->Blog_GetBlogs();
+ unset($aBlogs[$oBlog->getId()]);
+
+ $this->Viewer_Assign('blogs', $aBlogs);
+ }
+ /**
+ * Текущая роль пользователя в блоге
+ */
+ $this->Viewer_Assign('blogUserCurrent', $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $this->oUserCurrent->getId()));
+ }
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('blog_collective_show', array('oBlog' => $oBlog, 'sShowType' => $sShowType));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('blogUsers', $aBlogUsers);
+ $this->Viewer_Assign('blogModerators', $aBlogModerators);
+ $this->Viewer_Assign('blogAdministrators', $aBlogAdministrators);
+ $this->Viewer_Assign('countBlogUsers', $aBlogUsersResult['count']);
+ $this->Viewer_Assign('countBlogModerators', $aBlogModeratorsResult['count']);
+ $this->Viewer_Assign('countBlogAdministrators', $aBlogAdministratorsResult['count'] + 1);
+ $this->Viewer_Assign('blog', $oBlog);
+ $this->Viewer_Assign('isPrivateBlog', $bPrivateBlog);
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($oBlog->getTitle());
+ $this->Viewer_SetHtmlRssAlternate(Router::GetPath('rss') . 'blog/' . $oBlog->getUrl() . '/',
+ $oBlog->getTitle());
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('blog');
+ }
+
+ /**
+ * Обработка добавление комментария к топику через ajax
+ *
+ */
+ protected function AjaxAddComment()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $this->SubmitComment();
+ }
+
+ /**
+ * Проверка на соответсвие коментария требованиям безопасности
+ *
+ * @param ModuleTopic_EntityTopic $oTopic
+ * @param string $sText
+ *
+ * @return bool result
+ */
+ protected function CheckComment($oTopic, $sText)
+ {
+
+ $bOk = true;
+ /**
+ * Проверям авторизован ли пользователь
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ /**
+ * Проверяем топик
+ */
+ if (!$oTopic) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ return false;
+ }
+ /**
+ * Права на просмотр топика
+ */
+ if (!$this->ACL_IsAllowShowTopic($oTopic, $this->oUserCurrent)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ /**
+ * Проверяем разрешено ли постить комменты
+ */
+ if (!$this->ACL_CanPostComment($this->oUserCurrent, $oTopic)) {
+ $this->Message_AddErrorSingle($this->Rbac_GetMsgLast());
+ $bOk = false;
+ }
+ /**
+ * Проверяем запрет на добавления коммента автором топика
+ */
+ if ($oTopic->getForbidComment()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('topic.comments.notices.not_allowed'),
+ $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ /**
+ * Проверяем текст комментария
+ */
+ if (!func_check($sText, 'text', 2, 10000)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('topic.comments.notices.error_text'),
+ $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+
+ $this->Hook_Run('comment_check', array('oTopic' => $oTopic, 'sText' => $sText, 'bOk' => &$bOk));
+
+ return $bOk;
+ }
+
+ /**
+ * Проверка на соответсвие коментария родительскому коментарию
+ *
+ * @param ModuleTopic_EntityTopic $oTopic
+ * @param string $sText
+ * @param ModuleComment_EntityComment $oCommentParent
+ *
+ * @return bool result
+ */
+ protected function CheckParentComment($oTopic, $sText, $oCommentParent)
+ {
+
+ $sParentId = 0;
+ if ($oCommentParent) {
+ $sParentId = $oCommentParent->GetCommentId();
+ }
+
+ $bOk = true;
+ /**
+ * Проверям на какой коммент отвечаем
+ */
+ if (!func_check($sParentId, 'id')) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+
+ if ($sParentId) {
+ /**
+ * Проверяем существует ли комментарий на который отвечаем
+ */
+ if (!($oCommentParent)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ /**
+ * Проверяем из одного топика ли новый коммент и тот на который отвечаем
+ */
+ if ($oCommentParent->getTargetId() != $oTopic->getId()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ } else {
+ $sParentId = null;
+ }
+
+ /**
+ * Проверка на дублирующий коммент
+ */
+ if ($this->Comment_GetCommentUnique($oTopic->getId(), 'topic', $this->oUserCurrent->getId(), $sParentId,
+ md5($sText))
+ ) {
+ $this->Message_AddErrorSingle($this->Lang_Get('topic.comments.notices.spam'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+
+ $this->Hook_Run('comment_check_parent',
+ array('oTopic' => $oTopic, 'sText' => $sText, 'oCommentParent' => $oCommentParent, 'bOk' => &$bOk));
+
+ return $bOk;
+ }
+
+ /**
+ * Обработка добавление комментария к топику
+ *
+ */
+ protected function SubmitComment()
+ {
+
+ $oTopic = $this->Topic_GetTopicById(getRequestStr('comment_target_id'));
+ $sText = getRequestStr('comment_text');
+ $sParentId = (int)getRequest('reply');
+ $oCommentParent = null;
+
+ if (!$sParentId) {
+ /**
+ * Корневой комментарий
+ */
+ $sParentId = null;
+ } else {
+ /**
+ * Родительский комментарий
+ */
+ $oCommentParent = $this->Comment_GetCommentById($sParentId);
+ }
+
+ /**
+ * Проверка на соответсвие комментария требованиям безопасности
+ */
+ if (!$this->CheckComment($oTopic, $sText)) {
+ return;
+ }
+
+ /**
+ * Проверка на соответсвие комментария родительскому коментарию
+ */
+ if (!$this->CheckParentComment($oTopic, $sText, $oCommentParent)) {
+ return;
+ }
+
+ /**
+ * Создаём коммент
+ */
+ $oCommentNew = Engine::GetEntity('Comment');
+ $oCommentNew->setTargetId($oTopic->getId());
+ $oCommentNew->setTargetType('topic');
+ $oCommentNew->setTargetParentId($oTopic->getBlog()->getId());
+ $oCommentNew->setUserId($this->oUserCurrent->getId());
+ $oCommentNew->setText($this->Text_Parser($sText));
+ $oCommentNew->setTextSource($sText);
+ $oCommentNew->setDate(date("Y-m-d H:i:s"));
+ $oCommentNew->setUserIp(func_getIp());
+ $oCommentNew->setPid($sParentId);
+ $oCommentNew->setTextHash(md5($sText));
+ $oCommentNew->setPublish($oTopic->getPublish());
+ /**
+ * Добавляем коммент
+ */
+ $this->Hook_Run('comment_add_before',
+ array('oCommentNew' => $oCommentNew, 'oCommentParent' => $oCommentParent, 'oTopic' => $oTopic));
+ if ($this->Comment_AddComment($oCommentNew)) {
+ $this->Hook_Run('comment_add_after',
+ array('oCommentNew' => $oCommentNew, 'oCommentParent' => $oCommentParent, 'oTopic' => $oTopic));
+
+ $this->Viewer_AssignAjax('sCommentId', $oCommentNew->getId());
+ if ($oTopic->getPublish()) {
+ /**
+ * Добавляем коммент в прямой эфир если топик не в черновиках
+ */
+ $oCommentOnline = Engine::GetEntity('Comment_CommentOnline');
+ $oCommentOnline->setTargetId($oCommentNew->getTargetId());
+ $oCommentOnline->setTargetType($oCommentNew->getTargetType());
+ $oCommentOnline->setTargetParentId($oCommentNew->getTargetParentId());
+ $oCommentOnline->setCommentId($oCommentNew->getId());
+
+ $this->Comment_AddCommentOnline($oCommentOnline);
+ }
+ /**
+ * Сохраняем дату последнего коммента для юзера
+ */
+ $this->oUserCurrent->setDateCommentLast(date("Y-m-d H:i:s"));
+ $this->User_Update($this->oUserCurrent);
+ /**
+ * Фиксируем ID у media файлов комментария
+ */
+ $this->Media_ReplaceTargetTmpById('comment', $oCommentNew->getId());
+ /**
+ * Список емайлов на которые не нужно отправлять уведомление
+ */
+ $aExcludeMail = array($this->oUserCurrent->getMail());
+ /**
+ * Отправляем уведомление тому на чей коммент ответили
+ */
+ if ($oCommentParent and $oCommentParent->getUserId() != $oTopic->getUserId() and $oCommentNew->getUserId() != $oCommentParent->getUserId()) {
+ $oUserAuthorComment = $oCommentParent->getUser();
+ $aExcludeMail[] = $oUserAuthorComment->getMail();
+ $this->Topic_SendNotifyCommentReplyToAuthorParentComment($oUserAuthorComment, $oTopic, $oCommentNew,
+ $this->oUserCurrent);
+ }
+ /**
+ * Отправка уведомления автору топика
+ */
+ $this->Subscribe_Send('topic_new_comment', $oTopic->getId(),
+ 'comment_new.tpl', $this->Lang_Get('emails.comment_new.subject'),
+ array(
+ 'oTopic' => $oTopic,
+ 'oComment' => $oCommentNew,
+ 'oUserComment' => $this->oUserCurrent,
+ ), $aExcludeMail);
+ /**
+ * Добавляем событие в ленту
+ */
+ $this->Stream_write($oCommentNew->getUserId(), 'add_comment', $oCommentNew->getId(),
+ $oTopic->getPublish() && $oTopic->getBlog()->getType() != 'close');
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ /**
+ * Получение новых комментариев
+ *
+ */
+ protected function AjaxResponseComment()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Топик существует?
+ */
+ $idTopic = getRequestStr('target_id', null, 'post');
+ if (!($oTopic = $this->Topic_GetTopicById($idTopic))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Есть доступ к комментариям этого топика? Закрытый блог?
+ */
+ if (!$this->ACL_IsAllowShowBlog($oTopic->getBlog(), $this->oUserCurrent)) {
+ return $this->EventErrorDebug();
+ }
+
+ $idCommentLast = getRequestStr('last_comment_id', null, 'post');
+ $selfIdComment = getRequestStr('self_comment_id', null, 'post');
+ $aComments = array();
+ /**
+ * Если используется постраничность, возвращаем только добавленный комментарий
+ */
+ if (getRequest('use_paging', null, 'post') and $selfIdComment) {
+ if ($oComment = $this->Comment_GetCommentById($selfIdComment) and $oComment->getTargetId() == $oTopic->getId() and $oComment->getTargetType() == 'topic') {
+ $oViewerLocal = $this->Viewer_GetLocalViewer();
+
+ $oViewerLocal->Assign('oUserCurrent', $this->oUserCurrent);
+ $oViewerLocal->Assign('oneComment', true, true);
+ $oViewerLocal->Assign('useFavourite', true, true);
+ $oViewerLocal->Assign('useVote', true, true);
+ $oViewerLocal->Assign('comment', $oComment, true);
+
+ $sHtml = $oViewerLocal->Fetch($this->Comment_GetTemplateCommentByTarget($oTopic->getId(), 'topic'));
+
+ $aCmt = array();
+ $aCmt[] = array(
+ 'html' => $sHtml,
+ 'obj' => $oComment,
+ );
+ } else {
+ $aCmt = array();
+ }
+ $aReturn['comments'] = $aCmt;
+ $aReturn['iMaxIdComment'] = $selfIdComment;
+ } else {
+ $aReturn = $this->Comment_GetCommentsNewByTargetId($oTopic->getId(), 'topic', $idCommentLast);
+ }
+ $iMaxIdComment = $aReturn['iMaxIdComment'];
+
+ $oTopicRead = Engine::GetEntity('Topic_TopicRead');
+ $oTopicRead->setTopicId($oTopic->getId());
+ $oTopicRead->setUserId($this->oUserCurrent->getId());
+ $oTopicRead->setCommentCountLast($oTopic->getCountComment());
+ $oTopicRead->setCommentIdLast($iMaxIdComment);
+ $oTopicRead->setDateRead(date("Y-m-d H:i:s"));
+ $this->Topic_SetTopicRead($oTopicRead);
+
+ $aCmts = $aReturn['comments'];
+ if ($aCmts and is_array($aCmts)) {
+ foreach ($aCmts as $aCmt) {
+ $aComments[] = array(
+ 'html' => $aCmt['html'],
+ 'parent_id' => $aCmt['obj']->getPid(),
+ 'id' => $aCmt['obj']->getId(),
+ );
+ }
+ }
+
+ $this->Viewer_AssignAjax('last_comment_id', $iMaxIdComment);
+ $this->Viewer_AssignAjax('comments', $aComments);
+ }
+
+ /**
+ * Обработка ajax запроса на отправку
+ * пользователям приглашения вступить в закрытый блог
+ */
+ protected function AjaxAddBlogInvite()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $aUsers = getRequest('users', null, 'post');
+ $sBlogId = getRequestStr('target_id', null, 'post');
+ /**
+ * Если пользователь не авторизирован, возвращаем ошибку
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ /**
+ * Проверяем существование блога
+ */
+ if (!$oBlog = $this->Blog_GetBlogById($sBlogId) or !is_array($aUsers)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверяем тип блога
+ */
+ if ($oBlog->getType() != 'close') {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверяем, имеет ли право текущий пользователь добавлять invite в blog
+ */
+ $oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $this->oUserCurrent->getId());
+ $bIsAdministratorBlog = $oBlogUser ? $oBlogUser->getIsAdministrator() : false;
+ if ($oBlog->getOwnerId() != $this->oUserCurrent->getId() and !$this->oUserCurrent->isAdministrator() and !$bIsAdministratorBlog) {
+ return $this->EventErrorDebug();
+ }
+
+ $aResult = array();
+ /**
+ * Обрабатываем добавление по каждому из переданных логинов
+ */
+ foreach ($aUsers as $iUserId) {
+ $iUserId = (int)$iUserId;
+
+ if (!$iUserId) {
+ continue;
+ }
+
+ /**
+ * Если пользователь не найден или неактивен,
+ * возвращаем ошибку
+ */
+ if (!$oUser = $this->User_GetUserById($iUserId) or $oUser->getActivate() != 1) {
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('user.notices.not_found_by_id', array('id' => $iUserId))
+ );
+ continue;
+ }
+ /**
+ * Запрещаем отправлять инвайт создателю блога
+ */
+ if ($oUser->getId() == $oBlog->getOwnerId()) {
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('blog.invite.notices.add_self')
+ );
+ continue;
+ }
+
+ if (!($oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $oUser->getId()))) {
+ /**
+ * Создаем нового блог-пользователя со статусом INVITED
+ */
+ $oBlogUserNew = Engine::GetEntity('Blog_BlogUser');
+ $oBlogUserNew->setBlogId($oBlog->getId());
+ $oBlogUserNew->setUserId($oUser->getId());
+ $oBlogUserNew->setUserRole(ModuleBlog::BLOG_USER_ROLE_INVITE);
+
+ if ($this->Blog_AddRelationBlogUser($oBlogUserNew)) {
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('user', $oUser, true);
+ $oViewer->Assign('showActions', true, true);
+
+ $aResult[] = array(
+ 'bStateError' => false,
+ 'sMsgTitle' => $this->Lang_Get('common.attention'),
+ 'sMsg' => $this->Lang_Get('blog.invite.notices.add',
+ array('login' => $oUser->getLogin())),
+ 'user_id' => $oUser->getId(),
+ 'user_login' => $oUser->getLogin(),
+ 'html' => $oViewer->Fetch("component@blog.invite-item")
+ );
+ $this->SendBlogInvite($oBlog, $oUser);
+ } else {
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('common.error.system.base'),
+ 'user_login' => $oUser->getLogin()
+ );
+ }
+ } else {
+ /**
+ * Попытка добавить приглашение уже существующему пользователю,
+ * возвращаем ошибку (сначала определяя ее точный текст)
+ */
+ switch (true) {
+ case ($oBlogUser->getUserRole() == ModuleBlog::BLOG_USER_ROLE_INVITE):
+ $sErrorMessage = $this->Lang_Get('blog.invite.notices.already_invited',
+ array('login' => $oUser->getLogin()));
+ break;
+ case ($oBlogUser->getUserRole() > ModuleBlog::BLOG_USER_ROLE_GUEST):
+ $sErrorMessage = $this->Lang_Get('blog.invite.notices.already_joined',
+ array('login' => $oUser->getLogin()));
+ break;
+ case ($oBlogUser->getUserRole() == ModuleBlog::BLOG_USER_ROLE_REJECT):
+ $sErrorMessage = $this->Lang_Get('blog.invite.notices.reject',
+ array('login' => $oUser->getLogin()));
+ break;
+ default:
+ $sErrorMessage = $this->Lang_Get('common.error.system.base');
+ }
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $sErrorMessage,
+ 'user_login' => $oUser->getLogin()
+ );
+ continue;
+ }
+ }
+ /**
+ * Передаем во вьевер массив с результатами обработки по каждому пользователю
+ */
+ $this->Viewer_AssignAjax('users', $aResult);
+ }
+
+ /**
+ * Обработка ajax запроса на отправку
+ * повторного приглашения вступить в закрытый блог
+ */
+ protected function AjaxReBlogInvite()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $sUserId = getRequestStr('user_id', null, 'post');
+ $sBlogId = getRequestStr('target_id', null, 'post');
+ /**
+ * Если пользователь не авторизирован, возвращаем ошибку
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ /**
+ * Проверяем существование блога
+ */
+ if (!$oBlog = $this->Blog_GetBlogById($sBlogId)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Пользователь существует и активен?
+ */
+ if (!$oUser = $this->User_GetUserById($sUserId) or $oUser->getActivate() != 1) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверяем, имеет ли право текущий пользователь добавлять invite в blog
+ */
+ $oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $this->oUserCurrent->getId());
+ $bIsAdministratorBlog = $oBlogUser ? $oBlogUser->getIsAdministrator() : false;
+ if ($oBlog->getOwnerId() != $this->oUserCurrent->getId() and !$this->oUserCurrent->isAdministrator() and !$bIsAdministratorBlog) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Попытка отправить инвайт пользователю, который не состоит в данном блоге
+ */
+ if (!($oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $oUser->getId()))) {
+ return $this->EventErrorDebug();
+ }
+ if ($oBlogUser->getUserRole() == ModuleBlog::BLOG_USER_ROLE_INVITE) {
+ $this->SendBlogInvite($oBlog, $oUser);
+ $this->Message_AddNoticeSingle($this->Lang_Get('blog.invite.notices.add',
+ array('login' => $oUser->getLogin())), $this->Lang_Get('common.attention'));
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ /**
+ * Обработка ajax запроса на удаление вступить в закрытый блог
+ */
+ protected function AjaxRemoveBlogInvite()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $sUserId = getRequestStr('user_id', null, 'post');
+ $sBlogId = getRequestStr('target_id', null, 'post');
+ /**
+ * Если пользователь не авторизирован, возвращаем ошибку
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ /**
+ * Проверяем существование блога
+ */
+ if (!$oBlog = $this->Blog_GetBlogById($sBlogId)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Пользователь существует и активен?
+ */
+ if (!$oUser = $this->User_GetUserById($sUserId) or $oUser->getActivate() != 1) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверяем, имеет ли право текущий пользователь добавлять invite в blog
+ */
+ $oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $this->oUserCurrent->getId());
+ $bIsAdministratorBlog = $oBlogUser ? $oBlogUser->getIsAdministrator() : false;
+ if ($oBlog->getOwnerId() != $this->oUserCurrent->getId() and !$this->oUserCurrent->isAdministrator() and !$bIsAdministratorBlog) {
+ return $this->EventErrorDebug();
+ }
+
+ $oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $oUser->getId());
+ if ($oBlogUser->getUserRole() == ModuleBlog::BLOG_USER_ROLE_INVITE) {
+ /**
+ * Удаляем связь/приглашение
+ */
+ $this->Blog_DeleteRelationBlogUser($oBlogUser);
+ $this->Message_AddNoticeSingle($this->Lang_Get('blog.invite.notices.remove',
+ array('login' => $oUser->getLogin())), $this->Lang_Get('common.attention'));
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ /**
+ * Выполняет отправку приглашения в блог
+ * (по внутренней почте и на email)
+ *
+ * @param ModuleBlog_EntityBlog $oBlog
+ * @param ModuleUser_EntityUser $oUser
+ */
+ protected function SendBlogInvite($oBlog, $oUser)
+ {
+ $sTitle = $this->Lang_Get(
+ 'blog.invite.email.title',
+ array(
+ 'blog_title' => $oBlog->getTitle()
+ )
+ );
+
+ require_once Config::Get('path.framework.libs_vendor.server') . '/XXTEA/encrypt.php';
+ /**
+ * Формируем код подтверждения в URL
+ */
+ $sCode = $oBlog->getId() . '_' . $oUser->getId();
+ $sCode = rawurlencode(base64_encode(xxtea_encrypt($sCode, Config::Get('module.blog.encrypt'))));
+
+ $aPath = array(
+ 'accept' => Router::GetPath('blog') . 'invite/accept/?code=' . $sCode,
+ 'reject' => Router::GetPath('blog') . 'invite/reject/?code=' . $sCode
+ );
+
+ $sText = $this->Lang_Get(
+ 'blog.invite.email.text',
+ array(
+ 'login' => $this->oUserCurrent->getLogin(),
+ 'accept_path' => $aPath['accept'],
+ 'reject_path' => $aPath['reject'],
+ 'blog_title' => $oBlog->getTitle()
+ )
+ );
+ $oTalk = $this->Talk_SendTalk($sTitle, $sText, $this->oUserCurrent, array($oUser), false, false);
+ /**
+ * Отправляем пользователю заявку
+ */
+ $this->Blog_SendNotifyBlogUserInvite(
+ $oUser, $this->oUserCurrent, $oBlog,
+ Router::GetPath('talk') . 'read/' . $oTalk->getId() . '/'
+ );
+ /**
+ * Удаляем отправляющего юзера из переписки
+ */
+ $this->Talk_DeleteTalkUserByArray($oTalk->getId(), $this->oUserCurrent->getId());
+ }
+
+ /**
+ * Обработка отправленого пользователю приглашения вступить в блог
+ */
+ protected function EventInviteBlog()
+ {
+ require_once Config::Get('path.framework.libs_vendor.server') . '/XXTEA/encrypt.php';
+ /**
+ * Получаем код подтверждения из ревеста и дешефруем его
+ */
+ $sCode = xxtea_decrypt(base64_decode(rawurldecode(getRequestStr('code'))), Config::Get('module.blog.encrypt'));
+ if (!$sCode) {
+ return $this->EventNotFound();
+ }
+ list($sBlogId, $sUserId) = explode('_', $sCode, 2);
+
+ $sAction = $this->GetParam(0);
+ /**
+ * Получаем текущего пользователя
+ */
+ if (!$this->User_IsAuthorization()) {
+ return $this->EventNotFound();
+ }
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ /**
+ * Если приглашенный пользователь не является авторизированным
+ */
+ if ($this->oUserCurrent->getId() != $sUserId) {
+ return $this->EventNotFound();
+ }
+ /**
+ * Получаем указанный блог
+ */
+ if ((!$oBlog = $this->Blog_GetBlogById($sBlogId)) || $oBlog->getType() != 'close') {
+ return $this->EventNotFound();
+ }
+ /**
+ * Получаем связь "блог-пользователь" и проверяем,
+ * чтобы ее тип был INVITE или REJECT
+ */
+ if (!$oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $this->oUserCurrent->getId())) {
+ return $this->EventNotFound();
+ }
+ if ($oBlogUser->getUserRole() > ModuleBlog::BLOG_USER_ROLE_GUEST) {
+ $sMessage = $this->Lang_Get('blog.invite.alerts.already_joined');
+ $this->Message_AddError($sMessage, $this->Lang_Get('common.error.error'), true);
+ Router::Location(Router::GetPath('talk'));
+ return;
+ }
+ if (!in_array($oBlogUser->getUserRole(),
+ array(ModuleBlog::BLOG_USER_ROLE_INVITE, ModuleBlog::BLOG_USER_ROLE_REJECT))
+ ) {
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'), true);
+ Router::Location(Router::GetPath('talk'));
+ return;
+ }
+ /**
+ * Обновляем роль пользователя до читателя
+ */
+ $oBlogUser->setUserRole(($sAction == 'accept') ? ModuleBlog::BLOG_USER_ROLE_USER : ModuleBlog::BLOG_USER_ROLE_REJECT);
+ if (!$this->Blog_UpdateRelationBlogUser($oBlogUser)) {
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'), true);
+ Router::Location(Router::GetPath('talk'));
+ return;
+ }
+ if ($sAction == 'accept') {
+ /**
+ * Увеличиваем число читателей блога
+ */
+ $oBlog->setCountUser($oBlog->getCountUser() + 1);
+ $this->Blog_UpdateBlog($oBlog);
+ $sMessage = $this->Lang_Get('blog.invite.alerts.accepted');
+ /**
+ * Добавляем событие в ленту
+ */
+ $this->Stream_write($oBlogUser->getUserId(), 'join_blog', $oBlog->getId());
+ } else {
+ $sMessage = $this->Lang_Get('blog.invite.alerts.rejected');
+ }
+ $this->Message_AddNotice($sMessage, $this->Lang_Get('common.attention'), true);
+ /**
+ * Перенаправляем на страницу личной почты
+ */
+ Router::Location(Router::GetPath('talk'));
+ }
+
+ /**
+ * Удаление блога
+ *
+ */
+ protected function EventDeleteBlog()
+ {
+ $this->Security_ValidateSendForm();
+ /**
+ * Проверяем передан ли в УРЛе номер блога
+ */
+ $sBlogId = $this->GetParam(0);
+ if (!$oBlog = $this->Blog_GetBlogById($sBlogId)) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Проверям авторизован ли пользователь
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.not_access'), $this->Lang_Get('common.error.error'));
+ return Router::Action('error');
+ }
+ /**
+ * проверяем есть ли право на удаление топика
+ */
+ if (!$bAccess = $this->ACL_IsAllowDeleteBlog($oBlog, $this->oUserCurrent)) {
+ return parent::EventNotFound();
+ }
+ $aTopics = $this->Topic_GetTopicsByBlogId($sBlogId, 1, 1, array(),
+ false); // нужно переделать функционал переноса топиков в дргугой блог
+ switch ($bAccess) {
+ case ModuleACL::CAN_DELETE_BLOG_EMPTY_ONLY :
+ if ($aTopics['count']) {
+ $this->Message_AddErrorSingle($this->Lang_Get('blog.remove.alerts.not_empty'),
+ $this->Lang_Get('common.error.error'), true);
+ Router::Location($oBlog->getUrlFull());
+ }
+ break;
+ case ModuleACL::CAN_DELETE_BLOG_WITH_TOPICS :
+ /**
+ * Если указан идентификатор блога для перемещения,
+ * то делаем попытку переместить топики.
+ *
+ * (-1) - выбран пункт меню "удалить топики".
+ */
+ if ($sBlogIdNew = getRequestStr('topic_move_to') and ($sBlogIdNew != -1) and $aTopics['count']) {
+ if (!$oBlogNew = $this->Blog_GetBlogById($sBlogIdNew)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('blog.remove.alerts.move_error'),
+ $this->Lang_Get('common.error.error'), true);
+ Router::Location($oBlog->getUrlFull());
+ }
+ /**
+ * Если выбранный блог является персональным, возвращаем ошибку
+ */
+ if ($oBlogNew->getType() == 'personal') {
+ $this->Message_AddErrorSingle($this->Lang_Get('blog.remove.alerts.move_personal_error'),
+ $this->Lang_Get('common.error.error'), true);
+ Router::Location($oBlog->getUrlFull());
+ }
+ /**
+ * Перемещаем топики
+ */
+ $this->Topic_MoveTopics($sBlogId, $sBlogIdNew);
+ }
+ break;
+ default:
+ return parent::EventNotFound();
+ }
+ /**
+ * Удаляяем блог и перенаправляем пользователя к списку блогов
+ */
+ $this->Hook_Run('blog_delete_before', array('sBlogId' => $sBlogId));
+ if ($this->Blog_DeleteBlog($sBlogId)) {
+ $this->Hook_Run('blog_delete_after', array('sBlogId' => $sBlogId));
+ $this->Message_AddNoticeSingle($this->Lang_Get('blog.remove.alerts.success'), $this->Lang_Get('common.attention'),
+ true);
+ Router::Location(Router::GetPath('blogs'));
+ } else {
+ Router::Location($oBlog->getUrlFull());
+ }
+ }
+
+ /**
+ * Получение описания блога
+ *
+ */
+ protected function AjaxBlogInfo()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $sBlogId = getRequestStr('blog_id', null, 'post');
+ /**
+ * Определяем тип блога и получаем его
+ */
+ if ($sBlogId == 0) {
+ if ($this->oUserCurrent) {
+ $oBlog = $this->Blog_GetPersonalBlogByUserId($this->oUserCurrent->getId());
+ }
+ } else {
+ $oBlog = $this->Blog_GetBlogById($sBlogId);
+ }
+ /**
+ * если блог найден, то возвращаем описание
+ */
+ if (isset($oBlog)) {
+ $sText = $oBlog->getDescription();
+
+ /**
+ * если блог персональный — возвращаем текущий языковой эквивалент
+ */
+ if ($sBlogId == 0) {
+ $sText = $this->Lang_Get('blog.personal_description');
+ }
+ $this->Viewer_AssignAjax('text', $sText);
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ /**
+ * Подключение/отключение к блогу
+ *
+ */
+ protected function AjaxBlogJoin()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Блог существует?
+ */
+ $idBlog = getRequestStr('blog_id', null, 'post');
+ if (!($oBlog = $this->Blog_GetBlogById($idBlog))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверяем тип блога
+ */
+ if (!in_array($oBlog->getType(), array('open', 'close'))) {
+ $this->Message_AddErrorSingle($this->Lang_Get('blog.join.notices.error_invite'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Получаем текущий статус пользователя в блоге
+ */
+ $oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $this->oUserCurrent->getId());
+ if (!$oBlogUser || ($oBlogUser->getUserRole() < ModuleBlog::BLOG_USER_ROLE_GUEST && $oBlog->getType() == 'close')) {
+ if ($oBlog->getOwnerId() != $this->oUserCurrent->getId()) {
+ /**
+ * Присоединяем юзера к блогу
+ */
+ $bResult = false;
+ if ($oBlogUser) {
+ $oBlogUser->setUserRole(ModuleBlog::BLOG_USER_ROLE_USER);
+ $bResult = $this->Blog_UpdateRelationBlogUser($oBlogUser);
+ } elseif ($oBlog->getType() == 'open') {
+ $oBlogUserNew = Engine::GetEntity('Blog_BlogUser');
+ $oBlogUserNew->setBlogId($oBlog->getId());
+ $oBlogUserNew->setUserId($this->oUserCurrent->getId());
+ $oBlogUserNew->setUserRole(ModuleBlog::BLOG_USER_ROLE_USER);
+ $bResult = $this->Blog_AddRelationBlogUser($oBlogUserNew);
+ }
+ if ($bResult) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('blog.join.notices.join_success'),
+ $this->Lang_Get('common.attention'));
+ $this->Viewer_AssignAjax('bState', true);
+ /**
+ * Увеличиваем число читателей блога
+ */
+ $oBlog->setCountUser($oBlog->getCountUser() + 1);
+ $this->Blog_UpdateBlog($oBlog);
+ $this->Viewer_AssignAjax('iCountUser', $oBlog->getCountUser());
+ /**
+ * Добавляем событие в ленту
+ */
+ $this->Stream_write($this->oUserCurrent->getId(), 'join_blog', $oBlog->getId());
+ /**
+ * Добавляем подписку на этот блог в ленту пользователя
+ */
+ $this->Userfeed_subscribeUser($this->oUserCurrent->getId(), ModuleUserfeed::SUBSCRIBE_TYPE_BLOG,
+ $oBlog->getId());
+ } else {
+ $sMsg = ($oBlog->getType() == 'close')
+ ? $this->Lang_Get('blog.join.notices.error_invite')
+ : $this->Lang_Get('common.error.system.base');
+ $this->Message_AddErrorSingle($sMsg, $this->Lang_Get('common.error.error'));
+ return;
+ }
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('blog.join.notices.error_self'),
+ $this->Lang_Get('common.attention'));
+ return;
+ }
+ }
+ if ($oBlogUser && $oBlogUser->getUserRole() > ModuleBlog::BLOG_USER_ROLE_GUEST) {
+ /**
+ * Покидаем блог
+ */
+ if ($this->Blog_DeleteRelationBlogUser($oBlogUser)) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('blog.join.notices.leave_success'),
+ $this->Lang_Get('common.attention'));
+ $this->Viewer_AssignAjax('bState', false);
+ /**
+ * Уменьшаем число читателей блога
+ */
+ $oBlog->setCountUser($oBlog->getCountUser() - 1);
+ $this->Blog_UpdateBlog($oBlog);
+ $this->Viewer_AssignAjax('iCountUser', $oBlog->getCountUser());
+ /**
+ * Удаляем подписку на этот блог в ленте пользователя
+ */
+ $this->Userfeed_unsubscribeUser($this->oUserCurrent->getId(), ModuleUserfeed::SUBSCRIBE_TYPE_BLOG,
+ $oBlog->getId());
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+ }
+
+ /**
+ * Загрузка аватара в блог
+ */
+ protected function EventAjaxUploadAvatar()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ if (!isset($_FILES['photo']['tmp_name'])) {
+ return $this->EventErrorDebug();
+ }
+
+ if (!$oBlog = $this->Blog_GetBlogById(getRequestStr('target_id'))) {
+ return $this->EventErrorDebug();
+ }
+ if (!$oBlog->isAllowEdit()) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Копируем загруженный файл
+ */
+ $sFileTmp = Config::Get('sys.cache.dir') . func_generator();
+ if (!move_uploaded_file($_FILES['photo']['tmp_name'], $sFileTmp)) {
+ return false;
+ }
+ /**
+ * Если объект изображения не создан, возвращаем ошибку
+ */
+ if (!$oImage = $this->Image_Open($sFileTmp)) {
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ $this->Message_AddError($this->Image_GetLastError());
+ return;
+ }
+ /**
+ * Ресайзим и сохраняем именьшенную копию
+ * Храним две копии - мелкую для показа пользователю и крупную в качестве исходной для ресайза
+ */
+ $sDir = Config::Get('path.uploads.images') . "/tmp/blog/{$oBlog->getId()}";
+ if ($sFileOriginal = $oImage->resize(1000, null)->saveSmart($sDir, 'original', array('skip_watermark' => true))) {
+ if ($sFilePreview = $oImage->resize(350, null)->saveSmart($sDir, 'preview', array('skip_watermark' => true))) {
+ list($iOriginalWidth, $iOriginalHeight) = @getimagesize($this->Fs_GetPathServer($sFileOriginal));
+ list($iWidth, $iHeight) = @getimagesize($this->Fs_GetPathServer($sFilePreview));
+ /**
+ * Сохраняем в сессии временный файл с изображением
+ */
+ $this->Session_Set('sBlogAvatarFileTmp', $sFileOriginal);
+ $this->Session_Set('sBlogAvatarFilePreviewTmp', $sFilePreview);
+ $this->Viewer_AssignAjax('path', $this->Fs_GetPathWeb($sFilePreview));
+ $this->Viewer_AssignAjax('original_width', $iOriginalWidth);
+ $this->Viewer_AssignAjax('original_height', $iOriginalHeight);
+ $this->Viewer_AssignAjax('width', $iWidth);
+ $this->Viewer_AssignAjax('height', $iHeight);
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ return;
+ }
+ }
+ $this->Message_AddError($this->Image_GetLastError());
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ }
+
+ /**
+ * Обрезка аватара блога
+ */
+ protected function EventAjaxCropAvatar()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$oBlog = $this->Blog_GetBlogById(getRequestStr('target_id'))) {
+ return $this->EventErrorDebug();
+ }
+ if (!$oBlog->isAllowEdit()) {
+ return $this->EventErrorDebug();
+ }
+
+ $sFile = $this->Session_Get('sBlogAvatarFileTmp');
+ $sFilePreview = $this->Session_Get('sBlogAvatarFilePreviewTmp');
+ if (!$this->Image_IsExistsFile($sFile)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'));
+ return;
+ }
+
+ if (true === ($res = $this->Blog_CreateAvatar($sFile, $oBlog, getRequest('size'),
+ getRequestStr('canvas_width')))
+ ) {
+ $this->Image_RemoveFile($sFile);
+ $this->Image_RemoveFile($sFilePreview);
+ $this->Session_Drop('sBlogAvatarFileTmp');
+ $this->Session_Drop('sBlogAvatarFilePreviewTmp');
+
+ $this->Viewer_AssignAjax('upload_text', $this->Lang_Get('user.photo.actions.change_photo'));
+ $this->Viewer_AssignAjax('photo', $oBlog->getAvatarBig());
+ } else {
+ $this->Message_AddError(is_string($res) ? $res : $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ /**
+ * Удаляет временные файлы кропа аватара
+ */
+ protected function EventAjaxCropCancelAvatar()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$oBlog = $this->Blog_GetBlogById(getRequestStr('target_id'))) {
+ return $this->EventErrorDebug();
+ }
+ if (!$oBlog->isAllowEdit()) {
+ return $this->EventErrorDebug();
+ }
+
+ $sFile = $this->Session_Get('sBlogAvatarFileTmp');
+ $sFilePreview = $this->Session_Get('sBlogAvatarFilePreviewTmp');
+
+ $this->Image_RemoveFile($sFile);
+ $this->Image_RemoveFile($sFilePreview);
+ $this->Session_Drop('sBlogAvatarFileTmp');
+ $this->Session_Drop('sBlogAvatarFilePreviewTmp');
+ }
+
+ /**
+ * Удаление аватара блога
+ */
+ protected function EventAjaxRemoveAvatar()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$oBlog = $this->Blog_GetBlogById(getRequestStr('target_id'))) {
+ return $this->EventErrorDebug();
+ }
+ if (!$oBlog->isAllowEdit()) {
+ return $this->EventErrorDebug();
+ }
+
+ $this->Blog_DeleteBlogAvatar($oBlog);
+ $this->Blog_UpdateBlog($oBlog);
+
+ $this->Viewer_AssignAjax('upload_text', $this->Lang_Get('user.photo.actions.upload_photo'));
+ $this->Viewer_AssignAjax('photo', $oBlog->getAvatarBig());
+ $this->Viewer_AssignAjax('avatars', $oBlog->GetProfileAvatarsPath());
+ }
+
+ /**
+ * Показывает модальное окно с кропом аватара
+ */
+ protected function EventAjaxModalCropAvatar()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+
+ $oViewer->Assign('image', getRequestStr('path'), true);
+ $oViewer->Assign('originalWidth', (int)getRequest('original_width'), true);
+ $oViewer->Assign('originalHeight', (int)getRequest('original_height'), true);
+ $oViewer->Assign('width', (int)getRequest('width'), true);
+ $oViewer->Assign('height', (int)getRequest('height'), true);
+
+ $this->Viewer_AssignAjax('sText', $oViewer->Fetch("component@blog.modal.crop-avatar"));
+ }
+
+ /**
+ * Выполняется при завершении работы экшена
+ *
+ */
+ public function EventShutdown()
+ {
+ /**
+ * Загружаем в шаблон необходимые переменные
+ */
+ $this->Viewer_Assign('sMenuHeadItemSelect', $this->sMenuHeadItemSelect);
+ $this->Viewer_Assign('sMenuItemSelect', $this->sMenuItemSelect);
+ $this->Viewer_Assign('sMenuSubItemSelect', $this->sMenuSubItemSelect);
+ $this->Viewer_Assign('sMenuSubBlogUrl', $this->sMenuSubBlogUrl);
+ $this->Viewer_Assign('iCountTopicsCollectiveNew', $this->iCountTopicsCollectiveNew);
+ $this->Viewer_Assign('iCountTopicsPersonalNew', $this->iCountTopicsPersonalNew);
+ $this->Viewer_Assign('iCountTopicsBlogNew', $this->iCountTopicsBlogNew);
+ $this->Viewer_Assign('iCountTopicsNew', $this->iCountTopicsNew);
+ $this->Viewer_Assign('iCountTopicsSubNew', $this->iCountTopicsSubNew);
+ $this->Viewer_Assign('sNavTopicsSubUrl', $this->sNavTopicsSubUrl);
+
+ $this->Viewer_Assign('BLOG_USER_ROLE_GUEST', ModuleBlog::BLOG_USER_ROLE_GUEST);
+ $this->Viewer_Assign('BLOG_USER_ROLE_USER', ModuleBlog::BLOG_USER_ROLE_USER);
+ $this->Viewer_Assign('BLOG_USER_ROLE_MODERATOR', ModuleBlog::BLOG_USER_ROLE_MODERATOR);
+ $this->Viewer_Assign('BLOG_USER_ROLE_ADMINISTRATOR', ModuleBlog::BLOG_USER_ROLE_ADMINISTRATOR);
+ $this->Viewer_Assign('BLOG_USER_ROLE_INVITE', ModuleBlog::BLOG_USER_ROLE_INVITE);
+ $this->Viewer_Assign('BLOG_USER_ROLE_REJECT', ModuleBlog::BLOG_USER_ROLE_REJECT);
+ $this->Viewer_Assign('BLOG_USER_ROLE_BAN', ModuleBlog::BLOG_USER_ROLE_BAN);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionBlogs.class.php b/application/classes/actions/ActionBlogs.class.php
new file mode 100644
index 0000000..2b9178b
--- /dev/null
+++ b/application/classes/actions/ActionBlogs.class.php
@@ -0,0 +1,189 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки УРЛа вида /comments/
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionBlogs extends Action
+{
+ /**
+ * Текущий пользователь
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+
+ /**
+ * Инициализация
+ */
+ public function Init()
+ {
+ /**
+ * Загружаем в шаблон JS текстовки
+ */
+ $this->Lang_AddLangJs(array(
+ 'blog.join.join',
+ 'blog.join.leave'
+ ));
+ /**
+ * Получаем текущего пользователя
+ */
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('blog.menu.all_list'));
+ }
+
+ /**
+ * Регистрируем евенты
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEventPreg('/^(page([1-9]\d{0,5}))?$/i', 'EventShowBlogs');
+ $this->AddEventPreg('/^ajax-search$/i', 'EventAjaxSearch');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Поиск блогов по названию
+ */
+ protected function EventAjaxSearch()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Фильтр
+ */
+ $aFilter = array(
+ 'exclude_type' => 'personal',
+ );
+ $sOrderWay = in_array(getRequestStr('order'), array('desc', 'asc')) ? getRequestStr('order') : 'desc';
+ $sOrderField = in_array(getRequestStr('sort_by'), array(
+ 'blog_id',
+ 'blog_title',
+ 'blog_count_user',
+ 'blog_count_topic'
+ )) ? getRequestStr('sort_by') : 'blog_count_user';
+ if (is_numeric(getRequestStr('next_page')) and getRequestStr('next_page') > 0) {
+ $iPage = getRequestStr('next_page');
+ } else {
+ $iPage = 1;
+ }
+ /**
+ * Получаем из реквеста первые буквы блога
+ */
+ if ($sTitle = getRequestStr('sText')) {
+ $sTitle = str_replace('%', '', $sTitle);
+ } else {
+ $sTitle = '';
+ }
+ if ($sTitle) {
+ $aFilter['title'] = "%{$sTitle}%";
+ }
+ /**
+ * Категории
+ */
+ if (getRequestStr('category') and $oCategory = $this->Category_GetCategoryById(getRequestStr('category'))) {
+ /**
+ * Получаем ID всех блогов
+ * По сути это костыль, но т.к. блогов обычно не много, то норм
+ */
+ $aBlogIds = $this->Blog_GetTargetIdsByCategory($oCategory, 1, 1000, true);
+ $aFilter['id'] = $aBlogIds ? $aBlogIds : array(0);
+ }
+ /**
+ * Тип
+ */
+ if (in_array(getRequestStr('type'), array('open', 'close'))) {
+ $aFilter['type'] = getRequestStr('type');
+ }
+ /**
+ * Принадлежность
+ */
+ if ($this->oUserCurrent) {
+ if (getRequestStr('relation') == 'my') {
+ $aFilter['user_owner_id'] = $this->oUserCurrent->getId();
+ } elseif (getRequestStr('relation') == 'join') {
+ $aFilter['roles'] = array(ModuleBlog::BLOG_USER_ROLE_USER, ModuleBlog::BLOG_USER_ROLE_ADMINISTRATOR, ModuleBlog::BLOG_USER_ROLE_MODERATOR);
+ }
+ }
+ /**
+ * Ищем блоги
+ */
+ $aResult = $this->Blog_GetBlogsByFilter($aFilter, array($sOrderField => $sOrderWay), $iPage,
+ Config::Get('module.blog.per_page'));
+ $bHideMore = $iPage * Config::Get('module.blog.per_page') >= $aResult['count'];
+ /**
+ * Формируем и возвращает ответ
+ */
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('blogs', $aResult['collection'], true);
+ $oViewer->Assign('oUserCurrent', $this->User_GetUserCurrent());
+ $this->Viewer_AssignAjax('html', $oViewer->Fetch("component@blog.list-loop"));
+ /**
+ * Для подгрузки
+ */
+ $this->Viewer_AssignAjax('count_loaded', count($aResult['collection']));
+ $this->Viewer_AssignAjax('next_page', count($aResult['collection']) > 0 ? $iPage + 1 : $iPage);
+ $this->Viewer_AssignAjax('searchCount', (int)$aResult['count']);
+ $this->Viewer_AssignAjax('hide', $bHideMore or !$aResult['count'] or !count($aResult['collection']));
+ $this->Viewer_AssignAjax('textEmpty', $this->Lang_Get('search.alerts.empty'));
+ }
+
+ /**
+ * Отображение списка блогов
+ */
+ protected function EventShowBlogs()
+ {
+ /**
+ * Фильтр поиска блогов
+ */
+ $aFilter = array(
+ 'exclude_type' => 'personal'
+ );
+ /**
+ * Получаем список блогов
+ */
+ $aResult = $this->Blog_GetBlogsByFilter($aFilter, array('blog_count_user' => 'desc'), 1,
+ Config::Get('module.blog.per_page'));
+ $aBlogs = $aResult['collection'];
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('blogs', $aBlogs);
+ $this->Viewer_Assign('searchCount', $aResult['count']);
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('index');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionComments.class.php b/application/classes/actions/ActionComments.class.php
new file mode 100644
index 0000000..a4f40f6
--- /dev/null
+++ b/application/classes/actions/ActionComments.class.php
@@ -0,0 +1,108 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки УРЛа вида /comments/
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionComments extends Action
+{
+ /**
+ * Текущий юзер
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+ /**
+ * Главное меню
+ *
+ * @var string
+ */
+ protected $sMenuHeadItemSelect = 'blog';
+
+ /**
+ * Инициализация
+ */
+ public function Init()
+ {
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Регистрация евентов
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEventPreg('/^\d+$/i', 'EventShowComment');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Обрабатывает ссылку на конкретный комментарий, определят к какому топику он относится и перенаправляет на него
+ * Актуально при использовании постраничности комментариев
+ */
+ protected function EventShowComment()
+ {
+ $iCommentId = $this->sCurrentEvent;
+ /**
+ * Проверяем к чему относится комментарий
+ */
+ if (!($oComment = $this->Comment_GetCommentById($iCommentId))) {
+ return parent::EventNotFound();
+ }
+ if ($oComment->getTargetType() != 'topic' or !($oTopic = $oComment->getTarget())) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Определяем необходимую страницу для отображения комментария
+ */
+ if (!Config::Get('module.comment.use_nested') or !Config::Get('module.comment.nested_per_page')) {
+ Router::Location($oTopic->getUrl() . '#comment' . $oComment->getId());
+ }
+ $iPage = $this->Comment_GetPageCommentByTargetId($oComment->getTargetId(), $oComment->getTargetType(),
+ $oComment);
+ if ($iPage == 1) {
+ Router::Location($oTopic->getUrl() . '#comment' . $oComment->getId());
+ } else {
+ Router::Location($oTopic->getUrl() . "?cmtpage={$iPage}#comment" . $oComment->getId());
+ }
+ exit();
+ }
+
+ /**
+ * Выполняется при завершении работы экшена
+ *
+ */
+ public function EventShutdown()
+ {
+ /**
+ * Загружаем в шаблон необходимые переменные
+ */
+ $this->Viewer_Assign('sMenuHeadItemSelect', $this->sMenuHeadItemSelect);
+ }
+}
diff --git a/application/classes/actions/ActionContent.class.php b/application/classes/actions/ActionContent.class.php
new file mode 100644
index 0000000..a9909fa
--- /dev/null
+++ b/application/classes/actions/ActionContent.class.php
@@ -0,0 +1,745 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки УРЛа вида /content/ - управление своими топиками
+ *
+ * @package application.actions
+ * @since 2.0
+ */
+class ActionContent extends Action
+{
+ /**
+ * Главное меню
+ *
+ * @var string
+ */
+ protected $sMenuHeadItemSelect = 'blog';
+ /**
+ * Меню
+ *
+ * @var string
+ */
+ protected $sMenuItemSelect = 'topic';
+ /**
+ * СубМеню
+ *
+ * @var string
+ */
+ protected $sMenuSubItemSelect = 'topic';
+ /**
+ * Текущий юзер
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ /**
+ * Проверяем авторизован ли юзер
+ */
+ if (!$this->User_IsAuthorization()) {
+ return parent::EventNotFound();
+ }
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ /**
+ * Усанавливаем дефолтный евент
+ */
+ $this->SetDefaultEvent('add');
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('topic.topics'));
+ }
+
+ /**
+ * Регистрируем евенты
+ *
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEventPreg('/^add$/i', '/^[a-z_0-9]{1,50}$/i', '/^$/i', 'EventAdd');
+ $this->AddEventPreg('/^edit$/i', '/^\d{1,10}$/i', '/^$/i', 'EventEdit');
+ $this->AddEventPreg('/^delete$/i', '/^\d{1,10}$/i', '/^$/i', 'EventDelete');
+
+ $this->AddEventPreg('/^published$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventShowTopics');
+ $this->AddEventPreg('/^drafts$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventShowTopics');
+ $this->AddEventPreg('/^deferred$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventShowTopics');
+
+ $this->AddEventPreg('/^ajax$/i', '/^add$/i', '/^$/i', 'EventAjaxAdd');
+ $this->AddEventPreg('/^ajax$/i', '/^edit$/i', '/^$/i', 'EventAjaxEdit');
+ $this->AddEventPreg('/^ajax$/i', '/^preview$/i', '/^$/i', 'EventAjaxPreview');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Выводит список топиков
+ *
+ */
+ protected function EventShowTopics()
+ {
+ /**
+ * Меню
+ */
+ $this->sMenuSubItemSelect = $this->sCurrentEvent;
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(0, 2) ? $this->GetParamEventMatch(0, 2) : 1;
+ /**
+ * Получаем список топиков
+ */
+ if ($this->sCurrentEvent == 'deferred') {
+ $aResult = $this->Topic_GetTopicsPersonalDeferredByUser($this->oUserCurrent->getId(), $iPage, Config::Get('module.topic.per_page'));
+ $this->SetTemplateAction('drafts');
+ } else {
+ $aResult = $this->Topic_GetTopicsPersonalByUser($this->oUserCurrent->getId(),
+ $this->sCurrentEvent == 'published' ? 1 : 0, $iPage, Config::Get('module.topic.per_page'));
+ }
+ $aTopics = $aResult['collection'];
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), Router::GetPath('content') . $this->sCurrentEvent);
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('topics', $aTopics);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('topic.nav.' . $this->sCurrentEvent));
+ }
+
+ protected function EventDelete()
+ {
+ $this->Security_ValidateSendForm();
+ /**
+ * Получаем номер топика из УРЛ и проверяем существует ли он
+ */
+ $sTopicId = $this->GetParam(0);
+ if (!($oTopic = $this->Topic_GetTopicById($sTopicId))) {
+ return parent::EventNotFound();
+ }
+ /**
+ * проверяем есть ли право на удаление топика
+ */
+ if (!$this->ACL_IsAllowDeleteTopic($oTopic, $this->oUserCurrent)) {
+ $this->Message_AddErrorSingle($this->Rbac_GetMsgLast());
+ return Router::Action('error');
+ }
+ /**
+ * Удаляем топик
+ */
+ $this->Hook_Run('topic_delete_before', array('oTopic' => $oTopic));
+ $this->Topic_DeleteTopic($oTopic);
+ $this->Hook_Run('topic_delete_after', array('oTopic' => $oTopic));
+ /**
+ * Перенаправляем на страницу со списком топиков из блога этого топика
+ */
+ Router::Location($oTopic->getBlog()->getUrlFull());
+ }
+
+ protected function EventEdit()
+ {
+ /**
+ * Получаем номер топика из УРЛ и проверяем существует ли он
+ */
+ $sTopicId = $this->GetParam(0);
+ if (!($oTopic = $this->Topic_GetTopicById($sTopicId))) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Проверяем тип топика
+ */
+ if (!$oTopicType = $this->Topic_GetTopicType($oTopic->getType())) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Если права на редактирование
+ */
+ if (!$this->ACL_IsAllowEditTopic($oTopic, $this->oUserCurrent)) {
+ return parent::EventNotFound();
+ }
+
+ /**
+ * Получаем доступные блоги по типам
+ */
+ $aBlogs = array();
+ $aBlogs['open'] = $this->Blog_GetBlogsByType('open');
+ /**
+ * Убираем из списка блоги в которые не доступен постинг
+ */
+ $aBlogsCurrent = $oTopic->getBlogIds();
+ foreach ($aBlogs['open'] as $k => $oBlogOpen) {
+ if (!$this->ACL_IsAllowBlog($oBlogOpen, $this->oUserCurrent) and !in_array($oBlogOpen->getId(), $aBlogsCurrent)) {
+ unset($aBlogs['open'][$k]);
+ }
+ }
+ if ($this->oUserCurrent->isAdministrator()) {
+ $aBlogs['close'] = $this->Blog_GetBlogsByType('close');
+ } else {
+ $aBlogs['close'] = $this->Blog_GetBlogsByTypeAndUserId('close', $this->oUserCurrent->getId());
+ }
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('topic_edit_show', array('oTopic' => $oTopic, 'aBlogs' => &$aBlogs));
+
+ /**
+ * Дополнительно загружам превью
+ */
+ $aFilter = array(
+ 'target_type' => 'topic',
+ 'is_preview' => 1,
+ 'target_id' => $sTopicId
+ );
+ $aTargetItems = $this->Media_GetTargetItemsByFilter($aFilter);
+ $this->Viewer_Assign('imagePreviewItems', $aTargetItems);
+
+ /**
+ * Проверяем на отсутствие блогов
+ */
+ $bSkipBlogs = true;
+ foreach ($aBlogs as $aBlogsType) {
+ if ($aBlogsType) {
+ $bSkipBlogs = false;
+ }
+ }
+
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('blogsAllow', $aBlogs);
+ $this->Viewer_Assign('skipBlogs', $bSkipBlogs);
+ $this->Viewer_Assign('topicType', $oTopicType);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('topic.add.title.edit'));
+
+ $this->Viewer_Assign('topicEdit', $oTopic);
+ $this->SetTemplateAction('add');
+ }
+
+ /**
+ * Добавление топика
+ *
+ */
+ protected function EventAdd()
+ {
+ $sTopicType = $this->GetParam(0);
+ $iBlogId = (int)getRequest('blog_id');
+
+ if (!$oTopicType = $this->Topic_GetTopicType($sTopicType)) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Проверяем права на создание топика
+ */
+ if (!$this->ACL_CanAddTopic($this->oUserCurrent, $oTopicType)) {
+ $this->Message_AddErrorSingle($this->Rbac_GetMsgLast());
+ return Router::Action('error');
+ }
+ $this->sMenuSubItemSelect = $sTopicType;
+ /**
+ * Получаем доступные блоги по типам
+ */
+ $aBlogs = array();
+ $aBlogs['open'] = $this->Blog_GetBlogsByType('open');
+ /**
+ * Убираем из списка блоги в которые не доступен постинг
+ */
+ foreach ($aBlogs['open'] as $k => $oBlogOpen) {
+ if (!$this->ACL_IsAllowBlog($oBlogOpen, $this->oUserCurrent)) {
+ unset($aBlogs['open'][$k]);
+ }
+ }
+ if ($this->oUserCurrent->isAdministrator()) {
+ $aBlogs['close'] = $this->Blog_GetBlogsByType('close');
+ } else {
+ $aBlogs['close'] = $this->Blog_GetBlogsByTypeAndUserId('close', $this->oUserCurrent->getId());
+ }
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('topic_add_show', array('aBlogs' => &$aBlogs));
+ /**
+ * Проверяем на отсутствие блогов
+ */
+ $bSkipBlogs = true;
+ foreach ($aBlogs as $aBlogsType) {
+ if ($aBlogsType) {
+ $bSkipBlogs = false;
+ }
+ }
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('topicType', $oTopicType);
+ $this->Viewer_Assign('blogsAllow', $aBlogs);
+ $this->Viewer_Assign('skipBlogs', $bSkipBlogs);
+ $this->Viewer_Assign('blogId', $iBlogId);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('topic.add.title.add'));
+ $this->SetTemplateAction('add');
+ }
+
+ protected function EventAjaxEdit()
+ {
+ $this->Viewer_SetResponseAjax();
+
+ $aTopicRequest = getRequest('topic');
+ if (!(isset($aTopicRequest['id']) and $oTopic = $this->Topic_GetTopicById($aTopicRequest['id']))) {
+ return $this->EventErrorDebug();
+ }
+ if (!$this->Topic_IsAllowTopicType($oTopic->getType())) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверяем разрешено ли постить топик по времени
+ */
+ if (!isPost('is_draft') and !$oTopic->getPublishDraft() and !$this->ACL_CanPostTopicTime($this->oUserCurrent)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('topic.add.notices.time_limit'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+
+ /**
+ * Если права на редактирование
+ */
+ if (!$this->ACL_IsAllowEditTopic($oTopic, $this->oUserCurrent)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Сохраняем старое значение идентификатора основного блога и всех блогов
+ */
+ $sBlogIdOld = $oTopic->getBlogId();
+ $aBlogsIdOld = $oTopic->getBlogsId();
+
+ $oTopic->_setDataSafe(getRequest('topic'));
+ $oTopic->setProperties(getRequest('property'));
+ $oTopic->setUserCreator($this->oUserCurrent);
+ $oTopic->setUserIp(func_getIp());
+ if (!$oTopic->getTags() or !$oTopic->getTypeObject()->getParam('allow_tags')) {
+ $oTopic->setTags('');
+ }
+ /**
+ * Принудительный вывод на главную
+ */
+ if ($this->ACL_IsAllowTopicPublishIndex($this->oUserCurrent)) {
+ if (isset($_REQUEST['topic']['topic_publish_index'])) {
+ $oTopic->setPublishIndex(1);
+ } else {
+ $oTopic->setPublishIndex(0);
+ }
+ }
+ /**
+ * Принудительный запрет вывода на главную
+ */
+ if ($this->ACL_IsAllowTopicSkipIndex($this->oUserCurrent)) {
+ if (isset($_REQUEST['topic']['topic_skip_index'])) {
+ $oTopic->setSkipIndex(1);
+ } else {
+ $oTopic->setSkipIndex(0);
+ }
+ }
+ /**
+ * Запрет на комментарии к топику
+ */
+ $oTopic->setForbidComment(0);
+ if (isset($_REQUEST['topic']['topic_forbid_comment'])) {
+ $oTopic->setForbidComment(1);
+ }
+ /**
+ * Дата редактирования контента
+ */
+ $oTopic->setDateEditContent(date('Y-m-d H:i:s'));
+
+ $this->Hook_Run('topic_edit_validate_before', array('oTopic' => $oTopic));
+ if ($oTopic->_Validate()) {
+ /**
+ * Публикуем или сохраняем в черновиках
+ */
+ $bSendNotify = false;
+ if (!isset($_REQUEST['is_draft'])) {
+ $oTopic->setPublish(1);
+ if ($oTopic->getPublishDraft() == 0) {
+ $oTopic->setPublishDraft(1);
+ $oTopic->setDatePublish(date("Y-m-d H:i:s"));
+ $bSendNotify = true;
+ }
+ } else {
+ $oTopic->setPublish(0);
+ }
+ /**
+ * Отложенная публикация
+ */
+ if ($oTopic->getPublishDateRaw()) {
+ $oTopic->setDatePublish(date("Y-m-d H:i:s", $oTopic->getPublishDateRaw()));
+ $bSendNotify = false;
+ } else {
+ /**
+ * Снятие даты публикации, только при условии, что была установлена дата в будущем
+ */
+ if ($oTopic->getDatePublish() and strtotime($oTopic->getDatePublish()) > time()) {
+ $oTopic->setDatePublish(date("Y-m-d H:i:s"));
+ /**
+ * Если сохраняем отложенный в черновик, то считаем, что он еще ниразу не публиковался
+ */
+ if (isset($_REQUEST['is_draft'])) {
+ $oTopic->setPublishDraft(0);
+ }
+ }
+ }
+ $oBlog = $oTopic->getBlog();
+ /**
+ * Получаемый и устанавливаем разрезанный текст по тегу
+ */
+ if ($oTopic->getTypeObject()->getParam('allow_text')) {
+ list($sTextShort, $sTextNew, $sTextCut) = $this->Text_Cut($oTopic->getTextSource());
+ $oTopic->setCutText($sTextCut);
+ // TODO: передача параметров в Topic_Parser пока не используется - нужно заменить на этот вызов все места с парсингом топика
+ $oTopic->setText($this->Topic_Parser($sTextNew, $oTopic));
+ if ($sTextShort != $sTextNew) {
+ $oTopic->setTextShort($this->Topic_Parser($sTextShort, $oTopic));
+ } else {
+ $oTopic->setTextShort('');
+ }
+ } else {
+ $oTopic->setCutText('');
+ $oTopic->setText('');
+ $oTopic->setTextShort('');
+ $oTopic->setTextSource('');
+ }
+ $this->Hook_Run('topic_edit_before', array('oTopic' => $oTopic, 'oBlog' => $oBlog));
+ /**
+ * Сохраняем топик
+ */
+ if ($this->Topic_UpdateTopic($oTopic)) {
+ $this->Hook_Run('topic_edit_after',
+ array('oTopic' => $oTopic, 'oBlog' => $oBlog, 'bSendNotify' => &$bSendNotify));
+ /**
+ * Обновляем данные в комментариях, если топик был перенесен в новый блог
+ */
+ if ($sBlogIdOld != $oTopic->getBlogId()) {
+ $this->Comment_UpdateTargetParentByTargetId($oTopic->getBlogId(), 'topic', $oTopic->getId());
+ $this->Comment_UpdateTargetParentByTargetIdOnline($oTopic->getBlogId(), 'topic', $oTopic->getId());
+ }
+ /**
+ * Обновляем количество топиков в блоге
+ */
+ if ($aBlogsIdOld != $oTopic->getBlogsId()) {
+ $this->Blog_RecalculateCountTopicByBlogId($aBlogsIdOld);
+ }
+ $this->Blog_RecalculateCountTopicByBlogId($oTopic->getBlogsId());
+ /**
+ * Добавляем событие в ленту
+ */
+ $this->Stream_write($oTopic->getUserId(), 'add_topic', $oTopic->getId(),
+ $oTopic->getPublish() && $oBlog->getType() != 'close', $oTopic->getDatePublish());
+ /**
+ * Рассылаем о новом топике подписчикам блога
+ */
+ if ($bSendNotify) {
+ $this->Topic_SendNotifyTopicNew($oTopic, $oTopic->getUser());
+ }
+ if (!$oTopic->getPublish() and !$this->oUserCurrent->isAdministrator() and $this->oUserCurrent->getId() != $oTopic->getUserId()) {
+ $sUrlRedirect = $oBlog->getUrlFull();
+ } else {
+ $sUrlRedirect = $oTopic->getUrl();
+ }
+
+ $this->Viewer_AssignAjax('sUrlRedirect', $sUrlRedirect);
+ $this->Message_AddNotice($this->Lang_Get('topic.add.notices.update_complete'), $this->Lang_Get('common.attention'));
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'));
+ }
+ } else {
+ $this->Message_AddError($oTopic->_getValidateError(), $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ protected function EventAjaxAdd()
+ {
+ $this->Viewer_SetResponseAjax();
+ /**
+ * Проверяем тип топика
+ */
+ $sTopicType = getRequestStr('topic_type');
+ if (!$oTopicType = $this->Topic_GetTopicType($sTopicType)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверяем права на создание топика
+ */
+ if (!$this->ACL_CanAddTopic($this->oUserCurrent, $oTopicType)) {
+ $this->Message_AddErrorSingle($this->Rbac_GetMsgLast());
+ return false;
+ }
+ /**
+ * Создаем топик
+ */
+ $oTopic = Engine::GetEntity('Topic');
+ $oTopic->_setDataSafe(getRequest('topic'));
+
+ $oTopic->setProperties(getRequest('property'));
+ $oTopic->setUserCreator($this->oUserCurrent);
+ $oTopic->setUserId($this->oUserCurrent->getId());
+ $oTopic->setDateAdd(date("Y-m-d H:i:s"));
+ $oTopic->setUserIp(func_getIp());
+ $oTopic->setTopicType($sTopicType);
+ if (!$oTopic->getTags() or !$oTopic->getTypeObject()->getParam('allow_tags')) {
+ $oTopic->setTags('');
+ }
+ /**
+ * Публикуем или сохраняем
+ */
+ if (!isset($_REQUEST['is_draft'])) {
+ $oTopic->setPublish(1);
+ $oTopic->setPublishDraft(1);
+ } else {
+ $oTopic->setPublish(0);
+ $oTopic->setPublishDraft(0);
+ }
+ /**
+ * Принудительный вывод на главную
+ */
+ $oTopic->setPublishIndex(0);
+ if ($this->ACL_IsAllowTopicPublishIndex($this->oUserCurrent)) {
+ if (isset($_REQUEST['topic']['topic_publish_index'])) {
+ $oTopic->setPublishIndex(1);
+ }
+ }
+ /**
+ * Принудительный запрет вывода на главную
+ */
+ $oTopic->setSkipIndex(0);
+ if ($this->ACL_IsAllowTopicSkipIndex($this->oUserCurrent)) {
+ if (isset($_REQUEST['topic']['topic_skip_index'])) {
+ $oTopic->setSkipIndex(1);
+ }
+ }
+ /**
+ * Запрет на комментарии к топику
+ */
+ $oTopic->setForbidComment(0);
+ if (isset($_REQUEST['topic']['topic_forbid_comment'])) {
+ $oTopic->setForbidComment(1);
+ }
+
+ $this->Hook_Run('topic_add_validate_before', array('oTopic' => $oTopic));
+ if ($oTopic->_Validate()) {
+ if ($oTopic->getPublishDateRaw()) {
+ $oTopic->setDatePublish(date("Y-m-d H:i:s", $oTopic->getPublishDateRaw()));
+ }
+ $oBlog = $oTopic->getBlog();
+ /**
+ * Получаем и устанавливаем разрезанный текст по тегу
+ */
+ if ($oTopic->getTypeObject()->getParam('allow_text')) {
+ list($sTextShort, $sTextNew, $sTextCut) = $this->Text_Cut($oTopic->getTextSource());
+ $oTopic->setCutText($sTextCut);
+ $oTopic->setText($this->Topic_Parser($sTextNew, $oTopic));
+ if ($sTextShort != $sTextNew) {
+ $oTopic->setTextShort($this->Topic_Parser($sTextShort, $oTopic));
+ } else {
+ $oTopic->setTextShort('');
+ }
+ } else {
+ $oTopic->setCutText('');
+ $oTopic->setText('');
+ $oTopic->setTextShort('');
+ $oTopic->setTextSource('');
+ }
+
+ $this->Hook_Run('topic_add_before', array('oTopic' => $oTopic, 'oBlog' => $oBlog));
+ if ($this->Topic_AddTopic($oTopic)) {
+ $this->Hook_Run('topic_add_after', array('oTopic' => $oTopic, 'oBlog' => $oBlog));
+ /**
+ * Получаем топик, чтоб подцепить связанные данные
+ */
+ $oTopic = $this->Topic_GetTopicById($oTopic->getId());
+ /**
+ * Обновляем количество топиков в блогах
+ */
+ $this->Blog_RecalculateCountTopicByBlogId($oTopic->getBlogsId());
+ /**
+ * Фиксируем ID у media файлов топика
+ */
+ $this->Media_ReplaceTargetTmpById('topic', $oTopic->getId());
+ /**
+ * Фиксируем ID у опросов
+ */
+ if ($oTopicType->getParam('allow_poll')) {
+ $this->Poll_ReplaceTargetTmpById('topic', $oTopic->getId());
+ }
+ /**
+ * Добавляем автора топика в подписчики на новые комментарии к этому топику
+ */
+ $oUser = $oTopic->getUser();
+ if ($oUser) {
+ $this->Subscribe_AddSubscribeSimple('topic_new_comment', $oTopic->getId(), $oUser->getMail(),
+ $oUser->getId());
+ }
+ /**
+ * Делаем рассылку спама всем, кто состоит в этом блоге
+ */
+ if ($oTopic->getPublish() == 1 and $oBlog->getType() != 'personal' and strtotime($oTopic->getDatePublish()) <= time()) {
+ $this->Topic_SendNotifyTopicNew($oTopic, $oUser);
+ }
+ /**
+ * Добавляем событие в ленту
+ */
+ $this->Stream_write($oTopic->getUserId(), 'add_topic', $oTopic->getId(),
+ $oTopic->getPublish() && $oBlog->getType() != 'close', $oTopic->getDatePublish());
+
+ $this->Viewer_AssignAjax('sUrlRedirect', $oTopic->getUrl());
+ $this->Message_AddNotice($this->Lang_Get('topic.add.notices.create_complete'), $this->Lang_Get('common.attention'));
+ } else {
+ $this->Message_AddError($this->Lang_Get('common.error.error'));
+ }
+ } else {
+ $this->Message_AddError($oTopic->_getValidateError(), $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ public function EventAjaxPreview()
+ {
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Допустимый тип топика?
+ */
+ if (!$this->Topic_IsAllowTopicType($sType = getRequestStr('topic_type'))) {
+ $this->Message_AddErrorSingle($this->Lang_Get('topic.add.notices.error_type'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $aTopicRequest = getRequest('topic');
+ /**
+ * Проверка на ID при редактировании топика
+ */
+ $iId = isset($aTopicRequest['id']) ? (int)$aTopicRequest['id'] : null;
+ if ($iId and !($oTopicOriginal = $this->Topic_GetTopicById($iId))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Если права на редактирование
+ */
+ if ($iId and !$this->ACL_IsAllowEditTopic($oTopicOriginal, $this->oUserCurrent)) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Создаем объект топика для валидации данных
+ */
+ $oTopic = Engine::GetEntity('ModuleTopic_EntityTopic');
+ $oTopic->setTitle(isset($aTopicRequest['topic_title']) ? strip_tags($aTopicRequest['topic_title']) : '');
+ $oTopic->setTextSource(isset($aTopicRequest['topic_text_source']) ? $aTopicRequest['topic_text_source'] : '');
+ $oTopic->setTags(isset($aTopicRequest['topic_tags']) ? $aTopicRequest['topic_tags'] : '');
+ $oTopic->setDateAdd(date("Y-m-d H:i:s"));
+ $oTopic->setDatePublish(date("Y-m-d H:i:s"));
+ $oTopic->setUserId($this->oUserCurrent->getId());
+ $oTopic->setType($sType);
+ $oTopic->setPublish(1);
+ $oTopic->setProperties(getRequest('property'));
+ /**
+ * Перед валидацией аттачим существующие свойста
+ */
+ if ($iId) {
+ $oTopic->setId($iId);
+ $a = $oTopic->getPropertyList();
+ }
+ /**
+ * Валидируем необходимые поля топика
+ */
+ $oTopic->_Validate(array('topic_title', 'topic_text', 'topic_tags', 'topic_type', 'properties'), false);
+ if ($oTopic->_hasValidateErrors()) {
+ $this->Message_AddErrorSingle($oTopic->_getValidateError());
+ return false;
+ }
+ /**
+ * Аттачим опросы
+ */
+ if (!$oTopic->getId()) {
+ $aPolls = array();
+ if ($sPollTargetTmp = $this->Session_GetCookie('poll_target_tmp_topic')) {
+ $aPolls = $this->Poll_GetPollItemsByFilter(array(
+ 'target_type' => 'topic',
+ 'target_tmp' => $sPollTargetTmp,
+ '#order' => array('id' => 'asc')
+ ));
+ }
+ $oTopic->setPolls($aPolls);
+ }
+ /**
+ * Аттачим дополнительные поля к топику
+ */
+ $this->Property_AttachPropertiesForTarget($oTopic, $oTopic->getPropertiesObject());
+ /**
+ * Формируем текст топика
+ */
+ list($sTextShort, $sTextNew, $sTextCut) = $this->Text_Cut($oTopic->getTextSource());
+ $oTopic->setCutText($sTextCut);
+ $oTopic->setText($this->Topic_Parser($sTextNew, $oTopic));
+ $oTopic->setTextShort($this->Topic_Parser($sTextShort, $oTopic));
+ /**
+ * Рендерим шаблон для предпросмотра топика
+ */
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $aParams = array(
+ 'isPreview' => true,
+ 'topic' => $oTopic,
+ );
+ foreach ($aParams as $sName => $mValue) {
+ $oViewer->Assign($sName, $mValue, true);
+ }
+ $oViewer->Assign('params', $aParams); // fix для корректной работы подключения внутренних шаблонов компонента
+
+ $sTemplate = 'component@topic.type';
+ $sTextResult = $oViewer->Fetch($sTemplate);
+ /**
+ * Передаем результат в ajax ответ
+ */
+ $this->Viewer_AssignAjax('sText', $sTextResult);
+ return true;
+ }
+
+ /**
+ * При завершении экшена загружаем необходимые переменные
+ *
+ */
+ public function EventShutdown()
+ {
+ $this->Viewer_Assign('sMenuHeadItemSelect', $this->sMenuHeadItemSelect);
+ $this->Viewer_Assign('sMenuItemSelect', $this->sMenuItemSelect);
+ $this->Viewer_Assign('sMenuSubItemSelect', $this->sMenuSubItemSelect);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionDonate.class.php b/application/classes/actions/ActionDonate.class.php
new file mode 100644
index 0000000..d0bebe7
--- /dev/null
+++ b/application/classes/actions/ActionDonate.class.php
@@ -0,0 +1,43 @@
+SetDefaultEvent('index');
+ }
+
+ /**
+ * Регистрируем евенты
+ *
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEvent('index', 'EventIndex');
+ }
+
+ /**
+ * Вывод правил
+ *
+ */
+ protected function EventIndex()
+ {
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle('Поддержать IFHub');
+ $this->SetTemplateAction('index');
+ }
+}
diff --git a/application/classes/actions/ActionError.class.php b/application/classes/actions/ActionError.class.php
new file mode 100644
index 0000000..724b8e5
--- /dev/null
+++ b/application/classes/actions/ActionError.class.php
@@ -0,0 +1,103 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки УРЛа вида /error/ т.е. ошибок
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionError extends Action
+{
+ /**
+ * Список специфических HTTP ошибок для которых необходимо отдавать header
+ *
+ * @var array
+ */
+ protected $aHttpErrors = array(
+ '404' => array(
+ 'header' => '404 Not Found',
+ ),
+ '403' => array(
+ 'header' => '403 Forbidden',
+ ),
+ '500' => array(
+ 'header' => '500 Internal Server Error',
+ ),
+ );
+
+ /**
+ * Инициализация экшена
+ *
+ */
+ public function Init()
+ {
+ /**
+ * Устанавливаем дефолтный евент
+ */
+ $this->SetDefaultEvent('index');
+ /**
+ * Запрешаем отображать статистику выполнения
+ */
+ Router::SetIsShowStats(false);
+ }
+
+ /**
+ * Регистрируем евенты
+ *
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEvent('index', 'EventError');
+ $this->AddEventPreg('/^\d{3}$/i', 'EventError');
+ }
+
+ /**
+ * Вывод ошибки
+ *
+ */
+ protected function EventError()
+ {
+ /**
+ * Если евент равен одной из ошибок из $aHttpErrors, то шлем браузеру специфичный header
+ * Например, для 404 в хидере будет послан браузеру заголовок HTTP/1.1 404 Not Found
+ */
+ if (array_key_exists($this->sCurrentEvent, $this->aHttpErrors)) {
+ /**
+ * Смотрим есть ли сообщения об ошибках
+ */
+ if (!$this->Message_GetError()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.code.' . $this->sCurrentEvent),
+ $this->sCurrentEvent);
+ }
+ $aHttpError = $this->aHttpErrors[$this->sCurrentEvent];
+ if (isset($aHttpError['header'])) {
+ $sProtocol = isset($_SERVER['SERVER_PROTOCOL']) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.1';
+ header("{$sProtocol} {$aHttpError['header']}");
+ }
+ }
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('common.error.error'));
+ $this->SetTemplateAction('index');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionIndex.class.php b/application/classes/actions/ActionIndex.class.php
new file mode 100644
index 0000000..be61d37
--- /dev/null
+++ b/application/classes/actions/ActionIndex.class.php
@@ -0,0 +1,372 @@
+
+ *
+ */
+
+/**
+ * Обработка главной страницы, т.е. УРЛа вида /index/
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionIndex extends Action
+{
+ /**
+ * Главное меню
+ *
+ * @var string
+ */
+ protected $sMenuHeadItemSelect = 'blog';
+ /**
+ * Меню
+ *
+ * @var string
+ */
+ protected $sMenuItemSelect = 'index';
+ /**
+ * Субменю
+ *
+ * @var string
+ */
+ protected $sMenuSubItemSelect = 'good';
+ /**
+ * Число новых топиков
+ *
+ * @var int
+ */
+ protected $iCountTopicsNew = 0;
+ /**
+ * Число новых топиков в коллективных блогах
+ *
+ * @var int
+ */
+ protected $iCountTopicsCollectiveNew = 0;
+ /**
+ * Число новых топиков в персональных блогах
+ *
+ * @var int
+ */
+ protected $iCountTopicsPersonalNew = 0;
+ /**
+ * URL-префикс для навигации по топикам
+ *
+ * @var string
+ */
+ protected $sNavTopicsSubUrl = '';
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ /**
+ * Подсчитываем новые топики
+ */
+ $this->iCountTopicsCollectiveNew = $this->Topic_GetCountTopicsCollectiveNew();
+ $this->iCountTopicsPersonalNew = $this->Topic_GetCountTopicsPersonalNew();
+ $this->iCountTopicsNew = $this->iCountTopicsCollectiveNew + $this->iCountTopicsPersonalNew;
+ $this->sNavTopicsSubUrl = Router::GetPath('index');
+ }
+
+ /**
+ * Регистрация евентов
+ *
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEventPreg('/^(page([1-9]\d{0,5}))?$/i', 'EventIndex');
+ $this->AddEventPreg('/^new$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventNew');
+ $this->AddEventPreg('/^newall$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventNewAll');
+ $this->AddEventPreg('/^discussed$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventDiscussed');
+ $this->AddEventPreg('/^top$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventTop');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Вывод рейтинговых топиков
+ */
+ protected function EventTop()
+ {
+ $sPeriod = Config::Get('module.topic.default_period_top');
+ if (in_array(getRequestStr('period'), array(1, 7, 30, 'all'))) {
+ $sPeriod = getRequestStr('period');
+ }
+ if (!$sPeriod) {
+ $sPeriod = 1;
+ }
+ /**
+ * Меню
+ */
+ $this->sMenuSubItemSelect = 'top';
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(0, 2) ? $this->GetParamEventMatch(0, 2) : 1;
+ if ($iPage == 1 and !getRequest('period')) {
+ $this->Viewer_SetHtmlCanonical(Router::GetPath('index') . 'top/');
+ }
+ /**
+ * Получаем список топиков
+ */
+ $aResult = $this->Topic_GetTopicsTop($iPage, Config::Get('module.topic.per_page'),
+ $sPeriod == 'all' ? null : $sPeriod * 60 * 60 * 24);
+ /**
+ * Если нет топиков за 1 день, то показываем за неделю (7)
+ */
+ if (!$aResult['count'] and $iPage == 1 and !getRequest('period')) {
+ $sPeriod = 7;
+ $aResult = $this->Topic_GetTopicsTop($iPage, Config::Get('module.topic.per_page'),
+ $sPeriod == 'all' ? null : $sPeriod * 60 * 60 * 24);
+ }
+ $aTopics = $aResult['collection'];
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('topics_list_show', array('aTopics' => &$aTopics));
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), Router::GetPath('index') . 'top', array('period' => $sPeriod));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('topics', $aTopics);
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('periodSelectCurrent', $sPeriod);
+ $this->Viewer_Assign('periodSelectCurrentTitle', $this->Lang_Get('blog.menu.top_period_' . $sPeriod));
+ $this->Viewer_Assign('periodSelectRoot', Router::GetPath('index') . 'top/');
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('index');
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('blog.menu.all_top'));
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('blog.menu.top_period_' . $sPeriod));
+ }
+
+ /**
+ * Вывод обсуждаемых топиков
+ */
+ protected function EventDiscussed()
+ {
+ $sPeriod = Config::Get('module.topic.default_period_discussed');
+ if (in_array(getRequestStr('period'), array(1, 7, 30, 'all'))) {
+ $sPeriod = getRequestStr('period');
+ }
+ if (!$sPeriod) {
+ $sPeriod = 1;
+ }
+ /**
+ * Меню
+ */
+ $this->sMenuSubItemSelect = 'discussed';
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(0, 2) ? $this->GetParamEventMatch(0, 2) : 1;
+ if ($iPage == 1 and !getRequest('period')) {
+ $this->Viewer_SetHtmlCanonical(Router::GetPath('index') . 'discussed/');
+ }
+ /**
+ * Получаем список топиков
+ */
+ $aResult = $this->Topic_GetTopicsDiscussed($iPage, Config::Get('module.topic.per_page'),
+ $sPeriod == 'all' ? null : $sPeriod * 60 * 60 * 24);
+ /**
+ * Если нет топиков за 1 день, то показываем за неделю (7)
+ */
+ if (!$aResult['count'] and $iPage == 1 and !getRequest('period')) {
+ $sPeriod = 7;
+ $aResult = $this->Topic_GetTopicsDiscussed($iPage, Config::Get('module.topic.per_page'),
+ $sPeriod == 'all' ? null : $sPeriod * 60 * 60 * 24);
+ }
+ $aTopics = $aResult['collection'];
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('topics_list_show', array('aTopics' => &$aTopics));
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), Router::GetPath('index') . 'discussed', array('period' => $sPeriod));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('topics', $aTopics);
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('periodSelectCurrent', $sPeriod);
+ $this->Viewer_Assign('periodSelectCurrentTitle', $this->Lang_Get('blog.menu.top_period_' . $sPeriod));
+ $this->Viewer_Assign('periodSelectRoot', Router::GetPath('index') . 'discussed/');
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('index');
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('blog.menu.all_discussed'));
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('blog.menu.top_period_' . $sPeriod));
+ }
+
+ /**
+ * Вывод новых топиков
+ */
+ protected function EventNew()
+ {
+ $this->Viewer_SetHtmlRssAlternate(Router::GetPath('rss') . 'new/', Config::Get('view.name'));
+ /**
+ * Меню
+ */
+ $this->sMenuSubItemSelect = 'new';
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(0, 2) ? $this->GetParamEventMatch(0, 2) : 1;
+ /**
+ * Получаем список топиков
+ */
+ $aResult = $this->Topic_GetTopicsNew($iPage, Config::Get('module.topic.per_page'));
+ $aTopics = $aResult['collection'];
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('topics_list_show', array('aTopics' => &$aTopics));
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), Router::GetPath('index') . 'new');
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('topics', $aTopics);
+ $this->Viewer_Assign('paging', $aPaging);
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('index');
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('blog.menu.all_new'));
+ }
+
+ /**
+ * Вывод ВСЕХ новых топиков
+ */
+ protected function EventNewAll()
+ {
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('blog.menu.all'));
+ $this->Viewer_SetHtmlRssAlternate(Router::GetPath('rss') . 'new/', Config::Get('view.name'));
+ /**
+ * Меню
+ */
+ $this->sMenuSubItemSelect = 'new';
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(0, 2) ? $this->GetParamEventMatch(0, 2) : 1;
+ /**
+ * Получаем список топиков
+ */
+ $aResult = $this->Topic_GetTopicsNewAll($iPage, Config::Get('module.topic.per_page'));
+ $aTopics = $aResult['collection'];
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('topics_list_show', array('aTopics' => &$aTopics));
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), Router::GetPath('index') . 'newall');
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('topics', $aTopics);
+ $this->Viewer_Assign('paging', $aPaging);
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Вывод интересных на главную
+ *
+ */
+ protected function EventIndex()
+ {
+ $this->Viewer_SetHtmlRssAlternate(Router::GetPath('rss') . 'index/', Config::Get('view.name'));
+ /**
+ * Меню
+ */
+ $this->sMenuSubItemSelect = 'good';
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetEventMatch(2) ? $this->GetEventMatch(2) : 1;
+ /**
+ * Устанавливаем основной URL для поисковиков
+ */
+ if ($iPage == 1) {
+ $this->Viewer_SetHtmlCanonical(Router::GetPath('/'));
+ }
+ /**
+ * Получаем список топиков
+ */
+ $aResult = $this->Topic_GetTopicsGood($iPage, Config::Get('module.topic.per_page'));
+ $aTopics = $aResult['collection'];
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('topics_list_show', array('aTopics' => &$aTopics));
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), Router::GetPath('index'));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('topics', $aTopics);
+ $this->Viewer_Assign('paging', $aPaging);
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * При завершении экшена загружаем переменные в шаблон
+ *
+ */
+ public function EventShutdown()
+ {
+ $this->Viewer_Assign('sMenuHeadItemSelect', $this->sMenuHeadItemSelect);
+ $this->Viewer_Assign('sMenuItemSelect', $this->sMenuItemSelect);
+ $this->Viewer_Assign('sMenuSubItemSelect', $this->sMenuSubItemSelect);
+ $this->Viewer_Assign('iCountTopicsNew', $this->iCountTopicsNew);
+ $this->Viewer_Assign('iCountTopicsCollectiveNew', $this->iCountTopicsCollectiveNew);
+ $this->Viewer_Assign('iCountTopicsPersonalNew', $this->iCountTopicsPersonalNew);
+ $this->Viewer_Assign('iCountTopicsSubNew', $this->iCountTopicsNew);
+ $this->Viewer_Assign('sNavTopicsSubUrl', $this->sNavTopicsSubUrl);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionPeople.class.php b/application/classes/actions/ActionPeople.class.php
new file mode 100644
index 0000000..9f7e048
--- /dev/null
+++ b/application/classes/actions/ActionPeople.class.php
@@ -0,0 +1,236 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки статистики юзеров, т.е. УРЛа вида /people/
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionPeople extends Action
+{
+ /**
+ * Главное меню
+ *
+ * @var string
+ */
+ protected $sMenuHeadItemSelect = 'people';
+ /**
+ * Меню
+ *
+ * @var string
+ */
+ protected $sMenuItemSelect = 'all';
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.users'));
+ }
+
+ /**
+ * Регистрируем евенты
+ *
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEventPreg('/^(index)?$/i', '/^(page([1-9]\d{0,5}))?$/i', '/^$/i', 'EventIndex');
+ $this->AddEventPreg('/^ajax-search$/i', 'EventAjaxSearch');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Поиск пользователей по логину
+ */
+ protected function EventAjaxSearch()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Формируем фильтр
+ */
+ $aFilter = array(
+ 'activate' => 1
+ );
+ $sOrderWay = in_array(getRequestStr('order'), array('desc', 'asc')) ? getRequestStr('order') : 'desc';
+ $sOrderField = in_array(getRequestStr('sort_by'), array(
+ 'user_rating',
+ 'user_date_register',
+ 'user_login',
+ 'user_profile_name'
+ )) ? getRequestStr('sort_by') : 'user_rating';
+ if (is_numeric(getRequestStr('next_page')) and getRequestStr('next_page') > 0) {
+ $iPage = getRequestStr('next_page');
+ } else {
+ $iPage = 1;
+ }
+ /**
+ * Получаем из реквеста первые буквы для поиска пользователей по логину
+ */
+ $sTitle = getRequest('sText');
+ if (is_string($sTitle) and mb_strlen($sTitle, 'utf-8')) {
+ $sTitle = str_replace(array('_', '%'), array('\_', '\%'), $sTitle);
+ } else {
+ $sTitle = '';
+ }
+ /**
+ * Как именно искать: совпадение в любой части логина, или только начало или конец логина
+ */
+ if ($sTitle) {
+ if (getRequest('isPrefix')) {
+ $sTitle .= '%';
+ } elseif (getRequest('isPostfix')) {
+ $sTitle = '%' . $sTitle;
+ } else {
+ $sTitle = '%' . $sTitle . '%';
+ }
+ }
+ if ($sTitle) {
+ $aFilter['name'] = $sTitle;
+ }
+ /**
+ * Пол
+ */
+ if (in_array(getRequestStr('sex'), array('man', 'woman', 'other'))) {
+ $aFilter['profile_sex'] = getRequestStr('sex');
+ }
+ /**
+ * Онлайн
+ * date_last
+ */
+ if (getRequest('is_online')) {
+ $aFilter['date_last_more'] = date('Y-m-d H:i:s', time() - Config::Get('module.user.time_onlive'));
+ }
+ /**
+ * Geo привязка
+ */
+ if (getRequestStr('city')) {
+ $aFilter['geo_city'] = getRequestStr('city');
+ } elseif (getRequestStr('region')) {
+ $aFilter['geo_region'] = getRequestStr('region');
+ } elseif (getRequestStr('country')) {
+ $aFilter['geo_country'] = getRequestStr('country');
+ }
+ /**
+ * Ищем пользователей
+ */
+ $aResult = $this->User_GetUsersByFilter($aFilter, array($sOrderField => $sOrderWay), $iPage,
+ Config::Get('module.user.per_page'));
+ $bHideMore = $iPage * Config::Get('module.user.per_page') >= $aResult['count'];
+ /**
+ * Формируем ответ
+ */
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('users', $aResult['collection'], true);
+ $oViewer->Assign('oUserCurrent', $this->User_GetUserCurrent());
+ $this->Viewer_AssignAjax('html', $oViewer->Fetch("component@user.list-loop"));
+ /**
+ * Для подгрузки
+ */
+ $this->Viewer_AssignAjax('count_loaded', count($aResult['collection']));
+ $this->Viewer_AssignAjax('next_page', count($aResult['collection']) > 0 ? $iPage + 1 : $iPage);
+ $this->Viewer_AssignAjax('hide', $bHideMore or !$aResult['count'] or !count($aResult['collection']));
+ $this->Viewer_AssignAjax('searchCount', (int)$aResult['count']);
+ $this->Viewer_AssignAjax('count_left', (int)($aResult['count'] - ($iPage - 1) * Config::Get('module.user.per_page') - count($aResult['collection'])));
+ $this->Viewer_AssignAjax('textEmpty', $this->Lang_Get('search.alerts.empty'));
+ }
+
+ /**
+ * Показываем юзеров
+ *
+ */
+ protected function EventIndex()
+ {
+ /**
+ * Получаем статистику
+ */
+ $this->GetStats();
+ $aFilter = array(
+ 'activate' => 1
+ );
+ /**
+ * Получаем список юзеров
+ */
+ $aResult = $this->User_GetUsersByFilter($aFilter, array('user_rating' => 'desc'), 1,
+ Config::Get('module.user.per_page'));
+ /**
+ * Получаем алфавитный указатель на список пользователей
+ */
+ $aPrefixUser = $this->User_GetGroupPrefixUser(1);
+ /**
+ * Список используемых стран
+ */
+ $aCountriesUsed = $this->Geo_GetCountriesUsedByTargetType('user');
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('users', $aResult['collection']);
+ $this->Viewer_Assign('searchCount', $aResult['count']);
+ $this->Viewer_Assign('prefixUser', $aPrefixUser);
+ $this->Viewer_Assign('countriesUsed', $aCountriesUsed);
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Получение статистики
+ *
+ */
+ protected function GetStats()
+ {
+ /**
+ * Статистика кто, где и т.п.
+ */
+ $aStat = $this->User_GetStatUsers();
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('usersStat', $aStat);
+ }
+
+ /**
+ * Выполняется при завершении работы экшена
+ *
+ */
+ public function EventShutdown()
+ {
+ /**
+ * Загружаем в шаблон необходимые переменные
+ */
+ $this->Viewer_Assign('sMenuHeadItemSelect', $this->sMenuHeadItemSelect);
+ $this->Viewer_Assign('sMenuItemSelect', $this->sMenuItemSelect);
+ }
+}
diff --git a/application/classes/actions/ActionProfile.class.php b/application/classes/actions/ActionProfile.class.php
new file mode 100644
index 0000000..7b240b0
--- /dev/null
+++ b/application/classes/actions/ActionProfile.class.php
@@ -0,0 +1,1292 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки профайла юзера, т.е. УРЛ вида /profile/login/
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionProfile extends Action
+{
+ /**
+ * Объект юзера чей профиль мы смотрим
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserProfile;
+ /**
+ * Главное меню
+ *
+ * @var string
+ */
+ protected $sMenuHeadItemSelect = 'people';
+ /**
+ * Меню профиля пользователя
+ *
+ * @var string
+ */
+ protected $sMenuProfileItemSelect = '';
+ /**
+ * Субменю
+ *
+ * @var string
+ */
+ protected $sMenuSubItemSelect = '';
+ /**
+ * Текущий пользователь
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent;
+
+ /**
+ * Инициализация
+ */
+ public function Init()
+ {
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Регистрация евентов
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEvent('friendoffer', 'EventFriendOffer');
+
+ $this->AddEvent('ajaxfriendadd', 'EventAjaxFriendAdd');
+ $this->AddEvent('ajaxfrienddelete', 'EventAjaxFriendDelete');
+ $this->AddEvent('ajaxfriendaccept', 'EventAjaxFriendAccept');
+ $this->AddEvent('ajax-modal-add-friend', 'EventAjaxModalAddFriend');
+
+ $this->AddEvent('ajax-note-save', 'EventAjaxNoteSave');
+ $this->AddEvent('ajax-note-remove', 'EventAjaxNoteRemove');
+
+ $this->AddEvent('ajax-modal-complaint', 'EventAjaxModalComplaint');
+ $this->AddEvent('ajax-complaint-add', 'EventAjaxComplaintAdd');
+
+ $this->AddEventPreg('/^.+$/i', '/^(whois)?$/i', 'EventWhois');
+
+ $this->AddEventPreg('/^.+$/i', '/^wall$/i', '/^$/i', 'EventWall');
+
+ $this->AddEventPreg('/^.+$/i', '/^favourites$/i', '/^comments$/i', '/^(page([1-9]\d{0,5}))?$/i',
+ 'EventFavouriteComments');
+ $this->AddEventPreg('/^.+$/i', '/^favourites$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventFavourite');
+ $this->AddEventPreg('/^.+$/i', '/^favourites$/i', '/^topics/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventFavourite');
+ $this->AddEventPreg('/^.+$/i', '/^favourites$/i', '/^topics/i', '/^tag/i', '/^.+/i',
+ '/^(page([1-9]\d{0,5}))?$/i', 'EventFavouriteTopicsTag');
+
+ $this->AddEventPreg('/^.+$/i', '/^created/i', '/^notes/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventCreatedNotes');
+ $this->AddEventPreg('/^.+$/i', '/^created/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventCreatedTopics');
+ $this->AddEventPreg('/^.+$/i', '/^created/i', '/^topics/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventCreatedTopics');
+ $this->AddEventPreg('/^.+$/i', '/^created/i', '/^comments$/i', '/^(page([1-9]\d{0,5}))?$/i',
+ 'EventCreatedComments');
+
+ $this->AddEventPreg('/^.+$/i', '/^friends/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventFriends');
+ $this->AddEventPreg('/^.+$/i', '/^stream/i', '/^$/i', 'EventStream');
+
+ $this->AddEventPreg('/^changemail$/i', '/^confirm-from/i', '/^\w{32}$/i', 'EventChangemailConfirmFrom');
+ $this->AddEventPreg('/^changemail$/i', '/^confirm-to/i', '/^\w{32}$/i', 'EventChangemailConfirmTo');
+ }
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Проверка корректности профиля
+ */
+ protected function CheckUserProfile()
+ {
+ /**
+ * Проверяем есть ли такой юзер
+ */
+ if (!($this->oUserProfile = $this->User_GetUserByLogin($this->sCurrentEvent))) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Показывает модальное окно для жалобы
+ */
+ protected function EventAjaxModalComplaint()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ if (!$this->oUserCurrent) {
+ return parent::EventNotFound();
+ }
+
+ /**
+ * Получаем список типов жалоб
+ */
+ $types = array();
+
+ foreach ( Config::Get('module.user.complaint_type') as $type ) {
+ $types[] = array(
+ 'value' => $type,
+ 'text' => $this->Lang_Get( 'user.report.types.' . $type )
+ );
+ }
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('types', $types, true);
+ $this->Viewer_AssignAjax('sText', $oViewer->Fetch("component@report.report"));
+ }
+
+ /**
+ * Показывает модальное окно для жалобы
+ */
+ protected function EventAjaxComplaintAdd()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ if (!$this->oUserCurrent) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Создаем жалобу и проводим валидацию
+ */
+ $oComplaint = Engine::GetEntity('ModuleUser_EntityComplaint');
+ $oComplaint->setTargetUserId(getRequestStr('target_id'));
+ $oComplaint->setUserId($this->oUserCurrent->getId());
+ $oComplaint->setText(getRequestStr('text'));
+ $oComplaint->setType(getRequestStr('type'));
+ $oComplaint->setCaptcha(getRequestStr('captcha'));
+ $oComplaint->setState(ModuleUser::COMPLAINT_STATE_NEW);
+
+ if ($oComplaint->_Validate()) {
+ /**
+ * Экранируем текст и добавляем запись в БД
+ */
+ $oComplaint->setText(htmlspecialchars($oComplaint->getText()));
+ if ($this->User_AddComplaint($oComplaint)) {
+ $this->Message_AddNotice($this->Lang_Get('report.notices.success'), $this->Lang_Get('common.attention'));
+ /**
+ * Убиваем каптчу
+ */
+ $this->Session_Drop('captcha_keystring_complaint_user');
+ /**
+ * Отправляем уведомление админу
+ */
+ if (Config::Get('module.user.complaint_notify_by_mail')) {
+ $this->User_SendNotifyUserComplaint($oComplaint);
+ }
+ return true;
+ } else {
+ $this->Message_AddError($this->Lang_Get('common.error.save'), $this->Lang_Get('common.error.error'));
+ }
+ } else {
+ $this->Message_AddError($oComplaint->_getValidateError(), $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ /**
+ * Чтение активности пользователя (stream)
+ */
+ protected function EventStream()
+ {
+ if (!$this->CheckUserProfile()) {
+ return parent::EventNotFound();
+ }
+ $this->sMenuProfileItemSelect = 'activity';
+
+ $this->Viewer_Assign('activityEvents', $this->Stream_ReadByUserId($this->oUserProfile->getId()));
+ $this->Viewer_Assign('activityEventsAllCount', $this->Stream_GetCountByUserId($this->oUserProfile->getId()));
+
+ $this->SetTemplateAction('activity');
+ }
+
+ /**
+ * Список друзей пользователей
+ */
+ protected function EventFriends()
+ {
+ if (!$this->CheckUserProfile()) {
+ return parent::EventNotFound();
+ }
+ $this->sMenuProfileItemSelect = 'friends';
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(1, 2) ? $this->GetParamEventMatch(1, 2) : 1;
+ /**
+ * Получаем список комментов
+ */
+ $aResult = $this->User_GetUsersFriend($this->oUserProfile->getId(), $iPage,
+ Config::Get('module.user.per_page'));
+ $aFriends = $aResult['collection'];
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.user.per_page'),
+ Config::Get('pagination.pages.count'), $this->oUserProfile->getUserWebPath() . 'friends');
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('friends', $aFriends);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.friends.title') . ' ' . $this->oUserProfile->getLogin());
+
+ $this->SetTemplateAction('friends');
+ }
+
+ /**
+ * Список топиков пользователя
+ */
+ protected function EventCreatedTopics()
+ {
+ if (!$this->CheckUserProfile()) {
+ return parent::EventNotFound();
+ }
+ $this->sMenuProfileItemSelect = 'created';
+ $this->sMenuSubItemSelect = 'topics';
+ /**
+ * Передан ли номер страницы
+ */
+ if ($this->GetParamEventMatch(1, 0) == 'topics') {
+ $iPage = $this->GetParamEventMatch(2, 2) ? $this->GetParamEventMatch(2, 2) : 1;
+ } else {
+ $iPage = $this->GetParamEventMatch(1, 2) ? $this->GetParamEventMatch(1, 2) : 1;
+ }
+ /**
+ * Получаем список топиков
+ */
+ $aResult = $this->Topic_GetTopicsPersonalByUser($this->oUserProfile->getId(), 1, $iPage,
+ Config::Get('module.topic.per_page'));
+ $aTopics = $aResult['collection'];
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('topics_list_show', array('aTopics' => $aTopics));
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), $this->oUserProfile->getUserWebPath() . 'created/topics');
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('topics', $aTopics);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.publications.title') . ' ' . $this->oUserProfile->getLogin());
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.publications.nav.topics'));
+ $this->Viewer_SetHtmlRssAlternate(Router::GetPath('rss') . 'personal_blog/' . $this->oUserProfile->getLogin() . '/',
+ $this->oUserProfile->getLogin());
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('created.topics');
+ }
+
+ /**
+ * Вывод комментариев пользователя
+ */
+ protected function EventCreatedComments()
+ {
+ if (!$this->CheckUserProfile()) {
+ return parent::EventNotFound();
+ }
+ $this->sMenuProfileItemSelect = 'created';
+ $this->sMenuSubItemSelect = 'comments';
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(2, 2) ? $this->GetParamEventMatch(2, 2) : 1;
+ /**
+ * Получаем список комментов
+ */
+ $aResult = $this->Comment_GetCommentsByUserId($this->oUserProfile->getId(), 'topic', $iPage,
+ Config::Get('module.comment.per_page'));
+ $aComments = $aResult['collection'];
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.comment.per_page'),
+ Config::Get('pagination.pages.count'), $this->oUserProfile->getUserWebPath() . 'created/comments');
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('comments', $aComments);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.publications.title') . ' ' . $this->oUserProfile->getLogin());
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.publications.nav.comments'));
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('created.comments');
+ }
+
+ /**
+ * Выводит список избранноего юзера
+ *
+ */
+ protected function EventFavourite()
+ {
+ if (!$this->CheckUserProfile()) {
+ return parent::EventNotFound();
+ }
+ $this->sMenuProfileItemSelect = 'favourites';
+ $this->sMenuSubItemSelect = 'topics';
+ /**
+ * Передан ли номер страницы
+ */
+ if ($this->GetParamEventMatch(1, 0) == 'topics') {
+ $iPage = $this->GetParamEventMatch(2, 2) ? $this->GetParamEventMatch(2, 2) : 1;
+ } else {
+ $iPage = $this->GetParamEventMatch(1, 2) ? $this->GetParamEventMatch(1, 2) : 1;
+ }
+ /**
+ * Получаем список избранных топиков
+ */
+ $aResult = $this->Topic_GetTopicsFavouriteByUserId($this->oUserProfile->getId(), $iPage,
+ Config::Get('module.topic.per_page'));
+ $aTopics = $aResult['collection'];
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('topics_list_show', array('aTopics' => $aTopics));
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), $this->oUserProfile->getUserWebPath() . 'favourites/topics');
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('topics', $aTopics);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.profile.title') . ' ' . $this->oUserProfile->getLogin());
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.favourites.title'));
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.favourites.nav.topics'));
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('favourite.topics');
+ }
+
+ /**
+ * Список топиков из избранного по тегу
+ */
+ protected function EventFavouriteTopicsTag()
+ {
+ if (!$this->CheckUserProfile()) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Пользователь авторизован и просматривает свой профиль?
+ */
+ if (!$this->oUserCurrent or $this->oUserProfile->getId() != $this->oUserCurrent->getId()) {
+ return parent::EventNotFound();
+ }
+ $this->sMenuProfileItemSelect = 'favourites';
+ $this->sMenuSubItemSelect = 'topics';
+ $sTag = $this->GetParamEventMatch(3, 0);
+ /*
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(4, 2) ? $this->GetParamEventMatch(4, 2) : 1;
+ /**
+ * Получаем список избранных топиков
+ */
+ $aResult = $this->Favourite_GetTags(array(
+ 'target_type' => 'topic',
+ 'user_id' => $this->oUserProfile->getId(),
+ 'text' => $sTag
+ ), array('target_id' => 'desc'), $iPage, Config::Get('module.topic.per_page'));
+ $aTopicId = array();
+ foreach ($aResult['collection'] as $oTag) {
+ $aTopicId[] = $oTag->getTargetId();
+ }
+ $aTopics = $this->Topic_GetTopicsAdditionalData($aTopicId);
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'),
+ $this->oUserProfile->getUserWebPath() . 'favourites/topics/tag/' . htmlspecialchars($sTag));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('topics', $aTopics);
+ $this->Viewer_Assign('activeFavouriteTag', htmlspecialchars($sTag));
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.profile.title') . ' ' . $this->oUserProfile->getLogin());
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.favourites.title'));
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('favourite.topics');
+ }
+
+ /**
+ * Выводит список избранноего юзера
+ *
+ */
+ protected function EventFavouriteComments()
+ {
+ if (!$this->CheckUserProfile()) {
+ return parent::EventNotFound();
+ }
+ $this->sMenuProfileItemSelect = 'favourites';
+ $this->sMenuSubItemSelect = 'comments';
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(2, 2) ? $this->GetParamEventMatch(2, 2) : 1;
+ /**
+ * Получаем список избранных комментариев
+ */
+ $aResult = $this->Comment_GetCommentsFavouriteByUserId($this->oUserProfile->getId(), $iPage,
+ Config::Get('module.comment.per_page'));
+ $aComments = $aResult['collection'];
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.comment.per_page'),
+ Config::Get('pagination.pages.count'), $this->oUserProfile->getUserWebPath() . 'favourites/comments');
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('comments', $aComments);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.profile.title') . ' ' . $this->oUserProfile->getLogin());
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.favourites.title'));
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.favourites.nav.comments'));
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('favourite.comments');
+ }
+
+ /**
+ * Показывает инфу профиля
+ *
+ */
+ protected function EventWhois()
+ {
+ if (!$this->CheckUserProfile()) {
+ return parent::EventNotFound();
+ }
+ $this->sMenuProfileItemSelect = 'whois';
+ $this->sMenuSubItemSelect = 'main';
+ /**
+ * Получаем список друзей
+ */
+ $aUsersFriend = $this->User_GetUsersFriend($this->oUserProfile->getId(), 1,
+ Config::Get('module.user.friend_on_profile'));
+ /**
+ * Получаем список тех кого пригласил юзер
+ */
+ $aUsersInvite = $this->Invite_GetUsersInvite($this->oUserProfile->getId());
+ $this->Viewer_Assign('usersInvited', $aUsersInvite);
+ /**
+ * Получаем того юзера, кто пригласил текущего
+ */
+ $oUserInviteFrom = $this->Invite_GetUserInviteFrom($this->oUserProfile->getId());
+ $this->Viewer_Assign('invitedByUser', $oUserInviteFrom);
+ /**
+ * Получаем список юзеров блога
+ */
+ $aBlogUsers = $this->Blog_GetBlogUsersByUserId($this->oUserProfile->getId(), ModuleBlog::BLOG_USER_ROLE_USER);
+ $aBlogModerators = $this->Blog_GetBlogUsersByUserId($this->oUserProfile->getId(),
+ ModuleBlog::BLOG_USER_ROLE_MODERATOR);
+ $aBlogAdministrators = $this->Blog_GetBlogUsersByUserId($this->oUserProfile->getId(),
+ ModuleBlog::BLOG_USER_ROLE_ADMINISTRATOR);
+ /**
+ * Получаем список блогов которые создал юзер
+ */
+ $aBlogsOwner = $this->Blog_GetBlogsByOwnerId($this->oUserProfile->getId());
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('profile_whois_show', array("oUserProfile" => $this->oUserProfile));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('blogsJoined', $aBlogUsers);
+ $this->Viewer_Assign('blogsModerate', $aBlogModerators);
+ $this->Viewer_Assign('blogsAdminister', $aBlogAdministrators);
+ $this->Viewer_Assign('blogsCreated', $aBlogsOwner);
+ $this->Viewer_Assign('userFriends', $aUsersFriend['collection']);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.profile.title') . ' ' . $this->oUserProfile->getLogin());
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.profile.title')); // TODO: i18n
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('info');
+ }
+
+ /**
+ * Отображение стены пользователя
+ */
+ public function EventWall()
+ {
+ if (!$this->CheckUserProfile()) {
+ return parent::EventNotFound();
+ }
+ $this->sMenuProfileItemSelect = 'wall';
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('wall');
+ }
+
+ /**
+ * Сохраняет заметку о пользователе
+ */
+ public function EventAjaxNoteSave()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Создаем заметку и проводим валидацию
+ */
+ $oNote = Engine::GetEntity('ModuleUser_EntityNote');
+ $oNote->setTargetUserId(getRequestStr('user_id'));
+ $oNote->setUserId($this->oUserCurrent->getId());
+ $oNote->setText(getRequestStr('text'));
+
+ if ($oNote->_Validate()) {
+ /**
+ * Экранируем текст и добавляем запись в БД
+ */
+ $oNote->setText(htmlspecialchars(strip_tags($oNote->getText())));
+ if ($this->User_SaveNote($oNote)) {
+ $this->Viewer_AssignAjax('sText', $oNote->getText());
+ } else {
+ $this->Message_AddError($this->Lang_Get('common.error.save'), $this->Lang_Get('common.error.error'));
+ }
+ } else {
+ $this->Message_AddError($oNote->_getValidateError(), $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ /**
+ * Удаляет заметку о пользователе
+ */
+ public function EventAjaxNoteRemove()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ if (!$this->oUserCurrent) {
+ return $this->EventErrorDebug();
+ }
+
+ if (!($oUserTarget = $this->User_GetUserById(getRequestStr('user_id')))) {
+ return $this->EventErrorDebug();
+ }
+ if (!($oNote = $this->User_GetUserNote($oUserTarget->getId(), $this->oUserCurrent->getId()))) {
+ return $this->EventErrorDebug();
+ }
+ $this->User_DeleteUserNoteById($oNote->getId());
+ }
+
+ /**
+ * Список созданных заметок
+ */
+ public function EventCreatedNotes()
+ {
+ if (!$this->CheckUserProfile()) {
+ return parent::EventNotFound();
+ }
+ $this->sMenuSubItemSelect = 'notes';
+ $this->sMenuProfileItemSelect = 'created';
+ /**
+ * Заметки может читать только сам пользователь
+ */
+ if (!$this->oUserCurrent or $this->oUserCurrent->getId() != $this->oUserProfile->getId()) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(2, 2) ? $this->GetParamEventMatch(2, 2) : 1;
+ /**
+ * Получаем список заметок
+ */
+ $aResult = $this->User_GetUsersByNoteAndUserId($this->oUserProfile->getId(), $iPage,
+ Config::Get('module.user.usernote_per_page'));
+ $aNotes = $aResult['collection'];
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.user.usernote_per_page'),
+ Config::Get('pagination.pages.count'), $this->oUserProfile->getUserWebPath() . 'created/notes');
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('notesUsers', $aNotes);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.publications.title') . ' ' . $this->oUserProfile->getLogin());
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.publications.nav.notes'));
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('created.notes');
+ }
+
+ /**
+ * Добавление пользователя в друзья, по отправленной заявке
+ */
+ public function EventFriendOffer()
+ {
+ require_once Config::Get('path.framework.libs_vendor.server') . '/XXTEA/encrypt.php';
+ /**
+ * Из реквеста дешефруем ID польователя
+ */
+ $sUserId = xxtea_decrypt(base64_decode(rawurldecode(getRequestStr('code'))),
+ Config::Get('module.talk.encrypt'));
+ if (!$sUserId) {
+ return $this->EventNotFound();
+ }
+ list($sUserId,) = explode('_', $sUserId, 2);
+
+ $sAction = $this->GetParam(0);
+ /**
+ * Получаем текущего пользователя
+ */
+ if (!$this->User_IsAuthorization()) {
+ return $this->EventNotFound();
+ }
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ /**
+ * Получаем объект пользователя приславшего заявку,
+ * если пользователь не найден, переводим в раздел сообщений (Talk) -
+ * так как пользователь мог перейти сюда либо из talk-сообщений,
+ * либо из e-mail письма-уведомления
+ */
+ if (!$oUser = $this->User_GetUserById($sUserId)) {
+ $this->Message_AddError($this->Lang_Get('user.notices.not_found'), $this->Lang_Get('common.error.error'), true);
+ Router::Location(Router::GetPath('talk'));
+ return;
+ }
+ /**
+ * Получаем связь дружбы из базы данных.
+ * Если связь не найдена либо статус отличен от OFFER,
+ * переходим в раздел Talk и возвращаем сообщение об ошибке
+ */
+ $oFriend = $this->User_GetFriend($this->oUserCurrent->getId(), $oUser->getId(), 0);
+ if (!$oFriend
+ || !in_array(
+ $oFriend->getFriendStatus(),
+ array(
+ ModuleUser::USER_FRIEND_OFFER + ModuleUser::USER_FRIEND_NULL,
+ )
+ )
+ ) {
+ $sMessage = ($oFriend)
+ ? $this->Lang_Get('user.friends.notices.offer_already_done')
+ : $this->Lang_Get('user.friends.notices.offer_not_found');
+ $this->Message_AddError($sMessage, $this->Lang_Get('common.error.error'), true);
+
+ Router::Location(Router::GetPath('talk'));
+ return;
+ }
+ /**
+ * Устанавливаем новый статус связи
+ */
+ $oFriend->setStatusTo(
+ ($sAction == 'accept')
+ ? ModuleUser::USER_FRIEND_ACCEPT
+ : ModuleUser::USER_FRIEND_REJECT
+ );
+
+ if ($this->User_UpdateFriend($oFriend)) {
+ $sMessage = ($sAction == 'accept')
+ ? $this->Lang_Get('user.friends.notices.add_success')
+ : $this->Lang_Get('user.friends.rejected');
+
+ $this->Message_AddNoticeSingle($sMessage, $this->Lang_Get('common.attention'), true);
+ $this->NoticeFriendOffer($oUser, $sAction);
+ } else {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('common.error.system.base'),
+ $this->Lang_Get('common.error.error'),
+ true
+ );
+ }
+ Router::Location(Router::GetPath('talk'));
+ }
+
+ /**
+ * Подтверждение заявки на добавления в друзья
+ */
+ public function EventAjaxFriendAccept()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $sUserId = getRequestStr('idUser', null, 'post');
+ /**
+ * Если пользователь не авторизирован, возвращаем ошибку
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('common.error.need_authorization'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ /**
+ * При попытке добавить в друзья себя, возвращаем ошибку
+ */
+ if ($this->oUserCurrent->getId() == $sUserId) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Если пользователь не найден, возвращаем ошибку
+ */
+ if (!$oUser = $this->User_GetUserById($sUserId)) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('user.notices.not_found'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ $this->oUserProfile = $oUser;
+ /**
+ * Получаем статус дружбы между пользователями
+ */
+ $oFriend = $this->User_GetFriend($oUser->getId(), $this->oUserCurrent->getId());
+ /**
+ * При попытке потдвердить ранее отклоненную заявку,
+ * проверяем, чтобы изменяющий был принимающей стороной
+ */
+ if ($oFriend
+ && ($oFriend->getStatusFrom() == ModuleUser::USER_FRIEND_OFFER || $oFriend->getStatusFrom() == ModuleUser::USER_FRIEND_ACCEPT)
+ && ($oFriend->getStatusTo() == ModuleUser::USER_FRIEND_REJECT || $oFriend->getStatusTo() == ModuleUser::USER_FRIEND_NULL)
+ && $oFriend->getUserTo() == $this->oUserCurrent->getId()
+ ) {
+ /**
+ * Меняем статус с отвергнутое, на акцептованное
+ */
+ $oFriend->setStatusByUserId(ModuleUser::USER_FRIEND_ACCEPT, $this->oUserCurrent->getId());
+ if ($this->User_UpdateFriend($oFriend)) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('user.friends.notices.add_success'),
+ $this->Lang_Get('common.attention'));
+ $this->NoticeFriendOffer($oUser, 'accept');
+ /**
+ * Добавляем событие в ленту
+ */
+ $this->Stream_write($oFriend->getUserFrom(), 'add_friend', $oFriend->getUserTo());
+ $this->Stream_write($oFriend->getUserTo(), 'add_friend', $oFriend->getUserFrom());
+ /**
+ * Добавляем пользователей к друг другу в ленту активности
+ */
+ $this->Stream_subscribeUser($oFriend->getUserFrom(), $oFriend->getUserTo());
+ $this->Stream_subscribeUser($oFriend->getUserTo(), $oFriend->getUserFrom());
+ } else {
+ return $this->EventErrorDebug();
+ }
+ return;
+ }
+
+ return $this->EventErrorDebug();
+ }
+
+ /**
+ * Отправляет пользователю Talk уведомление о принятии или отклонении его заявки
+ *
+ * @param ModuleUser_EntityUser $oUser
+ * @param string $sAction
+ */
+ protected function NoticeFriendOffer($oUser, $sAction)
+ {
+ /**
+ * Проверяем допустимость действия
+ */
+ if (!in_array($sAction, array('accept', 'reject'))) {
+ return false;
+ }
+ /**
+ * Проверяем настройки (нужно ли отправлять уведомление)
+ */
+ if (!Config::Get("module.user.friend_notice.{$sAction}")) {
+ return false;
+ }
+
+ $sTitle = $this->Lang_Get("user.friends.messages.{$sAction}.title");
+ $sText = $this->Lang_Get(
+ "user.friends.messages.{$sAction}.text",
+ array(
+ 'login' => $this->oUserCurrent->getLogin(),
+ )
+ );
+ $oTalk = $this->Talk_SendTalk($sTitle, $sText, $this->oUserCurrent, array($oUser), false, false);
+ $this->Talk_DeleteTalkUserByArray($oTalk->getId(), $this->oUserCurrent->getId());
+ }
+
+ /**
+ * Обработка Ajax добавления в друзья
+ */
+ public function EventAjaxFriendAdd()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $sUserId = getRequestStr('idUser');
+ $sUserText = getRequestStr('userText', '');
+ /**
+ * Если пользователь не авторизирован, возвращаем ошибку
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('common.error.need_authorization'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ /**
+ * При попытке добавить в друзья себя, возвращаем ошибку
+ */
+ if ($this->oUserCurrent->getId() == $sUserId) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Если пользователь не найден, возвращаем ошибку
+ */
+ if (!$oUser = $this->User_GetUserById($sUserId)) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('user.notices.not_found'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ $this->oUserProfile = $oUser;
+ /**
+ * Получаем статус дружбы между пользователями
+ */
+ $oFriend = $this->User_GetFriend($oUser->getId(), $this->oUserCurrent->getId());
+ /**
+ * Если связи ранее не было в базе данных, добавляем новую
+ */
+ if (!$oFriend) {
+ $this->SubmitAddFriend($oUser, $sUserText, $oFriend);
+ return;
+ }
+ /**
+ * Если статус связи соответствует статусам отправленной и акцептованной заявки,
+ * то предупреждаем что этот пользователь уже является нашим другом
+ */
+ if ($oFriend->getFriendStatus() == ModuleUser::USER_FRIEND_OFFER + ModuleUser::USER_FRIEND_ACCEPT) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('user.friends.notices.already_exist'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ /**
+ * Если пользователь ранее отклонил нашу заявку,
+ * возвращаем сообщение об ошибке
+ */
+ if ($oFriend->getUserFrom() == $this->oUserCurrent->getId()
+ && $oFriend->getStatusTo() == ModuleUser::USER_FRIEND_REJECT
+ ) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('user.friends.rejected'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ /**
+ * Если дружба была удалена, то проверяем кто ее удалил
+ * и разрешаем восстановить только удалившему
+ */
+ if ($oFriend->getFriendStatus() > ModuleUser::USER_FRIEND_DELETE
+ && $oFriend->getFriendStatus() < ModuleUser::USER_FRIEND_REJECT
+ ) {
+ /**
+ * Определяем статус связи текущего пользователя
+ */
+ $iStatusCurrent = $oFriend->getStatusByUserId($this->oUserCurrent->getId());
+
+ if ($iStatusCurrent == ModuleUser::USER_FRIEND_DELETE) {
+ /**
+ * Меняем статус с удаленного, на акцептованное
+ */
+ $oFriend->setStatusByUserId(ModuleUser::USER_FRIEND_ACCEPT, $this->oUserCurrent->getId());
+ if ($this->User_UpdateFriend($oFriend)) {
+ /**
+ * Добавляем событие в ленту
+ */
+ $this->Stream_write($oFriend->getUserFrom(), 'add_friend', $oFriend->getUserTo());
+ $this->Stream_write($oFriend->getUserTo(), 'add_friend', $oFriend->getUserFrom());
+ $this->Message_AddNoticeSingle($this->Lang_Get('user.friends.notices.add_success'),
+ $this->Lang_Get('common.attention'));
+ } else {
+ return $this->EventErrorDebug();
+ }
+ return;
+ } else {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('user.friends.notices.rejected'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ }
+ }
+
+ /**
+ * Функция создает локальный объект вьювера для рендеринга html-объектов в ajax запросах
+ *
+ * @return ModuleViewer
+ */
+ protected function GetViewerLocal()
+ {
+ /**
+ * Получаем HTML код inject-объекта
+ */
+ $oViewerLocal = $this->Viewer_GetLocalViewer();
+ $oViewerLocal->Assign('oUserCurrent', $this->oUserCurrent);
+ $oViewerLocal->Assign('oUserProfile', $this->oUserProfile);
+
+ $oViewerLocal->Assign('USER_FRIEND_NULL', ModuleUser::USER_FRIEND_NULL);
+ $oViewerLocal->Assign('USER_FRIEND_OFFER', ModuleUser::USER_FRIEND_OFFER);
+ $oViewerLocal->Assign('USER_FRIEND_ACCEPT', ModuleUser::USER_FRIEND_ACCEPT);
+ $oViewerLocal->Assign('USER_FRIEND_REJECT', ModuleUser::USER_FRIEND_REJECT);
+ $oViewerLocal->Assign('USER_FRIEND_DELETE', ModuleUser::USER_FRIEND_DELETE);
+
+ return $oViewerLocal;
+ }
+
+ /**
+ * Обработка добавления в друзья
+ *
+ * @param $oUser
+ * @param $sUserText
+ * @param null $oFriend
+ * @return bool
+ */
+ protected function SubmitAddFriend($oUser, $sUserText, $oFriend = null)
+ {
+ /**
+ * Ограничения на добавления в друзья, т.к. приглашение отправляется в личку, то и ограничиваем по ней
+ */
+ if (!$this->ACL_CanSendTalkTime($this->oUserCurrent)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('user.friends.notices.time_limit'), $this->Lang_Get('common.error.error'));
+ return false;
+ }
+ /**
+ * Обрабатываем текст заявки
+ */
+ $sUserText = $this->Text_Parser($sUserText);
+ /**
+ * Создаем связь с другом
+ */
+ $oFriendNew = Engine::GetEntity('User_Friend');
+ $oFriendNew->setUserTo($oUser->getId());
+ $oFriendNew->setUserFrom($this->oUserCurrent->getId());
+ // Добавляем заявку в друзья
+ $oFriendNew->setStatusFrom(ModuleUser::USER_FRIEND_OFFER);
+ $oFriendNew->setStatusTo(ModuleUser::USER_FRIEND_NULL);
+
+ $bStateError = ($oFriend)
+ ? !$this->User_UpdateFriend($oFriendNew)
+ : !$this->User_AddFriend($oFriendNew);
+
+ if (!$bStateError) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('user.friends.sent'), $this->Lang_Get('common.attention'));
+
+ $sTitle = $this->Lang_Get(
+ 'user.friends.messages.offer.title',
+ array(
+ 'login' => $this->oUserCurrent->getLogin(),
+ 'friend' => $oUser->getLogin()
+ )
+ );
+
+ require_once Config::Get('path.framework.libs_vendor.server') . '/XXTEA/encrypt.php';
+ $sCode = $this->oUserCurrent->getId() . '_' . $oUser->getId();
+ $sCode = rawurlencode(base64_encode(xxtea_encrypt($sCode, Config::Get('module.talk.encrypt'))));
+
+ $aPath = array(
+ 'accept' => Router::GetPath('profile') . 'friendoffer/accept/?code=' . $sCode,
+ 'reject' => Router::GetPath('profile') . 'friendoffer/reject/?code=' . $sCode
+ );
+
+ $sText = $this->Lang_Get(
+ 'user.friends.messages.offer.text',
+ array(
+ 'login' => $this->oUserCurrent->getLogin(),
+ 'accept_path' => $aPath['accept'],
+ 'reject_path' => $aPath['reject'],
+ 'user_text' => $sUserText
+ )
+ );
+ $oTalk = $this->Talk_SendTalk($sTitle, $sText, $this->oUserCurrent, array($oUser), false, false);
+ /**
+ * Отправляем пользователю заявку
+ */
+ $this->User_SendNotifyUserFriendNew(
+ $oUser, $this->oUserCurrent, $sUserText,
+ Router::GetPath('talk') . 'read/' . $oTalk->getId() . '/'
+ );
+ /**
+ * Удаляем отправляющего юзера из переписки
+ */
+ $this->Talk_DeleteTalkUserByArray($oTalk->getId(), $this->oUserCurrent->getId());
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ /**
+ * Удаление пользователя из друзей
+ */
+ public function EventAjaxFriendDelete()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $sUserId = getRequestStr('idUser', null, 'post');
+ /**
+ * Если пользователь не авторизирован, возвращаем ошибку
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('common.error.need_authorization'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ /**
+ * При попытке добавить в друзья себя, возвращаем ошибку
+ */
+ if ($this->oUserCurrent->getId() == $sUserId) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Если пользователь не найден, возвращаем ошибку
+ */
+ if (!$oUser = $this->User_GetUserById($sUserId)) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('user.friends.notices.not_found'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ $this->oUserProfile = $oUser;
+ /**
+ * Получаем статус дружбы между пользователями.
+ * Если статус не определен, или отличается от принятой заявки,
+ * возвращаем ошибку
+ */
+ $oFriend = $this->User_GetFriend($oUser->getId(), $this->oUserCurrent->getId());
+ $aAllowedFriendStatus = array(
+ ModuleUser::USER_FRIEND_ACCEPT + ModuleUser::USER_FRIEND_OFFER,
+ ModuleUser::USER_FRIEND_ACCEPT + ModuleUser::USER_FRIEND_ACCEPT
+ );
+ if (!$oFriend || !in_array($oFriend->getFriendStatus(), $aAllowedFriendStatus)) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('user.friends.notices.not_found'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ /**
+ * Удаляем из друзей
+ */
+ if ($this->User_DeleteFriend($oFriend)) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('user.friends.notices.remove_success'),
+ $this->Lang_Get('common.attention'));
+
+ /**
+ * Отправляем пользователю сообщение об удалении дружеской связи
+ */
+ if (Config::Get('module.user.friend_notice.delete')) {
+ $sText = $this->Lang_Get(
+ 'user.friends.messages.deleted.text',
+ array(
+ 'login' => $this->oUserCurrent->getLogin(),
+ )
+ );
+ $oTalk = $this->Talk_SendTalk(
+ $this->Lang_Get('user.friends.messages.deleted.title'),
+ $sText, $this->oUserCurrent,
+ array($oUser), false, false
+ );
+ $this->Talk_DeleteTalkUserByArray($oTalk->getId(), $this->oUserCurrent->getId());
+ }
+ return;
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ /**
+ * Показывает модальное окно
+ */
+ protected function EventAjaxModalAddFriend()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$this->oUserCurrent) {
+ return parent::EventNotFound();
+ }
+
+ $iTarget = (int)getRequest('target');
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('target', $iTarget, true);
+ $this->Viewer_AssignAjax('sText', $oViewer->Fetch("component@user.modal.add-friend"));
+ }
+
+ /**
+ * Обработка подтверждения старого емайла при его смене
+ * TODO: Перенести в экшн Settings
+ */
+ public function EventChangemailConfirmFrom()
+ {
+ if (!($oChangemail = $this->User_GetUserChangemailByCodeFrom($this->GetParamEventMatch(1, 0)))) {
+ return parent::EventNotFound();
+ }
+
+ if ($oChangemail->getConfirmFrom() or strtotime($oChangemail->getDateExpired()) < time()) {
+ return parent::EventNotFound();
+ }
+
+ $oChangemail->setConfirmFrom(1);
+ $this->User_UpdateUserChangemail($oChangemail);
+
+ /**
+ * Отправляем уведомление
+ */
+ $oUser = $this->User_GetUserById($oChangemail->getUserId());
+ $this->Notify_Send($oChangemail->getMailTo(),
+ 'user_changemail_to.tpl',
+ $this->Lang_Get('emails.user_changemail.subject'),
+ array(
+ 'oUser' => $oUser,
+ 'oChangemail' => $oChangemail,
+ ));
+
+ $this->Viewer_Assign('sText', $this->Lang_Get('user.settings.account.fields.email.notices.change_to_notice'));
+ $this->SetTemplate('actions/ActionSettings/account.change_email_confirm.tpl');
+ }
+
+ /**
+ * Обработка подтверждения нового емайла при смене старого
+ */
+ public function EventChangemailConfirmTo()
+ {
+ if (!($oChangemail = $this->User_GetUserChangemailByCodeTo($this->GetParamEventMatch(1, 0)))) {
+ return parent::EventNotFound();
+ }
+
+ if (!$oChangemail->getConfirmFrom() or $oChangemail->getConfirmTo() or strtotime($oChangemail->getDateExpired()) < time()) {
+ return parent::EventNotFound();
+ }
+
+ $oChangemail->setConfirmTo(1);
+ $oChangemail->setDateUsed(date("Y-m-d H:i:s"));
+ $this->User_UpdateUserChangemail($oChangemail);
+
+ $oUser = $this->User_GetUserById($oChangemail->getUserId());
+ $oUser->setMail($oChangemail->getMailTo());
+ $this->User_Update($oUser);
+
+ /**
+ * Меняем емайл в подписках
+ */
+ if ($oChangemail->getMailFrom()) {
+ $this->Subscribe_ChangeSubscribeMail($oChangemail->getMailFrom(), $oChangemail->getMailTo(),
+ $oUser->getId());
+ }
+
+ $this->Viewer_Assign('sText', $this->Lang_Get('user.settings.account.fields.email.notices.change_ok',
+ array('mail' => htmlspecialchars($oChangemail->getMailTo()))));
+ $this->SetTemplate('actions/ActionSettings/account.change_email_confirm.tpl');
+ }
+
+ /**
+ * Выполняется при завершении работы экшена
+ */
+ public function EventShutdown()
+ {
+ if (!$this->oUserProfile) {
+ return;
+ }
+ /**
+ * Загружаем в шаблон необходимые переменные
+ */
+ $iCountTopicFavourite = $this->Topic_GetCountTopicsFavouriteByUserId($this->oUserProfile->getId());
+ $iCountTopicUser = $this->Topic_GetCountTopicsPersonalByUser($this->oUserProfile->getId(), 1);
+ $iCountCommentUser = $this->Comment_GetCountCommentsByUserId($this->oUserProfile->getId(), 'topic');
+ $iCountCommentFavourite = $this->Comment_GetCountCommentsFavouriteByUserId($this->oUserProfile->getId());
+ $iCountNoteUser = $this->User_GetCountUserNotesByUserId($this->oUserProfile->getId());
+
+ $this->Viewer_Assign('oUserProfile', $this->oUserProfile);
+ $this->Viewer_Assign('iCountTopicUser', $iCountTopicUser);
+ $this->Viewer_Assign('iCountCommentUser', $iCountCommentUser);
+ $this->Viewer_Assign('iCountTopicFavourite', $iCountTopicFavourite);
+ $this->Viewer_Assign('iCountCommentFavourite', $iCountCommentFavourite);
+ $this->Viewer_Assign('iCountNoteUser', $iCountNoteUser);
+ $this->Viewer_Assign('iCountWallUser',
+ $this->Wall_GetCountWall(array('wall_user_id' => $this->oUserProfile->getId(), 'pid' => null)));
+ /**
+ * Общее число публикация и избранного
+ */
+ $this->Viewer_Assign('iCountCreated',
+ (($this->oUserCurrent and $this->oUserCurrent->getId() == $this->oUserProfile->getId()) ? $iCountNoteUser : 0) + $iCountTopicUser + $iCountCommentUser);
+ $this->Viewer_Assign('iCountFavourite', $iCountCommentFavourite + $iCountTopicFavourite);
+ /**
+ * Заметка текущего пользователя о юзере
+ */
+ if ($this->oUserCurrent) {
+ $this->Viewer_Assign('oUserNote', $this->oUserProfile->getUserNote());
+ }
+ $this->Viewer_Assign('iCountFriendsUser', $this->User_GetCountUsersFriend($this->oUserProfile->getId()));
+
+ $this->Viewer_Assign('sMenuSubItemSelect', $this->sMenuSubItemSelect);
+ $this->Viewer_Assign('sMenuHeadItemSelect', $this->sMenuHeadItemSelect);
+ $this->Viewer_Assign('sMenuProfileItemSelect', $this->sMenuProfileItemSelect);
+ $this->Viewer_Assign('USER_FRIEND_NULL', ModuleUser::USER_FRIEND_NULL);
+ $this->Viewer_Assign('USER_FRIEND_OFFER', ModuleUser::USER_FRIEND_OFFER);
+ $this->Viewer_Assign('USER_FRIEND_ACCEPT', ModuleUser::USER_FRIEND_ACCEPT);
+ $this->Viewer_Assign('USER_FRIEND_REJECT', ModuleUser::USER_FRIEND_REJECT);
+ $this->Viewer_Assign('USER_FRIEND_DELETE', ModuleUser::USER_FRIEND_DELETE);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionProperty.class.php b/application/classes/actions/ActionProperty.class.php
new file mode 100644
index 0000000..1f16eda
--- /dev/null
+++ b/application/classes/actions/ActionProperty.class.php
@@ -0,0 +1,123 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки УРЛа вида /property/
+ *
+ * @package application.actions
+ * @since 2.0
+ */
+class ActionProperty extends Action
+{
+ /**
+ * Текущий пользователь
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+
+ /**
+ * Инициализация
+ */
+ public function Init()
+ {
+ /**
+ * Достаём текущего пользователя
+ */
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Регистрируем евенты
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEventPreg('/^download$/i', '/^[\w]{10,32}$/i', '/^$/i', 'EventDownloadFile');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Загрузка файла
+ */
+ protected function EventDownloadFile()
+ {
+ $sKey = $this->GetParam(0);
+ /**
+ * Выполняем проверки
+ */
+ if (!$oValue = $this->Property_GetValueByValueVarchar($sKey)) {
+ return parent::EventNotFound();
+ }
+ if (!$oProperty = $oValue->getProperty()) {
+ return parent::EventNotFound();
+ }
+ if ($oProperty->getType() != ModuleProperty::PROPERTY_TYPE_FILE) {
+ return parent::EventNotFound();
+ }
+ if (!$oTargetRel = $this->Property_GetTargetByType($oValue->getTargetType())) {
+ return parent::EventNotFound();
+ }
+ if ($oTargetRel->getState() != ModuleProperty::TARGET_STATE_ACTIVE) {
+ return parent::EventNotFound();
+ }
+
+ $bAllowDownload = false;
+ if (!$this->oUserCurrent) {
+ if ($oProperty->getParam('access_only_auth')) {
+ return Router::Action('error', '403');
+ } else {
+ $bAllowDownload = true;
+ }
+ }
+ if (!$bAllowDownload) {
+ /**
+ * Проверяем доступ пользователя к объекту, которому принадлежит свойство
+ */
+ if ($this->Property_CheckAllowTargetObject($oValue->getTargetType(), $oValue->getTargetId(),
+ array('user' => $this->oUserCurrent))
+ ) {
+ $bAllowDownload = true;
+ }
+ }
+ if ($bAllowDownload) {
+ /**
+ * Увеличиваем количество загрузок
+ */
+ $aStats = $oValue->getDataOne('stats');
+ $aStats['count_download'] = (isset($aStats['count_download']) ? $aStats['count_download'] : 0) + 1;
+ $oValue->setDataOne('stats', $aStats);
+ $oValue->Update();
+ $oValueType = $oValue->getValueTypeObject();
+ if (!$oValueType->DownloadFile()) {
+ return parent::EventNotFound();
+ }
+ } else {
+ return Router::Action('error', '403');
+ }
+
+ $this->SetTemplate(false);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionRss.class.php b/application/classes/actions/ActionRss.class.php
new file mode 100644
index 0000000..c8e95d6
--- /dev/null
+++ b/application/classes/actions/ActionRss.class.php
@@ -0,0 +1,460 @@
+
+ *
+ */
+
+/**
+ * Экшен бработки RSS
+ * Автор класса vovazol(http://livestreet.ru/profile/vovazol/)
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionRss extends Action
+{
+ /**
+ * Инициализация
+ */
+ public function Init()
+ {
+ $this->SetDefaultEvent('index');
+ Router::SetIsShowStats(false);
+ }
+
+ /**
+ * Указывает браузеру правильный content type в случае вывода RSS-ленты
+ */
+ protected function InitRss()
+ {
+ header('Content-Type: application/rss+xml; charset=utf-8');
+ }
+
+ /**
+ * Регистрация евентов
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEvent('index', 'RssGood');
+ $this->AddEvent('full', 'RssFull');
+ $this->AddEvent('new', 'RssNew');
+ $this->AddEvent('allcomments', 'RssComments');
+ $this->AddEvent('comments', 'RssTopicComments');
+ $this->AddEvent('tag', 'RssTag');
+ $this->AddEvent('blog', 'RssColectiveBlog');
+ $this->AddEvent('personal_blog', 'RssPersonalBlog');
+ }
+
+ /**
+ * Вывод RSS интересных топиков
+ */
+ protected function RssGood()
+ {
+ /**
+ * Получаем топики
+ */
+ $aResult = $this->Topic_GetTopicsGood(1, Config::Get('module.topic.max_rss_count'), false);
+ $aTopics = $aResult['collection'];
+ /**
+ * Формируем данные канала RSS
+ */
+ $aChannel['title'] = Config::Get('view.name');
+ $aChannel['link'] = Router::GetPath('/');
+ $aChannel['description'] = Config::Get('view.name') . ' / RSS channel';
+ $aChannel['language'] = 'ru';
+ $aChannel['managingEditor'] = Config::Get('general.rss_editor_mail');
+ $aChannel['generator'] = Config::Get('view.name');
+ /**
+ * Формируем записи RSS
+ */
+ $topics = array();
+ foreach ($aTopics as $oTopic) {
+ $item['title'] = $oTopic->getTitle();
+ $item['guid'] = $oTopic->getUrl();
+ $item['link'] = $oTopic->getUrl();
+ $item['description'] = $this->getTopicText($oTopic);
+ $item['pubDate'] = $oTopic->getDatePublish();
+ $item['author'] = $oTopic->getUser()->getLogin();
+ $item['category'] = htmlspecialchars($oTopic->getTags());
+ $topics[] = $item;
+ }
+ /**
+ * Формируем ответ
+ */
+ $this->InitRss();
+ $this->Viewer_Assign('aChannel', $aChannel);
+ $this->Viewer_Assign('aItems', $topics);
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Вывод RSS новых топиков
+ */
+ protected function RssNew()
+ {
+ /**
+ * Получаем топики
+ */
+ $aResult = $this->Topic_GetTopicsNew(1, Config::Get('module.topic.max_rss_count'), false);
+ $aTopics = $aResult['collection'];
+ /**
+ * Формируем данные канала RSS
+ */
+ $aChannel['title'] = Config::Get('view.name');
+ $aChannel['link'] = Router::GetPath('/');
+ $aChannel['description'] = Router::GetPath('/') . ' / RSS channel';
+ $aChannel['language'] = 'ru';
+ $aChannel['managingEditor'] = Config::Get('general.rss_editor_mail');
+ $aChannel['generator'] = Router::GetPath('/');
+ /**
+ * Формируем записи RSS
+ */
+ $topics = array();
+ foreach ($aTopics as $oTopic) {
+ $item['title'] = $oTopic->getTitle();
+ $item['guid'] = $oTopic->getUrl();
+ $item['link'] = $oTopic->getUrl();
+ $item['description'] = $this->getTopicText($oTopic);
+ $item['pubDate'] = $oTopic->getDatePublish();
+ $item['author'] = $oTopic->getUser()->getLogin();
+ $item['category'] = htmlspecialchars($oTopic->getTags());
+ $topics[] = $item;
+ }
+ /**
+ * Формируем ответ
+ */
+ $this->InitRss();
+ $this->Viewer_Assign('aChannel', $aChannel);
+ $this->Viewer_Assign('aItems', $topics);
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Вывод полнотекстового RSS интересных топиков
+ */
+ protected function RssFull()
+ {
+ /**
+ * Получаем топики
+ */
+ $aResult = $this->Topic_GetTopicsNew(1, Config::Get('module.topic.max_rss_count'), false);
+ $aTopics = $aResult['collection'];
+ /**
+ * Формируем данные канала RSS
+ */
+ $aChannel['title'] = Config::Get('view.name');
+ $aChannel['link'] = Router::GetPath('/');
+ $aChannel['description'] = Config::Get('view.name') . ' / RSS channel';
+ $aChannel['language'] = 'ru';
+ $aChannel['managingEditor'] = Config::Get('general.rss_editor_mail');
+ $aChannel['generator'] = Config::Get('view.name');
+ /**
+ * Формируем записи RSS
+ */
+ $topics = array();
+ foreach ($aTopics as $oTopic) {
+ $item['title'] = $oTopic->getTitle();
+ $item['guid'] = $oTopic->getUrl();
+ $item['link'] = $oTopic->getUrl();
+ $item['description'] = $this->getTopicFullText($oTopic);
+ $item['pubDate'] = $oTopic->getDatePublish();
+ $item['author'] = $oTopic->getUser()->getLogin();
+ $item['category'] = htmlspecialchars($oTopic->getTags());
+ $topics[] = $item;
+ }
+ /**
+ * Формируем ответ
+ */
+ $this->InitRss();
+ $this->Viewer_Assign('aChannel', $aChannel);
+ $this->Viewer_Assign('aItems', $topics);
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Вывод RSS последних комментариев
+ */
+ protected function RssComments()
+ {
+ /**
+ * Получаем закрытые блоги, чтобы исключить их из выдачи
+ */
+ $aCloseBlogs = $this->Blog_GetInaccessibleBlogsByUser();
+ /**
+ * Получаем комментарии
+ */
+ $aResult = $this->Comment_GetCommentsAll('topic', 1, Config::Get('module.comment.max_rss_count'), array(),
+ $aCloseBlogs);
+ $aComments = $aResult['collection'];
+ /**
+ * Формируем данные канала RSS
+ */
+ $aChannel['title'] = Config::Get('view.name');
+ $aChannel['link'] = Router::GetPath('/');
+ $aChannel['description'] = Router::GetPath('/') . ' / RSS channel';
+ $aChannel['language'] = 'ru';
+ $aChannel['managingEditor'] = Config::Get('general.rss_editor_mail');
+ $aChannel['generator'] = Router::GetPath('/');
+ /**
+ * Формируем записи RSS
+ */
+ $comments = array();
+ foreach ($aComments as $oComment) {
+ $item['title'] = 'Comments: ' . $oComment->getTarget()->getTitle();
+ $item['guid'] = $oComment->getTarget()->getUrl() . '#comment' . $oComment->getId();
+ $item['link'] = $oComment->getTarget()->getUrl() . '#comment' . $oComment->getId();
+ $item['description'] = $oComment->getText();
+ $item['pubDate'] = $oComment->getDate();
+ $item['author'] = $oComment->getUser()->getLogin();
+ $item['category'] = 'comments';
+ $comments[] = $item;
+ }
+ /**
+ * Формируем ответ
+ */
+ $this->InitRss();
+ $this->Viewer_Assign('aChannel', $aChannel);
+ $this->Viewer_Assign('aItems', $comments);
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Вывод RSS комментариев конкретного топика
+ */
+ protected function RssTopicComments()
+ {
+ $sTopicId = $this->GetParam(0);
+ /**
+ * Топик существует?
+ */
+ if (!($oTopic = $this->Topic_GetTopicById($sTopicId)) or !$oTopic->getPublish() or $oTopic->getBlog()->getType() == 'close') {
+ return parent::EventNotFound();
+ }
+ /**
+ * Получаем комментарии
+ */
+ $aResult = $this->Comment_GetCommentsByFilter(array('target_id' => $oTopic->getId(),
+ 'target_type' => 'topic',
+ 'delete' => 0
+ ), array('comment_id' => 'desc'), 1, 100);
+ $aComments = $aResult['collection'];
+ /**
+ * Формируем данные канала RSS
+ */
+ $aChannel['title'] = Config::Get('view.name');
+ $aChannel['link'] = Router::GetPath('/');
+ $aChannel['description'] = Router::GetPath('/') . ' / RSS channel';
+ $aChannel['language'] = 'ru';
+ $aChannel['managingEditor'] = Config::Get('general.rss_editor_mail');
+ $aChannel['generator'] = Router::GetPath('/');
+ /**
+ * Формируем записи RSS
+ */
+ $comments = array();
+ foreach ($aComments as $oComment) {
+ $item['title'] = 'Comments: ' . $oTopic->getTitle();
+ $item['guid'] = $oTopic->getUrl() . '#comment' . $oComment->getId();
+ $item['link'] = $oTopic->getUrl() . '#comment' . $oComment->getId();
+ $item['description'] = $oComment->getText();
+ $item['pubDate'] = $oComment->getDate();
+ $item['author'] = $oComment->getUser()->getLogin();
+ $item['category'] = 'comments';
+ $comments[] = $item;
+ }
+ /**
+ * Формируем ответ
+ */
+ $this->InitRss();
+ $this->Viewer_Assign('aChannel', $aChannel);
+ $this->Viewer_Assign('aItems', $comments);
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Вывод RSS топиков по определенному тегу
+ */
+ protected function RssTag()
+ {
+ $sTag = urldecode($this->GetParam(0));
+ /**
+ * Получаем топики
+ */
+ $aResult = $this->Topic_GetTopicsByTag($sTag, 1, Config::Get('module.topic.max_rss_count'), false);
+ $aTopics = $aResult['collection'];
+ /**
+ * Формируем данные канала RSS
+ */
+ $aChannel['title'] = Config::Get('view.name');
+ $aChannel['link'] = Router::GetPath('/');
+ $aChannel['description'] = Router::GetPath('/') . ' / RSS channel';
+ $aChannel['language'] = 'ru';
+ $aChannel['managingEditor'] = Config::Get('general.rss_editor_mail');
+ $aChannel['generator'] = Router::GetPath('/');
+ /**
+ * Формируем записи RSS
+ */
+ $topics = array();
+ foreach ($aTopics as $oTopic) {
+ $item['title'] = $oTopic->getTitle();
+ $item['guid'] = $oTopic->getUrl();
+ $item['link'] = $oTopic->getUrl();
+ $item['description'] = $this->getTopicText($oTopic);
+ $item['pubDate'] = $oTopic->getDatePublish();
+ $item['author'] = $oTopic->getUser()->getLogin();
+ $item['category'] = htmlspecialchars($oTopic->getTags());
+ $topics[] = $item;
+ }
+ /**
+ * Формируем ответ
+ */
+ $this->InitRss();
+ $this->Viewer_Assign('aChannel', $aChannel);
+ $this->Viewer_Assign('aItems', $topics);
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Вывод RSS топиков из коллективного блога
+ */
+ protected function RssColectiveBlog()
+ {
+ $sBlogUrl = $this->GetParam(0);
+ /**
+ * Если блог существует, то получаем записи
+ */
+ if (!$sBlogUrl or !($oBlog = $this->Blog_GetBlogByUrl($sBlogUrl)) or $oBlog->getType() == "close") {
+ return parent::EventNotFound();
+ } else {
+ $aResult = $this->Topic_GetTopicsByBlog($oBlog, 1, Config::Get('module.topic.max_rss_count'), 'good');
+ }
+ $aTopics = $aResult['collection'];
+ /**
+ * Формируем данные канала RSS
+ */
+ $aChannel['title'] = Config::Get('view.name');
+ $aChannel['link'] = Router::GetPath('/');
+ $aChannel['description'] = Router::GetPath('/') . ' / ' . $oBlog->getTitle() . ' / RSS channel';
+ $aChannel['language'] = 'ru';
+ $aChannel['managingEditor'] = Config::Get('general.rss_editor_mail');
+ $aChannel['generator'] = Router::GetPath('/');
+ /**
+ * Формируем записи RSS
+ */
+ $topics = array();
+ foreach ($aTopics as $oTopic) {
+ $item['title'] = $oTopic->getTitle();
+ $item['guid'] = $oTopic->getUrl();
+ $item['link'] = $oTopic->getUrl();
+ $item['description'] = $this->getTopicText($oTopic);
+ $item['pubDate'] = $oTopic->getDatePublish();
+ $item['author'] = $oTopic->getUser()->getLogin();
+ $item['category'] = htmlspecialchars($oTopic->getTags());
+ $topics[] = $item;
+ }
+ /**
+ * Формируем ответ
+ */
+ $this->InitRss();
+ $this->Viewer_Assign('aChannel', $aChannel);
+ $this->Viewer_Assign('aItems', $topics);
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Вывод RSS топиков из персонального блога или всех персональных
+ */
+ protected function RssPersonalBlog()
+ {
+ $this->sUserLogin = $this->GetParam(0);
+ if (!$this->sUserLogin) {
+ /**
+ * RSS-лента всех записей из персональных блогов
+ */
+ $aResult = $this->Topic_GetTopicsPersonal(1, Config::Get('module.topic.max_rss_count'));
+ } elseif (!$oUser = $this->User_GetUserByLogin($this->sUserLogin)) {
+ return parent::EventNotFound();
+ } else {
+ /**
+ * RSS-лента записей персонального блога указанного пользователя
+ */
+ $aResult = $this->Topic_GetTopicsPersonalByUser($oUser->getId(), 1, 1,
+ Config::Get('module.topic.max_rss_count'));
+ }
+ $aTopics = $aResult['collection'];
+ /**
+ * Формируем данные канала RSS
+ */
+ $aChannel['title'] = Config::Get('view.name');
+ $aChannel['link'] = Router::GetPath('/');
+ $aChannel['description'] = ($this->sUserLogin)
+ ? Router::GetPath('/') . ' / ' . $oUser->getLogin() . ' / RSS channel'
+ : Router::GetPath('/') . ' / RSS channel';
+ $aChannel['language'] = 'ru';
+ $aChannel['managingEditor'] = Config::Get('general.rss_editor_mail');
+ $aChannel['generator'] = Router::GetPath('/');
+ /**
+ * Формируем записи RSS
+ */
+ $topics = array();
+ foreach ($aTopics as $oTopic) {
+ $item['title'] = $oTopic->getTitle();
+ $item['guid'] = $oTopic->getUrl();
+ $item['link'] = $oTopic->getUrl();
+ $item['description'] = $this->getTopicText($oTopic);
+ $item['pubDate'] = $oTopic->getDatePublish();
+ $item['author'] = $oTopic->getUser()->getLogin();
+ $item['category'] = htmlspecialchars($oTopic->getTags());
+ $topics[] = $item;
+ }
+ /**
+ * Формируем ответ
+ */
+ $this->InitRss();
+ $this->Viewer_Assign('aChannel', $aChannel);
+ $this->Viewer_Assign('aItems', $topics);
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Формирует текст топика для RSS
+ *
+ */
+ protected function getTopicText($oTopic)
+ {
+ $sText = $oTopic->getTextShort();
+ if ($oTopic->getTextShort() != $oTopic->getText()) {
+ $sText .= "getUrl()}#cut\" title=\"{$this->Lang_Get('topic.read_more')}\">";
+ if ($oTopic->getCutText()) {
+ $sText .= htmlspecialchars($oTopic->getCutText());
+ } else {
+ $sText .= $this->Lang_Get('topic.read_more');
+ }
+ $sText .= " ";
+ }
+ return $sText;
+ }
+
+ /**
+ * Формирует полный текст топика (без ката) для RSS
+ *
+ */
+ protected function getTopicFullText($oTopic)
+ {
+ return $oTopic->getText();
+ }
+}
diff --git a/application/classes/actions/ActionRules.class.php b/application/classes/actions/ActionRules.class.php
new file mode 100644
index 0000000..f80a664
--- /dev/null
+++ b/application/classes/actions/ActionRules.class.php
@@ -0,0 +1,43 @@
+SetDefaultEvent('index');
+ }
+
+ /**
+ * Регистрируем евенты
+ *
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEvent('index', 'EventIndex');
+ }
+
+ /**
+ * Вывод правил
+ *
+ */
+ protected function EventIndex()
+ {
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle('Правила Ифхаба');
+ $this->SetTemplateAction('index');
+ }
+}
diff --git a/application/classes/actions/ActionSearch.class.php b/application/classes/actions/ActionSearch.class.php
new file mode 100644
index 0000000..e134e12
--- /dev/null
+++ b/application/classes/actions/ActionSearch.class.php
@@ -0,0 +1,172 @@
+
+ *
+ */
+
+/**
+ * Обработка основного поиска
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionSearch extends Action
+{
+
+ public function Init()
+ {
+ $this->SetDefaultEvent('index');
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('search.search'));
+ }
+
+ /**
+ * Регистрация евентов
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEvent('index', 'EventIndex');
+ $this->AddEventPreg('/^topics$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventTopics');
+ $this->AddEventPreg('/^comments$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventComments');
+ $this->AddEvent('opensearch', 'EventOpenSearch');
+ }
+
+ /**
+ * Главная страница поиска
+ */
+ protected function EventIndex()
+ {
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Обработка стандарта для браузеров Open Search
+ */
+ function EventOpenSearch()
+ {
+ Router::SetIsShowStats(false);
+ header('Content-type: text/xml; charset=utf-8');
+ }
+
+ /**
+ * Обработка поиска топиков
+ */
+ protected function EventTopics()
+ {
+ $this->SetTemplateAction('index');
+ $sSearchType = $this->sCurrentEvent;
+ $iPage = $this->GetParamEventMatch(0, 2) ? $this->GetParamEventMatch(0, 2) : 1;
+ /**
+ * Получаем список слов для поиска
+ */
+ $aWords = $this->Search_GetWordsForSearch(mb_strtolower(getRequestStr('q'),"utf-8"));
+ if (!$aWords) {
+ $this->Message_AddErrorSingle($this->Lang_Get('search.alerts.query_incorrect'));
+ return;
+ }
+ $sQuery = join(' ', $aWords);
+ /**
+ * Формируем регулярное выражение для поиска
+ */
+ $sRegexp = $this->Search_GetRegexpForWords($aWords);
+ /**
+ * Выполняем поиск
+ */
+ $aResult = $this->Search_SearchTopics($sRegexp, $iPage, Config::Get('module.topic.per_page'));
+ $aResultItems = $aResult['collection'];
+ /**
+ * Конфигурируем парсер jevix
+ */
+ $this->Text_LoadJevixConfig('search');
+ /**
+ * Делаем сниппеты
+ */
+ foreach ($aResultItems AS $oItem) {
+ /**
+ * Т.к. текст в сниппетах небольшой, то можно прогнать через парсер
+ */
+ $oItem->setTextShort($this->Text_JevixParser($this->Search_BuildExcerpts($oItem->getText(), $aWords)));
+ }
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), Router::GetPath('search') . $sSearchType, array('q' => $sQuery));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('resultItems', $aResultItems);
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('searchType', $sSearchType);
+ $this->Viewer_Assign('query', $sQuery);
+ $this->Viewer_Assign('typeCounts', array($sSearchType => $aResult['count']));
+ }
+
+ /**
+ * Обработка поиска комментариев
+ */
+ protected function EventComments()
+ {
+ $this->SetTemplateAction('index');
+ $sSearchType = $this->sCurrentEvent;
+ $iPage = $this->GetParamEventMatch(0, 2) ? $this->GetParamEventMatch(0, 2) : 1;
+ /**
+ * Получаем список слов для поиска
+ */
+ $aWords = $this->Search_GetWordsForSearch(mb_strtolower(getRequestStr('q'),"utf-8"));
+ if (!$aWords) {
+ $this->Message_AddErrorSingle($this->Lang_Get('search.alerts.query_incorrect'));
+ return;
+ }
+ $sQuery = join(' ', $aWords);
+ /**
+ * Формируем регулярное выражение для поиска
+ */
+ $sRegexp = $this->Search_GetRegexpForWords($aWords);
+ /**
+ * Выполняем поиск
+ */
+ $aResult = $this->Search_SearchComments($sRegexp, $iPage, 4, 'topic');
+ $aResultItems = $aResult['collection'];
+ /**
+ * Конфигурируем парсер jevix
+ */
+ $this->Text_LoadJevixConfig('search');
+ /**
+ * Делаем сниппеты
+ */
+ foreach ($aResultItems AS $oItem) {
+ /**
+ * Т.к. текст в сниппетах небольшой, то можно прогнать через парсер
+ */
+ $oItem->setText($this->Text_JevixParser($this->Search_BuildExcerpts($oItem->getText(), $aWords)));
+ }
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, 4, Config::Get('pagination.pages.count'),
+ Router::GetPath('search') . $sSearchType, array('q' => $sQuery));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('resultItems', $aResultItems);
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('searchType', $sSearchType);
+ $this->Viewer_Assign('query', $sQuery);
+ $this->Viewer_Assign('typeCounts', array($sSearchType => $aResult['count']));
+ }
+}
diff --git a/application/classes/actions/ActionSettings.class.php b/application/classes/actions/ActionSettings.class.php
new file mode 100644
index 0000000..7c8de3b
--- /dev/null
+++ b/application/classes/actions/ActionSettings.class.php
@@ -0,0 +1,731 @@
+
+ *
+ */
+
+/**
+ * Экшен обрабтки настроек профиля юзера (/settings/)
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionSettings extends Action
+{
+ /**
+ * Какое меню активно
+ *
+ * @var string
+ */
+ protected $sMenuItemSelect = 'settings';
+ /**
+ * Меню профиля пользователя
+ *
+ * @var string
+ */
+ protected $sMenuProfileItemSelect = 'settings';
+ /**
+ * Какое подменю активно
+ *
+ * @var string
+ */
+ protected $sMenuSubItemSelect = 'profile';
+ /**
+ * Текущий юзер
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ /**
+ * Проверяем авторизован ли юзер
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.not_access'), $this->Lang_Get('common.error.error'));
+ return Router::Action('error');
+ }
+ /**
+ * Получаем текущего юзера
+ */
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ $this->SetDefaultEvent('profile');
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.settings.title'));
+ }
+
+ /**
+ * Регистрация евентов
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEvent('profile', 'EventProfile');
+ $this->AddEvent('invite', 'EventInvite');
+ $this->AddEvent('tuning', 'EventTuning');
+ $this->AddEvent('account', 'EventAccount');
+
+ $this->AddEventPreg('/^ajax-upload-photo$/i', '/^$/i', 'EventAjaxUploadPhoto');
+ $this->AddEventPreg('/^ajax-crop-photo$/i', '/^$/i', 'EventAjaxCropPhoto');
+ $this->AddEventPreg('/^ajax-crop-cancel-photo$/i', '/^$/i', 'EventAjaxCropCancelPhoto');
+ $this->AddEventPreg('/^ajax-remove-photo$/i', '/^$/i', 'EventAjaxRemovePhoto');
+ $this->AddEventPreg('/^ajax-change-avatar$/i', '/^$/i', 'EventAjaxChangeAvatar');
+ $this->AddEventPreg('/^ajax-modal-crop-photo$/i', '/^$/i', 'EventAjaxModalCropPhoto');
+ $this->AddEventPreg('/^ajax-modal-crop-avatar$/i', '/^$/i', 'EventAjaxModalCropAvatar');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Загрузка фотографии в профиль пользователя
+ */
+ protected function EventAjaxUploadPhoto()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ if (!isset($_FILES['photo']['tmp_name'])) {
+ return $this->EventErrorDebug();
+ }
+
+ if (!$oUser = $this->User_GetUserById(getRequestStr('target_id'))) {
+ return $this->EventErrorDebug();
+ }
+ if (!$oUser->isAllowEdit()) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Копируем загруженный файл
+ */
+ $sFileTmp = Config::Get('sys.cache.dir') . func_generator();
+ if (!move_uploaded_file($_FILES['photo']['tmp_name'], $sFileTmp)) {
+ return false;
+ }
+ /**
+ * Если объект изображения не создан, возвращаем ошибку
+ */
+ if (!$oImage = $this->Image_Open($sFileTmp)) {
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ $this->Message_AddError($this->Image_GetLastError());
+ return;
+ }
+ /**
+ * Ресайзим и сохраняем именьшенную копию
+ * Храним две копии - мелкую для показа пользователю и крупную в качестве исходной для ресайза
+ */
+ $sDir = Config::Get('path.uploads.images') . "/tmp/userphoto/{$oUser->getId()}";
+ $aPhotoSizes = $this->Media_ParsedImageSize(Config::Get('module.user.profile_photo_size'));
+ $sSaveWidth = $aPhotoSizes['w'] > 1000 ? $aPhotoSizes['w'] : 1000;
+ if ($sFileOriginal = $oImage->resize($sSaveWidth, null)->saveSmart($sDir, 'original', array('skip_watermark' => true))) {
+ if ($sFilePreview = $oImage->resize(350, null)->saveSmart($sDir, 'preview', array('skip_watermark' => true))) {
+ list($iOriginalWidth, $iOriginalHeight) = @getimagesize($this->Fs_GetPathServer($sFileOriginal));
+ list($iWidth, $iHeight) = @getimagesize($this->Fs_GetPathServer($sFilePreview));
+ /**
+ * Сохраняем в сессии временный файл с изображением
+ */
+ $this->Session_Set('sPhotoFileTmp', $sFileOriginal);
+ $this->Session_Set('sPhotoFilePreviewTmp', $sFilePreview);
+ $this->Viewer_AssignAjax('path', $this->Fs_GetPathWeb($sFilePreview));
+ $this->Viewer_AssignAjax('original_width', $iOriginalWidth);
+ $this->Viewer_AssignAjax('original_height', $iOriginalHeight);
+ $this->Viewer_AssignAjax('width', $iWidth);
+ $this->Viewer_AssignAjax('height', $iHeight);
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ return;
+ }
+ }
+ $this->Message_AddError($this->Image_GetLastError());
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ }
+
+ /**
+ * Обрезка фотографии в профиль пользователя
+ */
+ protected function EventAjaxCropPhoto()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$oUser = $this->User_GetUserById(getRequestStr('target_id'))) {
+ return $this->EventErrorDebug();
+ }
+ if (!$oUser->isAllowEdit()) {
+ return $this->EventErrorDebug();
+ }
+
+ $sFile = $this->Session_Get('sPhotoFileTmp');
+ $sFilePreview = $this->Session_Get('sPhotoFilePreviewTmp');
+ if (!$this->Image_IsExistsFile($sFile)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'));
+ return;
+ }
+
+ if (true === ($res = $this->User_CreateProfilePhoto($sFile, $oUser, getRequest('size'),
+ getRequestStr('canvas_width')))
+ ) {
+ $this->Image_RemoveFile($sFile);
+ $this->Image_RemoveFile($sFilePreview);
+ $this->Session_Drop('sPhotoFileTmp');
+ $this->Session_Drop('sPhotoFilePreviewTmp');
+ /**
+ * Создаем аватар на основе фото
+ */
+ $this->User_CreateProfileAvatar($oUser->getProfileFoto(), $oUser);
+
+ $this->Viewer_AssignAjax('upload_text', $this->Lang_Get('user.photo.actions.change_photo'));
+ $this->Viewer_AssignAjax('photo', $oUser->getProfileFotoPath());
+ } else {
+ $this->Message_AddError(is_string($res) ? $res : $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ /**
+ * Показывает модальное окно с кропом фото
+ */
+ protected function EventAjaxModalCropPhoto()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+
+ $oViewer->Assign('image', getRequestStr('path'), true);
+ $oViewer->Assign('originalWidth', (int)getRequest('original_width'), true);
+ $oViewer->Assign('originalHeight', (int)getRequest('original_height'), true);
+ $oViewer->Assign('width', (int)getRequest('width'), true);
+ $oViewer->Assign('height', (int)getRequest('height'), true);
+
+ $this->Viewer_AssignAjax('sText', $oViewer->Fetch("component@photo.modal-photo"));
+ }
+
+ /**
+ * Показывает модальное окно с кропом аватарки
+ */
+ protected function EventAjaxModalCropAvatar()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+
+ $oViewer->Assign('image', getRequestStr('path'), true);
+ $oViewer->Assign('originalWidth', (int)getRequest('original_width'), true);
+ $oViewer->Assign('originalHeight', (int)getRequest('original_height'), true);
+ $oViewer->Assign('width', (int)getRequest('width'), true);
+ $oViewer->Assign('height', (int)getRequest('height'), true);
+ $oViewer->Assign('usePreview', true, true);
+
+ $this->Viewer_AssignAjax('sText', $oViewer->Fetch("component@photo.modal-avatar"));
+ }
+
+ /**
+ * Удаляет временные файлы кропа фото
+ */
+ protected function EventAjaxCropCancelPhoto()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$oUser = $this->User_GetUserById(getRequestStr('target_id'))) {
+ return $this->EventErrorDebug();
+ }
+ if (!$oUser->isAllowEdit()) {
+ return $this->EventErrorDebug();
+ }
+
+ $sFile = $this->Session_Get('sPhotoFileTmp');
+ $sFilePreview = $this->Session_Get('sPhotoFilePreviewTmp');
+
+ $this->Image_RemoveFile($sFile);
+ $this->Image_RemoveFile($sFilePreview);
+ $this->Session_Drop('sPhotoFileTmp');
+ $this->Session_Drop('sPhotoFilePreviewTmp');
+ }
+
+ /**
+ * Удаление фотографии профиля
+ */
+ protected function EventAjaxRemovePhoto()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$oUser = $this->User_GetUserById(getRequestStr('target_id'))) {
+ return $this->EventErrorDebug();
+ }
+ if (!$oUser->isAllowEdit()) {
+ return $this->EventErrorDebug();
+ }
+
+ $this->User_DeleteProfilePhoto($oUser);
+ $this->User_DeleteProfileAvatar($oUser);
+ $this->User_Update($oUser);
+
+ $this->Viewer_AssignAjax('upload_text', $this->Lang_Get('user.photo.actions.upload_photo'));
+ $this->Viewer_AssignAjax('photo', $oUser->getProfileFotoPath());
+ $this->Viewer_AssignAjax('avatars', $oUser->GetProfileAvatarsPath());
+ }
+
+ /**
+ * Обновление аватара на основе фото профиля
+ */
+ protected function EventAjaxChangeAvatar()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$oUser = $this->User_GetUserById(getRequestStr('target_id'))) {
+ return $this->EventErrorDebug();
+ }
+ if (!$oUser->isAllowEdit()) {
+ return $this->EventErrorDebug();
+ }
+
+ if (true === ($res = $this->User_CreateProfileAvatar($oUser->getProfileFoto(), $oUser, getRequest('size'),
+ getRequestStr('canvas_width')))
+ ) {
+ // Формируем массив с путями до аватаров
+ $this->Viewer_AssignAjax('avatars', $oUser->GetProfileAvatarsPath());
+ } else {
+ $this->Message_AddError(is_string($res) ? $res : $this->Lang_Get('common.error.error'));
+ }
+ }
+
+ /**
+ * Дополнительные настройки сайта
+ */
+ protected function EventTuning()
+ {
+ $this->sMenuItemSelect = 'settings';
+ $this->sMenuSubItemSelect = 'tuning';
+
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.settings.nav.tuning'));
+ $aTimezoneList = DateTimeZone::listIdentifiers();
+ $this->Viewer_Assign('aTimezoneList', $aTimezoneList);
+ /**
+ * Если отправили форму с настройками - сохраняем
+ */
+ if (isPost()) {
+ $this->Security_ValidateSendForm();
+
+ if (in_array(getRequestStr('settings_general_timezone'), $aTimezoneList)) {
+ $this->oUserCurrent->setSettingsTimezone(getRequestStr('settings_general_timezone'));
+ }
+
+ $this->oUserCurrent->setSettingsNoticeNewTopic(getRequest('settings_notice_new_topic') ? 1 : 0);
+ $this->oUserCurrent->setSettingsNoticeNewComment(getRequest('settings_notice_new_comment') ? 1 : 0);
+ $this->oUserCurrent->setSettingsNoticeNewTalk(getRequest('settings_notice_new_talk') ? 1 : 0);
+ $this->oUserCurrent->setSettingsNoticeReplyComment(getRequest('settings_notice_reply_comment') ? 1 : 0);
+ $this->oUserCurrent->setSettingsNoticeNewFriend(getRequest('settings_notice_new_friend') ? 1 : 0);
+ $this->oUserCurrent->setProfileDate(date("Y-m-d H:i:s"));
+ /**
+ * Запускаем выполнение хуков
+ */
+ $this->Hook_Run('settings_tuning_save_before', array('oUser' => $this->oUserCurrent));
+ if ($this->User_Update($this->oUserCurrent)) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('common.success.save'));
+ $this->Hook_Run('settings_tuning_save_after', array('oUser' => $this->oUserCurrent));
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'));
+ }
+ } else {
+ if (is_null($this->oUserCurrent->getSettingsTimezone())) {
+ $_REQUEST['settings_general_timezone'] = date_default_timezone_get();
+ } else {
+ $_REQUEST['settings_general_timezone'] = $this->oUserCurrent->getSettingsTimezone();
+ }
+ }
+ }
+
+ /**
+ * Показ и обработка формы приглаешний
+ *
+ */
+ protected function EventInvite()
+ {
+ $this->sMenuSubItemSelect = 'invite';
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.settings.nav.invites'));
+
+ $this->Viewer_Assign('iCountInviteAvailable', $this->Invite_GetCountInviteAvailable($this->oUserCurrent));
+ $this->Viewer_Assign('iCountInviteUsed', $this->Invite_GetCountInviteUsed($this->oUserCurrent->getId()));
+ $this->Viewer_Assign('sReferralLink', $this->Invite_GetReferralLink($this->oUserCurrent));
+ /**
+ * Если отправили форму
+ */
+ if (isPost()) {
+ $this->Security_ValidateSendForm();
+
+ $bError = false;
+ /**
+ * Есть права на отправку инвайтов?
+ */
+ if (!$this->ACL_CanSendInvite($this->oUserCurrent)) {
+ $this->Message_AddErrorSingle($this->Rbac_GetMsgLast());
+ return;
+ }
+ /**
+ * Емайл корректен?
+ */
+ if (!$this->Validate_Validate('email', getRequestStr('invite_mail'), array('allowEmpty' => false))) {
+ $this->Message_AddError($this->Validate_GetErrorLast());
+ return;
+ }
+
+ if (Config::Get('general.reg.invite')) {
+ if (!($oInvite = $this->Invite_GenerateInvite($this->oUserCurrent))) {
+ return $this->EventErrorDebug();
+ }
+ $sRefCode = $oInvite->getCode();
+ } else {
+ if (!($sRefCode = $this->Invite_GetReferralCode($this->oUserCurrent))) {
+ return $this->EventErrorDebug();
+ }
+ }
+ /**
+ * Если нет ошибок, то отправляем инвайт
+ */
+ if (!$bError) {
+ /**
+ * Запускаем выполнение хуков
+ */
+ $this->Hook_Run('settings_invite_send_before', array('oUser' => $this->oUserCurrent, 'sRefCode' => $sRefCode));
+
+ $this->Invite_SendNotifyInvite($this->oUserCurrent, getRequestStr('invite_mail'), $sRefCode);
+ $this->Message_AddNoticeSingle($this->Lang_Get('user.settings.invites.notices.success'));
+ $this->Hook_Run('settings_invite_send_after', array('oUser' => $this->oUserCurrent, 'sRefCode' => $sRefCode));
+ }
+ }
+ }
+
+ /**
+ * Форма смены пароля, емайла
+ */
+ protected function EventAccount()
+ {
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.settings.nav.account'));
+ $this->sMenuSubItemSelect = 'account';
+ /**
+ * Если нажали кнопку "Сохранить"
+ */
+ if (isPost()) {
+ $this->Security_ValidateSendForm();
+
+ $bError = false;
+ /**
+ * Проверка мыла
+ */
+ if (func_check(getRequestStr('mail'), 'mail')) {
+ if ($oUserMail = $this->User_GetUserByMail(getRequestStr('mail')) and $oUserMail->getId() != $this->oUserCurrent->getId()) {
+ $this->Message_AddError($this->Lang_Get('user.settings.account.fields.email.notices.error_used'),
+ $this->Lang_Get('common.error.error'));
+ $bError = true;
+ }
+ } else {
+ $this->Message_AddError($this->Lang_Get('fields.email.notices.error'), $this->Lang_Get('common.error.error'));
+ $bError = true;
+ }
+ /**
+ * Проверка на смену пароля
+ */
+ if (getRequestStr('password', '') != '') {
+ if (func_check(getRequestStr('password'), 'password', 5)) {
+ if (getRequestStr('password') == getRequestStr('password_confirm')) {
+ if ($this->oUserCurrent->verifyPassword(getRequestStr('password_now'))) {
+ $this->oUserCurrent->setPassword($this->User_MakeHashPassword(getRequestStr('password')));
+ } else {
+ $bError = true;
+ $this->Message_AddError($this->Lang_Get('user.settings.account.fields.password.notices.error'),
+ $this->Lang_Get('common.error.error'));
+ }
+ } else {
+ $bError = true;
+ $this->Message_AddError($this->Lang_Get('user.settings.account.fields.password_confirm.notices.error'),
+ $this->Lang_Get('common.error.error'));
+ }
+ } else {
+ $bError = true;
+ $this->Message_AddError($this->Lang_Get('user.settings.account.fields.password_new.notices.error'),
+ $this->Lang_Get('common.error.error'));
+ }
+ }
+ /**
+ * Ставим дату последнего изменения
+ */
+ $this->oUserCurrent->setProfileDate(date("Y-m-d H:i:s"));
+ /**
+ * Запускаем выполнение хуков
+ */
+ $this->Hook_Run('settings_account_save_before',
+ array('oUser' => $this->oUserCurrent, 'bError' => &$bError));
+ /**
+ * Сохраняем изменения
+ */
+ if (!$bError) {
+ if ($this->User_Update($this->oUserCurrent)) {
+ $this->Message_AddNoticeSingle($this->Lang_Get('common.success.save'));
+ /**
+ * Подтверждение смены емайла
+ */
+ if (getRequestStr('mail') and getRequestStr('mail') != $this->oUserCurrent->getMail()) {
+ if ($oChangemail = $this->User_MakeUserChangemail($this->oUserCurrent, getRequestStr('mail'))) {
+ if ($oChangemail->getMailFrom()) {
+ $this->Message_AddNotice($this->Lang_Get('user.settings.account.fields.email.notices.change_from_notice'));
+ } else {
+ $this->Message_AddNotice($this->Lang_Get('user.settings.account.fields.email.notices.change_to_notice'));
+ }
+ }
+ }
+
+ $this->Hook_Run('settings_account_save_after', array('oUser' => $this->oUserCurrent));
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'));
+ }
+ }
+ }
+ }
+
+ /**
+ * Выводит форму для редактирования профиля и обрабатывает её
+ *
+ */
+ protected function EventProfile()
+ {
+ /**
+ * Устанавливаем title страницы
+ */
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('user.settings.nav.profile'));
+ $this->Viewer_Assign('aUserFields', $this->User_getUserFields(''));
+ $this->Viewer_Assign('aUserFieldsContact', $this->User_getUserFields(array('contact', 'social')));
+ /**
+ * Загружаем в шаблон JS текстовки
+ */
+ $this->Lang_AddLangJs(array(
+ 'user.settings.profile.notices.error_max_userfields'
+ ));
+ /**
+ * Если нажали кнопку "Сохранить"
+ */
+ if (isPost()) {
+ $this->Security_ValidateSendForm();
+
+ $bError = false;
+ /**
+ * Заполняем профиль из полей формы
+ */
+ /**
+ * Определяем гео-объект
+ */
+ $aGeo = getRequest('geo');
+
+ if (isset($aGeo['city']) && $aGeo['city']) {
+ $oGeoObject = $this->Geo_GetGeoObject('city', (int)$aGeo['city']);
+ } elseif (isset($aGeo['region']) && $aGeo['region']) {
+ $oGeoObject = $this->Geo_GetGeoObject('region', (int)$aGeo['region']);
+ } elseif (isset($aGeo['country']) && $aGeo['country']) {
+ $oGeoObject = $this->Geo_GetGeoObject('country', (int)$aGeo['country']);
+ } else {
+ $oGeoObject = null;
+ }
+ /**
+ * Проверяем имя
+ */
+ if (func_check(getRequestStr('profile_name'), 'text', 2, Config::Get('module.user.name_max'))) {
+ $this->oUserCurrent->setProfileName(getRequestStr('profile_name'));
+ } else {
+ $this->oUserCurrent->setProfileName(null);
+ }
+ /**
+ * Проверяем пол
+ */
+ if (in_array(getRequestStr('profile_sex'), array('man', 'woman', 'other'))) {
+ $this->oUserCurrent->setProfileSex(getRequestStr('profile_sex'));
+ } else {
+ $this->oUserCurrent->setProfileSex('other');
+ }
+ /**
+ * Проверяем дату рождения
+ */
+ $this->oUserCurrent->setProfileBirthday(null);
+ if ($this->Validate_Validate('date', getRequestStr('profile_birthday'),
+ array('format' => 'dd.MM.yyyy', 'allowEmpty' => false))
+ ) {
+ $iBirthdayTime = strtotime(getRequestStr('profile_birthday'));
+ if ($iBirthdayTime < time() and $iBirthdayTime > strtotime('-100 year')) {
+ $this->oUserCurrent->setProfileBirthday(date("Y-m-d H:i:s", $iBirthdayTime));
+ }
+ }
+ /**
+ * Проверяем информацию о себе
+ */
+ if (func_check(getRequestStr('profile_about'), 'text', 1, 3000)) {
+ $this->oUserCurrent->setProfileAbout($this->Text_Parser(getRequestStr('profile_about')));
+ } else {
+ $this->oUserCurrent->setProfileAbout(null);
+ }
+ /**
+ * Ставим дату последнего изменения профиля
+ */
+ $this->oUserCurrent->setProfileDate(date("Y-m-d H:i:s"));
+ /**
+ * Запускаем выполнение хуков
+ */
+ $this->Hook_Run('settings_profile_save_before',
+ array('oUser' => $this->oUserCurrent, 'bError' => &$bError));
+ /**
+ * Сохраняем изменения профиля
+ */
+ if (!$bError) {
+ if ($this->User_Update($this->oUserCurrent)) {
+ /**
+ * Создаем связь с гео-объектом
+ */
+ if ($oGeoObject) {
+ $this->Geo_CreateTarget($oGeoObject, 'user', $this->oUserCurrent->getId());
+ if ($oCountry = $oGeoObject->getCountry()) {
+ $this->oUserCurrent->setProfileCountry($oCountry->getName());
+ } else {
+ $this->oUserCurrent->setProfileCountry(null);
+ }
+ if ($oRegion = $oGeoObject->getRegion()) {
+ $this->oUserCurrent->setProfileRegion($oRegion->getName());
+ } else {
+ $this->oUserCurrent->setProfileRegion(null);
+ }
+ if ($oCity = $oGeoObject->getCity()) {
+ $this->oUserCurrent->setProfileCity($oCity->getName());
+ } else {
+ $this->oUserCurrent->setProfileCity(null);
+ }
+ } else {
+ $this->Geo_DeleteTargetsByTarget('user', $this->oUserCurrent->getId());
+ $this->oUserCurrent->setProfileCountry(null);
+ $this->oUserCurrent->setProfileRegion(null);
+ $this->oUserCurrent->setProfileCity(null);
+ }
+ $this->User_Update($this->oUserCurrent);
+
+ /**
+ * Обрабатываем дополнительные поля, type = ''
+ */
+ $aFields = $this->User_getUserFields('');
+ $aData = array();
+ foreach ($aFields as $iId => $aField) {
+ if (isset($_REQUEST['profile_user_field_' . $iId])) {
+ $aData[$iId] = getRequestStr('profile_user_field_' . $iId);
+ }
+ }
+ $this->User_setUserFieldsValues($this->oUserCurrent->getId(), $aData);
+ /**
+ * Динамические поля контактов, type = array('contact','social')
+ */
+ $aType = array('contact', 'social');
+ $aFields = $this->User_getUserFields($aType);
+ /**
+ * Удаляем все поля с этим типом
+ */
+ $this->User_DeleteUserFieldValues($this->oUserCurrent->getId(), $aType);
+ $aFieldsContactType = getRequest('profile_user_field_type');
+ $aFieldsContactValue = getRequest('profile_user_field_value');
+ if (is_array($aFieldsContactType)) {
+ foreach ($aFieldsContactType as $k => $v) {
+ $v = (string)$v;
+ if (isset($aFields[$v]) and isset($aFieldsContactValue[$k]) and is_string($aFieldsContactValue[$k])) {
+ $this->User_setUserFieldsValues($this->oUserCurrent->getId(),
+ array($v => $aFieldsContactValue[$k]),
+ Config::Get('module.user.userfield_max_identical'));
+ }
+ }
+ }
+ $this->Message_AddNoticeSingle($this->Lang_Get('common.success.save'));
+ $this->Hook_Run('settings_profile_save_after', array('oUser' => $this->oUserCurrent));
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'));
+ }
+ }
+ }
+ /**
+ * Загружаем гео-объект привязки
+ */
+ $oGeoTarget = $this->Geo_GetTargetByTarget('user', $this->oUserCurrent->getId());
+ $this->Viewer_Assign('oGeoTarget', $oGeoTarget);
+ /**
+ * Загружаем в шаблон список стран, регионов, городов
+ */
+ $aCountries = $this->Geo_GetCountries(array(), array('sort' => 'asc'), 1, 300);
+ $this->Viewer_Assign('aGeoCountries', $aCountries['collection']);
+ if ($oGeoTarget) {
+ if ($oGeoTarget->getCountryId()) {
+ $aRegions = $this->Geo_GetRegions(array('country_id' => $oGeoTarget->getCountryId()),
+ array('sort' => 'asc'), 1, 500);
+ $this->Viewer_Assign('aGeoRegions', $aRegions['collection']);
+ }
+ if ($oGeoTarget->getRegionId()) {
+ $aCities = $this->Geo_GetCities(array('region_id' => $oGeoTarget->getRegionId()),
+ array('sort' => 'asc'), 1, 500);
+ $this->Viewer_Assign('aGeoCities', $aCities['collection']);
+ }
+ }
+
+ }
+
+ /**
+ * Выполняется при завершении работы экшена
+ *
+ */
+ public function EventShutdown()
+ {
+ $iCountTopicFavourite = $this->Topic_GetCountTopicsFavouriteByUserId($this->oUserCurrent->getId());
+ $iCountTopicUser = $this->Topic_GetCountTopicsPersonalByUser($this->oUserCurrent->getId(), 1);
+ $iCountCommentUser = $this->Comment_GetCountCommentsByUserId($this->oUserCurrent->getId(), 'topic');
+ $iCountCommentFavourite = $this->Comment_GetCountCommentsFavouriteByUserId($this->oUserCurrent->getId());
+ $iCountNoteUser = $this->User_GetCountUserNotesByUserId($this->oUserCurrent->getId());
+
+ $this->Viewer_Assign('oUserProfile', $this->oUserCurrent);
+ $this->Viewer_Assign('iCountWallUser',
+ $this->Wall_GetCountWall(array('wall_user_id' => $this->oUserCurrent->getId(), 'pid' => null)));
+ /**
+ * Общее число публикация и избранного
+ */
+ $this->Viewer_Assign('iCountCreated', $iCountNoteUser + $iCountTopicUser + $iCountCommentUser);
+ $this->Viewer_Assign('iCountFavourite', $iCountCommentFavourite + $iCountTopicFavourite);
+ $this->Viewer_Assign('iCountFriendsUser', $this->User_GetCountUsersFriend($this->oUserCurrent->getId()));
+
+ /**
+ * Загружаем в шаблон необходимые переменные
+ */
+ $this->Viewer_Assign('sMenuItemSelect', $this->sMenuItemSelect);
+ $this->Viewer_Assign('sMenuProfileItemSelect', $this->sMenuProfileItemSelect);
+ $this->Viewer_Assign('sMenuSubItemSelect', $this->sMenuSubItemSelect);
+
+ $this->Hook_Run('action_shutdown_settings');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionStream.class.php b/application/classes/actions/ActionStream.class.php
new file mode 100644
index 0000000..2b01802
--- /dev/null
+++ b/application/classes/actions/ActionStream.class.php
@@ -0,0 +1,356 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки ленты активности
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionStream extends Action
+{
+ /**
+ * Текущий пользователь
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent;
+
+ /**
+ * Какое меню активно
+ *
+ * @var string
+ */
+ protected $sMenuItemSelect = 'user';
+
+ /**
+ * Инициализация
+ */
+ public function Init()
+ {
+ $this->oUserCurrent = $this->User_getUserCurrent();
+
+ // Личная лента доступна только для авторизованных, гостям показываем общую ленту
+ if ($this->oUserCurrent) {
+ $this->SetDefaultEvent('personal');
+ } else {
+ $this->SetDefaultEvent('all');
+ }
+
+ $this->Viewer_Assign('sMenuHeadItemSelect', 'stream');
+
+ /**
+ * Загружаем в шаблон JS текстовки
+ */
+ $this->Lang_AddLangJs(array(
+ 'activity.notices.error_already_subscribed',
+ 'error'
+ ));
+ }
+
+ /**
+ * Регистрация евентов
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEvent('personal', 'EventPersonal');
+ $this->AddEvent('all', 'EventAll');
+
+ $this->AddEvent('subscribe', 'EventSubscribe'); // TODO: возможно нужно удалить
+ $this->AddEvent('ajaxadduser', 'EventAjaxAddUser');
+ $this->AddEvent('ajaxremoveuser', 'EventAjaxRemoveUser');
+ $this->AddEvent('switchEventType', 'EventSwitchEventType');
+
+ $this->AddEvent('get_more_all', 'EventGetMoreAll');
+ $this->AddEvent('get_more_personal', 'EventGetMore');
+ $this->AddEvent('get_more_user', 'EventGetMoreUser');
+ }
+
+ /**
+ * Персональная активность
+ */
+ protected function EventPersonal()
+ {
+ if (!$this->oUserCurrent) {
+ return parent::EventNotFound();
+ }
+
+ $this->Viewer_AddBlock('right', 'activitySettings');
+ $this->Viewer_AddBlock('right', 'activityUsers');
+
+ $this->Viewer_Assign('activityEvents', $this->Stream_Read());
+ $this->Viewer_Assign('activityEventsAllCount', $this->Stream_GetCountByReaderId($this->oUserCurrent->getId()));
+ }
+
+ /**
+ * Общая активность
+ */
+ protected function EventAll()
+ {
+ $this->sMenuItemSelect = 'all';
+
+ $this->Viewer_Assign('activityEvents', $this->Stream_ReadAll());
+ $this->Viewer_Assign('activityEventsAllCount', $this->Stream_GetCountAll());
+ }
+
+ /**
+ * Активаци/деактивация типа события
+ */
+ protected function EventSwitchEventType()
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$this->oUserCurrent) {
+ return parent::EventNotFound();
+ }
+
+ if (!getRequest('type')) {
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ }
+
+ /**
+ * Активируем/деактивируем тип
+ */
+ $this->Stream_switchUserEventType($this->oUserCurrent->getId(), getRequestStr('type'));
+ $this->Message_AddNotice($this->Lang_Get('common.success.save'), $this->Lang_Get('common.attention'));
+ }
+
+ /**
+ * Подгрузка событий (замена постраничности)
+ */
+ protected function EventGetMore()
+ {
+ if (!$this->oUserCurrent) {
+ return parent::EventNotFound();
+ }
+
+ $_this = $this;
+ $this->GetMore(function ($lastId) use ($_this) {
+ return $_this->Stream_Read(null, $lastId);
+ });
+ }
+
+ /**
+ * Подгрузка событий для всего сайта
+ */
+ protected function EventGetMoreAll()
+ {
+ $_this = $this;
+ $this->GetMore(function ($lastId) use ($_this) {
+ return $_this->Stream_ReadAll(null, $lastId);
+ });
+ }
+
+ /**
+ * Подгрузка событий для пользователя
+ */
+ protected function EventGetMoreUser()
+ {
+ $_this = $this;
+ $this->GetMore(function ($lastId) use ($_this) {
+ if (!($oUser = $_this->User_GetUserById(getRequestStr('target_id')))) {
+ return false;
+ }
+
+ return $_this->Stream_ReadByUserId($oUser->getId(), null, $lastId);
+ });
+ }
+
+ /**
+ * Общий метод подгрузки событий
+ *
+ * @param callback $getEvents Метод возвращающий список событий
+ */
+ protected function GetMore($getEvents)
+ {
+ $this->Viewer_SetResponseAjax('json');
+
+ // Необходимо передать последний просмотренный ID событий
+ $iLastId = getRequestStr('last_id');
+
+ if (!$iLastId) {
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+
+ // Получаем события
+ $aEvents = $getEvents($iLastId);
+
+ if ($aEvents === false) {
+ return $this->EventErrorDebug();
+ }
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+
+ $oViewer->Assign('events', $aEvents, true);
+ if (preg_match('#^\d{4}\-\d{1,2}\-\d{1,2}$#', getRequestStr('date_last'))) {
+ $oViewer->Assign('dateLast', getRequestStr('date_last'), true);
+ }
+
+ if (count($aEvents)) {
+ $this->Viewer_AssignAjax('last_id', end($aEvents)->getId(), true);
+ }
+
+ $this->Viewer_AssignAjax('count_loaded', count($aEvents));
+ $this->Viewer_AssignAjax('html', $oViewer->Fetch('component@activity.event-list'));
+ }
+
+ /**
+ * Подписка на пользователя по ID
+ *
+ */
+ protected function EventSubscribe()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Проверяем существование пользователя
+ */
+ if (!$this->User_getUserById(getRequestStr('id'))) {
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ }
+ if ($this->oUserCurrent->getId() == getRequestStr('id')) {
+ $this->Message_AddError($this->Lang_Get('user_list_add.notices.error_self'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Подписываем на пользователя
+ */
+ $this->Stream_subscribeUser($this->oUserCurrent->getId(), getRequestStr('id'));
+ $this->Message_AddNotice($this->Lang_Get('stream_subscribes_updated'), $this->Lang_Get('common.attention'));
+ }
+
+ /**
+ * Подписка на пользователя по логину
+ */
+ protected function EventAjaxAddUser()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $aUsers = getRequest('users', null, 'post');
+
+ /**
+ * Валидация
+ */
+ if (!is_array($aUsers)) {
+ return $this->EventErrorDebug();
+ }
+
+ /**
+ * Если пользователь не авторизирован, возвращаем ошибку
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+
+ $aResult = array();
+ /**
+ * Обрабатываем добавление по каждому из переданных логинов
+ */
+ foreach ($aUsers as $iUserId) {
+ $iUserId = (int)$iUserId;
+
+ if (!$iUserId) {
+ continue;
+ }
+
+ /**
+ * Если пользователь не найден или неактивен, возвращаем ошибку
+ */
+ if ($oUser = $this->User_GetUserById($iUserId) and $oUser->getActivate() == 1) {
+ $this->Stream_subscribeUser($this->oUserCurrent->getId(), $oUser->getId());
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('user', $oUser, true);
+ $oViewer->Assign('showActions', true, true);
+
+ $aResult[] = array(
+ 'bStateError' => false,
+ 'sMsgTitle' => $this->Lang_Get('common.attention'),
+ 'sMsg' => $this->Lang_Get('common.success.add', array('login' => $oUser->getLogin())),
+ 'user_id' => $oUser->getId(),
+ 'user_login' => $oUser->getLogin(),
+ 'html' => $oViewer->Fetch("component@user-list-add.item")
+ );
+ } else {
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('user.notices.not_found_by_id', array('id' => $iUserId))
+ );
+ }
+ }
+ /**
+ * Передаем во вьевер массив с результатами обработки по каждому пользователю
+ */
+ $this->Viewer_AssignAjax('users', $aResult);
+ }
+
+ /**
+ * Отписка от пользователя
+ */
+ protected function EventAjaxRemoveUser()
+ {
+ $iUserId = (int)getRequestStr('user_id');
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Пользователь авторизован?
+ */
+ if (!$this->oUserCurrent) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Пользователь с таким ID существует?
+ */
+ if (!$this->User_GetUserById($iUserId)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Отписываем
+ */
+ $this->Stream_unsubscribeUser($this->oUserCurrent->getId(), $iUserId);
+ $this->Message_AddNotice($this->Lang_Get('common.success.remove'), $this->Lang_Get('common.attention'));
+ }
+
+ /**
+ * Выполняется при завершении работы экшена
+ */
+ public function EventShutdown()
+ {
+ /**
+ * Загружаем в шаблон необходимые переменные
+ */
+ $this->Viewer_Assign('sMenuItemSelect', $this->sMenuItemSelect);
+ }
+}
diff --git a/application/classes/actions/ActionSubscribe.class.php b/application/classes/actions/ActionSubscribe.class.php
new file mode 100644
index 0000000..b9a7a4d
--- /dev/null
+++ b/application/classes/actions/ActionSubscribe.class.php
@@ -0,0 +1,147 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки подписок пользователей
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionSubscribe extends Action
+{
+ /**
+ * Текущий пользователь
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Регистрация евентов
+ *
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEventPreg('/^unsubscribe$/i', '/^\w{32}$/i', 'EventUnsubscribe');
+ $this->AddEvent('ajax-subscribe-toggle', 'EventAjaxSubscribeToggle');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+
+ /**
+ * Отписка от подписки
+ */
+ protected function EventUnsubscribe()
+ {
+ /**
+ * Получаем подписку по ключу
+ */
+ if ($oSubscribe = $this->Subscribe_GetSubscribeByKey($this->getParam(0)) and $oSubscribe->getStatus() == 1) {
+ /**
+ * Отписываем пользователя
+ */
+ $oSubscribe->setStatus(0);
+ $oSubscribe->setDateRemove(date("Y-m-d H:i:s"));
+ $this->Subscribe_UpdateSubscribe($oSubscribe);
+
+ $this->Message_AddNotice($this->Lang_Get('common.success.save'), null, true);
+ }
+ /**
+ * Получаем URL для редиректа
+ */
+ if ((!$sUrl = $this->Subscribe_GetUrlTarget($oSubscribe->getTargetType(), $oSubscribe->getTargetId()))) {
+ $sUrl = Router::GetPath('index');
+ }
+ Router::Location($sUrl);
+ }
+
+ /**
+ * Изменение состояния подписки
+ */
+ protected function EventAjaxSubscribeToggle()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Получаем емайл подписки и проверяем его на валидность
+ */
+ $sMail = getRequestStr('mail');
+ if ($this->oUserCurrent) {
+ $sMail = $this->oUserCurrent->getMail();
+ }
+ if (!func_check($sMail, 'mail')) {
+ $this->Message_AddError($this->Lang_Get('field.email.notices.error'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Получаем тип объекта подписки
+ */
+ $sTargetType = getRequestStr('target_type');
+ if (!$this->Subscribe_IsAllowTargetType($sTargetType)) {
+ return $this->EventErrorDebug();
+ }
+ $sTargetId = getRequestStr('target_id') ? getRequestStr('target_id') : null;
+ $iValue = getRequest('value') ? 1 : 0;
+
+ $oSubscribe = null;
+ /**
+ * Есть ли доступ к подписке гостям?
+ */
+ if (!$this->oUserCurrent and !$this->Subscribe_IsAllowTargetForGuest($sTargetType)) {
+ $this->Message_AddError($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Проверка объекта подписки
+ */
+ if (!$this->Subscribe_CheckTarget($sTargetType, $sTargetId, $iValue)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Если подписка еще не существовала, то создаем её
+ */
+ if ($oSubscribe = $this->Subscribe_AddSubscribeSimple($sTargetType, $sTargetId, $sMail,
+ $this->oUserCurrent ? $this->oUserCurrent->getId() : null)
+ ) {
+ $oSubscribe->setStatus($iValue);
+ $this->Subscribe_UpdateSubscribe($oSubscribe);
+ $this->Message_AddNotice($this->Lang_Get('common.success.save'), $this->Lang_Get('common.attention'));
+ return;
+ }
+ return $this->EventErrorDebug();
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionTag.class.php b/application/classes/actions/ActionTag.class.php
new file mode 100644
index 0000000..6b8e3e2
--- /dev/null
+++ b/application/classes/actions/ActionTag.class.php
@@ -0,0 +1,119 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки поиска по тегам
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionTag extends Action
+{
+ /**
+ * Главное меню
+ *
+ * @var string
+ */
+ protected $sMenuHeadItemSelect = 'blog';
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ }
+
+ /**
+ * Регистрация евентов
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEventPreg('/^.+$/i', '/^(page([1-9]\d{0,5}))?$/i', 'EventTags');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Отображение топиков
+ *
+ */
+ protected function EventTags()
+ {
+ /**
+ * Получаем тег из УРЛа
+ */
+ $sTag = $this->sCurrentEvent;
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetParamEventMatch(0, 2) ? $this->GetParamEventMatch(0, 2) : 1;
+ /**
+ * Получаем список топиков
+ */
+ $aResult = $this->Topic_GetTopicsByTag($sTag, $iPage, Config::Get('module.topic.per_page'));
+ $aTopics = $aResult['collection'];
+ /**
+ * Вызов хуков
+ */
+ $this->Hook_Run('topics_list_show', array('aTopics' => $aTopics));
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), Router::GetPath('tag') . htmlspecialchars($sTag));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('topics', $aTopics);
+ $this->Viewer_Assign('tag', $sTag);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('tag_title'));
+ $this->Viewer_AddHtmlTitle($sTag);
+ $this->Viewer_SetHtmlRssAlternate(Router::GetPath('rss') . 'tag/' . $sTag . '/', $sTag);
+ /**
+ * Если не удалось найти топиков, то ыставляем 404 заголовок
+ */
+ if (!count($aTopics)) {
+ header("HTTP/1.1 404 Not Found");
+ }
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->SetTemplateAction('index');
+ }
+
+ /**
+ * Выполняется при завершении работы экшена
+ *
+ */
+ public function EventShutdown()
+ {
+ /**
+ * Загружаем в шаблон необходимые переменные
+ */
+ $this->Viewer_Assign('sMenuHeadItemSelect', $this->sMenuHeadItemSelect);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionTalk.class.php b/application/classes/actions/ActionTalk.class.php
new file mode 100644
index 0000000..91713f8
--- /dev/null
+++ b/application/classes/actions/ActionTalk.class.php
@@ -0,0 +1,1303 @@
+
+ *
+ */
+
+/**
+ * Экшен обработки личной почты (сообщения /talk/)
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionTalk extends Action
+{
+ /**
+ * Текущий юзер
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+ /**
+ * Меню профиля пользователя
+ *
+ * @var string
+ */
+ protected $sMenuProfileItemSelect = 'talk';
+ /**
+ * Подменю
+ *
+ * @var string
+ */
+ protected $sMenuSubItemSelect = '';
+ /**
+ * Массив ID юзеров адресатов
+ *
+ * @var array
+ */
+ protected $aUsersId = array();
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ /**
+ * Проверяем авторизован ли юзер
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.not_access'));
+ return Router::Action('error');
+ }
+ /**
+ * Получаем текущего юзера
+ */
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ $this->SetDefaultEvent('inbox');
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('talk.nav.inbox'));
+
+ /**
+ * Загружаем в шаблон JS текстовки
+ */
+ $this->Lang_AddLangJs(array(
+ 'delete'
+ ));
+ }
+
+ /**
+ * Регистрация евентов
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEvent('inbox', 'EventInbox');
+ $this->AddEvent('add', 'EventAdd');
+ $this->AddEvent('read', 'EventRead');
+ $this->AddEvent('delete', 'EventDelete');
+ $this->AddEvent('favourites', 'EventFavourites');
+ $this->AddEvent('blacklist', 'EventBlacklist');
+
+ $this->AddEvent('ajaxaddcomment', 'AjaxAddComment');
+ $this->AddEvent('ajaxresponsecomment', 'AjaxResponseComment');
+ $this->AddEvent('ajaxaddtoblacklist', 'AjaxAddToBlacklist');
+ $this->AddEvent('ajaxdeletefromblacklist', 'AjaxDeleteFromBlacklist');
+ $this->AddEvent('ajaxdeletetalkuser', 'AjaxDeleteTalkUser');
+ $this->AddEvent('ajaxaddtalkuser', 'AjaxAddTalkUser');
+ $this->AddEvent('ajaxnewmessages', 'AjaxNewMessages');
+ }
+
+
+ /**********************************************************************************
+ ************************ РЕАЛИЗАЦИЯ ЭКШЕНА ***************************************
+ **********************************************************************************
+ */
+
+ /**
+ * Удаление письма
+ */
+ protected function EventDelete()
+ {
+ $this->Security_ValidateSendForm();
+ /**
+ * Получаем номер сообщения из УРЛ и проверяем существует ли оно
+ */
+ $sTalkId = $this->GetParam(0);
+ if (!($oTalk = $this->Talk_GetTalkById($sTalkId))) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Пользователь входит в переписку?
+ */
+ if (!($oTalkUser = $this->Talk_GetTalkUser($oTalk->getId(), $this->oUserCurrent->getId()))) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Обработка удаления сообщения
+ */
+ $this->Talk_DeleteTalkUserByArray($sTalkId, $this->oUserCurrent->getId());
+ Router::Location(Router::GetPath('talk'));
+ }
+
+ /**
+ * Отображение списка сообщений
+ */
+ protected function EventInbox()
+ {
+ /**
+ * Обработка удаления сообщений
+ */
+ if (getRequestStr('form_action') == 'remove') {
+ $this->Security_ValidateSendForm();
+
+ $aTalksIdDel = getRequest('talk_select');
+ if (is_array($aTalksIdDel)) {
+ $this->Talk_DeleteTalkUserByArray(array_keys($aTalksIdDel), $this->oUserCurrent->getId());
+ }
+ }
+ /**
+ * Обработка отметки о прочтении
+ */
+ if (getRequestStr('form_action') == 'mark_as_read') {
+ $this->Security_ValidateSendForm();
+
+ $aTalksIdDel = getRequest('talk_select');
+ if (is_array($aTalksIdDel)) {
+ $this->Talk_MarkReadTalkUserByArray(array_keys($aTalksIdDel), $this->oUserCurrent->getId());
+ }
+ }
+ $this->sMenuSubItemSelect = 'inbox';
+ /**
+ * Количество сообщений на страницу
+ */
+ $iPerPage = Config::Get('module.talk.per_page');
+ /**
+ * Формируем фильтр для поиска сообщений
+ */
+ $aFilter = $this->BuildFilter();
+ /**
+ * Если только новые, то добавляем условие в фильтр
+ */
+ if ($this->GetParam(0) == 'new') {
+ $this->sMenuSubItemSelect = 'new';
+ $aFilter['only_new'] = true;
+ $iPerPage = 50; // новых отображаем только последние 50 писем, без постраничности
+ }
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = preg_match("/^page([1-9]\d{0,5})$/i", $this->getParam(0), $aMatch) ? $aMatch[1] : 1;
+ /**
+ * Получаем список писем
+ */
+ $aResult = $this->Talk_GetTalksByFilter(
+ $aFilter, $iPage, $iPerPage
+ );
+
+ $aTalks = $aResult['collection'];
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging(
+ $aResult['count'], $iPage, $iPerPage, Config::Get('pagination.pages.count'),
+ Router::GetPath('talk') . $this->sCurrentEvent,
+ array_intersect_key(
+ $_REQUEST,
+ array_fill_keys(
+ array('start', 'end', 'keyword', 'sender', 'keyword_text', 'favourite'),
+ ''
+ )
+ )
+ );
+ /**
+ * Показываем сообщение, если происходит поиск по фильтру
+ */
+ if (getRequest('submit_talk_filter')) {
+ $this->Message_AddNotice(
+ ($aResult['count'])
+ ? $this->Lang_Get('talk.search.notices.result_count', array('count' => $aResult['count']))
+ : $this->Lang_Get('talk.search.notices.result_empty')
+ );
+ }
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('talks', $aTalks);
+ }
+
+ /**
+ * Формирует из REQUEST массива фильтр для отбора писем
+ *
+ * @return array
+ */
+ protected function BuildFilter()
+ {
+ /**
+ * Текущий пользователь
+ */
+ $aFilter = array(
+ 'user_id' => $this->oUserCurrent->getId(),
+ );
+ /**
+ * Дата старта поиска
+ */
+ if ($start = getRequestStr('start')) {
+ if (func_check($start, 'text', 6, 10) && substr_count($start, '.') == 2) {
+ list($d, $m, $y) = explode('.', $start);
+ if (@checkdate($m, $d, $y)) {
+ $aFilter['date_min'] = "{$y}-{$m}-{$d}";
+ } else {
+ $this->Message_AddError(
+ $this->Lang_Get('talk.search.notices.error_date_format'),
+ $this->Lang_Get('talk.search.notices.error')
+ );
+ unset($_REQUEST['start']);
+ }
+ } else {
+ $this->Message_AddError(
+ $this->Lang_Get('talk.search.notices.error_date_format'),
+ $this->Lang_Get('talk.search.notices.error')
+ );
+ unset($_REQUEST['start']);
+ }
+ }
+ /**
+ * Дата окончания поиска
+ */
+ if ($end = getRequestStr('end')) {
+ if (func_check($end, 'text', 6, 10) && substr_count($end, '.') == 2) {
+ list($d, $m, $y) = explode('.', $end);
+ if (@checkdate($m, $d, $y)) {
+ $aFilter['date_max'] = "{$y}-{$m}-{$d} 23:59:59";
+ } else {
+ $this->Message_AddError(
+ $this->Lang_Get('talk.search.notices.error_date_format'),
+ $this->Lang_Get('talk.search.notices.error')
+ );
+ unset($_REQUEST['end']);
+ }
+ } else {
+ $this->Message_AddError(
+ $this->Lang_Get('talk.search.notices.error_date_format'),
+ $this->Lang_Get('talk.search.notices.error')
+ );
+ unset($_REQUEST['end']);
+ }
+ }
+ /**
+ * Ключевые слова в теме сообщения
+ */
+ if ($sKeyRequest = getRequest('keyword') and is_string($sKeyRequest)) {
+ $sKeyRequest = urldecode($sKeyRequest);
+ preg_match_all('~(\S+)~u', $sKeyRequest, $aWords);
+
+ if (is_array($aWords[1]) && isset($aWords[1]) && count($aWords[1])) {
+ $aFilter['keyword'] = '%' . implode('%', $aWords[1]) . '%';
+ } else {
+ unset($_REQUEST['keyword']);
+ }
+ }
+ /**
+ * Ключевые слова в тексте сообщения
+ */
+ if ($sKeyRequest = getRequest('keyword_text') and is_string($sKeyRequest)) {
+ $sKeyRequest = urldecode($sKeyRequest);
+ preg_match_all('~(\S+)~u', $sKeyRequest, $aWords);
+
+ if (is_array($aWords[1]) && isset($aWords[1]) && count($aWords[1])) {
+ $aFilter['text_like'] = '%' . implode('%', $aWords[1]) . '%';
+ } else {
+ unset($_REQUEST['keyword_text']);
+ }
+ }
+ /**
+ * Отправитель
+ */
+ if ($sender = getRequest('sender') and is_string($sender)) {
+ $aFilter['user_login'] = urldecode($sender);
+ }
+ /**
+ * Отправитель
+ */
+ if ($sReceiver = urldecode(getRequestStr('receiver')) and $oUserReceiver = $this->User_GetUserByLogin($sReceiver)) {
+ $aFilter['receiver_user_id'] = $oUserReceiver->getId();
+ }
+ /**
+ * Искать только в избранных письмах
+ */
+ if (getRequest('favourite')) {
+ $aTalkIdResult = $this->Favourite_GetFavouritesByUserId($this->oUserCurrent->getId(), 'talk', 1,
+ 500); // ограничиваем
+ $aFilter['id'] = $aTalkIdResult['collection'];
+ $_REQUEST['favourite'] = 1;
+ } else {
+ unset($_REQUEST['favourite']);
+ }
+ return $aFilter;
+ }
+
+ /**
+ * Отображение списка блэк-листа
+ */
+ protected function EventBlacklist()
+ {
+ $this->sMenuSubItemSelect = 'blacklist';
+ $aUsersBlacklist = $this->Talk_GetBlacklistByUserId($this->oUserCurrent->getId());
+ $this->Viewer_Assign('talkBlacklistUsers', $aUsersBlacklist);
+ }
+
+ /**
+ * Отображение списка избранных писем
+ */
+ protected function EventFavourites()
+ {
+ $this->sMenuSubItemSelect = 'favourites';
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = preg_match("/^page([1-9]\d{0,5})$/i", $this->getParam(0), $aMatch) ? $aMatch[1] : 1;
+ /**
+ * Получаем список писем
+ */
+ $aResult = $this->Talk_GetTalksFavouriteByUserId(
+ $this->oUserCurrent->getId(),
+ $iPage, Config::Get('module.talk.per_page')
+ );
+ $aTalks = $aResult['collection'];
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging(
+ $aResult['count'], $iPage, Config::Get('module.talk.per_page'), Config::Get('pagination.pages.count'),
+ Router::GetPath('talk') . $this->sCurrentEvent
+ );
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('paging', $aPaging);
+ $this->Viewer_Assign('talks', $aTalks);
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('talk.nav.favourites'));
+ }
+
+ /**
+ * Страница создания письма
+ */
+ protected function EventAdd()
+ {
+ $this->sMenuSubItemSelect = 'add';
+ $this->Viewer_AddHtmlTitle($this->Lang_Get('talk.nav.add'));
+ /**
+ * Получаем список друзей
+ */
+ $aUsersFriend = $this->User_GetUsersFriend($this->oUserCurrent->getId());
+ if ($aUsersFriend['collection']) {
+ $this->Viewer_Assign('aUsersFriend', $aUsersFriend['collection']);
+ }
+ /**
+ * Проверяем отправлена ли форма с данными
+ */
+ if (!isPost()) {
+ $iUserId = (int) getRequest('talk_recepient_id');
+ $oUser = $this->User_GetUserById($iUserId);
+
+ if ($oUser) {
+ $this->Viewer_Assign('recepient', $oUser);
+ }
+
+ return false;
+ }
+ /**
+ * Проверяем разрешено ли отправлять личное сообщение
+ */
+ if (!$this->ACL_CanAddTalk($this->oUserCurrent)) {
+ $this->Message_AddErrorSingle($this->Rbac_GetMsgLast());
+ return Router::Action('error');
+ }
+ /**
+ * Проверка корректности полей формы
+ */
+ if (!$this->checkTalkFields()) {
+ return false;
+ }
+ /**
+ * Отправляем письмо
+ */
+ if ($oTalk = $this->Talk_SendTalk(strip_tags(getRequestStr('talk_title')),
+ $this->Text_Parser(getRequestStr('talk_text')), $this->oUserCurrent, $this->aUsersId)
+ ) {
+ /**
+ * Фиксируем ID у media файлов
+ */
+ $this->Media_ReplaceTargetTmpById('talk', $oTalk->getId());
+ Router::Location(Router::GetPath('talk') . 'read/' . $oTalk->getId() . '/');
+ } else {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.system.base'));
+ return Router::Action('error');
+ }
+ }
+
+ /**
+ * Чтение письма
+ * TODO: Пагинация для комментов не передается
+ */
+ protected function EventRead()
+ {
+ $this->sMenuSubItemSelect = 'read';
+ /**
+ * Получаем номер сообщения из УРЛ и проверяем существует ли оно
+ */
+ $sTalkId = $this->GetParam(0);
+ if (!($oTalk = $this->Talk_GetTalkById($sTalkId))) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Пользователь есть в переписке?
+ */
+ if (!($oTalkUser = $this->Talk_GetTalkUser($oTalk->getId(), $this->oUserCurrent->getId()))) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Пользователь активен в переписке?
+ */
+ if ($oTalkUser->getUserActive() != ModuleTalk::TALK_USER_ACTIVE) {
+ return parent::EventNotFound();
+ }
+ /**
+ * Достаём комменты к сообщению
+ */
+ $aReturn = $this->Comment_GetCommentsByTargetId($oTalk->getId(), 'talk');
+ $iMaxIdComment = $aReturn['iMaxIdComment'];
+ $aComments = $aReturn['comments'];
+ /**
+ * Помечаем дату последнего просмотра
+ */
+ $oTalkUser->setDateLast(date("Y-m-d H:i:s"));
+ $oTalkUser->setCommentIdLast($iMaxIdComment);
+ $oTalkUser->setCommentCountNew(0);
+ $this->Talk_UpdateTalkUser($oTalkUser);
+
+ $this->Viewer_AddHtmlTitle($oTalk->getTitle());
+ $this->Viewer_Assign('talk', $oTalk);
+ $this->Viewer_Assign('comments', $aComments);
+ $this->Viewer_Assign('lastCommentId', $iMaxIdComment);
+
+ /**
+ * Кол-во активных участников диалога без учета текущего пользователя
+ */
+ $iActiveSpeakers = 0;
+
+ foreach ((array)$oTalk->getTalkUsers() as $oTalkUser) {
+ if (($oTalkUser->getUserId() != $this->oUserCurrent->getId())
+ && $oTalkUser->getUserActive() == ModuleTalk::TALK_USER_ACTIVE
+ ) {
+ $iActiveSpeakers++;
+ break;
+ }
+ }
+
+ $this->Viewer_Assign('activeParticipantsCount', $iActiveSpeakers);
+
+ $this->SetTemplateAction('talk');
+ }
+
+ /**
+ * Проверка полей при создании письма
+ *
+ * @return bool
+ */
+ protected function checkTalkFields()
+ {
+ $this->Security_ValidateSendForm();
+
+ $bOk = true;
+ /**
+ * Проверяем есть ли заголовок
+ */
+ if (!func_check(getRequestStr('talk_title'), 'text', 2, 200)) {
+ $this->Message_AddError($this->Lang_Get('talk.add.notices.title_error'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ /**
+ * Проверяем есть ли содержание топика
+ */
+ if (!func_check(getRequestStr('talk_text'), 'text', 2, 3000)) {
+ $this->Message_AddError($this->Lang_Get('talk.add.notices.text_error'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ /**
+ * Проверяем адресатов
+ */
+ $aUsers = getRequest('talk_users');
+ $aUsersNew = array();
+ $aUserInBlacklist = $this->Talk_GetBlacklistByTargetId($this->oUserCurrent->getId());
+
+ $this->aUsersId = array();
+
+ if (!is_array($aUsers)) $aUsers = array();
+
+ foreach ($aUsers as $iUserId) {
+ $iUserId = (int) $iUserId;
+ if ($iUserId == 0 or $iUserId == $this->oUserCurrent->getId()) {
+ continue;
+ }
+ if ($oUser = $this->User_GetUserById($iUserId) and $oUser->getActivate() == 1) {
+ // Проверяем, попал ли отправиль в блек лист
+ if (!in_array($oUser->getId(), $aUserInBlacklist)) {
+ $this->aUsersId[] = $oUser->getId();
+ $aUsersNew[] = array(
+ 'value' => $oUser->getId(),
+ 'text' => $oUser->getLogin()
+ );
+ } else {
+ $this->Message_AddError(
+ str_replace(
+ 'login',
+ $oUser->getLogin(),
+ $this->Lang_Get('talk.blacklist.notices.blocked',
+ array('login' => htmlspecialchars($oUser->getLogin())))
+ ),
+ $this->Lang_Get('common.error.error')
+ );
+ $bOk = false;
+ continue;
+ }
+ } else {
+ $this->Message_AddError($this->Lang_Get('talk.add.notices.users_error_not_found') . ' «' . $iUserId . '»',
+ $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ }
+ if (!count($aUsersNew)) {
+ $this->Message_AddError($this->Lang_Get('talk.add.notices.users_error'), $this->Lang_Get('common.error.error'));
+ $_REQUEST['talk_users'] = '';
+ $bOk = false;
+ } else {
+ if (count($aUsersNew) > Config::Get('module.talk.max_users') and !$this->oUserCurrent->isAdministrator()) {
+ $this->Message_AddError($this->Lang_Get('talk.add.notices.users_error_many'), $this->Lang_Get('common.error.error'));
+ $bOk = false;
+ }
+ $_REQUEST['talk_users'] = $aUsersNew;
+ }
+ /**
+ * Выполнение хуков
+ */
+ $this->Hook_Run('check_talk_fields', array('bOk' => &$bOk));
+
+ return $bOk;
+ }
+
+ /**
+ * Получение новых комментариев
+ *
+ */
+ protected function AjaxResponseComment()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $idCommentLast = getRequestStr('last_comment_id');
+ /**
+ * Проверям авторизован ли пользователь
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Проверяем разговор
+ */
+ if (!($oTalk = $this->Talk_GetTalkById(getRequestStr('target_id')))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Доступен?
+ */
+ if (!($oTalkUser = $this->Talk_GetTalkUser($oTalk->getId(), $this->oUserCurrent->getId()))) {
+ return $this->EventErrorDebug();
+ }
+ if (!in_array($oTalkUser->getUserActive(),array(ModuleTalk::TALK_USER_ACTIVE))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Получаем комментарии
+ */
+ $aReturn = $this->Comment_GetCommentsNewByTargetId($oTalk->getId(), 'talk', $idCommentLast);
+ $iMaxIdComment = $aReturn['iMaxIdComment'];
+ /**
+ * Отмечаем дату прочтения письма
+ */
+ $oTalkUser->setDateLast(date("Y-m-d H:i:s"));
+ if ($iMaxIdComment != 0) {
+ $oTalkUser->setCommentIdLast($iMaxIdComment);
+ }
+ $oTalkUser->setCommentCountNew(0);
+ $this->Talk_UpdateTalkUser($oTalkUser);
+
+ $aComments = array();
+ $aCmts = $aReturn['comments'];
+ if ($aCmts and is_array($aCmts)) {
+ foreach ($aCmts as $aCmt) {
+ $aComments[] = array(
+ 'html' => $aCmt['html'],
+ 'parent_id' => $aCmt['obj']->getPid(),
+ 'id' => $aCmt['obj']->getId(),
+ );
+ }
+ }
+ $this->Viewer_AssignAjax('comments', $aComments);
+ $this->Viewer_AssignAjax('last_comment_id', $iMaxIdComment);
+ }
+
+ /**
+ * Обработка добавление комментария к письму через ajax
+ *
+ */
+ protected function AjaxAddComment()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $this->SubmitComment();
+ }
+
+ /**
+ * Обработка добавление комментария к письму
+ *
+ */
+ protected function SubmitComment()
+ {
+ /**
+ * Проверям авторизован ли пользователь
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Проверяем разговор
+ */
+ if (!($oTalk = $this->Talk_GetTalkById(getRequestStr('comment_target_id')))) {
+ return $this->EventErrorDebug();
+ }
+ if (!($oTalkUser = $this->Talk_GetTalkUser($oTalk->getId(), $this->oUserCurrent->getId()))) {
+ return $this->EventErrorDebug();
+ }
+ if (!in_array($oTalkUser->getUserActive(),array(ModuleTalk::TALK_USER_ACTIVE))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверяем разрешено ли постить комменты
+ */
+ if (!$this->ACL_CanPostTalkComment($this->oUserCurrent)) {
+ $this->Message_AddErrorSingle($this->Rbac_GetMsgLast());
+ return;
+ }
+ /**
+ * Проверяем текст комментария
+ */
+ $sText = getRequestStr('comment_text');
+ if (!func_check($sText, 'text', 2, 3000)) {
+ $this->Message_AddErrorSingle($this->Lang_Get('talk.message.notices.error_text'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Проверям на какой коммент отвечаем
+ */
+ $sParentId = (int)getRequest('reply');
+ if (!func_check($sParentId, 'id')) {
+ return $this->EventErrorDebug();
+ }
+ $oCommentParent = null;
+ if ($sParentId != 0) {
+ /**
+ * Проверяем существует ли комментарий на который отвечаем
+ */
+ if (!($oCommentParent = $this->Comment_GetCommentById($sParentId))) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Проверяем из одного топика ли новый коммент и тот на который отвечаем
+ */
+ if ($oCommentParent->getTargetId() != $oTalk->getId()) {
+ return $this->EventErrorDebug();
+ }
+ } else {
+ /**
+ * Корневой комментарий
+ */
+ $sParentId = null;
+ }
+ /**
+ * Проверка на дублирующий коммент
+ */
+ if ($this->Comment_GetCommentUnique($oTalk->getId(), 'talk', $this->oUserCurrent->getId(), $sParentId,
+ md5($sText))
+ ) {
+ $this->Message_AddErrorSingle($this->Lang_Get('topic.comments.notices.spam'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Создаём коммент
+ */
+ $oCommentNew = Engine::GetEntity('Comment');
+ $oCommentNew->setTargetId($oTalk->getId());
+ $oCommentNew->setTargetType('talk');
+ $oCommentNew->setUserId($this->oUserCurrent->getId());
+ $oCommentNew->setText($this->Text_Parser($sText));
+ $oCommentNew->setTextSource($sText);
+ $oCommentNew->setDate(date("Y-m-d H:i:s"));
+ $oCommentNew->setUserIp(func_getIp());
+ $oCommentNew->setPid($sParentId);
+ $oCommentNew->setTextHash(md5($sText));
+ $oCommentNew->setPublish(1);
+ /**
+ * Добавляем коммент
+ */
+ $this->Hook_Run('talk_comment_add_before',
+ array('oCommentNew' => $oCommentNew, 'oCommentParent' => $oCommentParent, 'oTalk' => $oTalk));
+ if ($this->Comment_AddComment($oCommentNew)) {
+ $this->Hook_Run('talk_comment_add_after',
+ array('oCommentNew' => $oCommentNew, 'oCommentParent' => $oCommentParent, 'oTalk' => $oTalk));
+
+ $this->Viewer_AssignAjax('sCommentId', $oCommentNew->getId());
+ $oTalk->setDateLast(date("Y-m-d H:i:s"));
+ $oTalk->setUserIdLast($oCommentNew->getUserId());
+ $oTalk->setCommentIdLast($oCommentNew->getId());
+ $oTalk->setCountComment($oTalk->getCountComment() + 1);
+ $this->Talk_UpdateTalk($oTalk);
+ /**
+ * Отсылаем уведомления всем адресатам
+ */
+ $aUsersTalk = $this->Talk_GetUsersTalk($oTalk->getId(), ModuleTalk::TALK_USER_ACTIVE);
+
+ foreach ($aUsersTalk as $oUserTalk) {
+ if ($oUserTalk->getId() != $oCommentNew->getUserId()) {
+ $this->Talk_SendNotifyTalkCommentNew($oUserTalk, $this->oUserCurrent, $oTalk, $oCommentNew);
+ }
+ }
+ /**
+ * Увеличиваем число новых комментов
+ */
+ $this->Talk_increaseCountCommentNew($oTalk->getId(), $oCommentNew->getUserId());
+ } else {
+ return $this->EventErrorDebug();
+ }
+ }
+
+ /**
+ * Добавление нового пользователя(-лей) в блек лист (ajax)
+ *
+ */
+ public function AjaxAddToBlacklist()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $aUsers = getRequest('users', null, 'post');
+
+ /**
+ * Валидация
+ */
+ if (!is_array($aUsers)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Если пользователь не авторизирован, возвращаем ошибку
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Получаем блекслист пользователя
+ */
+ $aUserBlacklist = $this->Talk_GetBlacklistByUserId($this->oUserCurrent->getId());
+
+ $aResult = array();
+ /**
+ * Обрабатываем добавление по каждому из переданных логинов
+ */
+ foreach ($aUsers as $iUserId) {
+ $iUserId = (int) $iUserId;
+
+ if (!$iUserId) {
+ continue;
+ }
+
+ /**
+ * Если пользователь пытается добавить в блеклист самого себя,
+ * возвращаем ошибку
+ */
+ if ($iUserId == $this->oUserCurrent->getId()) {
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('user_list_add.notices.error_self')
+ );
+ continue;
+ }
+ /**
+ * Если пользователь не найден или неактивен, возвращаем ошибку
+ */
+ if ($oUser = $this->User_GetUserById($iUserId) and $oUser->getActivate() == 1) {
+ if (!isset($aUserBlacklist[$oUser->getId()])) {
+ if ($this->Talk_AddUserToBlackList($oUser->getId(), $this->oUserCurrent->getId())) {
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('user', $oUser, true);
+ $oViewer->Assign('showActions', true, true);
+
+ $aResult[] = array(
+ 'bStateError' => false,
+ 'sMsgTitle' => $this->Lang_Get('common.attention'),
+ 'sMsg' => $this->Lang_Get('common.success.add',
+ array('login' => $oUser->getLogin())),
+ 'user_id' => $oUser->getId(),
+ 'user_login' => $oUser->getLogin(),
+ 'html' => $oViewer->Fetch("component@user-list-add.item")
+ );
+ } else {
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('common.error.system.base'),
+ 'user_login' => $oUser->getLogin()
+ );
+ }
+ } else {
+ /**
+ * Попытка добавить уже существующего в блеклисте пользователя, возвращаем ошибку
+ */
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('user_list_add.notices.error_already_added',
+ array('login' => $oUser->getLogin())),
+ 'user_login' => $oUser->getLogin()
+ );
+ continue;
+ }
+ } else {
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('user.notices.not_found_by_id',
+ array('id' => $iUserId))
+ );
+ }
+ }
+ /**
+ * Передаем во вьевер массив с результатами обработки по каждому пользователю
+ */
+ $this->Viewer_AssignAjax('users', $aResult);
+ }
+
+ /**
+ * Удаление пользователя из блек листа (ajax)
+ *
+ */
+ public function AjaxDeleteFromBlacklist()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $iUserId = getRequestStr('user_id', null, 'post');
+ /**
+ * Если пользователь не авторизирован, возвращаем ошибку
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('common.error.need_authorization'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ /**
+ * Если пользователь не существуем, возращаем ошибку
+ */
+ if (!$oUserTarget = $this->User_GetUserById($iUserId)) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('user.notices.not_found_by_id', array('id' => htmlspecialchars($iUserId))),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ /**
+ * Получаем блеклист пользователя
+ */
+ $aBlacklist = $this->Talk_GetBlacklistByUserId($this->oUserCurrent->getId());
+ /**
+ * Если указанный пользователь не найден в блекслисте, возвращаем ошибку
+ */
+ if (!isset($aBlacklist[$oUserTarget->getId()])) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get(
+ 'talk.blacklist.notices.user_not_found',
+ array('login' => $oUserTarget->getLogin())
+ ),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ /**
+ * Производим удаление пользователя из блекслиста
+ */
+ if (!$this->Talk_DeleteUserFromBlacklist($iUserId, $this->oUserCurrent->getId())) {
+ return $this->EventErrorDebug();
+ }
+ $this->Message_AddNoticeSingle(
+ $this->Lang_Get(
+ 'common.success.remove',
+ array('login' => $oUserTarget->getLogin())
+ ),
+ $this->Lang_Get('common.attention')
+ );
+ }
+
+ /**
+ * Удаление участника разговора (ajax)
+ *
+ */
+ public function AjaxDeleteTalkUser()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $iUserId = getRequestStr('user_id', null, 'post');
+ $iTalkId = getRequestStr('target_id', null, 'post');
+ /**
+ * Если пользователь не авторизирован, возвращаем ошибку
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('common.error.need_authorization'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ /**
+ * Если удаляемый участник не существует в базе данных, возвращаем ошибку
+ */
+ if (!$oUserTarget = $this->User_GetUserById($iUserId)) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('user.notices.not_found_by_id', array('id' => htmlspecialchars($iUserId))),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ /**
+ * Если разговор не найден, или пользователь не является его автором (либо админом), возвращаем ошибку
+ */
+ if ((!$oTalk = $this->Talk_GetTalkById($iTalkId))
+ || (($oTalk->getUserId() != $this->oUserCurrent->getId()) && !$this->oUserCurrent->isAdministrator())
+ ) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('talk.notices.not_found'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ /**
+ * Получаем список всех участников разговора
+ */
+ $aTalkUsers = $oTalk->getTalkUsers();
+ /**
+ * Если пользователь не является участником разговора или удалил себя самостоятельно возвращаем ошибку
+ */
+ if (!isset($aTalkUsers[$iUserId])
+ || $aTalkUsers[$iUserId]->getUserActive() == ModuleTalk::TALK_USER_DELETE_BY_SELF
+ ) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get(
+ 'talk.users.notices.user_not_found',
+ array('login' => $oUserTarget->getLogin())
+ ),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ /**
+ * Удаляем пользователя из разговора, если удаление прошло неудачно - возвращаем системную ошибку
+ */
+ if (!$this->Talk_DeleteTalkUserByArray($iTalkId, $iUserId, ModuleTalk::TALK_USER_DELETE_BY_AUTHOR)) {
+ return $this->EventErrorDebug();
+ }
+ $this->Message_AddNoticeSingle(
+ $this->Lang_Get(
+ 'common.success.remove',
+ array('login' => $oUserTarget->getLogin())
+ ),
+ $this->Lang_Get('common.attention')
+ );
+ }
+
+ /**
+ * Добавление нового участника разговора (ajax)
+ *
+ */
+ public function AjaxAddTalkUser()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $aUsers = getRequest('users', null, 'post');
+ $idTalk = getRequestStr('target_id', null, 'post');
+ /**
+ * Валидация
+ */
+ if (!is_array($aUsers)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Если пользователь не авторизирован, возвращаем ошибку
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('common.error.need_authorization'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ /**
+ * Если разговор не найден, или пользователь не является его автором (или админом), возвращаем ошибку
+ */
+ if ((!$oTalk = $this->Talk_GetTalkById($idTalk))
+ || (($oTalk->getUserId() != $this->oUserCurrent->getId()) && !$this->oUserCurrent->isAdministrator())
+ ) {
+ $this->Message_AddErrorSingle(
+ $this->Lang_Get('talk.notices.not_found'),
+ $this->Lang_Get('common.error.error')
+ );
+ return;
+ }
+ /**
+ * Получаем список всех участников разговора
+ */
+ $aTalkUsers = $oTalk->getTalkUsers();
+ /**
+ * Получаем список пользователей, которые не принимают письма
+ */
+ $aUserInBlacklist = $this->Talk_GetBlacklistByTargetId($this->oUserCurrent->getId());
+ /**
+ * Ограничения на максимальное число участников разговора
+ */
+ if (count($aTalkUsers) >= Config::Get('module.talk.max_users') and !$this->oUserCurrent->isAdministrator()) {
+ $this->Message_AddError($this->Lang_Get('talk.add.notices.users_error_many'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Обрабатываем добавление по каждому переданному логину пользователя
+ */
+ foreach ($aUsers as $iUserId) {
+ $iUserId = (int) $iUserId;
+
+ if (!$iUserId) {
+ continue;
+ }
+
+ /**
+ * Попытка добавить себя
+ */
+ if ($iUserId == $this->oUserCurrent->getId()) {
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('user_list_add.notices.error_self')
+ );
+ continue;
+ }
+ if (($oUser = $this->User_GetUserById($iUserId))
+ && ($oUser->getActivate() == 1)
+ ) {
+ if (!in_array($oUser->getId(), $aUserInBlacklist)) {
+ if (array_key_exists($oUser->getId(), $aTalkUsers)) {
+ switch ($aTalkUsers[$oUser->getId()]->getUserActive()) {
+ /**
+ * Если пользователь ранее был удален админом разговора, то добавляем его снова
+ */
+ case ModuleTalk::TALK_USER_DELETE_BY_AUTHOR:
+ if (
+ $this->Talk_AddTalkUser(
+ Engine::GetEntity('Talk_TalkUser',
+ array(
+ 'talk_id' => $idTalk,
+ 'user_id' => $oUser->getId(),
+ 'date_last' => null,
+ 'talk_user_active' => ModuleTalk::TALK_USER_ACTIVE
+ )
+ )
+ )
+ ) {
+ $this->Talk_SendNotifyTalkNew($oUser, $this->oUserCurrent, $oTalk);
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('user', $oUser, true);
+ $oViewer->Assign('showActions', true, true);
+ $oViewer->Assign('oUserCurrent', $this->oUserCurrent);
+
+ $aResult[] = array(
+ 'bStateError' => false,
+ 'sMsgTitle' => $this->Lang_Get('common.attention'),
+ 'sMsg' => $this->Lang_Get('user_list_add.notices.success_add',
+ array('login', $oUser->getLogin())),
+ 'user_id' => $oUser->getId(),
+ 'user_login' => $oUser->getLogin(),
+ 'html' => $oViewer->Fetch("component@talk.participants-item")
+ );
+ $bState = true;
+ } else {
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('common.error.system.base')
+ );
+ }
+ break;
+ /**
+ * Если пользователь является активным участником разговора, возвращаем ошибку
+ */
+ case ModuleTalk::TALK_USER_ACTIVE:
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('user_list_add.notices.error_already_added',
+ array('login' => $oUser->getLogin()))
+ );
+ break;
+ /**
+ * Если пользователь удалил себя из разговора самостоятельно, то блокируем повторное добавление
+ */
+ case ModuleTalk::TALK_USER_DELETE_BY_SELF:
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('talk.users.notices.deleted',
+ array('login' => $oUser->getLogin()))
+ );
+ break;
+
+ default:
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('common.error.system.base')
+ );
+ }
+ } elseif (
+ $this->Talk_AddTalkUser(
+ Engine::GetEntity('Talk_TalkUser',
+ array(
+ 'talk_id' => $idTalk,
+ 'user_id' => $oUser->getId(),
+ 'date_last' => null,
+ 'talk_user_active' => ModuleTalk::TALK_USER_ACTIVE
+ )
+ )
+ )
+ ) {
+ $this->Talk_SendNotifyTalkNew($oUser, $this->oUserCurrent, $oTalk);
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('user', $oUser, true);
+ $oViewer->Assign('showActions', true, true);
+
+ $aResult[] = array(
+ 'bStateError' => false,
+ 'sMsgTitle' => $this->Lang_Get('common.attention'),
+ 'sMsg' => $this->Lang_Get('user_list_add.notices.success_add',
+ array('login', $oUser->getLogin())),
+ 'user_id' => $oUser->getId(),
+ 'html' => $oViewer->Fetch("component@talk.participants-item")
+ );
+ $bState = true;
+ } else {
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('common.error.system.base')
+ );
+ }
+ } else {
+ /**
+ * Добавляем пользователь не принимает сообщения
+ */
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('talk.blacklist.notices.blocked',
+ array('login' => $oUser->getLogin()))
+ );
+ }
+ } else {
+ /**
+ * Пользователь не найден в базе данных или не активен
+ */
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('user.notices.not_found_by_id',
+ array('id' => $iUserId))
+ );
+ }
+ }
+ /**
+ * Передаем во вьевер массив результатов обработки по каждому пользователю
+ */
+ $this->Viewer_AssignAjax('users', $aResult);
+ }
+
+ /**
+ * Возвращает количество новых сообщений
+ */
+ public function AjaxNewMessages()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+
+ if (!$this->oUserCurrent) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ $iCountTalkNew = $this->Talk_GetCountTalkNew($this->oUserCurrent->getId());
+ $this->Viewer_AssignAjax('iCountTalkNew', $iCountTalkNew);
+ }
+
+ /**
+ * Обработка завершения работу экшена
+ */
+ public function EventShutdown()
+ {
+ if (!$this->oUserCurrent) {
+ return;
+ }
+ $iCountTalkFavourite = $this->Talk_GetCountTalksFavouriteByUserId($this->oUserCurrent->getId());
+ $this->Viewer_Assign('iCountTalkFavourite', $iCountTalkFavourite);
+
+ $iCountTopicFavourite = $this->Topic_GetCountTopicsFavouriteByUserId($this->oUserCurrent->getId());
+ $iCountTopicUser = $this->Topic_GetCountTopicsPersonalByUser($this->oUserCurrent->getId(), 1);
+ $iCountCommentUser = $this->Comment_GetCountCommentsByUserId($this->oUserCurrent->getId(), 'topic');
+ $iCountCommentFavourite = $this->Comment_GetCountCommentsFavouriteByUserId($this->oUserCurrent->getId());
+ $iCountNoteUser = $this->User_GetCountUserNotesByUserId($this->oUserCurrent->getId());
+
+ $this->Viewer_Assign('oUserProfile', $this->oUserCurrent);
+ $this->Viewer_Assign('iCountWallUser',
+ $this->Wall_GetCountWall(array('wall_user_id' => $this->oUserCurrent->getId(), 'pid' => null)));
+ /**
+ * Общее число публикация и избранного
+ */
+ $this->Viewer_Assign('iCountCreated', $iCountNoteUser + $iCountTopicUser + $iCountCommentUser);
+ $this->Viewer_Assign('iCountFavourite', $iCountCommentFavourite + $iCountTopicFavourite);
+ $this->Viewer_Assign('iCountFriendsUser', $this->User_GetCountUsersFriend($this->oUserCurrent->getId()));
+
+ $this->Viewer_Assign('sMenuProfileItemSelect', $this->sMenuProfileItemSelect);
+ $this->Viewer_Assign('sMenuSubItemSelect', $this->sMenuSubItemSelect);
+ /**
+ * Передаем во вьевер константы состояний участников разговора
+ */
+ $this->Viewer_Assign('TALK_USER_ACTIVE', ModuleTalk::TALK_USER_ACTIVE);
+ $this->Viewer_Assign('TALK_USER_DELETE_BY_SELF', ModuleTalk::TALK_USER_DELETE_BY_SELF);
+ $this->Viewer_Assign('TALK_USER_DELETE_BY_AUTHOR', ModuleTalk::TALK_USER_DELETE_BY_AUTHOR);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/actions/ActionUserfeed.class.php b/application/classes/actions/ActionUserfeed.class.php
new file mode 100644
index 0000000..f241637
--- /dev/null
+++ b/application/classes/actions/ActionUserfeed.class.php
@@ -0,0 +1,281 @@
+
+ *
+ */
+
+/**
+ * Обрабатывает пользовательские ленты контента
+ *
+ * @package application.actions
+ * @since 1.0
+ */
+class ActionUserfeed extends Action
+{
+ /**
+ * Текущий пользователь
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent;
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ /**
+ * Доступ только у авторизованных пользователей
+ */
+ $this->oUserCurrent = $this->User_getUserCurrent();
+ if (!$this->oUserCurrent) {
+ parent::EventNotFound();
+ }
+ $this->Viewer_Assign('sMenuItemSelect', 'feed');
+ }
+
+ /**
+ * Регистрация евентов
+ *
+ */
+ protected function RegisterEvent()
+ {
+ $this->AddEventPreg('/^(page([1-9]\d{0,5}))?$/i', 'EventIndex');
+ $this->AddEvent('subscribe', 'EventSubscribe');
+ $this->AddEvent('ajaxadduser', 'EventAjaxAddUser');
+ $this->AddEvent('unsubscribe', 'EventUnSubscribe');
+ }
+
+ /**
+ * Выводит ленту контента(топики) для пользователя
+ *
+ */
+ protected function EventIndex()
+ {
+ /**
+ * Передан ли номер страницы
+ */
+ $iPage = $this->GetEventMatch(2) ? $this->GetEventMatch(2) : 1;
+
+ $aResult = $this->Userfeed_read($this->oUserCurrent->getId(),$iPage,Config::Get('module.topic.per_page'));
+ $aTopics = $aResult['collection'];
+
+ // Вызов хуков
+ $this->Hook_Run('topics_list_show', array('aTopics' => $aTopics));
+ /**
+ * Формируем постраничность
+ */
+ $aPaging = $this->Viewer_MakePaging($aResult['count'], $iPage, Config::Get('module.topic.per_page'),
+ Config::Get('pagination.pages.count'), Router::GetPath('feed'));
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('topics', $aTopics);
+ $this->Viewer_Assign('paging', $aPaging);
+
+ $this->SetTemplateAction('list');
+ }
+
+ /**
+ * Подписка на контент блога или пользователя
+ *
+ */
+ protected function EventSubscribe()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ /**
+ * Проверяем наличие ID блога или пользователя
+ */
+ if (!getRequest('id')) {
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ }
+ $sType = getRequestStr('type');
+ $iType = null;
+ /**
+ * Определяем тип подписки
+ */
+ switch ($sType) {
+ case 'blogs':
+ $iType = ModuleUserfeed::SUBSCRIBE_TYPE_BLOG;
+ /**
+ * Проверяем существование блога
+ */
+ if (!($oBlog=$this->Blog_GetBlogById(getRequestStr('id'))) or !$this->ACL_IsAllowShowBlog($oBlog,$this->oUserCurrent)) {
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ break;
+ case 'users':
+ $iType = ModuleUserfeed::SUBSCRIBE_TYPE_USER;
+ /**
+ * Проверяем существование пользователя
+ */
+ if (!$this->User_GetUserById(getRequestStr('id'))) {
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ if ($this->oUserCurrent->getId() == getRequestStr('id')) {
+ $this->Message_AddError($this->Lang_Get('user_list_add.notices.error_self'),
+ $this->Lang_Get('common.error.error'));
+ return;
+ }
+ break;
+ default:
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Подписываем
+ */
+ $this->Userfeed_subscribeUser($this->oUserCurrent->getId(), $iType, getRequestStr('id'));
+ $this->Message_AddNotice($this->Lang_Get('common.success.save'), $this->Lang_Get('common.attention'));
+ }
+
+ /**
+ * Подписка на пользвователя по логину
+ *
+ */
+ protected function EventAjaxAddUser()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $aUsers = getRequest('users', null, 'post');
+ /**
+ * Валидация
+ */
+ if (!is_array($aUsers)) {
+ return $this->EventErrorDebug();
+ }
+ /**
+ * Если пользователь не авторизирован, возвращаем ошибку
+ */
+ if (!$this->User_IsAuthorization()) {
+ $this->Message_AddErrorSingle($this->Lang_Get('common.error.need_authorization'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+
+ $aResult = array();
+ /**
+ * Обрабатываем добавление по каждому из переданных логинов
+ */
+ foreach ($aUsers as $iUserId) {
+ $iUserId = (int) $iUserId;
+
+ if (!$iUserId) {
+ continue;
+ }
+
+ /**
+ * Если пользователь не найден или неактивен, возвращаем ошибку
+ */
+ if ($oUser = $this->User_GetUserById($iUserId) and $oUser->getActivate() == 1) {
+ $this->Userfeed_subscribeUser($this->oUserCurrent->getId(), ModuleUserfeed::SUBSCRIBE_TYPE_USER,
+ $oUser->getId());
+
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('user', $oUser, true);
+ $oViewer->Assign('showActions', true, true);
+
+ $aResult[] = array(
+ 'bStateError' => false,
+ 'sMsgTitle' => $this->Lang_Get('common.attention'),
+ 'sMsg' => $this->Lang_Get('common.success.add', array('login' => $oUser->getLogin())),
+ 'user_id' => $oUser->getId(),
+ 'user_login' => $oUser->getLogin(),
+ 'html' => $oViewer->Fetch("component@user-list-add.item")
+ );
+ } else {
+ $aResult[] = array(
+ 'bStateError' => true,
+ 'sMsgTitle' => $this->Lang_Get('common.error.error'),
+ 'sMsg' => $this->Lang_Get('user.notices.not_found_by_id', array('id' => $iUserId))
+ );
+ }
+ }
+ /**
+ * Передаем во вьевер массив с результатами обработки по каждому пользователю
+ */
+ $this->Viewer_AssignAjax('users', $aResult);
+ }
+
+ /**
+ * Отписка от блога или пользователя
+ *
+ */
+ protected function EventUnsubscribe()
+ {
+ /**
+ * Устанавливаем формат Ajax ответа
+ */
+ $this->Viewer_SetResponseAjax('json');
+ $sId = getRequestStr('id');
+
+ $sType = getRequestStr('type');
+ $iType = null;
+ /**
+ * Определяем от чего отписываемся
+ */
+ switch ($sType) {
+ case 'blogs':
+ $iType = ModuleUserfeed::SUBSCRIBE_TYPE_BLOG;
+ break;
+ case 'users':
+ $iType = ModuleUserfeed::SUBSCRIBE_TYPE_USER;
+ $sId = getRequestStr('user_id');
+ break;
+ default:
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ if (!$sId) {
+ $this->Message_AddError($this->Lang_Get('common.error.system.base'), $this->Lang_Get('common.error.error'));
+ return;
+ }
+ /**
+ * Отписываем пользователя
+ */
+ $this->Userfeed_unsubscribeUser($this->oUserCurrent->getId(), $iType, $sId);
+ $this->Message_AddNotice($this->Lang_Get('common.success.remove'), $this->Lang_Get('common.attention'));
+ }
+
+ /**
+ * При завершении экшена загружаем в шаблон необходимые переменные
+ *
+ */
+ public function EventShutdown()
+ {
+ /**
+ * Подсчитываем новые топики
+ */
+ $iCountTopicsCollectiveNew = $this->Topic_GetCountTopicsCollectiveNew();
+ $iCountTopicsPersonalNew = $this->Topic_GetCountTopicsPersonalNew();
+ $iCountTopicsNew = $iCountTopicsCollectiveNew + $iCountTopicsPersonalNew;
+ /**
+ * Загружаем переменные в шаблон
+ */
+ $this->Viewer_Assign('iCountTopicsCollectiveNew', $iCountTopicsCollectiveNew);
+ $this->Viewer_Assign('iCountTopicsPersonalNew', $iCountTopicsPersonalNew);
+ $this->Viewer_Assign('iCountTopicsNew', $iCountTopicsNew);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/blocks/BlockActivityRecent.class.php b/application/classes/blocks/BlockActivityRecent.class.php
new file mode 100644
index 0000000..ccbde31
--- /dev/null
+++ b/application/classes/blocks/BlockActivityRecent.class.php
@@ -0,0 +1,47 @@
+
+ *
+ */
+
+/**
+ * Обработка блока с топиками (прямой эфир)
+ *
+ * @package application.blocks
+ * @since 1.0
+ */
+class BlockActivityRecent extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ /**
+ * Получаем топики
+ */
+ if ($oTopics = $this->Topic_GetTopicsLast(Config::Get('block.stream.row'))) {
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('topics', $oTopics, true);
+ $sTextResult = $oViewer->Fetch("component@activity.recent-topics");
+ $this->Viewer_Assign('content', $sTextResult, true);
+ }
+
+ $this->SetTemplate('component@activity.block.recent');
+ }
+}
diff --git a/application/classes/blocks/BlockActivitySettings.class.php b/application/classes/blocks/BlockActivitySettings.class.php
new file mode 100644
index 0000000..b1fbce9
--- /dev/null
+++ b/application/classes/blocks/BlockActivitySettings.class.php
@@ -0,0 +1,45 @@
+
+ *
+ */
+
+/**
+ * Блок настройки ленты активности
+ *
+ * @package application.blocks
+ * @since 1.0
+ */
+class BlockActivitySettings extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ /**
+ * пользователь авторизован?
+ */
+ if ($oUserCurrent = $this->User_getUserCurrent()) {
+ $this->Viewer_Assign('types', $this->Stream_getEventTypes());
+ $this->Viewer_Assign('typesActive', $this->Stream_getTypesList($oUserCurrent->getId()));
+ }
+
+ $this->SetTemplate('component@activity.block.settings');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/blocks/BlockActivityUsers.class.php b/application/classes/blocks/BlockActivityUsers.class.php
new file mode 100644
index 0000000..6cd4147
--- /dev/null
+++ b/application/classes/blocks/BlockActivityUsers.class.php
@@ -0,0 +1,44 @@
+
+ *
+ */
+
+/**
+ * Блок выбора пользователей для чтения в ленте активности
+ *
+ * @package application.blocks
+ * @since 1.0
+ */
+class BlockActivityUsers extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ /**
+ * пользователь авторизован?
+ */
+ if ($oUserCurrent = $this->User_getUserCurrent()) {
+ $this->Viewer_Assign('users', $this->Stream_getUserSubscribes($oUserCurrent->getId()));
+ }
+
+ $this->SetTemplate('component@activity.block.users');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/blocks/BlockBlogs.class.php b/application/classes/blocks/BlockBlogs.class.php
new file mode 100644
index 0000000..c754833
--- /dev/null
+++ b/application/classes/blocks/BlockBlogs.class.php
@@ -0,0 +1,51 @@
+
+ *
+ */
+
+/**
+ * Обработка блока с рейтингом блогов
+ *
+ * @package application.blocks
+ * @since 1.0
+ */
+class BlockBlogs extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ /**
+ * Получаем список блогов
+ */
+ if ($aResult = $this->Blog_GetBlogsRating(1, Config::Get('block.blogs.row'))) {
+ $aBlogs = $aResult['collection'];
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign('aBlogs', $aBlogs);
+ /**
+ * Формируем результат в виде шаблона и возвращаем
+ */
+ $sTextResult = $oViewer->Fetch("component@blog.top");
+ $this->Viewer_Assign('sBlogsTop', $sTextResult);
+ }
+
+ $this->SetTemplate('component@blog.block.blogs');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/blocks/BlockBlogsSearch.class.php b/application/classes/blocks/BlockBlogsSearch.class.php
new file mode 100644
index 0000000..4840520
--- /dev/null
+++ b/application/classes/blocks/BlockBlogsSearch.class.php
@@ -0,0 +1,44 @@
+
+ *
+ */
+
+/**
+ * Обрабатывает блок категорий для блогов
+ *
+ * @package application.blocks
+ * @since 2.0
+ */
+class BlockBlogsSearch extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ if (!Config::Get('module.blog.category_allow')) {
+ return;
+ }
+ $aCategories = $this->Blog_GetCategoriesTree();
+ $aBlogsAll = $this->Blog_GetBlogsByFilter(array('exclude_type' => 'personal'), array(), 1, 1, array());
+ $this->Viewer_Assign('aBlogCategories', $aCategories);
+ $this->Viewer_Assign('iCountBlogsAll', $aBlogsAll['count']);
+ $this->SetTemplate('component@blog.block.search');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/blocks/BlockFieldCategory.class.php b/application/classes/blocks/BlockFieldCategory.class.php
new file mode 100644
index 0000000..39e615b
--- /dev/null
+++ b/application/classes/blocks/BlockFieldCategory.class.php
@@ -0,0 +1,72 @@
+
+ *
+ */
+
+/**
+ * Обработка блока с редактированием категорий объекта
+ *
+ * @package application.blocks
+ * @since 2.0
+ */
+class BlockFieldCategory extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ $sEntity = $this->GetParam('entity');
+ $oTarget = $this->GetParam('target');
+ $sTargetType = $this->GetParam('target_type');
+
+ if (!$oTarget) {
+ $oTarget = Engine::GetEntity($sEntity);
+ }
+
+ $aBehaviors = $oTarget->GetBehaviors();
+ foreach ($aBehaviors as $oBehavior) {
+ if ($oBehavior instanceof ModuleCategory_BehaviorEntity) {
+ /**
+ * Если в параметрах был тип, то переопределяем значение. Это необходимо для корректной работы, когда тип динамический.
+ */
+ if ($sTargetType) {
+ $oBehavior->setParam('target_type', $sTargetType);
+ }
+ /**
+ * Нужное нам поведение - получаем список текущих категорий
+ */
+ $this->Viewer_Assign('categoriesSelected', $oBehavior->getCategories(), true);
+ /**
+ * Загружаем параметры
+ */
+ $aParams = $oBehavior->getParams();
+ $this->Viewer_Assign('params', $aParams, true);
+ /**
+ * Загружаем список доступных категорий
+ */
+ $this->Viewer_Assign('categories',
+ $this->Category_GetCategoriesTreeByTargetType($oBehavior->getCategoryTargetType()), true);
+ break;
+ }
+ }
+
+ $this->SetTemplate('component@field.category');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/blocks/BlockMenu.class.php b/application/classes/blocks/BlockMenu.class.php
new file mode 100644
index 0000000..aad966d
--- /dev/null
+++ b/application/classes/blocks/BlockMenu.class.php
@@ -0,0 +1,72 @@
+
+ *
+ */
+
+/**
+ * Description of BlockMenu
+ *
+ * @author oleg
+ */
+class BlockMenu extends Block {
+
+ public function Exec() {
+ $sNameMenu = $this->GetParam('name');
+
+ if(!$oMenu = $this->Menu_Get($sNameMenu)){
+ return false;
+ }
+
+ $this->Hook_Run('menu_before_prepare', ['menu' => &$oMenu]);
+
+ $ItemsTree = $this->prepareItems($oMenu->getItems());
+
+ $this->Hook_Run('menu_after_prepare', ['items' => &$ItemsTree['items']]);
+
+ $this->Viewer_Assign('activeItem', $this->GetParam('activeItem', null), true);
+ $this->Viewer_Assign('mods', $this->GetParam('mods', null), true);
+ $this->Viewer_Assign('classes', $this->GetParam('classes', null), true);
+ $this->Viewer_Assign('template', $this->GetParam('template', $sNameMenu), true);
+ $this->Viewer_Assign('params', $ItemsTree);
+
+ $this->SetTemplate("component@menu");
+ }
+
+ public function prepareItems($ItemsTree) {
+ if( !is_array($ItemsTree) or !count($ItemsTree) ){
+ return null;
+ }
+ $aItemsNav = [];
+
+ foreach ($ItemsTree as $ItemTree) {
+ $aChildrens = $ItemTree->getChildren();
+ $aItemsNav[] = [
+ 'url' => Router::GetPath( $ItemTree->getUrl() ),
+ 'name' => $ItemTree->getName(),
+ 'text' => $this->Lang_Get($ItemTree->getTitle()),
+ 'count' => $ItemTree->getCount(),
+ 'is_enabled' => $ItemTree->getEnable(),
+ 'menu' => $this->prepareItems( $aChildrens )
+ ];
+ }
+ return [ 'items' => $aItemsNav];
+ }
+
+}
diff --git a/application/classes/blocks/BlockPollFormItems.class.php b/application/classes/blocks/BlockPollFormItems.class.php
new file mode 100644
index 0000000..be7c427
--- /dev/null
+++ b/application/classes/blocks/BlockPollFormItems.class.php
@@ -0,0 +1,59 @@
+
+ *
+ */
+
+/**
+ * Используется для вывода списка опросов в форме редактирования объекта
+ *
+ * @package application.blocks
+ * @since 2.0
+ */
+class BlockPollFormItems extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ $this->SetTemplate('component@poll.manage.list');
+
+ $sTargetType = $this->GetParam('target_type');
+ $sTargetId = $this->GetParam('target_id');
+ $sTargetTmp = $this->Session_GetCookie('poll_target_tmp_' . $sTargetType) ? $this->Session_GetCookie('poll_target_tmp_' . $sTargetType) : $this->GetParam('target_tmp');
+
+ $aFilter = array('target_type' => $sTargetType, '#order' => array('id' => 'asc'));
+ if ($sTargetId) {
+ $sTargetTmp = null;
+ if (!$this->Poll_CheckTarget($sTargetType, $sTargetId)) {
+ return false;
+ }
+ $aFilter['target_id'] = $sTargetId;
+ } else {
+ $sTargetId = null;
+ if (!$sTargetTmp or !$this->Poll_IsAllowTargetType($sTargetType)) {
+ return true; // показываем список, но пустой
+ }
+ $aFilter['target_tmp'] = $sTargetTmp;
+ }
+ $aPollItems = $this->Poll_GetPollItemsByFilter($aFilter);
+
+ $this->Viewer_Assign('aPollItems', $aPollItems);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/blocks/BlockPropertyUpdate.class.php b/application/classes/blocks/BlockPropertyUpdate.class.php
new file mode 100644
index 0000000..2f73ba3
--- /dev/null
+++ b/application/classes/blocks/BlockPropertyUpdate.class.php
@@ -0,0 +1,64 @@
+
+ *
+ */
+
+/**
+ * Обработка блока с редактированием свойств объекта
+ *
+ * @package application.blocks
+ * @since 2.0
+ */
+class BlockPropertyUpdate extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ $sEntity = $this->GetParam('entity');
+ $oTarget = $this->GetParam('target');
+ $sTargetType = $this->GetParam('target_type');
+
+ if (!$oTarget) {
+ $oTarget = Engine::GetEntity($sEntity);
+ }
+
+ $aBehaviors = $oTarget->GetBehaviors();
+ foreach ($aBehaviors as $oBehavior) {
+ /**
+ * Определяем нужное нам поведение
+ */
+ if ($oBehavior instanceof ModuleProperty_BehaviorEntity) {
+ /**
+ * Если в параметрах был тип, то переопределяем значение. Это необходимо для корректной работы, когда тип динамический.
+ */
+ if ($sTargetType) {
+ $oBehavior->setParam('target_type', $sTargetType);
+ }
+ $aProperties = $this->Property_GetPropertiesForUpdate($oBehavior->getPropertyTargetType(),
+ $oTarget->getId());
+ $this->Viewer_Assign('properties', $aProperties, true);
+ break;
+ }
+ }
+
+ $this->SetTemplate('component@property.input.list');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/blocks/BlockTagsPersonalTopic.class.php b/application/classes/blocks/BlockTagsPersonalTopic.class.php
new file mode 100644
index 0000000..8b732eb
--- /dev/null
+++ b/application/classes/blocks/BlockTagsPersonalTopic.class.php
@@ -0,0 +1,60 @@
+
+ *
+ */
+
+/**
+ * Обрабатывает блок облака тегов для избранного
+ *
+ * @package application.blocks
+ * @since 1.0
+ */
+class BlockTagsPersonalTopic extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if ($oUserCurrent = $this->User_getUserCurrent()) {
+ if (!($oUser = $this->getParam('user'))) {
+ $oUser = $oUserCurrent;
+ }
+ /**
+ * Получаем список тегов пользователя
+ */
+ $aTags = $this->Favourite_GetGroupTags($oUser->getId(), 'topic', true, 70);
+ /**
+ * Расчитываем логарифмическое облако тегов
+ */
+ $this->Tools_MakeCloud($aTags);
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->Viewer_Assign('tags', $aTags, true);
+ $this->Viewer_Assign('user', $oUser, true);
+ $this->Viewer_Assign('activeTag', $this->getParam('activeTag'), true);
+
+ $this->SetTemplate('component@tags-personal.cloud');
+ }
+ }
+}
\ No newline at end of file
diff --git a/application/classes/blocks/BlockTopicsTags.class.php b/application/classes/blocks/BlockTopicsTags.class.php
new file mode 100644
index 0000000..c981d98
--- /dev/null
+++ b/application/classes/blocks/BlockTopicsTags.class.php
@@ -0,0 +1,69 @@
+
+ *
+ */
+
+/**
+ * Обрабатывает блок облака тегов
+ *
+ * @package application.blocks
+ * @since 1.0
+ */
+class BlockTopicsTags extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ /**
+ * Получаем список тегов
+ */
+ $aTags = $this->Topic_GetOpenTopicTags(Config::Get('block.tags.tags_count'));
+ /**
+ * Расчитываем логарифмическое облако тегов
+ */
+ if ($aTags) {
+ $this->Tools_MakeCloud($aTags);
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->Viewer_Assign('tags', $aTags, true);
+ }
+ /**
+ * Теги пользователя
+ */
+ if ($oUserCurrent = $this->User_getUserCurrent()) {
+ $aTags = $this->Topic_GetOpenTopicTags(Config::Get('block.tags.personal_tags_count'),
+ $oUserCurrent->getId());
+ /**
+ * Расчитываем логарифмическое облако тегов
+ */
+ if ($aTags) {
+ $this->Tools_MakeCloud($aTags);
+ /**
+ * Устанавливаем шаблон вывода
+ */
+ $this->Viewer_Assign('tagsUser', $aTags, true);
+ }
+ }
+
+ $this->SetTemplate('component@topic.block.tags');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/blocks/BlockUserfeedBlogs.class.php b/application/classes/blocks/BlockUserfeedBlogs.class.php
new file mode 100644
index 0000000..23f93f2
--- /dev/null
+++ b/application/classes/blocks/BlockUserfeedBlogs.class.php
@@ -0,0 +1,65 @@
+
+ *
+ */
+
+/**
+ * Блок настройки списка блогов в ленте
+ *
+ * @package application.blocks
+ * @since 1.0
+ */
+class BlockUserfeedBlogs extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if ($oUserCurrent = $this->User_getUserCurrent()) {
+ $aUserSubscribes = $this->Userfeed_getUserSubscribes($oUserCurrent->getId());
+ /**
+ * Получаем список ID блогов, в которых состоит пользователь
+ */
+ $aBlogsId = $this->Blog_GetBlogUsersByUserId($oUserCurrent->getId(), array(
+ ModuleBlog::BLOG_USER_ROLE_USER,
+ ModuleBlog::BLOG_USER_ROLE_MODERATOR,
+ ModuleBlog::BLOG_USER_ROLE_ADMINISTRATOR
+ ), true);
+ /**
+ * Получаем список ID блогов, которые создал пользователь
+ */
+ $aBlogsOwnerId = $this->Blog_GetBlogsByOwnerId($oUserCurrent->getId(), true);
+ $aBlogsId = array_merge($aBlogsId, $aBlogsOwnerId);
+
+ $aBlogs = $this->Blog_GetBlogsAdditionalData($aBlogsId, array('owner' => array()),
+ array('blog_title' => 'asc'));
+ /**
+ * Выводим в шаблон
+ */
+ $this->Viewer_Assign('blogsSubscribed', $aUserSubscribes['blogs']);
+ $this->Viewer_Assign('blogsJoined', $aBlogs);
+ }
+
+ $this->SetTemplate('component@feed.block.blogs');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/blocks/BlockUserfeedUsers.class.php b/application/classes/blocks/BlockUserfeedUsers.class.php
new file mode 100644
index 0000000..38a02e1
--- /dev/null
+++ b/application/classes/blocks/BlockUserfeedUsers.class.php
@@ -0,0 +1,48 @@
+
+ *
+ */
+
+/**
+ * Блок настройки списка пользователей в ленте
+ *
+ * @package application.blocks
+ * @since 1.0
+ */
+class BlockUserfeedUsers extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ /**
+ * Пользователь авторизован?
+ */
+ if ($oUserCurrent = $this->User_getUserCurrent()) {
+ /**
+ * Получаем необходимые переменные и прогружаем в шаблон
+ */
+ $aResult = $this->Userfeed_getUserSubscribes($oUserCurrent->getId());
+ $this->Viewer_Assign('users', $aResult['users']);
+ }
+
+ $this->SetTemplate('component@feed.block.users');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/blocks/BlockWall.class.php b/application/classes/blocks/BlockWall.class.php
new file mode 100644
index 0000000..58472df
--- /dev/null
+++ b/application/classes/blocks/BlockWall.class.php
@@ -0,0 +1,51 @@
+
+ *
+ */
+
+/**
+ * Стена
+ *
+ * @package application.blocks
+ * @since 2.0
+ */
+class BlockWall extends Block
+{
+ /**
+ * Запуск обработки
+ */
+ public function Exec()
+ {
+ $wall = $this->Wall_GetWall(array('wall_user_id' => (int)$this->GetParam('user_id'), 'pid' => null),
+ array('id' => 'desc'), 1, Config::Get('module.wall.per_page'));
+ $posts = $wall['collection'];
+
+ $this->Viewer_Assign('posts', $posts, true);
+ $this->Viewer_Assign('count', $wall['count'], true);
+ $this->Viewer_Assign('classes', $this->GetParam('classes'), true);
+ $this->Viewer_Assign('attributes', $this->GetParam('attributes'), true);
+ $this->Viewer_Assign('mods', $this->GetParam('mods'), true);
+
+ if (count($posts)) {
+ $this->Viewer_Assign('lastId', end($posts)->getId(), true);
+ }
+
+ $this->SetTemplate('component@wall.wall');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/hooks/HookCopyright.class.php b/application/classes/hooks/HookCopyright.class.php
new file mode 100644
index 0000000..f826327
--- /dev/null
+++ b/application/classes/hooks/HookCopyright.class.php
@@ -0,0 +1,47 @@
+
+ *
+ */
+
+/**
+ * Регистрация хука для вывода ссылки копирайта
+ *
+ * @package application.hooks
+ * @since 1.0
+ */
+class HookCopyright extends Hook
+{
+ /**
+ * Регистрируем хуки
+ */
+ public function RegisterHook()
+ {
+ $this->AddHook('template_copyright', 'CopyrightLink', __CLASS__, -100);
+ }
+
+ /**
+ * Обработка хука копирайта
+ *
+ * @return string
+ */
+ public function CopyrightLink()
+ {
+ return '© Powered by LiveStreet CMS ';
+ }
+}
\ No newline at end of file
diff --git a/application/classes/hooks/HookMain.class.php b/application/classes/hooks/HookMain.class.php
new file mode 100644
index 0000000..d482183
--- /dev/null
+++ b/application/classes/hooks/HookMain.class.php
@@ -0,0 +1,150 @@
+
+ *
+ */
+
+/**
+ * Регистрация основных хуков
+ *
+ * @package application.hooks
+ * @since 1.0
+ */
+class HookMain extends Hook
+{
+ /**
+ * Регистрируем хуки
+ */
+ public function RegisterHook()
+ {
+ $this->AddHook('init_action', 'InitAction', __CLASS__, 1000);
+ $this->AddHook('start_action', 'StartAction', __CLASS__, 1000);
+ }
+
+ /**
+ * Обработка хука инициализации экшенов
+ * Может выполняться несколько раз, например, при использовании внутренних реврайтов
+ */
+ public function InitAction()
+ {
+ /**
+ * Проверка на закрытый режим
+ */
+ $oUserCurrent = $this->User_GetUserCurrent();
+ if (!$oUserCurrent and Config::Get('general.close') and !Router::CheckIsCurrentAction((array)Config::Get('general.close_exceptions'))) {
+ Router::Action('auth/login');
+ }
+ }
+
+ /**
+ * Обработка запуска экшена
+ * Выполняется всегда только один раз
+ */
+ public function StartAction()
+ {
+ $this->LoadDefaultJsVarAndLang();
+ /**
+ * Обработка сайтмапа
+ */
+ $this->Sitemap_AddTargetType('general', array(
+ 'callback_counters' => function () {
+ return 1;
+ },
+ 'callback_data' => function () {
+ return array(
+ $this->Sitemap_GetDataForSitemapRow(Router::GetPath('/'), time(), Config::Get('module.sitemap.index.priority'),
+ Config::Get('module.sitemap.index.changefreq')),
+ $this->Sitemap_GetDataForSitemapRow(Router::GetPath('stream/all'), time(), Config::Get('module.sitemap.stream.priority'),
+ Config::Get('module.sitemap.stream.changefreq')),
+ );
+ }
+ ));
+ $this->Topic_RegisterSitemap();
+ $this->Blog_RegisterSitemap();
+ $this->User_RegisterSitemap();
+ /**
+ * Запуск обработки сборщика
+ */
+ $this->Ls_SenderRun();
+ }
+
+ /**
+ * Загрузка необходимых переменных и текстовок в шаблон
+ */
+ public function LoadDefaultJsVarAndLang()
+ {
+ /**
+ * Загружаем JS переменные
+ */
+ $this->Viewer_AssignJs(
+ array(
+ 'recaptcha.site_key' => Config::Get('module.validate.recaptcha.site_key'),
+ 'comment_max_tree' => Config::Get('module.comment.max_tree'),
+ 'comment_show_form' => Config::Get('module.comment.show_form'),
+ 'comment_use_paging' => Config::Get('module.comment.use_nested'),
+ 'topic_max_blog_count' => Config::Get('module.topic.max_blog_count'),
+ 'block_stream_show_tip' => Config::Get('block.stream.show_tip'),
+ 'poll_max_answers' => Config::Get('module.poll.max_answers'),
+ )
+ );
+
+ /**
+ * Загрузка языковых текстовок
+ */
+ $this->Lang_AddLangJs(array(
+ 'comments.comments_declension',
+ 'comments.unsubscribe',
+ 'comments.subscribe',
+ 'comments.folding.unfold',
+ 'comments.folding.fold',
+ 'comments.folding.unfold_all',
+ 'comments.folding.fold_all',
+ 'poll.notices.error_answers_max',
+ 'favourite.add',
+ 'favourite.remove',
+ 'field.geo.select_city',
+ 'field.geo.select_region',
+ 'blog.blog',
+ 'blog.add.fields.type.note_open',
+ 'blog.add.fields.type.note_close',
+ 'blog.search.result_title',
+ 'blog.blocks.navigator.blog',
+ 'common.success.add',
+ 'common.success.remove',
+ 'common.remove_confirm',
+ 'pagination.notices.first',
+ 'pagination.notices.last',
+ 'user.actions.unfollow',
+ 'user.actions.follow',
+ 'user.friends.status.added',
+ 'user.friends.status.notfriends',
+ 'user.friends.status.pending',
+ 'user.friends.status.rejected',
+ 'user.friends.status.sent',
+ 'user.friends.status.linked',
+ 'user.settings.profile.notices.error_max_userfields',
+ 'user.search.result_title',
+ 'more.text',
+ 'more.text_count',
+ 'more.empty',
+ 'validate.tags.count',
+ 'uploader.attach.count',
+ 'uploader.attach.empty'
+ ));
+ }
+}
\ No newline at end of file
diff --git a/application/classes/hooks/HookStatisticsPerformance.class.php b/application/classes/hooks/HookStatisticsPerformance.class.php
new file mode 100644
index 0000000..061d0a0
--- /dev/null
+++ b/application/classes/hooks/HookStatisticsPerformance.class.php
@@ -0,0 +1,67 @@
+
+ *
+ */
+
+/**
+ * Регистрация хука для вывода статистики производительности
+ *
+ * @package application.hooks
+ * @since 1.0
+ */
+class HookStatisticsPerformance extends Hook
+{
+ /**
+ * Регистрируем хуки
+ */
+ public function RegisterHook()
+ {
+ $this->AddHook('template_body_end', 'Statistics', __CLASS__, -1000);
+ }
+
+ /**
+ * Обработка хука перед закрывающим тегом body
+ *
+ * @return string
+ */
+ public function Statistics()
+ {
+ if (!$this->User_GetIsAdmin()) {
+ return '';
+ }
+ $oEngine = Engine::getInstance();
+ /**
+ * Подсчитываем время выполнения
+ */
+ $iTimeInit = $oEngine->GetTimeInit();
+ $iTimeFull = round(microtime(true) - $iTimeInit, 3);
+ $this->Viewer_Assign('timeFullPerformance', $iTimeFull, true);
+ /**
+ * Получаем статистику по кешу и БД
+ */
+ $aStats = $oEngine->getStats();
+ $aStats['cache']['time'] = round($aStats['cache']['time'], 5);
+ $this->Viewer_Assign('stats', $aStats, true);
+ $this->Viewer_Assign('bIsShowStatsPerformance', Router::GetIsShowStats());
+ /**
+ * В ответ рендерим шаблон статистики
+ */
+ return $this->Viewer_Fetch('component@performance.performance');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/acl/ACL.class.php b/application/classes/modules/acl/ACL.class.php
new file mode 100644
index 0000000..83ce6c4
--- /dev/null
+++ b/application/classes/modules/acl/ACL.class.php
@@ -0,0 +1,826 @@
+
+ *
+ */
+
+/**
+ * ACL(Access Control List)
+ * Модуль для разруливания ограничений по карме/рейтингу юзера
+ *
+ * @package application.modules.acl
+ * @since 1.0
+ */
+class ModuleACL extends Module
+{
+ /**
+ * Коды механизма удаления блога
+ */
+ const CAN_DELETE_BLOG_EMPTY_ONLY = 1;
+ const CAN_DELETE_BLOG_WITH_TOPICS = 2;
+
+ /**
+ * Инициализация модуля
+ *
+ */
+ public function Init()
+ {
+
+ }
+
+ /**
+ * Проверяет может ли пользователь создавать блоги
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function CanCreateBlog($oUser)
+ {
+ $that = $this; // fix for PHP < 5.4
+ return $this->Rbac_IsAllowUser($oUser, 'create_blog', array(
+ 'callback' => function ($oUser, $aParams) use ($that) {
+ if (!$oUser) {
+ return false;
+ }
+ if (!$oUser->isAllowCreateBlog()) {
+ return $that->Lang_Get('blog.add.alerts.acl');
+ }
+ return true;
+ }
+ ));
+ }
+
+ /**
+ * Проверяет может ли пользователь создавать топики
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @param ModuleTopic_EntityTopicType $oTopicType Объект типа топика
+ * @return bool
+ */
+ public function CanAddTopic($oUser, $oTopicType)
+ {
+ $that = $this; // fix for PHP < 5.4
+ return $this->Rbac_IsAllowUser($oUser, 'create_topic', array(
+ 'callback' => function ($oUser, $aParams) use ($that) {
+ if (!$oUser) {
+ return false;
+ }
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ /**
+ * Проверяем хватает ли рейтинга юзеру чтоб создать топик
+ */
+ if ($oUser->getRating() <= Config::Get('acl.create.topic.limit_rating')) {
+ return $that->Lang_Get('topic.add.notices.rating_limit');
+ }
+ /**
+ * Проверяем лимит по времени
+ */
+ if (!$that->CanPostTopicTime($oUser)) {
+ return $that->Lang_Get('topic.add.notices.time_limit');
+ }
+ return true;
+ }
+ ));
+ }
+
+ /**
+ * Проверяет может ли пользователь создавать комментарии
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @param ModuleTopic_EntityTopic|null $oTopic Топик
+ * @return bool
+ */
+ public function CanPostComment($oUser, $oTopic = null)
+ {
+ $that = $this; // fix for PHP < 5.4
+ return $this->Rbac_IsAllowUser($oUser, 'create_topic_comment', array(
+ 'callback' => function ($oUser, $aParams) use ($that, $oTopic) {
+ if (!$oUser) {
+ return false;
+ }
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ /**
+ * Проверяем на закрытый блог
+ */
+ if ($oTopic and !$that->IsAllowShowBlog($oTopic->getBlog(), $oUser)) {
+ return $that->Lang_Get('topic.comments.notices.acl');
+ }
+ /**
+ * Ограничение на рейтинг
+ */
+ if ($oUser->getRating() < Config::Get('acl.create.comment.rating')) {
+ return $that->Lang_Get('topic.comments.notices.acl');
+ }
+ /**
+ * Ограничение по времени
+ */
+ if (Config::Get('acl.create.comment.limit_time') > 0 and $oUser->getDateCommentLast()) {
+ $sDateCommentLast = strtotime($oUser->getDateCommentLast());
+ if ($oUser->getRating() < Config::Get('acl.create.comment.limit_time_rating') and ((time() - $sDateCommentLast) < Config::Get('acl.create.comment.limit_time'))) {
+ return $that->Lang_Get('topic.comments.notices.limit');
+ }
+ }
+ return true;
+ }
+ ));
+ }
+
+ /**
+ * Проверяет может ли пользователь создавать топик по времени
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function CanPostTopicTime($oUser)
+ {
+ // Для администраторов ограничение по времени не действует
+ if ($oUser->isAdministrator()
+ or Config::Get('acl.create.topic.limit_time') == 0
+ or $oUser->getRating() >= Config::Get('acl.create.topic.limit_time_rating')
+ ) {
+ return true;
+ }
+
+ /**
+ * Проверяем, если топик опубликованный меньше чем acl.create.topic.limit_time секунд назад
+ */
+ $aTopics = $this->Topic_GetLastTopicsByUserId($oUser->getId(), Config::Get('acl.create.topic.limit_time'));
+ if (isset($aTopics['count']) and $aTopics['count'] > 0) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Проверяет возможность отправки личного сообщения
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function CanAddTalk($oUser)
+ {
+ $that = $this; // fix for PHP < 5.4
+ return $this->Rbac_IsAllowUser($oUser, 'create_talk', array(
+ 'callback' => function ($oUser, $aParams) use ($that) {
+ if (!$oUser) {
+ return false;
+ }
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ if (!$that->CanSendTalkTime($oUser)) {
+ return $that->Lang_Get('talk.notices.time_limit');
+ }
+ return true;
+ }
+ ));
+ }
+
+ /**
+ * Проверяет может ли пользователь отправить инбокс по времени
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function CanSendTalkTime($oUser)
+ {
+ // Для администраторов ограничение по времени не действует
+ if ($oUser->isAdministrator()
+ or Config::Get('acl.create.talk.limit_time') == 0
+ or $oUser->getRating() >= Config::Get('acl.create.talk.limit_time_rating')
+ ) {
+ return true;
+ }
+
+ /**
+ * Проверяем, если топик опубликованный меньше чем acl.create.topic.limit_time секунд назад
+ */
+ $aTalks = $this->Talk_GetLastTalksByUserId($oUser->getId(), Config::Get('acl.create.talk.limit_time'));
+ if (isset($aTalks['count']) and $aTalks['count'] > 0) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Проверяет может ли пользователь создавать комментарии к личным сообщениям
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function CanPostTalkComment($oUser)
+ {
+ $that = $this; // fix for PHP < 5.4
+ return $this->Rbac_IsAllowUser($oUser, 'create_talk_comment', array(
+ 'callback' => function ($oUser, $aParams) use ($that) {
+ if (!$oUser) {
+ return false;
+ }
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ $aTalkComments = $that->Comment_GetCommentsByUserId($oUser->getId(), 'talk', 1, 1);
+ /**
+ * Если комментариев не было
+ */
+ if (!is_array($aTalkComments) or $aTalkComments['count'] == 0) {
+ return true;
+ }
+ /**
+ * Достаем последний комментарий
+ */
+ $oComment = array_shift($aTalkComments['collection']);
+ $sDate = strtotime($oComment->getDate());
+
+ if ($sDate and ((time() - $sDate) < Config::Get('acl.create.talk_comment.limit_time'))) {
+ return $that->Lang_Get('talk.add.notices.time_limit');
+ }
+ return true;
+ }
+ ));
+ }
+
+ /**
+ * Проверяет может ли пользователь голосовать за конкретный комментарий
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @param ModuleComment_EntityComment $oComment Комментарий
+ * @return bool
+ */
+ public function CanVoteComment($oUser, $oComment)
+ {
+ $that = $this; // fix for PHP < 5.4
+ return $this->Rbac_IsAllowUser($oUser, 'vote_comment', array(
+ 'callback' => function ($oUser, $aParams) use ($that, $oComment) {
+ if (!$oUser) {
+ return false;
+ }
+ /**
+ * Голосует автор комментария?
+ */
+ if ($oComment->getUserId() == $oUser->getId()) {
+ return $that->Lang_Get('vote.notices.error_self');
+ }
+ /**
+ * Пользователь уже голосовал?
+ */
+ if ($oTopicCommentVote = $that->Vote_GetVote($oComment->getId(), 'comment', $oUser->getId())) {
+ return $that->Lang_Get('vote.notices.error_already_voted');
+ }
+ /**
+ * Разрешаем админу
+ */
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ /**
+ * Ограничение по рейтингу
+ */
+ if ($oUser->getRating() < Config::Get('acl.vote.comment.rating')) {
+ return $that->Lang_Get('vote.notices.error_acl');
+ }
+ /**
+ * Время голосования истекло?
+ */
+ if (strtotime($oComment->getDate()) <= time() - Config::Get('acl.vote.comment.limit_time')) {
+ return $that->Lang_Get('vote.notices.error_time');
+ }
+ return true;
+ }
+ ));
+ }
+
+ /**
+ * Проверяет может ли пользователь голосовать за конкретный топик
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @param ModuleTopic_EntityTopic $oTopic Топик
+ * @param int $iValue Направление голосования
+ * @return bool
+ */
+ public function CanVoteTopic($oUser, $oTopic, $iValue)
+ {
+ $that = $this; // fix for PHP < 5.4
+ return $this->Rbac_IsAllowUser($oUser, 'vote_topic', array(
+ 'callback' => function ($oUser, $aParams) use ($that, $oTopic, $iValue) {
+ if (!$oUser) {
+ return false;
+ }
+ /**
+ * Голосует автор топика?
+ */
+ if ($oTopic->getUserId() == $oUser->getId()) {
+ return $that->Lang_Get('vote.notices.error_self');
+ }
+ /**
+ * Пользователь уже голосовал?
+ */
+ if ($oTopicVote = $that->Vote_GetVote($oTopic->getId(), 'topic', $oUser->getId())) {
+ return $that->Lang_Get('vote.notices.error_already_voted');
+ }
+ /**
+ * Разрешаем админу
+ */
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ /**
+ * Время голосования истекло?
+ */
+ if (strtotime($oTopic->getDatePublish()) <= time() - Config::Get('acl.vote.topic.limit_time')) {
+ return $that->Lang_Get('vote.notices.error_time');
+ }
+ /**
+ * Ограничение по рейтингу
+ */
+ if ($iValue != 0 and $oUser->getRating() < Config::Get('acl.vote.topic.rating')) {
+ return $that->Lang_Get('vote.notices.error_acl');
+ }
+ return true;
+ }
+ ));
+ }
+
+ /**
+ * Проверяет можно ли юзеру слать инвайты
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function CanSendInvite($oUser)
+ {
+ $that = $this; // fix for PHP < 5.4
+ return $this->Rbac_IsAllowUser($oUser, 'create_invite', array(
+ 'callback' => function ($oUser, $aParams) use ($that) {
+ if (!$oUser) {
+ return false;
+ }
+ if (!Config::Get('general.reg.invite')) {
+ // разрешаем приглашения всем, когда сайт открыт
+ return true;
+ }
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ if ($that->Invite_GetCountInviteAvailable($oUser) == 0) {
+ return $that->Lang_Get('user.settings.invites.available_no');
+ }
+ return true;
+ }
+ ));
+ }
+
+ /**
+ * Проверяет можно или нет юзеру постить в данный блог
+ *
+ * @param ModuleBlog_EntityBlog $oBlog Блог
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function IsAllowBlog($oBlog, $oUser)
+ {
+ if (!$oBlog || !$oUser) {
+ return false;
+ }
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ if ($oBlog->getOwnerId() == $oUser->getId()) {
+ return true;
+ }
+ if ($oBlog->getType() == 'close') {
+ /**
+ * Для закрытых блогов проверяем среди подписчиков
+ */
+ if ($oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $oUser->getId())) {
+ if ($oUser->getRating() >= $oBlog->getLimitRatingTopic() or $oBlogUser->getIsAdministrator() or $oBlogUser->getIsModerator()) {
+ return true;
+ }
+ }
+ } else {
+ /**
+ * Иначе смотрим ограничение на рейтинг
+ */
+ if ($oUser->getRating() >= $oBlog->getLimitRatingTopic()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет можно или нет юзеру просматривать блог
+ *
+ * @param ModuleBlog_EntityBlog $oBlog Блог
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function IsAllowShowBlog($oBlog, $oUser)
+ {
+ if ($oBlog->getType() != 'close') {
+ return true;
+ }
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ if ($oBlog->getOwnerId() == $oUser->getId()) {
+ return true;
+ }
+ if ($oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(),
+ $oUser->getId()) and $oBlogUser->getUserRole() > ModuleBlog::BLOG_USER_ROLE_GUEST
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет можно или нет пользователю редактировать данный топик
+ *
+ * @param ModuleTopic_EntityTopic $oTopic Топик
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function IsAllowEditTopic($oTopic, $oUser)
+ {
+ /**
+ * Разрешаем если это админ сайта или автор топика
+ */
+ if ($oTopic->getUserId() == $oUser->getId() or $oUser->isAdministrator()) {
+ return true;
+ }
+ /**
+ * Если автор(смотритель) блога
+ */
+ if ($oTopic->getBlog()->getOwnerId() == $oUser->getId()) {
+ return true;
+ }
+ /**
+ * Если модер или админ блога
+ */
+ if ($this->User_GetUserCurrent() and $this->User_GetUserCurrent()->getId() == $oUser->getId()) {
+ /**
+ * Для авторизованного пользователя данный код будет работать быстрее
+ */
+ if ($oTopic->getBlog()->getUserIsAdministrator() or $oTopic->getBlog()->getUserIsModerator()) {
+ return true;
+ }
+ } else {
+ $oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oTopic->getBlogId(), $oUser->getId());
+ if ($oBlogUser and ($oBlogUser->getIsModerator() or $oBlogUser->getIsAdministrator())) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Проверка на редактирование комментария
+ *
+ * @param ModuleComment_EntityComment $oComment
+ * @param ModuleUser_EntityUser $oUser
+ *
+ * @return bool
+ */
+ public function IsAllowEditComment($oComment, $oUser)
+ {
+ if (!$oUser) {
+ return false;
+ }
+ if (!in_array($oComment->getTargetType(), (array)Config::Get('module.comment.edit_target_allow'))) {
+ return false;
+ }
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ if ($oComment->getUserId() == $oUser->getId() and $oUser->getRating() >= Config::Get('acl.update.comment.rating')) {
+ /**
+ * Проверяем на лимит времени
+ */
+ if (!Config::Get('acl.update.comment.limit_time') or (time() - strtotime($oComment->getDate()) <= Config::Get('acl.update.comment.limit_time'))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Проверка на возможность добавления комментария в избранное
+ *
+ * @param $oComment
+ * @param $oUser
+ *
+ * @return bool
+ */
+ public function IsAllowFavouriteComment($oComment, $oUser)
+ {
+ $that = $this; // fix for PHP < 5.4
+ return $this->Rbac_IsAllowUser($oUser, 'create_comment_favourite', array(
+ 'callback' => function ($oUser, $aParams) use ($that, $oComment) {
+ if (!$oUser) {
+ return false;
+ }
+ if (!in_array($oComment->getTargetType(), array('topic'))) {
+ return false;
+ }
+ if (!$oTarget = $oComment->getTarget()) {
+ return false;
+ }
+ if ($oComment->getTargetType() == 'topic') {
+ /**
+ * Проверяем права на просмотр топика
+ */
+ if (!$that->IsAllowShowTopic($oTarget, $oUser)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ ));
+ }
+
+ /**
+ * Проверка на удаление комментария
+ *
+ * @param ModuleComment_EntityComment $oComment
+ * @param ModuleUser_EntityUser $oUser
+ *
+ * @return bool
+ */
+ public function IsAllowDeleteComment($oComment, $oUser)
+ {
+ /**
+ * Разрешаем если это админ сайта
+ */
+ if ($oUser and $oUser->isAdministrator()) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет можно или нет пользователю удалять данный топик
+ *
+ * @param ModuleTopic_EntityTopic $oTopic Топик
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function IsAllowDeleteTopic($oTopic, $oUser)
+ {
+ $that = $this; // fix for PHP < 5.4
+ return $this->Rbac_IsAllowUser($oUser, 'remove_topic', array(
+ 'callback' => function ($oUser, $aParams) use ($that, $oTopic) {
+ if (!$oUser) {
+ return false;
+ }
+ /**
+ * Разрешаем если это админ сайта или автор топика
+ */
+ if ($oTopic->getUserId() == $oUser->getId() or $oUser->isAdministrator()) {
+ return true;
+ }
+ /**
+ * Если автор(смотритель) блога
+ */
+ if ($oTopic->getBlog()->getOwnerId() == $oUser->getId()) {
+ return true;
+ }
+ /**
+ * Если модер или админ блога
+ */
+ if ($that->User_GetUserCurrent() and $that->User_GetUserCurrent()->getId() == $oUser->getId()) {
+ /**
+ * Для авторизованного пользователя данный код будет работать быстрее
+ */
+ if ($oTopic->getBlog()->getUserIsAdministrator() or $oTopic->getBlog()->getUserIsModerator()) {
+ return true;
+ }
+ } else {
+ $oBlogUser = $that->Blog_GetBlogUserByBlogIdAndUserId($oTopic->getBlogId(), $oUser->getId());
+ if ($oBlogUser and ($oBlogUser->getIsModerator() or $oBlogUser->getIsAdministrator())) {
+ return true;
+ }
+ }
+ return false;
+ }
+ ));
+ }
+
+ /**
+ * Проверка на возможность просмотра топика
+ *
+ * @param $oTopic
+ * @param $oUser
+ *
+ * @return bool
+ */
+ public function IsAllowShowTopic($oTopic, $oUser)
+ {
+ if (!$oTopic) {
+ return false;
+ }
+ /**
+ * Проверяем права на просмотр топика
+ */
+ if ((!$oTopic->getPublish() or $oTopic->getDatePublish() > date('Y-m-d H:i:s'))
+ and (!$oUser or ($oUser->getId() != $oTopic->getUserId() and !$oUser->isAdministrator()))
+ ) {
+ return false;
+ }
+ /**
+ * Определяем права на отображение записи из закрытого блога
+ */
+ if (!$this->IsAllowShowBlog($oTopic->getBlog(), $oUser)) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Проверяет можно или нет пользователю удалять данный блог
+ *
+ * @param ModuleBlog_EntityBlog $oBlog Блог
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function IsAllowDeleteBlog($oBlog, $oUser)
+ {
+ /**
+ * Разрешаем если это админ сайта или автор блога
+ */
+ if ($oUser->isAdministrator()) {
+ return self::CAN_DELETE_BLOG_WITH_TOPICS;
+ }
+ /**
+ * Разрешаем удалять администраторам блога и автору, но только пустой
+ */
+ if ($oBlog->getOwnerId() == $oUser->getId()) {
+ return self::CAN_DELETE_BLOG_EMPTY_ONLY;
+ }
+
+ $oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $oUser->getId());
+ if ($oBlogUser and $oBlogUser->getIsAdministrator()) {
+ return self::CAN_DELETE_BLOG_EMPTY_ONLY;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет может ли пользователь удалить комментарий
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function CanDeleteComment($oUser)
+ {
+ if (!$oUser || !$oUser->isAdministrator()) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Проверяет может ли пользователь публиковать на главной
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function IsAllowTopicPublishIndex(ModuleUser_EntityUser $oUser)
+ {
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет может ли пользователь блокировать топик на главной
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function IsAllowTopicSkipIndex(ModuleUser_EntityUser $oUser)
+ {
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет можно или нет пользователю редактировать данный блог
+ *
+ * @param ModuleBlog_EntityBlog $oBlog Блог
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function IsAllowEditBlog($oBlog, $oUser)
+ {
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ /**
+ * Разрешаем если это создатель блога
+ */
+ if ($oBlog->getOwnerId() == $oUser->getId()) {
+ return true;
+ }
+ /**
+ * Явлется ли авторизованный пользователь администратором блога
+ */
+ $oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $oUser->getId());
+
+ if ($oBlogUser && $oBlogUser->getIsAdministrator()) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет можно или нет пользователю управлять пользователями блога
+ *
+ * @param ModuleBlog_EntityBlog $oBlog Блог
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return bool
+ */
+ public function IsAllowAdminBlog($oBlog, $oUser)
+ {
+ if ($oUser->isAdministrator()) {
+ return true;
+ }
+ /**
+ * Разрешаем если это создатель блога
+ */
+ if ($oBlog->getOwnerId() == $oUser->getId()) {
+ return true;
+ }
+ /**
+ * Явлется ли авторизованный пользователь администратором блога
+ */
+ $oBlogUser = $this->Blog_GetBlogUserByBlogIdAndUserId($oBlog->getId(), $oUser->getId());
+ if ($oBlogUser && $oBlogUser->getIsAdministrator()) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверка на ограничение по времени на постинг на стене
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @param ModuleWall_EntityWall $oWall Объект сообщения на стене
+ * @return bool
+ */
+ public function CanAddWallTime($oUser, $oWall)
+ {
+ /**
+ * Для администраторов ограничение по времени не действует
+ */
+ if ($oUser->isAdministrator()
+ or Config::Get('acl.create.wall.limit_time') == 0
+ or $oUser->getRating() >= Config::Get('acl.create.wall.limit_time_rating')
+ ) {
+ return true;
+ }
+ if ($oWall->getUserId() == $oWall->getWallUserId()) {
+ return true;
+ }
+ /**
+ * Получаем последнее сообщение
+ */
+ $aWall = $this->Wall_GetWall(array('user_id' => $oWall->getUserId()), array('id' => 'desc'), 1, 1, array());
+ /**
+ * Если сообщений нет
+ */
+ if ($aWall['count'] == 0) {
+ return true;
+ }
+
+ $oWallLast = array_shift($aWall['collection']);
+ $sDate = strtotime($oWallLast->getDateAdd());
+ if ($sDate and ((time() - $sDate) < Config::Get('acl.create.wall.limit_time'))) {
+ return false;
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/blog/Blog.class.php b/application/classes/modules/blog/Blog.class.php
new file mode 100644
index 0000000..2a549f1
--- /dev/null
+++ b/application/classes/modules/blog/Blog.class.php
@@ -0,0 +1,1229 @@
+
+ *
+ */
+
+/**
+ * Модуль для работы с блогами
+ *
+ * @package application.modules.blog
+ * @since 1.0
+ */
+class ModuleBlog extends Module
+{
+ /**
+ * Возможные роли пользователя в блоге
+ */
+ const BLOG_USER_ROLE_GUEST = 0;
+ const BLOG_USER_ROLE_USER = 1;
+ const BLOG_USER_ROLE_MODERATOR = 2;
+ const BLOG_USER_ROLE_ADMINISTRATOR = 4;
+ /**
+ * Пользователь, приглашенный админом блога в блог
+ */
+ const BLOG_USER_ROLE_INVITE = -1;
+ /**
+ * Пользователь, отклонивший приглашение админа
+ */
+ const BLOG_USER_ROLE_REJECT = -2;
+ /**
+ * Забаненный в блоге пользователь
+ */
+ const BLOG_USER_ROLE_BAN = -4;
+ /**
+ * Список типов блога
+ *
+ * @var array
+ */
+ protected $aBlogTypes = array(
+ 'open',
+ 'close'
+ );
+
+ /**
+ * Объект маппера
+ *
+ * @var ModuleBlog_MapperBlog
+ */
+ protected $oMapperBlog;
+ /**
+ * Объект текущего пользователя
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+ /**
+ * Список поведений
+ *
+ * @var array
+ */
+ protected $aBehaviors = array(
+ // Категории
+ 'category' => array(
+ 'class' => 'ModuleCategory_BehaviorModule',
+ 'target_type' => 'blog',
+ ),
+ );
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ $this->oMapperBlog = Engine::GetMapper(__CLASS__);
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Возвращает список типов блога
+ *
+ * @return array
+ */
+ public function GetBlogTypes()
+ {
+ return $this->aBlogTypes;
+ }
+
+ /**
+ * Добавляет в новый тип блога
+ *
+ * @param string $sType Новый тип
+ * @return bool
+ */
+ public function AddBlogType($sType)
+ {
+ if (!in_array($sType, $this->aBlogTypes)) {
+ $this->aBlogTypes[] = $sType;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет разрешен ли данный тип блога
+ *
+ * @param string $sType Тип
+ * @return bool
+ */
+ public function IsAllowBlogType($sType)
+ {
+ return in_array($sType, $this->aBlogTypes);
+ }
+
+ /**
+ * Получает дополнительные данные(объекты) для блогов по их ID
+ *
+ * @param array $aBlogId Список ID блогов
+ * @param array $aAllowData Список типов дополнительных данных, которые нужно получить для блогов
+ * @param array $aOrder Порядок сортировки
+ * @return array
+ */
+ public function GetBlogsAdditionalData($aBlogId, $aAllowData = null, $aOrder = null)
+ {
+ if (is_null($aAllowData)) {
+ $aAllowData = array('vote', 'owner' => array(), 'relation_user');
+ }
+ func_array_simpleflip($aAllowData);
+ if (!is_array($aBlogId)) {
+ $aBlogId = array($aBlogId);
+ }
+ /**
+ * Получаем блоги
+ */
+ $aBlogs = $this->GetBlogsByArrayId($aBlogId, $aOrder);
+ /**
+ * Формируем ID дополнительных данных, которые нужно получить
+ */
+ $aUserId = array();
+ foreach ($aBlogs as $oBlog) {
+ if (isset($aAllowData['owner'])) {
+ $aUserId[] = $oBlog->getOwnerId();
+ }
+ }
+ /**
+ * Получаем дополнительные данные
+ */
+ $aBlogUsers = array();
+ $aBlogsVote = array();
+ $aUsers = isset($aAllowData['owner']) && is_array($aAllowData['owner']) ? $this->User_GetUsersAdditionalData($aUserId,
+ $aAllowData['owner']) : $this->User_GetUsersAdditionalData($aUserId);
+ if (isset($aAllowData['relation_user']) and $this->oUserCurrent) {
+ $aBlogUsers = $this->GetBlogUsersByArrayBlog($aBlogId, $this->oUserCurrent->getId());
+ }
+ if (isset($aAllowData['vote']) and $this->oUserCurrent) {
+ $aBlogsVote = $this->Vote_GetVoteByArray($aBlogId, 'blog', $this->oUserCurrent->getId());
+ }
+ /**
+ * Добавляем данные к результату - списку блогов
+ */
+ foreach ($aBlogs as $oBlog) {
+ if (isset($aUsers[$oBlog->getOwnerId()])) {
+ $oBlog->setOwner($aUsers[$oBlog->getOwnerId()]);
+ } else {
+ $oBlog->setOwner(null); // или $oBlog->setOwner(new ModuleUser_EntityUser());
+ }
+ if (isset($aBlogUsers[$oBlog->getId()])) {
+ $oBlog->setUserIsJoin(true);
+ $oBlog->setUserIsAdministrator($aBlogUsers[$oBlog->getId()]->getIsAdministrator());
+ $oBlog->setUserIsModerator($aBlogUsers[$oBlog->getId()]->getIsModerator());
+ } else {
+ $oBlog->setUserIsJoin(false);
+ $oBlog->setUserIsAdministrator(false);
+ $oBlog->setUserIsModerator(false);
+ }
+ if (isset($aBlogsVote[$oBlog->getId()])) {
+ $oBlog->setVote($aBlogsVote[$oBlog->getId()]);
+ } else {
+ $oBlog->setVote(null);
+ }
+ }
+ return $aBlogs;
+ }
+
+ /**
+ * Возвращает список блогов по ID
+ *
+ * @param array $aBlogId Список ID блогов
+ * @param array|null $aOrder Порядок сортировки
+ * @return array
+ */
+ public function GetBlogsByArrayId($aBlogId, $aOrder = null)
+ {
+ if (!$aBlogId) {
+ return array();
+ }
+ if (Config::Get('sys.cache.solid')) {
+ return $this->GetBlogsByArrayIdSolid($aBlogId, $aOrder);
+ }
+ if (!is_array($aBlogId)) {
+ $aBlogId = array($aBlogId);
+ }
+ $aBlogId = array_unique($aBlogId);
+ $aBlogs = array();
+ $aBlogIdNotNeedQuery = array();
+ /**
+ * Делаем мульти-запрос к кешу
+ */
+ $aCacheKeys = func_build_cache_keys($aBlogId, 'blog_');
+ if (false !== ($data = $this->Cache_Get($aCacheKeys))) {
+ /**
+ * проверяем что досталось из кеша
+ */
+ foreach ($aCacheKeys as $sValue => $sKey) {
+ if (array_key_exists($sKey, $data)) {
+ if ($data[$sKey]) {
+ $aBlogs[$data[$sKey]->getId()] = $data[$sKey];
+ } else {
+ $aBlogIdNotNeedQuery[] = $sValue;
+ }
+ }
+ }
+ }
+ /**
+ * Смотрим каких блогов не было в кеше и делаем запрос в БД
+ */
+ $aBlogIdNeedQuery = array_diff($aBlogId, array_keys($aBlogs));
+ $aBlogIdNeedQuery = array_diff($aBlogIdNeedQuery, $aBlogIdNotNeedQuery);
+ $aBlogIdNeedStore = $aBlogIdNeedQuery;
+ if ($data = $this->oMapperBlog->GetBlogsByArrayId($aBlogIdNeedQuery)) {
+ foreach ($data as $oBlog) {
+ /**
+ * Добавляем к результату и сохраняем в кеш
+ */
+ $aBlogs[$oBlog->getId()] = $oBlog;
+ $this->Cache_Set($oBlog, "blog_{$oBlog->getId()}", array(), 60 * 60 * 24 * 4);
+ $aBlogIdNeedStore = array_diff($aBlogIdNeedStore, array($oBlog->getId()));
+ }
+ }
+ /**
+ * Сохраняем в кеш запросы не вернувшие результата
+ */
+ foreach ($aBlogIdNeedStore as $sId) {
+ $this->Cache_Set(null, "blog_{$sId}", array(), 60 * 60 * 24 * 4);
+ }
+ /**
+ * Сортируем результат согласно входящему массиву
+ */
+ $aBlogs = func_array_sort_by_keys($aBlogs, $aBlogId);
+ return $aBlogs;
+ }
+
+ /**
+ * Возвращает список блогов по ID, но используя единый кеш
+ *
+ * @param array $aBlogId Список ID блогов
+ * @param array|null $aOrder Сортировка блогов
+ * @return array
+ */
+ public function GetBlogsByArrayIdSolid($aBlogId, $aOrder = null)
+ {
+ if (!is_array($aBlogId)) {
+ $aBlogId = array($aBlogId);
+ }
+ $aBlogId = array_unique($aBlogId);
+ $aBlogs = array();
+ $s = join(',', $aBlogId);
+ if (false === ($data = $this->Cache_Get("blog_id_{$s}"))) {
+ $data = $this->oMapperBlog->GetBlogsByArrayId($aBlogId, $aOrder);
+ foreach ($data as $oBlog) {
+ $aBlogs[$oBlog->getId()] = $oBlog;
+ }
+ $this->Cache_Set($aBlogs, "blog_id_{$s}", array("blog_update"), 60 * 60 * 24 * 1);
+ return $aBlogs;
+ }
+ return $data;
+ }
+
+ /**
+ * Получить персональный блог юзера
+ *
+ * @param int $sUserId ID пользователя
+ * @return ModuleBlog_EntityBlog
+ */
+ public function GetPersonalBlogByUserId($sUserId)
+ {
+ $id = $this->oMapperBlog->GetPersonalBlogByUserId($sUserId);
+ return $this->GetBlogById($id);
+ }
+
+ /**
+ * Получить блог по айдишнику(номеру)
+ *
+ * @param int $sBlogId ID блога
+ * @return ModuleBlog_EntityBlog|null
+ */
+ public function GetBlogById($sBlogId)
+ {
+ if (!is_numeric($sBlogId)) {
+ return null;
+ }
+ $aBlogs = $this->GetBlogsAdditionalData($sBlogId);
+ if (isset($aBlogs[$sBlogId])) {
+ return $aBlogs[$sBlogId];
+ }
+ return null;
+ }
+
+ /**
+ * Получить блог по УРЛу
+ *
+ * @param string $sBlogUrl URL блога
+ * @return ModuleBlog_EntityBlog|null
+ */
+ public function GetBlogByUrl($sBlogUrl)
+ {
+ if (false === ($id = $this->Cache_Get("blog_url_{$sBlogUrl}"))) {
+ if ($id = $this->oMapperBlog->GetBlogByUrl($sBlogUrl)) {
+ $this->Cache_Set($id, "blog_url_{$sBlogUrl}", array("blog_update_{$id}"), 60 * 60 * 24 * 2);
+ } else {
+ $this->Cache_Set(null, "blog_url_{$sBlogUrl}", array('blog_update', 'blog_new'), 60 * 60);
+ }
+ }
+ return $this->GetBlogById($id);
+ }
+
+ /**
+ * Получить блог по названию
+ *
+ * @param string $sTitle Название блога
+ * @return ModuleBlog_EntityBlog|null
+ */
+ public function GetBlogByTitle($sTitle)
+ {
+ if (false === ($id = $this->Cache_Get("blog_title_{$sTitle}"))) {
+ if ($id = $this->oMapperBlog->GetBlogByTitle($sTitle)) {
+ $this->Cache_Set($id, "blog_title_{$sTitle}", array("blog_update_{$id}", 'blog_new'), 60 * 60 * 24 * 2);
+ } else {
+ $this->Cache_Set(null, "blog_title_{$sTitle}", array('blog_update', 'blog_new'), 60 * 60);
+ }
+ }
+ return $this->GetBlogById($id);
+ }
+
+ /**
+ * Создаёт персональный блог
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @return ModuleBlog_EntityBlog|bool
+ */
+ public function CreatePersonalBlog(ModuleUser_EntityUser $oUser)
+ {
+ $oBlog = Engine::GetEntity('Blog');
+ $oBlog->setOwnerId($oUser->getId());
+ $oBlog->setTitle($this->Lang_Get('blog.personal_prefix') . ' ' . $oUser->getLogin());
+ $oBlog->setType('personal');
+ $oBlog->setDescription($this->Lang_Get('blog.personal_description'));
+ $oBlog->setDateAdd(date("Y-m-d H:i:s"));
+ $oBlog->setLimitRatingTopic(-1000);
+ $oBlog->setUrl(null);
+ $oBlog->setAvatar(null);
+ $oBlog->setSkipIndex(0);
+ return $this->AddBlog($oBlog);
+ }
+
+ /**
+ * Добавляет блог
+ *
+ * @param ModuleBlog_EntityBlog $oBlog Блог
+ * @return ModuleBlog_EntityBlog|bool
+ */
+ public function AddBlog(ModuleBlog_EntityBlog $oBlog)
+ {
+ if ($sId = $this->oMapperBlog->AddBlog($oBlog)) {
+ $oBlog->setId($sId);
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('blog_new'));
+ return $oBlog;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет блог
+ *
+ * @param ModuleBlog_EntityBlog $oBlog Блог
+ * @return ModuleBlog_EntityBlog|bool
+ */
+ public function UpdateBlog(ModuleBlog_EntityBlog $oBlog)
+ {
+ $oBlog->setDateEdit(date("Y-m-d H:i:s"));
+ $res = $this->oMapperBlog->UpdateBlog($oBlog);
+ if ($res) {
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array('blog_update', "blog_update_{$oBlog->getId()}", "topic_update"));
+ $this->Cache_Delete("blog_{$oBlog->getId()}");
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Добавляет отношение юзера к блогу, по сути присоединяет к блогу
+ *
+ * @param ModuleBlog_EntityBlogUser $oBlogUser Объект связи(отношения) блога с пользователем
+ * @return bool
+ */
+ public function AddRelationBlogUser(ModuleBlog_EntityBlogUser $oBlogUser)
+ {
+ if ($this->oMapperBlog->AddRelationBlogUser($oBlogUser)) {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array(
+ "blog_relation_change_{$oBlogUser->getUserId()}",
+ "blog_relation_change_blog_{$oBlogUser->getBlogId()}"
+ ));
+ $this->Cache_Delete("blog_relation_user_{$oBlogUser->getBlogId()}_{$oBlogUser->getUserId()}");
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Удалет отношение юзера к блогу, по сути отключает от блога
+ *
+ * @param ModuleBlog_EntityBlogUser $oBlogUser Объект связи(отношения) блога с пользователем
+ * @return bool
+ */
+ public function DeleteRelationBlogUser(ModuleBlog_EntityBlogUser $oBlogUser)
+ {
+ if ($this->oMapperBlog->DeleteRelationBlogUser($oBlogUser)) {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array(
+ "blog_relation_change_{$oBlogUser->getUserId()}",
+ "blog_relation_change_blog_{$oBlogUser->getBlogId()}"
+ ));
+ $this->Cache_Delete("blog_relation_user_{$oBlogUser->getBlogId()}_{$oBlogUser->getUserId()}");
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Получает список блогов по хозяину
+ *
+ * @param int $sUserId ID пользователя
+ * @param bool $bReturnIdOnly Возвращать только ID блогов или полные объекты
+ * @return array
+ */
+ public function GetBlogsByOwnerId($sUserId, $bReturnIdOnly = false)
+ {
+ $data = $this->oMapperBlog->GetBlogsByOwnerId($sUserId);
+ /**
+ * Возвращаем только иденитификаторы
+ */
+ if ($bReturnIdOnly) {
+ return $data;
+ }
+
+ $data = $this->GetBlogsAdditionalData($data);
+ return $data;
+ }
+
+ /**
+ * Получает список всех НЕ персональных блогов
+ *
+ * @param bool $bReturnIdOnly Возвращать только ID блогов или полные объекты
+ * @return array
+ */
+ public function GetBlogs($bReturnIdOnly = false)
+ {
+ $data = $this->oMapperBlog->GetBlogs();
+ /**
+ * Возвращаем только иденитификаторы
+ */
+ if ($bReturnIdOnly) {
+ return $data;
+ }
+
+ $data = $this->GetBlogsAdditionalData($data);
+ return $data;
+ }
+
+ public function GetBlogsByType($sType)
+ {
+ $aBlogs = $this->GetBlogsByFilter(array('type' => $sType), array('blog_title' => 'asc'), 1, 100);
+ return $aBlogs['collection'];
+ }
+
+ public function GetBlogsByTypeAndUserId($sType, $iUserId)
+ {
+ $aResult = array();
+ /**
+ * Получаем созданные юзером блоги
+ */
+ $aBlogs = $this->GetBlogsByFilter(array('user_owner_id' => $iUserId, 'type' => $sType), array(), 1, 100);
+ foreach ($aBlogs['collection'] as $oBlog) {
+ $aResult[$oBlog->getId()] = $oBlog;
+ }
+ /**
+ * Блоги в которых состоит
+ */
+ $aBlogs = $this->GetBlogsByFilter(array(
+ 'type' => $sType,
+ 'roles_user_id' => $iUserId,
+ 'roles' => array(
+ self::BLOG_USER_ROLE_USER,
+ self::BLOG_USER_ROLE_MODERATOR,
+ self::BLOG_USER_ROLE_ADMINISTRATOR
+ )
+ ), array(), 1, 100);
+ foreach ($aBlogs['collection'] as $oBlog) {
+ $aResult[$oBlog->getId()] = $oBlog;
+ }
+ /**
+ * Сотируем по названию
+ */
+ uasort($aResult, function ($a, $b) {
+ if ($a->getTitle() == $b->getTitle()) {
+ return 0;
+ }
+ return ($a->getTitle() < $b->getTitle()) ? -1 : 1;
+ });
+ return $aResult;
+ }
+
+ /**
+ * Получает список пользователей блога.
+ * Если роль не указана, то считаем что поиск производиться по положительным значениям (статусом выше GUEST).
+ *
+ * @param int|array $aBlogId ID блога или список ID блогов
+ * @param int|null $iRole Роль пользователей в блоге
+ * @param int $iPage Номер текущей страницы
+ * @param int $iPerPage Количество элементов на одну страницу
+ * @return array
+ */
+ public function GetBlogUsersByBlogId($aBlogId, $iRole = null, $iPage = 1, $iPerPage = 100)
+ {
+ if (!is_array($aBlogId)) {
+ $aBlogId = array($aBlogId);
+ }
+ $aFilter = array(
+ 'blog_id' => $aBlogId,
+ );
+ if ($iRole !== null) {
+ $aFilter['user_role'] = $iRole;
+ }
+ $s = serialize($aFilter);
+ if (false === ($data = $this->Cache_Get("blog_relation_user_by_filter_{$s}_{$iPage}_{$iPerPage}"))) {
+ $data = array(
+ 'collection' => $this->oMapperBlog->GetBlogUsers($aFilter, $iCount, $iPage, $iPerPage),
+ 'count' => $iCount
+ );
+ $aTags = array();
+ foreach ($aBlogId as $iBlogId) {
+ $aTags[] = "blog_relation_change_blog_{$iBlogId}";
+ }
+ $this->Cache_Set($data, "blog_relation_user_by_filter_{$s}_{$iPage}_{$iPerPage}", $aTags, 60 * 60 * 24 * 3);
+ }
+ /**
+ * Достаем дополнительные данные, для этого формируем список юзеров и делаем мульти-запрос
+ */
+ if ($data['collection']) {
+ $aUserId = array();
+ foreach ($data['collection'] as $oBlogUser) {
+ $aUserId[] = $oBlogUser->getUserId();
+ }
+ $aUsers = $this->User_GetUsersAdditionalData($aUserId);
+ $aBlogs = $this->Blog_GetBlogsAdditionalData($aBlogId);
+
+ $aResults = array();
+ foreach ($data['collection'] as $oBlogUser) {
+ if (isset($aUsers[$oBlogUser->getUserId()])) {
+ $oBlogUser->setUser($aUsers[$oBlogUser->getUserId()]);
+ } else {
+ $oBlogUser->setUser(null);
+ }
+ if (isset($aBlogs[$oBlogUser->getBlogId()])) {
+ $oBlogUser->setBlog($aBlogs[$oBlogUser->getBlogId()]);
+ } else {
+ $oBlogUser->setBlog(null);
+ }
+ $aResults[$oBlogUser->getUserId()] = $oBlogUser;
+ }
+ $data['collection'] = $aResults;
+ }
+ return $data;
+ }
+
+ /**
+ * Получает отношения юзера к блогам(состоит в блоге или нет)
+ *
+ * @param int $sUserId ID пользователя
+ * @param int|null $iRole Роль пользователя в блоге
+ * @param bool $bReturnIdOnly Возвращать только ID блогов или полные объекты
+ * @return array
+ */
+ public function GetBlogUsersByUserId($sUserId, $iRole = null, $bReturnIdOnly = false)
+ {
+ $aFilter = array(
+ 'user_id' => $sUserId
+ );
+ if ($iRole !== null) {
+ $aFilter['user_role'] = $iRole;
+ }
+ $s = serialize($aFilter);
+ if (false === ($data = $this->Cache_Get("blog_relation_user_by_filter_$s"))) {
+ $data = $this->oMapperBlog->GetBlogUsers($aFilter);
+ $this->Cache_Set($data, "blog_relation_user_by_filter_$s",
+ array("blog_update", "blog_relation_change_{$sUserId}"), 60 * 60 * 24 * 3);
+ }
+ /**
+ * Достаем дополнительные данные, для этого формируем список блогов и делаем мульти-запрос
+ */
+ $aBlogId = array();
+ if ($data) {
+ foreach ($data as $oBlogUser) {
+ $aBlogId[] = $oBlogUser->getBlogId();
+ }
+ /**
+ * Если указано возвращать полные объекты
+ */
+ if (!$bReturnIdOnly) {
+ $aUsers = $this->User_GetUsersAdditionalData($sUserId);
+ $aBlogs = $this->Blog_GetBlogsAdditionalData($aBlogId);
+ foreach ($data as $oBlogUser) {
+ if (isset($aUsers[$oBlogUser->getUserId()])) {
+ $oBlogUser->setUser($aUsers[$oBlogUser->getUserId()]);
+ } else {
+ $oBlogUser->setUser(null);
+ }
+ if (isset($aBlogs[$oBlogUser->getBlogId()])) {
+ $oBlogUser->setBlog($aBlogs[$oBlogUser->getBlogId()]);
+ } else {
+ $oBlogUser->setBlog(null);
+ }
+ }
+ }
+ }
+ return ($bReturnIdOnly) ? $aBlogId : $data;
+ }
+
+ /**
+ * Состоит ли юзер в конкретном блоге
+ *
+ * @param int $sBlogId ID блога
+ * @param int $sUserId ID пользователя
+ * @return ModuleBlog_EntityBlogUser|null
+ */
+ public function GetBlogUserByBlogIdAndUserId($sBlogId, $sUserId)
+ {
+ if ($aBlogUser = $this->GetBlogUsersByArrayBlog($sBlogId, $sUserId)) {
+ if (isset($aBlogUser[$sBlogId])) {
+ return $aBlogUser[$sBlogId];
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Получить список отношений блог-юзер по списку айдишников
+ *
+ * @param array $aBlogId Список ID блогов
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetBlogUsersByArrayBlog($aBlogId, $sUserId)
+ {
+ if (!$aBlogId) {
+ return array();
+ }
+ if (Config::Get('sys.cache.solid')) {
+ return $this->GetBlogUsersByArrayBlogSolid($aBlogId, $sUserId);
+ }
+ if (!is_array($aBlogId)) {
+ $aBlogId = array($aBlogId);
+ }
+ $aBlogId = array_unique($aBlogId);
+ $aBlogUsers = array();
+ $aBlogIdNotNeedQuery = array();
+ /**
+ * Делаем мульти-запрос к кешу
+ */
+ $aCacheKeys = func_build_cache_keys($aBlogId, 'blog_relation_user_', '_' . $sUserId);
+ if (false !== ($data = $this->Cache_Get($aCacheKeys))) {
+ /**
+ * проверяем что досталось из кеша
+ */
+ foreach ($aCacheKeys as $sValue => $sKey) {
+ if (array_key_exists($sKey, $data)) {
+ if ($data[$sKey]) {
+ $aBlogUsers[$data[$sKey]->getBlogId()] = $data[$sKey];
+ } else {
+ $aBlogIdNotNeedQuery[] = $sValue;
+ }
+ }
+ }
+ }
+ /**
+ * Смотрим каких блогов не было в кеше и делаем запрос в БД
+ */
+ $aBlogIdNeedQuery = array_diff($aBlogId, array_keys($aBlogUsers));
+ $aBlogIdNeedQuery = array_diff($aBlogIdNeedQuery, $aBlogIdNotNeedQuery);
+ $aBlogIdNeedStore = $aBlogIdNeedQuery;
+ if ($data = $this->oMapperBlog->GetBlogUsersByArrayBlog($aBlogIdNeedQuery, $sUserId)) {
+ foreach ($data as $oBlogUser) {
+ /**
+ * Добавляем к результату и сохраняем в кеш
+ */
+ $aBlogUsers[$oBlogUser->getBlogId()] = $oBlogUser;
+ $this->Cache_Set($oBlogUser, "blog_relation_user_{$oBlogUser->getBlogId()}_{$oBlogUser->getUserId()}",
+ array(), 60 * 60 * 24 * 4);
+ $aBlogIdNeedStore = array_diff($aBlogIdNeedStore, array($oBlogUser->getBlogId()));
+ }
+ }
+ /**
+ * Сохраняем в кеш запросы не вернувшие результата
+ */
+ foreach ($aBlogIdNeedStore as $sId) {
+ $this->Cache_Set(null, "blog_relation_user_{$sId}_{$sUserId}", array(), 60 * 60 * 24 * 4);
+ }
+ /**
+ * Сортируем результат согласно входящему массиву
+ */
+ $aBlogUsers = func_array_sort_by_keys($aBlogUsers, $aBlogId);
+ return $aBlogUsers;
+ }
+
+ /**
+ * Получить список отношений блог-юзер по списку айдишников используя общий кеш
+ *
+ * @param array $aBlogId Список ID блогов
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetBlogUsersByArrayBlogSolid($aBlogId, $sUserId)
+ {
+ if (!is_array($aBlogId)) {
+ $aBlogId = array($aBlogId);
+ }
+ $aBlogId = array_unique($aBlogId);
+ $aBlogUsers = array();
+ $s = join(',', $aBlogId);
+ if (false === ($data = $this->Cache_Get("blog_relation_user_{$sUserId}_id_{$s}"))) {
+ $data = $this->oMapperBlog->GetBlogUsersByArrayBlog($aBlogId, $sUserId);
+ foreach ($data as $oBlogUser) {
+ $aBlogUsers[$oBlogUser->getBlogId()] = $oBlogUser;
+ }
+ $this->Cache_Set($aBlogUsers, "blog_relation_user_{$sUserId}_id_{$s}",
+ array("blog_update", "blog_relation_change_{$sUserId}"), 60 * 60 * 24 * 1);
+ return $aBlogUsers;
+ }
+ return $data;
+ }
+
+ /**
+ * Обновляет отношения пользователя с блогом
+ *
+ * @param ModuleBlog_EntityBlogUser $oBlogUser Объект отновшения
+ * @return bool
+ */
+ public function UpdateRelationBlogUser(ModuleBlog_EntityBlogUser $oBlogUser)
+ {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array(
+ "blog_relation_change_{$oBlogUser->getUserId()}",
+ "blog_relation_change_blog_{$oBlogUser->getBlogId()}"
+ ));
+ $this->Cache_Delete("blog_relation_user_{$oBlogUser->getBlogId()}_{$oBlogUser->getUserId()}");
+ return $this->oMapperBlog->UpdateRelationBlogUser($oBlogUser);
+ }
+
+ /**
+ * Возвращает список блогов по фильтру
+ *
+ * @param array $aFilter Фильтр выборки блогов
+ * @param array $aOrder Сортировка блогов
+ * @param int $iCurrPage Номер текущей страницы
+ * @param int $iPerPage Количество элементов на одну страницу
+ * @param array $aAllowData Список типов данных, которые нужно подтянуть к списку блогов
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetBlogsByFilter($aFilter, $aOrder, $iCurrPage, $iPerPage, $aAllowData = null)
+ {
+ if (is_null($aAllowData)) {
+ $aAllowData = array('owner' => array(), 'relation_user');
+ }
+ $sKey = "blog_filter_" . serialize($aFilter) . serialize($aOrder) . "_{$iCurrPage}_{$iPerPage}";
+ if (false === ($data = $this->Cache_Get($sKey))) {
+ $data = array(
+ 'collection' => $this->oMapperBlog->GetBlogsByFilter($aFilter, $aOrder, $iCount, $iCurrPage, $iPerPage),
+ 'count' => $iCount
+ );
+ $this->Cache_Set($data, $sKey, array("blog_update", "blog_new"), 60 * 60 * 24 * 2);
+ }
+ $data['collection'] = $this->GetBlogsAdditionalData($data['collection'], $aAllowData);
+ return $data;
+ }
+
+ /**
+ * Получает список блогов по рейтингу
+ *
+ * @param int $iCurrPage Номер текущей страницы
+ * @param int $iPerPage Количество элементов на одну страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetBlogsRating($iCurrPage, $iPerPage)
+ {
+ return $this->GetBlogsByFilter(array('exclude_type' => 'personal'), array('blog_count_user' => 'desc'),
+ $iCurrPage,
+ $iPerPage);
+ }
+
+ /**
+ * Список подключенных блогов по рейтингу
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iLimit Ограничение на количество в ответе
+ * @return array
+ */
+ public function GetBlogsRatingJoin($sUserId, $iLimit)
+ {
+ if (false === ($data = $this->Cache_Get("blog_rating_join_{$sUserId}_{$iLimit}"))) {
+ $data = $this->oMapperBlog->GetBlogsRatingJoin($sUserId, $iLimit);
+ $this->Cache_Set($data, "blog_rating_join_{$sUserId}_{$iLimit}",
+ array('blog_update', "blog_relation_change_{$sUserId}"), 60 * 60 * 24);
+ }
+ return $data;
+ }
+
+ /**
+ * Список своих блогов по рейтингу
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iLimit Ограничение на количество в ответе
+ * @return array
+ */
+ public function GetBlogsRatingSelf($sUserId, $iLimit)
+ {
+ $aResult = $this->GetBlogsByFilter(array('exclude_type' => 'personal', 'user_owner_id' => $sUserId),
+ array('blog_count_user' => 'desc'), 1, $iLimit);
+ return $aResult['collection'];
+ }
+
+ /**
+ * Получает список блогов в которые может постить юзер
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @return array
+ */
+ public function GetBlogsAllowByUser($oUser)
+ {
+ if ($oUser->isAdministrator()) {
+ return $this->GetBlogs();
+ } else {
+ $aAllowBlogsUser = $this->GetBlogsByOwnerId($oUser->getId());
+ $aBlogUsers = $this->GetBlogUsersByUserId($oUser->getId());
+ foreach ($aBlogUsers as $oBlogUser) {
+ $oBlog = $oBlogUser->getBlog();
+ if ($oUser->getRating() >= $oBlog->getLimitRatingTopic() or $oBlogUser->getIsAdministrator() or $oBlogUser->getIsModerator()) {
+ $aAllowBlogsUser[$oBlog->getId()] = $oBlog;
+ }
+ }
+ return $aAllowBlogsUser;
+ }
+ }
+
+ /**
+ * Получаем массив блогов, которые являются открытыми для пользователя
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @return array
+ */
+ public function GetAccessibleBlogsByUser($oUser)
+ {
+ if ($oUser->isAdministrator()) {
+ return $this->GetBlogs(true);
+ }
+ if (false === ($aOpenBlogsUser = $this->Cache_Get("blog_accessible_user_{$oUser->getId()}"))) {
+ /**
+ * Заносим блоги, созданные пользователем
+ */
+ $aOpenBlogsUser = $this->GetBlogsByOwnerId($oUser->getId(), true);
+ /**
+ * Добавляем блоги, в которых состоит пользователь
+ * (читателем, модератором, или администратором)
+ */
+ $aOpenBlogsUser = array_merge($aOpenBlogsUser, $this->GetBlogUsersByUserId($oUser->getId(), null, true));
+ $this->Cache_Set($aOpenBlogsUser, "blog_accessible_user_{$oUser->getId()}",
+ array('blog_new', 'blog_update', "blog_relation_change_{$oUser->getId()}"), 60 * 60 * 24);
+ }
+ return $aOpenBlogsUser;
+ }
+
+ /**
+ * Получаем массив идентификаторов блогов, которые являются закрытыми для пользователя
+ *
+ * @param ModuleUser_EntityUser|null $oUser Пользователь
+ * @return array
+ */
+ public function GetInaccessibleBlogsByUser($oUser = null)
+ {
+ if ($oUser && $oUser->isAdministrator()) {
+ return array();
+ }
+ $sUserId = $oUser ? $oUser->getId() : 'quest';
+ if (false === ($aCloseBlogs = $this->Cache_Get("blog_inaccessible_user_{$sUserId}"))) {
+ $aCloseBlogs = $this->oMapperBlog->GetCloseBlogs();
+
+ if ($oUser) {
+ /**
+ * Получаем массив идентификаторов блогов,
+ * которые являются откытыми для данного пользователя
+ */
+ $aOpenBlogs = $this->GetBlogUsersByUserId($oUser->getId(), null, true);
+ /**
+ * Получаем закрытые блоги, где пользователь является автором
+ */
+ $aOwnerBlogs = $this->GetBlogsByFilter(array('type' => 'close', 'user_owner_id' => $oUser->getId()),
+ array(), 1, 100, array());
+ $aOwnerBlogs = array_keys($aOwnerBlogs['collection']);
+ $aCloseBlogs = array_diff($aCloseBlogs, $aOpenBlogs, $aOwnerBlogs);
+ }
+ /**
+ * Сохраняем в кеш
+ */
+ if ($oUser) {
+ $this->Cache_Set($aCloseBlogs, "blog_inaccessible_user_{$sUserId}",
+ array('blog_new', 'blog_update', "blog_relation_change_{$oUser->getId()}"), 60 * 60 * 24);
+ } else {
+ $this->Cache_Set($aCloseBlogs, "blog_inaccessible_user_{$sUserId}", array('blog_new', 'blog_update'),
+ 60 * 60 * 24 * 3);
+ }
+ }
+ return $aCloseBlogs;
+ }
+
+ /**
+ * Удаляет блог
+ *
+ * @param int $iBlogId ID блога
+ * @return bool
+ */
+ public function DeleteBlog($iBlogId)
+ {
+ if ($iBlogId instanceof ModuleBlog_EntityBlog) {
+ $oBlog = $iBlogId;
+ $iBlogId = $oBlog->getId();
+ } else {
+ $oBlog = $this->Blog_GetBlogById($iBlogId);
+ }
+ /**
+ * Получаем идентификаторы топиков блога. Удаляем топики блога.
+ * При удалении топиков удаляются комментарии к ним, голоса и т.п.
+ */
+ $iPage = 1;
+ while ($aTopicsRes = $this->Topic_GetTopicsByBlogId($iBlogId, $iPage, 100, array(),
+ false) and $aTopicsRes['collection']) {
+ /**
+ * Удаляем топики
+ */
+ foreach ($aTopicsRes['collection'] as $oTopic) {
+ $aBlogsCurrent = $oTopic->getBlogIds();
+ /**
+ * Удалять нужно только те топики, где текущий блог является единственным, у остальных просто удаляем связь
+ */
+ if (count($aBlogsCurrent) == 1) {
+ $this->Topic_DeleteTopic($oTopic);
+ } else {
+ array_splice($aBlogsCurrent, array_search($oBlog->getId(), $aBlogsCurrent), 1);
+ /**
+ * Устанавливаем новые связи с блогами
+ */
+ foreach ($aBlogsCurrent as $i => $iBlogCurrent) {
+ $sMethodSet = 'setBlogId' . ($i == 0 ? '' : ($i + 1));
+ call_user_func(array($oTopic, $sMethodSet), $iBlogCurrent);
+ }
+ for ($i = $i + 2; $i <= 10; $i++) {
+ $sMethodSet = 'setBlogId' . $i;
+ call_user_func(array($oTopic, $sMethodSet), null);
+ }
+ $this->Topic_UpdateTopic($oTopic);
+ }
+ }
+ $iPage++;
+ }
+ /**
+ * Если блог не удален, возвращаем false
+ */
+ if (!$this->oMapperBlog->DeleteBlog($iBlogId)) {
+ return false;
+ }
+ /**
+ * Чистим кеш
+ */
+ $this->Cache_Clean(
+ Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array(
+ "blog_update",
+ "blog_relation_change_blog_{$iBlogId}",
+ "topic_update",
+ "comment_online_update_topic",
+ "comment_update"
+ )
+ );
+ $this->Cache_Delete("blog_{$iBlogId}");
+ /**
+ * Удаляем аватар
+ */
+ $this->DeleteBlogAvatar($oBlog);
+ /**
+ * Удаляем связи пользователей блога.
+ */
+ $this->oMapperBlog->DeleteBlogUsersByBlogId($iBlogId);
+ /**
+ * Удаляем голосование за блог
+ */
+ $this->Vote_DeleteVoteByTarget($iBlogId, 'blog');
+ /**
+ * Удаляем медиа данные
+ */
+ $this->Media_RemoveTarget('blog', $iBlogId, true);
+ /**
+ * Обновляем категорию блога
+ */
+ $oBlog->category->CallbackAfterDelete();
+ return true;
+ }
+
+ /**
+ * Создает аватар пользователя на основе области из изображения
+ *
+ * @param $sFileFrom
+ * @param $oBlog
+ * @param $aSize
+ * @param null $iCanvasWidth
+ *
+ * @return bool
+ */
+ public function CreateAvatar($sFileFrom, $oBlog, $aSize = null, $iCanvasWidth = null)
+ {
+ $aParams = $this->Image_BuildParams('blog_avatar');
+ /**
+ * Если объект изображения не создан, возвращаем ошибку
+ */
+ if (!$oImage = $this->Image_OpenFrom($sFileFrom, $aParams)) {
+ return $this->Image_GetLastError();
+ }
+ /**
+ * Если нет области, то берем центральный квадрат
+ */
+ if (!$aSize) {
+ $oImage->cropSquare();
+ } else {
+ /**
+ * Вырезаем область из исходного файла
+ */
+ $oImage->cropFromSelected($aSize, $iCanvasWidth);
+ }
+ if ($sError = $this->Image_GetLastError()) {
+ return $sError;
+ }
+ /**
+ * Сохраняем во временный файл для дальнейшего ресайза
+ */
+ if (false === ($sFileTmp = $oImage->saveTmp())) {
+ return $this->Image_GetLastError();
+ }
+ $sPath = $this->Image_GetIdDir($oBlog->getId(), 'blogs');
+ /**
+ * Удаляем старый аватар
+ */
+ $this->DeleteBlogAvatar($oBlog);
+ /**
+ * Имя файла для сохранения
+ */
+ $sFileName = 'avatar-blog-' . $oBlog->getId();
+ /**
+ * Сохраняем оригинальный аватар
+ */
+ if (false === ($sFileResult = $oImage->saveSmart($sPath, $sFileName))) {
+ return $this->Image_GetLastError();
+ }
+ /**
+ * Генерируем варианты с необходимыми размерами
+ */
+ $this->Media_GenerateImageBySizes($sFileTmp, $sPath, $sFileName, Config::Get('module.blog.avatar_size'),
+ $aParams);
+ /**
+ * Теперь можно удалить временный файл
+ */
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ $oBlog->setAvatar($sFileResult);
+ $this->UpdateBlog($oBlog);
+ return true;
+ }
+
+ /**
+ * Удаляет аватар блога с сервера
+ *
+ * @param ModuleBlog_EntityBlog $oBlog Блог
+ */
+ public function DeleteBlogAvatar($oBlog)
+ {
+ /**
+ * Если аватар есть, удаляем его и его рейсайзы
+ */
+ if ($oBlog->getAvatar()) {
+ $this->Media_RemoveImageBySizes($oBlog->getAvatar(), Config::Get('module.blog.avatar_size'));
+ }
+ $oBlog->setAvatar(null);
+ }
+
+ /**
+ * Пересчет количества топиков в блогах
+ *
+ * @return bool
+ */
+ public function RecalculateCountTopic()
+ {
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('blog_update'));
+ return $this->oMapperBlog->RecalculateCountTopic();
+ }
+
+ /**
+ * Пересчет количества топиков в конкретном блоге
+ *
+ * @param int|array $aBlogIds Список ID блогов
+ * @return bool
+ */
+ public function RecalculateCountTopicByBlogId($aBlogIds)
+ {
+ if (!is_array($aBlogIds)) {
+ $aBlogIds = array($aBlogIds);
+ }
+ if ($aBlogIds) {
+ foreach ($aBlogIds as $iBlogId) {
+ //чистим зависимые кеши
+ $this->oMapperBlog->RecalculateCountTopic($iBlogId);
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("blog_update_{$iBlogId}"));
+ $this->Cache_Delete("blog_{$iBlogId}");
+ }
+ }
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('blog_update'));
+ return true;
+ }
+
+ /**
+ * Алиас для корректной работы ORM
+ *
+ * @param array $aBlogId Список ID блогов
+ * @return array
+ */
+ public function GetBlogItemsByArrayId($aBlogId)
+ {
+ return $this->GetBlogsByArrayId($aBlogId);
+ }
+
+ /**
+ * Отправляет пользователю сообщение о приглашение его в закрытый блог
+ *
+ * @param ModuleUser_EntityUser $oUserTo Объект пользователя, который отправляет приглашение
+ * @param ModuleUser_EntityUser $oUserFrom Объект пользователя, которого приглашаем
+ * @param ModuleBlog_EntityBlog $oBlog Объект блога
+ * @param $sPath
+ */
+ public function SendNotifyBlogUserInvite(
+ ModuleUser_EntityUser $oUserTo,
+ ModuleUser_EntityUser $oUserFrom,
+ ModuleBlog_EntityBlog $oBlog,
+ $sPath
+ ) {
+ $this->Notify_Send(
+ $oUserTo,
+ 'blog_invite_new.tpl',
+ $this->Lang_Get('emails.blog_invite_new.subject'),
+ array(
+ 'oUserTo' => $oUserTo,
+ 'oUserFrom' => $oUserFrom,
+ 'oBlog' => $oBlog,
+ 'sPath' => $sPath,
+ )
+ );
+ }
+
+ /**
+ * Регистрация сайтмапа для блогов
+ */
+ public function RegisterSitemap()
+ {
+ $aFilter = array(
+ 'type' => array(
+ 'open',
+ ),
+ );
+ $this->Sitemap_AddTargetType('blogs', array(
+ 'callback_data' => function ($iPage) use ($aFilter) {
+ $aBlogs = $this->GetBlogsByFilter($aFilter, array('blog_id' => 'asc'), $iPage, 500, array());
+ $aData = array();
+ foreach ($aBlogs['collection'] as $oBlog) {
+ $aData[] = $this->Sitemap_GetDataForSitemapRow(
+ $oBlog->getUrlFull(), null,
+ Config::Get('module.sitemap.blog.priority'),
+ Config::Get('module.sitemap.blog.changefreq')
+ );
+ }
+ return $aData;
+ },
+ 'callback_counters' => function () use ($aFilter) {
+ $aBlogs = $this->GetBlogsByFilter($aFilter, array(), 1, 1, array());
+ $iCount = (int)$aBlogs['count'];
+ return ceil($iCount / 500);
+ }
+ ));
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/blog/entity/Blog.entity.class.php b/application/classes/modules/blog/entity/Blog.entity.class.php
new file mode 100644
index 0000000..042cec4
--- /dev/null
+++ b/application/classes/modules/blog/entity/Blog.entity.class.php
@@ -0,0 +1,517 @@
+
+ *
+ */
+
+/**
+ * Сущность блога
+ *
+ * @package application.modules.blog
+ * @since 1.0
+ */
+class ModuleBlog_EntityBlog extends Entity
+{
+
+ protected $sPrimaryKey = 'blog_id';
+ /**
+ * Список поведений
+ *
+ * @var array
+ */
+ protected $aBehaviors = array(
+ // Категории
+ 'category' => array(
+ 'class' => 'ModuleCategory_BehaviorEntity',
+ 'target_type' => 'blog',
+ 'form_field' => 'category',
+ 'multiple' => false,
+ 'validate_require' => false,
+ 'validate_only_without_children' => true,
+ ),
+ );
+
+ /**
+ * Инициализация
+ */
+ public function Init()
+ {
+ parent::Init();
+ $this->aBehaviors['category']['validate_require'] = !Config::Get('module.blog.category_allow_empty');
+ $this->aBehaviors['category']['validate_only_without_children'] = Config::Get('module.blog.category_only_without_children');
+ }
+
+ /**
+ * Возвращает ID блога
+ *
+ * @return int|null
+ */
+ public function getId()
+ {
+ return $this->_getDataOne('blog_id');
+ }
+
+ /**
+ * Возвращает ID хозяина блога
+ *
+ * @return int|null
+ */
+ public function getOwnerId()
+ {
+ return $this->_getDataOne('user_owner_id');
+ }
+
+ /**
+ * Возвращает название блога
+ *
+ * @return string|null
+ */
+ public function getTitle()
+ {
+ return $this->_getDataOne('blog_title');
+ }
+
+ /**
+ * Возвращает описание блога
+ *
+ * @return string|null
+ */
+ public function getDescription()
+ {
+ return $this->_getDataOne('blog_description');
+ }
+
+ /**
+ * Возвращает тип блога
+ *
+ * @return string|null
+ */
+ public function getType()
+ {
+ return $this->_getDataOne('blog_type');
+ }
+
+ /**
+ * Возвращает дату создания блога
+ *
+ * @return string|null
+ */
+ public function getDateAdd()
+ {
+ return $this->_getDataOne('blog_date_add');
+ }
+
+ /**
+ * Возвращает дату редактирования блога
+ *
+ * @return string|null
+ */
+ public function getDateEdit()
+ {
+ return $this->_getDataOne('blog_date_edit');
+ }
+
+ /**
+ * Возврщает количество проголосовавших за блог
+ *
+ * @return int|null
+ */
+ public function getCountVote()
+ {
+ return $this->_getDataOne('blog_count_vote');
+ }
+
+ /**
+ * Возвращает количество пользователей в блоге
+ *
+ * @return int|null
+ */
+ public function getCountUser()
+ {
+ return $this->_getDataOne('blog_count_user');
+ }
+
+ /**
+ * Возвращает количество топиков в блоге
+ *
+ * @return int|null
+ */
+ public function getCountTopic()
+ {
+ return $this->_getDataOne('blog_count_topic');
+ }
+
+ /**
+ * Возвращает ограничение по рейтингу для постинга в блог
+ *
+ * @return int|null
+ */
+ public function getLimitRatingTopic()
+ {
+ return $this->_getDataOne('blog_limit_rating_topic');
+ }
+
+ /**
+ * Возвращает URL блога
+ *
+ * @return string|null
+ */
+ public function getUrl()
+ {
+ return $this->_getDataOne('blog_url');
+ }
+
+ /**
+ * Возвращает флаг пропуска топиков на главной
+ *
+ * @return int|null
+ */
+ public function getSkipIndex()
+ {
+ return $this->_getDataOne('blog_skip_index');
+ }
+
+ /**
+ * Возвращает полный серверный путь до аватара блога
+ *
+ * @return string|null
+ */
+ public function getAvatar()
+ {
+ return $this->_getDataOne('blog_avatar');
+ }
+
+ /**
+ * Возвращает расширения аватра блога
+ *
+ * @return string|null
+ */
+ public function getAvatarType()
+ {
+ return ($sPath = $this->getAvatarPath()) ? pathinfo($sPath, PATHINFO_EXTENSION) : null;
+ }
+
+
+ /**
+ * Возвращает объект пользователя хозяина блога
+ *
+ * @return ModuleUser_EntityUser|null
+ */
+ public function getOwner()
+ {
+ return $this->_getDataOne('owner');
+ }
+
+ /**
+ * Возвращает объект голосования за блог
+ *
+ * @return ModuleVote_EntityVote|null
+ */
+ public function getVote()
+ {
+ return $this->_getDataOne('vote');
+ }
+
+ /**
+ * Возвращает полный серверный путь до аватара блога определенного размера
+ *
+ * @param int $iSize Размер аватара
+ * @return string
+ */
+ public function getAvatarPath($iSize = 48)
+ {
+ if (is_numeric($iSize)) {
+ $iSize .= 'crop';
+ }
+ if ($sPath = $this->getAvatar()) {
+ return $this->Media_GetImageWebPath($sPath, $iSize);
+ } else {
+ return $this->Media_GetImagePathBySize(Router::GetFixPathWeb(Config::Get('path.skin.assets.web')) . '/images/avatars/avatar_blog.png', $iSize);
+ }
+ }
+
+ /**
+ * Возвращает путь до большого аватара блога
+ *
+ * @return string
+ */
+ public function getAvatarBig()
+ {
+ return $this->getAvatarPath(Config::Get('module.blog.avatar_size_big'));
+ }
+
+ /**
+ * Формирует массив с путями до аватаров
+ *
+ * @return array Массив с путями до аватаров
+ */
+ public function getAvatarsPath()
+ {
+ $aAvatars = array();
+
+ foreach (Config::Get('module.blog.avatar_size') as $sSize) {
+ $aAvatars[$sSize] = $this->getAvatarPath($sSize);
+ }
+
+ return $aAvatars;
+ }
+
+ /**
+ * Возвращает факт присоединения пользователя к блогу
+ *
+ * @return bool|null
+ */
+ public function getUserIsJoin()
+ {
+ return $this->_getDataOne('user_is_join');
+ }
+
+ /**
+ * Проверяет является ли пользователь администратором блога
+ *
+ * @return bool|null
+ */
+ public function getUserIsAdministrator()
+ {
+ return $this->_getDataOne('user_is_administrator');
+ }
+
+ /**
+ * Проверяет является ли пользователь модератором блога
+ *
+ * @return bool|null
+ */
+ public function getUserIsModerator()
+ {
+ return $this->_getDataOne('user_is_moderator');
+ }
+
+ /**
+ * Возвращает полный URL блога
+ *
+ * @return string
+ */
+ public function getUrlFull()
+ {
+ if ($this->getType() == 'personal') {
+ return $this->getOwner()->getUserWebPath() . 'created/topics/';
+ } else {
+ return Router::GetPath('blog') . $this->getUrl() . '/';
+ }
+ }
+
+ public function isAllowEdit()
+ {
+ if ($oUser = $this->User_GetUserCurrent()) {
+ if ($oUser->getId() == $this->getOwnerId() or $oUser->isAdministrator() or $this->getUserIsAdministrator()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Устанавливает ID блога
+ *
+ * @param int $data
+ */
+ public function setId($data)
+ {
+ $this->_aData['blog_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID хозяина блога
+ *
+ * @param int $data
+ */
+ public function setOwnerId($data)
+ {
+ $this->_aData['user_owner_id'] = $data;
+ }
+
+ /**
+ * Устанавливает заголовок блога
+ *
+ * @param string $data
+ */
+ public function setTitle($data)
+ {
+ $this->_aData['blog_title'] = $data;
+ }
+
+ /**
+ * Устанавливает описание блога
+ *
+ * @param string $data
+ */
+ public function setDescription($data)
+ {
+ $this->_aData['blog_description'] = $data;
+ }
+
+ /**
+ * Устанавливает тип блога
+ *
+ * @param string $data
+ */
+ public function setType($data)
+ {
+ $this->_aData['blog_type'] = $data;
+ }
+
+ /**
+ * Устанавливает дату создания блога
+ *
+ * @param string $data
+ */
+ public function setDateAdd($data)
+ {
+ $this->_aData['blog_date_add'] = $data;
+ }
+
+ /**
+ * Устанавливает дату редактирования топика
+ *
+ * @param string $data
+ */
+ public function setDateEdit($data)
+ {
+ $this->_aData['blog_date_edit'] = $data;
+ }
+
+ /**
+ * Устаналивает количество проголосовавших
+ *
+ * @param int $data
+ */
+ public function setCountVote($data)
+ {
+ $this->_aData['blog_count_vote'] = $data;
+ }
+
+ /**
+ * Устанавливает количество пользователей блога
+ *
+ * @param int $data
+ */
+ public function setCountUser($data)
+ {
+ $this->_aData['blog_count_user'] = $data;
+ }
+
+ /**
+ * Устанавливает количество топиков в блоге
+ *
+ * @param int $data
+ */
+ public function setCountTopic($data)
+ {
+ $this->_aData['blog_count_topic'] = $data;
+ }
+
+ /**
+ * Устанавливает ограничение на постинг в блог
+ *
+ * @param float $data
+ */
+ public function setLimitRatingTopic($data)
+ {
+ $this->_aData['blog_limit_rating_topic'] = $data;
+ }
+
+ /**
+ * Устанавливает URL блога
+ *
+ * @param string $data
+ */
+ public function setUrl($data)
+ {
+ $this->_aData['blog_url'] = $data;
+ }
+
+ /**
+ * Устанавливает флаг пропуска топиков на главной
+ *
+ * @param string $data
+ */
+ public function setSkipIndex($data)
+ {
+ $this->_aData['blog_skip_index'] = $data;
+ }
+
+ /**
+ * Устанавливает полный серверный путь до аватара блога
+ *
+ * @param string $data
+ */
+ public function setAvatar($data)
+ {
+ $this->_aData['blog_avatar'] = $data;
+ }
+
+ /**
+ * Устанавливает автора блога
+ *
+ * @param ModuleUser_EntityUser $data
+ */
+ public function setOwner($data)
+ {
+ $this->_aData['owner'] = $data;
+ }
+
+ /**
+ * Устанавливает статус администратора блога для текущего пользователя
+ *
+ * @param bool $data
+ */
+ public function setUserIsAdministrator($data)
+ {
+ $this->_aData['user_is_administrator'] = $data;
+ }
+
+ /**
+ * Устанавливает статус модератора блога для текущего пользователя
+ *
+ * @param bool $data
+ */
+ public function setUserIsModerator($data)
+ {
+ $this->_aData['user_is_moderator'] = $data;
+ }
+
+ /**
+ * Устаналивает статус присоединения польователя к блогу
+ *
+ * @param bool $data
+ */
+ public function setUserIsJoin($data)
+ {
+ $this->_aData['user_is_join'] = $data;
+ }
+
+ /**
+ * Устанавливает объект голосования за блог
+ *
+ * @param ModuleVote_EntityVote $data
+ */
+ public function setVote($data)
+ {
+ $this->_aData['vote'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/blog/entity/BlogUser.entity.class.php b/application/classes/modules/blog/entity/BlogUser.entity.class.php
new file mode 100644
index 0000000..26267e2
--- /dev/null
+++ b/application/classes/modules/blog/entity/BlogUser.entity.class.php
@@ -0,0 +1,190 @@
+
+ *
+ */
+
+/**
+ * Сущность связи пользователя и блога
+ *
+ * @package application.modules.blog
+ * @since 1.0
+ */
+class ModuleBlog_EntityBlogUser extends Entity
+{
+ /**
+ * Возвращает ID блога
+ *
+ * @return int|null
+ */
+ public function getBlogId()
+ {
+ return $this->_getDataOne('blog_id');
+ }
+
+ /**
+ * Возвращает ID пользователя
+ *
+ * @return int|null
+ */
+ public function getUserId()
+ {
+ return $this->_getDataOne('user_id');
+ }
+
+ /**
+ * Возвращает статус модератор пользователь или нет
+ *
+ * @return bool
+ */
+ public function getIsModerator()
+ {
+ return ($this->getUserRole() == ModuleBlog::BLOG_USER_ROLE_MODERATOR);
+ }
+
+ /**
+ * Возвращает статус администратор пользователь или нет
+ *
+ * @return bool
+ */
+ public function getIsAdministrator()
+ {
+ return ($this->getUserRole() == ModuleBlog::BLOG_USER_ROLE_ADMINISTRATOR);
+ }
+
+ /**
+ * Возвращает статус бана пользователя
+ *
+ * @return bool
+ */
+ public function getIsBanned()
+ {
+ return ($this->getUserRole() == ModuleBlog::BLOG_USER_ROLE_BAN);
+ }
+
+ /**
+ * Возвращает текущую роль пользователя в блоге
+ *
+ * @return int|null
+ */
+ public function getUserRole()
+ {
+ return $this->_getDataOne('user_role');
+ }
+
+ /**
+ * Возвращает объект блога
+ *
+ * @return ModuleBlog_EntityBlog|null
+ */
+ public function getBlog()
+ {
+ return $this->_getDataOne('blog');
+ }
+
+ /**
+ * Возвращает объект пользователя
+ *
+ * @return ModuleUser_EntityUser|null
+ */
+ public function getUser()
+ {
+ return $this->_getDataOne('user');
+ }
+
+
+ /**
+ * Устанавливает ID блога
+ *
+ * @param int $data
+ */
+ public function setBlogId($data)
+ {
+ $this->_aData['blog_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID пользователя
+ *
+ * @param int $data
+ */
+ public function setUserId($data)
+ {
+ $this->_aData['user_id'] = $data;
+ }
+
+ /**
+ * Устанавливает статус модератора блога
+ *
+ * @param bool $data
+ */
+ public function setIsModerator($data)
+ {
+ if ($data && !$this->getIsModerator()) {
+ /**
+ * Повышаем статус до модератора
+ */
+ $this->setUserRole(ModuleBlog::BLOG_USER_ROLE_MODERATOR);
+ }
+ }
+
+ /**
+ * Устанавливает статус администратора блога
+ *
+ * @param bool $data
+ */
+ public function setIsAdministrator($data)
+ {
+ if ($data && !$this->getIsAdministrator()) {
+ /**
+ * Повышаем статус до администратора
+ */
+ $this->setUserRole(ModuleBlog::BLOG_USER_ROLE_ADMINISTRATOR);
+ }
+ }
+
+ /**
+ * Устанавливает роль пользователя
+ *
+ * @param int $data
+ */
+ public function setUserRole($data)
+ {
+ $this->_aData['user_role'] = $data;
+ }
+
+ /**
+ * Устанавливает блог
+ *
+ * @param ModuleBlog_EntityBlog $data
+ */
+ public function setBlog($data)
+ {
+ $this->_aData['blog'] = $data;
+ }
+
+ /**
+ * Устанавливаем пользователя
+ *
+ * @param ModuleUser_EntityUser $data
+ */
+ public function setUser($data)
+ {
+ $this->_aData['user'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/blog/mapper/Blog.mapper.class.php b/application/classes/modules/blog/mapper/Blog.mapper.class.php
new file mode 100644
index 0000000..19f935f
--- /dev/null
+++ b/application/classes/modules/blog/mapper/Blog.mapper.class.php
@@ -0,0 +1,641 @@
+
+ *
+ */
+
+/**
+ * Маппер для работы с БД по части блогов
+ *
+ * @package application.modules.blog
+ * @since 1.0
+ */
+class ModuleBlog_MapperBlog extends Mapper
+{
+ /**
+ * Добавляет блог в БД
+ *
+ * @param ModuleBlog_EntityBlog $oBlog Объект блога
+ * @return int|bool
+ */
+ public function AddBlog(ModuleBlog_EntityBlog $oBlog)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.blog') . "
+ (user_owner_id,
+ blog_title,
+ blog_description,
+ blog_type,
+ blog_date_add,
+ blog_limit_rating_topic,
+ blog_url,
+ blog_skip_index,
+ blog_avatar
+ )
+ VALUES(?d, ?, ?, ?, ?, ?, ?, ?, ?)
+ ";
+ if ($iId = $this->oDb->query($sql, $oBlog->getOwnerId(), $oBlog->getTitle(), $oBlog->getDescription(),
+ $oBlog->getType(), $oBlog->getDateAdd(), $oBlog->getLimitRatingTopic(), $oBlog->getUrl(),
+ $oBlog->getSkipIndex(), $oBlog->getAvatar())
+ ) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет блог в БД
+ *
+ * @param ModuleBlog_EntityBlog $oBlog Объект блога
+ * @return bool
+ */
+ public function UpdateBlog(ModuleBlog_EntityBlog $oBlog)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.blog') . "
+ SET
+ blog_title= ?,
+ blog_description= ?,
+ blog_type= ?,
+ blog_date_edit= ?,
+ blog_count_vote = ?d,
+ blog_count_user= ?d,
+ blog_count_topic= ?d,
+ blog_limit_rating_topic= ?f ,
+ blog_url= ?,
+ blog_skip_index= ?d,
+ blog_avatar= ?
+ WHERE
+ blog_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oBlog->getTitle(), $oBlog->getDescription(), $oBlog->getType(),
+ $oBlog->getDateEdit(), $oBlog->getCountVote(), $oBlog->getCountUser(),
+ $oBlog->getCountTopic(), $oBlog->getLimitRatingTopic(), $oBlog->getUrl(), $oBlog->getSkipIndex(), $oBlog->getAvatar(),
+ $oBlog->getId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получает список блогов по ID
+ *
+ * @param array $aArrayId Список ID блогов
+ * @param array|null $aOrder Сортировка блогов
+ * @return array
+ */
+ public function GetBlogsByArrayId($aArrayId, $aOrder = null)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+
+ if (!is_array($aOrder)) {
+ $aOrder = array($aOrder);
+ }
+ $sOrder = '';
+ foreach ($aOrder as $key => $value) {
+ $value = (string)$value;
+ if (!in_array($key,
+ array('blog_id', 'blog_title', 'blog_type', 'blog_count_user', 'blog_date_add'))
+ ) {
+ unset($aOrder[$key]);
+ } elseif (in_array($value, array('asc', 'desc'))) {
+ $sOrder .= " {$key} {$value},";
+ }
+ }
+ $sOrder = trim($sOrder, ',');
+
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.blog') . "
+ WHERE
+ blog_id IN(?a)
+ ORDER BY
+ { FIELD(blog_id,?a) } ";
+ if ($sOrder != '') {
+ $sql .= $sOrder;
+ }
+
+ $aBlogs = array();
+ if ($aRows = $this->oDb->select($sql, $aArrayId, $sOrder == '' ? $aArrayId : DBSIMPLE_SKIP)) {
+ foreach ($aRows as $aBlog) {
+ $aBlogs[] = Engine::GetEntity('Blog', $aBlog);
+ }
+ }
+ return $aBlogs;
+ }
+
+ /**
+ * Добавляет связь пользователя с блогом в БД
+ *
+ * @param ModuleBlog_EntityBlogUser $oBlogUser Объект отношения пользователя с блогом
+ * @return bool
+ */
+ public function AddRelationBlogUser(ModuleBlog_EntityBlogUser $oBlogUser)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.blog_user') . "
+ (blog_id,
+ user_id,
+ user_role
+ )
+ VALUES(?d, ?d, ?d)
+ ";
+ if ($this->oDb->query($sql, $oBlogUser->getBlogId(), $oBlogUser->getUserId(), $oBlogUser->getUserRole()) === 0
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Удаляет отношение пользователя с блогом
+ *
+ * @param ModuleBlog_EntityBlogUser $oBlogUser Объект отношения пользователя с блогом
+ * @return bool
+ */
+ public function DeleteRelationBlogUser(ModuleBlog_EntityBlogUser $oBlogUser)
+ {
+ $sql = "DELETE FROM " . Config::Get('db.table.blog_user') . "
+ WHERE
+ blog_id = ?d
+ AND
+ user_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oBlogUser->getBlogId(), $oBlogUser->getUserId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Обновляет отношение пользователя с блогом
+ *
+ * @param ModuleBlog_EntityBlogUser $oBlogUser Объект отношения пользователя с блогом
+ * @return bool
+ */
+ public function UpdateRelationBlogUser(ModuleBlog_EntityBlogUser $oBlogUser)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.blog_user') . "
+ SET
+ user_role = ?d
+ WHERE
+ blog_id = ?d
+ AND
+ user_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oBlogUser->getUserRole(), $oBlogUser->getBlogId(), $oBlogUser->getUserId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получает список отношений пользователей с блогами
+ *
+ * @param array $aFilter Фильтр поиска отношений
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iCurrPage Номер текущейс страницы
+ * @param int $iPerPage Количество элементов на одну страницу
+ * @return array
+ */
+ public function GetBlogUsers($aFilter, &$iCount = null, $iCurrPage = null, $iPerPage = null)
+ {
+
+ if (isset($aFilter['blog_id']) and !is_array($aFilter['blog_id'])) {
+ $aFilter['blog_id']=array($aFilter['blog_id']);
+ }
+ if (isset($aFilter['user_role']) and !is_array($aFilter['user_role'])) {
+ $aFilter['user_role']=array($aFilter['user_role']);
+ }
+
+ if (is_null($iCurrPage)) {
+ $iCurrPage=1;
+ }
+ if (is_null($iPerPage)) {
+ $iPerPage=1000;
+ }
+
+ $sql = "SELECT
+ bu.*
+ FROM
+ " . Config::Get('db.table.blog_user') . " as bu
+ WHERE
+ 1=1
+ { AND bu.blog_id IN (?a) }
+ { AND bu.user_id = ?d }
+ { AND bu.user_role IN (?a) }
+ { AND bu.user_role > ?d }
+
+ LIMIT ?d, ?d
+ ";
+
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql,
+ (isset($aFilter['blog_id']) and count($aFilter['blog_id'])) ? $aFilter['blog_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['user_id']) ? $aFilter['user_id'] : DBSIMPLE_SKIP,
+ (isset($aFilter['user_role']) and count($aFilter['user_role'])) ? $aFilter['user_role'] : DBSIMPLE_SKIP,
+ !isset($aFilter['user_role']) ? ModuleBlog::BLOG_USER_ROLE_GUEST : DBSIMPLE_SKIP,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('Blog_BlogUser', $aRow);
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Получает список отношений пользователя к блогам
+ *
+ * @param array $aArrayId Список ID блогов
+ * @param int $sUserId ID блогов
+ * @return array
+ */
+ public function GetBlogUsersByArrayBlog($aArrayId, $sUserId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+
+ $sql = "SELECT
+ bu.*
+ FROM
+ " . Config::Get('db.table.blog_user') . " as bu
+ WHERE
+ bu.blog_id IN(?a)
+ AND
+ bu.user_id = ?d ";
+ $aBlogUsers = array();
+ if ($aRows = $this->oDb->select($sql, $aArrayId, $sUserId)) {
+ foreach ($aRows as $aUser) {
+ $aBlogUsers[] = Engine::GetEntity('Blog_BlogUser', $aUser);
+ }
+ }
+ return $aBlogUsers;
+ }
+
+ /**
+ * Получает ID персонального блога пользователя
+ *
+ * @param int $sUserId ID пользователя
+ * @return int|null
+ */
+ public function GetPersonalBlogByUserId($sUserId)
+ {
+ $sql = "SELECT blog_id FROM " . Config::Get('db.table.blog') . " WHERE user_owner_id = ?d and blog_type='personal'";
+ if ($aRow = $this->oDb->selectRow($sql, $sUserId)) {
+ return $aRow['blog_id'];
+ }
+ return null;
+ }
+
+ /**
+ * Получает блог по названию
+ *
+ * @param string $sTitle Нащвание блога
+ * @return ModuleBlog_EntityBlog|null
+ */
+ public function GetBlogByTitle($sTitle)
+ {
+ $sql = "SELECT blog_id FROM " . Config::Get('db.table.blog') . " WHERE blog_title = ? ";
+ if ($aRow = $this->oDb->selectRow($sql, $sTitle)) {
+ return $aRow['blog_id'];
+ }
+ return null;
+ }
+
+ /**
+ * Получает блог по URL
+ *
+ * @param string $sUrl URL блога
+ * @return ModuleBlog_EntityBlog|null
+ */
+ public function GetBlogByUrl($sUrl)
+ {
+ $sql = "SELECT
+ b.blog_id
+ FROM
+ " . Config::Get('db.table.blog') . " as b
+ WHERE
+ b.blog_url = ?
+ ";
+ if ($aRow = $this->oDb->selectRow($sql, $sUrl)) {
+ return $aRow['blog_id'];
+ }
+ return null;
+ }
+
+ /**
+ * Получить список блогов по хозяину
+ *
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetBlogsByOwnerId($sUserId)
+ {
+ $sql = "SELECT
+ b.blog_id
+ FROM
+ " . Config::Get('db.table.blog') . " as b
+ WHERE
+ b.user_owner_id = ?
+ AND
+ b.blog_type<>'personal'
+ ";
+ $aBlogs = array();
+ if ($aRows = $this->oDb->select($sql, $sUserId)) {
+ foreach ($aRows as $aBlog) {
+ $aBlogs[] = $aBlog['blog_id'];
+ }
+ }
+ return $aBlogs;
+ }
+
+ /**
+ * Возвращает список всех не персональных блогов
+ *
+ * @return array
+ */
+ public function GetBlogs()
+ {
+ $sql = "SELECT
+ b.blog_id
+ FROM
+ " . Config::Get('db.table.blog') . " as b
+ WHERE
+ b.blog_type<>'personal'
+ ";
+ $aBlogs = array();
+ if ($aRows = $this->oDb->select($sql)) {
+ foreach ($aRows as $aBlog) {
+ $aBlogs[] = $aBlog['blog_id'];
+ }
+ }
+ return $aBlogs;
+ }
+
+ /**
+ * Возвращает список не персональных блогов с сортировкой по рейтингу
+ *
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iCurrPage Номер текущей страницы
+ * @param int $iPerPage Количество элементов на одну страницу
+ * @return array
+ */
+ public function GetBlogsRating(&$iCount, $iCurrPage, $iPerPage)
+ {
+ $sql = "SELECT
+ b.blog_id
+ FROM
+ " . Config::Get('db.table.blog') . " as b
+ WHERE
+ b.blog_type<>'personal'
+ ORDER by b.blog_count_user desc
+ LIMIT ?d, ?d ";
+ $aReturn = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql, ($iCurrPage - 1) * $iPerPage, $iPerPage)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = $aRow['blog_id'];
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Получает список блогов в которых состоит пользователь
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iLimit Ограничение на выборку элементов
+ * @return array
+ */
+ public function GetBlogsRatingJoin($sUserId, $iLimit)
+ {
+ $sql = "SELECT
+ b.*
+ FROM
+ " . Config::Get('db.table.blog_user') . " as bu,
+ " . Config::Get('db.table.blog') . " as b
+ WHERE
+ bu.user_id = ?d
+ AND
+ bu.blog_id = b.blog_id
+ AND
+ b.blog_type<>'personal'
+ ORDER by b.blog_count_user desc
+ LIMIT 0, ?d
+ ;
+ ";
+ $aReturn = array();
+ if ($aRows = $this->oDb->select($sql, $sUserId, $iLimit)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = Engine::GetEntity('Blog', $aRow);
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Получает список блогов, которые создал пользователь
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iLimit Ограничение на выборку элементов
+ * @return array
+ */
+ public function GetBlogsRatingSelf($sUserId, $iLimit)
+ {
+ $sql = "SELECT
+ b.*
+ FROM
+ " . Config::Get('db.table.blog') . " as b
+ WHERE
+ b.user_owner_id = ?d
+ AND
+ b.blog_type<>'personal'
+ ORDER by b.blog_count_user desc
+ LIMIT 0, ?d
+ ;";
+ $aReturn = array();
+ if ($aRows = $this->oDb->select($sql, $sUserId, $iLimit)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = Engine::GetEntity('Blog', $aRow);
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Возвращает полный список закрытых блогов
+ *
+ * @return array
+ */
+ public function GetCloseBlogs()
+ {
+ $sql = "SELECT b.blog_id
+ FROM " . Config::Get('db.table.blog') . " as b
+ WHERE b.blog_type='close'
+ ;";
+ $aReturn = array();
+ if ($aRows = $this->oDb->select($sql)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = $aRow['blog_id'];
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Удаление блога из базы данных
+ *
+ * @param int $iBlogId ID блога
+ * @return bool
+ */
+ public function DeleteBlog($iBlogId)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.blog') . "
+ WHERE blog_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $iBlogId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Удалить пользователей блога по идентификатору блога
+ *
+ * @param int $iBlogId ID блога
+ * @return bool
+ */
+ public function DeleteBlogUsersByBlogId($iBlogId)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.blog_user') . "
+ WHERE blog_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $iBlogId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Пересчитывает число топиков в блогах
+ *
+ * @param int|null $iBlogId ID блога
+ * @return bool
+ */
+ public function RecalculateCountTopic($iBlogId = null)
+ {
+ $sql = "
+ UPDATE " . Config::Get('db.table.blog') . " b
+ SET b.blog_count_topic = (
+ SELECT count(*)
+ FROM " . Config::Get('db.table.topic') . " t
+ WHERE
+ (
+ t.blog_id = b.blog_id OR
+ t.blog_id2 = b.blog_id OR
+ t.blog_id3 = b.blog_id OR
+ t.blog_id4 = b.blog_id OR
+ t.blog_id5 = b.blog_id
+ )
+ AND
+ t.topic_publish = 1
+ )
+ WHERE 1=1
+ { and b.blog_id = ?d }
+ ";
+ $res = $this->oDb->query($sql, is_null($iBlogId) ? DBSIMPLE_SKIP : $iBlogId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получает список блогов по фильтру
+ *
+ * @param array $aFilter Фильтр выборки
+ * @param array $aOrder Сортировка
+ * @param int $iCount Возвращает общее количество элментов
+ * @param int $iCurrPage Номер текущей страницы
+ * @param int $iPerPage Количество элементов на одну страницу
+ * @return array
+ */
+ public function GetBlogsByFilter($aFilter, $aOrder, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $aOrderAllow = array('blog_id', 'blog_title', 'blog_count_user', 'blog_count_topic');
+ $sOrder = '';
+ foreach ($aOrder as $key => $value) {
+ if (!in_array($key, $aOrderAllow)) {
+ unset($aOrder[$key]);
+ } elseif (in_array($value, array('asc', 'desc'))) {
+ $sOrder .= " b.{$key} {$value},";
+ }
+ }
+ $sOrder = trim($sOrder, ',');
+ if ($sOrder == '') {
+ $sOrder = ' b.blog_id desc ';
+ }
+
+ if (isset($aFilter['exclude_type']) and !is_array($aFilter['exclude_type'])) {
+ $aFilter['exclude_type'] = array($aFilter['exclude_type']);
+ }
+ if (isset($aFilter['type']) and !is_array($aFilter['type'])) {
+ $aFilter['type'] = array($aFilter['type']);
+ }
+ if (isset($aFilter['id']) and !is_array($aFilter['id'])) {
+ $aFilter['id'] = array($aFilter['id']);
+ }
+
+ $iUserCurrentId=0;
+ if (isset($aFilter['roles_user_id'])) {
+ $iUserCurrentId=$aFilter['roles_user_id'];
+ } elseif ($oUserCurrent=$this->User_GetUserCurrent()) {
+ $iUserCurrentId=$oUserCurrent->getId();
+ }
+
+ $sql = "SELECT
+ b.blog_id
+ FROM
+ " . Config::Get('db.table.blog') . " as b
+ {
+ JOIN " . Config::Get('db.table.blog_user') . " as bu
+ ON ( bu.blog_id = b.blog_id and bu.user_id = '{$iUserCurrentId}'
+ and bu.user_role in (?a)
+ )
+ }
+ WHERE
+ 1 = 1
+ { AND b.blog_id IN (?a) }
+ { AND b.user_owner_id = ?d }
+ { AND b.blog_type IN (?a) }
+ { AND b.blog_type not IN (?a) }
+ { AND b.blog_url = ? }
+ { AND b.blog_title LIKE ? }
+ ORDER by {$sOrder}
+ LIMIT ?d, ?d ;
+ ";
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql,
+ (isset($aFilter['roles']) and count($aFilter['roles'])) ? $aFilter['roles'] : DBSIMPLE_SKIP,
+ (isset($aFilter['id']) and count($aFilter['id'])) ? $aFilter['id'] : DBSIMPLE_SKIP,
+ isset($aFilter['user_owner_id']) ? $aFilter['user_owner_id'] : DBSIMPLE_SKIP,
+ (isset($aFilter['type']) and count($aFilter['type'])) ? $aFilter['type'] : DBSIMPLE_SKIP,
+ (isset($aFilter['exclude_type']) and count($aFilter['exclude_type'])) ? $aFilter['exclude_type'] : DBSIMPLE_SKIP,
+ isset($aFilter['url']) ? $aFilter['url'] : DBSIMPLE_SKIP,
+ isset($aFilter['title']) ? $aFilter['title'] : DBSIMPLE_SKIP,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = $aRow['blog_id'];
+ }
+ }
+ return $aResult;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/category/Category.class.php b/application/classes/modules/category/Category.class.php
new file mode 100644
index 0000000..7e5d950
--- /dev/null
+++ b/application/classes/modules/category/Category.class.php
@@ -0,0 +1,577 @@
+
+ *
+ */
+
+/**
+ * Модуль управления универсальными категориями
+ *
+ * @package application.modules.category
+ * @since 2.0
+ */
+class ModuleCategory extends ModuleORM
+{
+ /**
+ * Список состояний типов объектов
+ */
+ const TARGET_STATE_ACTIVE = 1;
+ const TARGET_STATE_NOT_ACTIVE = 2;
+ const TARGET_STATE_REMOVE = 3;
+
+ /**
+ * Возвращает список категорий сущности
+ *
+ * @param $oTarget
+ * @param $sTargetType
+ *
+ * @return array
+ */
+ public function GetEntityCategories($oTarget, $sTargetType)
+ {
+ $aCategories = $oTarget->_getDataOne('_categories');
+ if (is_null($aCategories)) {
+ $this->AttachCategoriesForTargetItems($oTarget, $sTargetType);
+ return $oTarget->_getDataOne('_categories');
+ }
+ return $aCategories;
+ }
+
+ /**
+ * Обработка фильтра ORM запросов
+ *
+ * @param array $aFilter
+ * @param array $sEntityFull
+ * @param string $sTargetType
+ *
+ * @return array
+ */
+ public function RewriteFilter($aFilter, $sEntityFull, $sTargetType)
+ {
+ $oEntitySample = Engine::GetEntity($sEntityFull);
+
+ if (!isset($aFilter['#join'])) {
+ $aFilter['#join'] = array();
+ }
+
+ if (!isset($aFilter['#select'])) {
+ $aFilter['#select'] = array();
+ }
+
+ if (array_key_exists('#category', $aFilter)) {
+ $aCategoryId = $aFilter['#category'];
+ if (!is_array($aCategoryId)) {
+ $aCategoryId = array($aCategoryId);
+ }
+ $sJoin = "JOIN " . Config::Get('db.table.category_target') . " category ON
+ t.`{$oEntitySample->_getPrimaryKey()}` = category.target_id and
+ category.target_type = '{$sTargetType}' and
+ category.category_id IN ( ?a ) ";
+ $aFilter['#join'][$sJoin] = array($aCategoryId);
+ if (count($aFilter['#select'])) {
+ $aFilter['#select'][] = "distinct t.`{$oEntitySample->_getPrimaryKey()}`";
+ } else {
+ $aFilter['#select'][] = "distinct t.`{$oEntitySample->_getPrimaryKey()}`";
+ $aFilter['#select'][] = 't.*';
+ }
+ }
+ return $aFilter;
+ }
+
+ /**
+ * Переопределяем метод для возможности цеплять свои кастомные данные при ORM запросах - свойства
+ *
+ * @param array $aResult
+ * @param array $aFilter
+ * @param string $sTargetType
+ */
+ public function RewriteGetItemsByFilter($aResult, $aFilter, $sTargetType)
+ {
+ if (!$aResult) {
+ return;
+ }
+ /**
+ * Список на входе может быть двух видов:
+ * 1 - одномерный массив
+ * 2 - двумерный, если применялась группировка (использование '#index-group')
+ *
+ * Поэтому сначала сформируем линейный список
+ */
+ if (isset($aFilter['#index-group']) and $aFilter['#index-group']) {
+ $aEntitiesWork = array();
+ foreach ($aResult as $aItems) {
+ foreach ($aItems as $oItem) {
+ $aEntitiesWork[] = $oItem;
+ }
+ }
+ } else {
+ $aEntitiesWork = $aResult;
+ }
+
+ if (!$aEntitiesWork) {
+ return;
+ }
+ /**
+ * Проверяем необходимость цеплять категории
+ */
+ if (isset($aFilter['#with']['#category'])) {
+ $this->AttachCategoriesForTargetItems($aEntitiesWork, $sTargetType);
+ }
+ }
+
+ /**
+ * Цепляет для списка объектов категории
+ *
+ * @param array $aEntityItems
+ * @param string $sTargetType
+ */
+ public function AttachCategoriesForTargetItems($aEntityItems, $sTargetType)
+ {
+ if (!is_array($aEntityItems)) {
+ $aEntityItems = array($aEntityItems);
+ }
+ $aEntitiesId = array();
+ foreach ($aEntityItems as $oEntity) {
+ $aEntitiesId[] = $oEntity->getId();
+ }
+ /**
+ * Получаем категории для всех объектов
+ */
+ $sEntityCategory = $this->_NormalizeEntityRootName('Category');
+ $sEntityTarget = $this->_NormalizeEntityRootName('Target');
+ $aCategories = $this->GetCategoryItemsByFilter(array(
+ '#join' => array(
+ "JOIN " . Config::Get('db.table.category_target') . " category_target ON
+ t.id = category_target.category_id and
+ category_target.target_type = '{$sTargetType}' and
+ category_target.target_id IN ( ?a )
+ " => array($aEntitiesId)
+ ),
+ '#select' => array(
+ 't.*',
+ 'category_target.target_id'
+ ),
+ '#index-group' => 'target_id',
+ '#cache' => array(
+ null,
+ array(
+ $sEntityCategory . '_save',
+ $sEntityCategory . '_delete',
+ $sEntityTarget . '_save',
+ $sEntityTarget . '_delete'
+ )
+ )
+ ));
+ /**
+ * Собираем данные
+ */
+ foreach ($aEntityItems as $oEntity) {
+ if (isset($aCategories[$oEntity->_getPrimaryKeyValue()])) {
+ $oEntity->_setData(array('_categories' => $aCategories[$oEntity->_getPrimaryKeyValue()]));
+ } else {
+ $oEntity->_setData(array('_categories' => array()));
+ }
+ }
+ }
+
+ /**
+ * Возвращает дерево категорий
+ *
+ * @param int $sId Type ID
+ *
+ * @return array
+ */
+ public function GetCategoriesTreeByType($sId)
+ {
+ $aCategories = $this->LoadTreeOfCategory(array('type_id' => $sId));
+ return ModuleORM::buildTree($aCategories);
+ }
+
+ /**
+ * Возвращает дерево категорий
+ *
+ * @param string $sCode Type code
+ *
+ * @return array
+ */
+ public function GetCategoriesTreeByTargetType($sCode)
+ {
+ if ($oType = $this->GetTypeByTargetType($sCode)) {
+ return $this->GetCategoriesTreeByType($oType->getId());
+ }
+ return array();
+ }
+
+ /**
+ * Валидирует список категория
+ *
+ * @param array $aCategoryId
+ * @param int $iType
+ * @param bool $bReturnObjects
+ *
+ * @return array|bool
+ */
+ public function ValidateCategoryArray($aCategoryId, $iType, $bReturnObjects = false)
+ {
+ if (!is_array($aCategoryId)) {
+ return false;
+ }
+ $aIds = array();
+ foreach ($aCategoryId as $iId) {
+ $aIds[] = (int)$iId;
+ }
+ if ($aIds and $aCategories = $this->GetCategoryItemsByFilter(array(
+ 'id in' => $aIds,
+ 'type_id' => $iType,
+ '#index-from-primary'
+ ))
+ ) {
+ if ($bReturnObjects) {
+ return $aCategories;
+ } else {
+ return array_keys($aCategories);
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Сохраняет категории для объекта
+ *
+ * @param $oTarget
+ * @param $sTargetType
+ * @param $mCallbackCountTarget
+ */
+ public function SaveCategories($oTarget, $sTargetType, $mCallbackCountTarget = null)
+ {
+ $aCategoriesId = $oTarget->_getDataOne('_categories_for_save');
+ if (!is_array($aCategoriesId)) {
+ return;
+ }
+ /**
+ * Удаляем текущие связи
+ */
+ $aCategoryIdChanged = $this->RemoveRelation($oTarget->_getPrimaryKeyValue(), $sTargetType);
+ /**
+ * Создаем
+ */
+ $this->CreateRelation($aCategoriesId, $oTarget->_getPrimaryKeyValue(), $sTargetType);
+ /**
+ * Полный список категорий, которые затронули изменения
+ */
+ $aCategoryIdChanged = array_merge($aCategoryIdChanged, $aCategoriesId);
+ /**
+ * Подсчитываем количество новое элементов для каждой категории
+ */
+ $this->UpdateCountTarget($aCategoryIdChanged, $sTargetType, $mCallbackCountTarget);
+
+ $oTarget->_setData(array('_categories_for_save' => null));
+ }
+
+ /**
+ * Обновляет количество элементов у категорий (поле count_target в таблице категорий)
+ *
+ * @param $aCategoryId
+ * @param $sTargetType
+ * @param null $mCallback
+ */
+ protected function UpdateCountTarget($aCategoryId, $sTargetType, $mCallback = null)
+ {
+ if (!is_array($aCategoryId)) {
+ $aCategoryId = array($aCategoryId);
+ }
+ if (!count($aCategoryId)) {
+ return;
+ }
+ $aCategories = $this->GetCategoryItemsByArrayId($aCategoryId);
+ foreach ($aCategories as $oCategory) {
+ if ($mCallback) {
+ if (is_string($mCallback)) {
+ $mCallback = array($this, $mCallback);
+ }
+ $iCount = call_user_func_array($mCallback, array($oCategory, $sTargetType));
+ } else {
+ $iCount = $this->GetCountItemsByFilter(array('category_id' => $oCategory->getId()),
+ 'ModuleCategory_EntityTarget');
+ }
+ $oCategory->setCountTarget($iCount);
+ $oCategory->Update();
+ }
+ }
+
+ /**
+ * Удаляет категории у объекта
+ *
+ * @param $oTarget
+ * @param $sTargetType
+ * @param $mCallbackCountTarget
+ */
+ public function RemoveCategories($oTarget, $sTargetType, $mCallbackCountTarget = null)
+ {
+ $aCategoryIdChanged = $this->RemoveRelation($oTarget->_getPrimaryKeyValue(), $sTargetType);
+ /**
+ * Подсчитываем количество новое элементов для каждой категории
+ */
+ $this->UpdateCountTarget($aCategoryIdChanged, $sTargetType, $mCallbackCountTarget);
+ }
+
+ /**
+ * Создает новую связь конкретного объекта с категориями
+ *
+ * @param array $aCategoryId
+ * @param int $iTargetId
+ * @param int|string $iType type_id или target_type
+ *
+ * @return bool
+ */
+ public function CreateRelation($aCategoryId, $iTargetId, $iType)
+ {
+ if (!$aCategoryId or (is_array($aCategoryId) and !count($aCategoryId))) {
+ return false;
+ }
+ if (!is_array($aCategoryId)) {
+ $aCategoryId = array($aCategoryId);
+ }
+ if (is_numeric($iType)) {
+ $oType = $this->GetTypeById($iType);
+ } else {
+ $oType = $this->GetTypeByTargetType($iType);
+ }
+ if (!$oType) {
+ return false;
+ }
+ foreach ($aCategoryId as $iCategoryId) {
+ if (!$this->GetTargetByCategoryIdAndTargetIdAndTypeId($iCategoryId, $iTargetId, $oType->getId())) {
+ $oTarget = Engine::GetEntity('ModuleCategory_EntityTarget');
+ $oTarget->setCategoryId($iCategoryId);
+ $oTarget->setTargetId($iTargetId);
+ $oTarget->setTargetType($oType->getTargetType());
+ $oTarget->setTypeId($oType->getId());
+ $oTarget->Add();
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Удаляет связь конкретного объекта с категориями
+ *
+ * @param int $iTargetId
+ * @param int|string $iType type_id или target_type
+ *
+ * @return bool|array
+ */
+ public function RemoveRelation($iTargetId, $iType)
+ {
+ if (!is_numeric($iType)) {
+ if ($oType = $this->GetTypeByTargetType($iType)) {
+ $iType = $oType->getId();
+ } else {
+ return false;
+ }
+ }
+ $aRemovedCategory = array();
+ $aTargets = $this->GetTargetItemsByTargetIdAndTypeId($iTargetId, $iType);
+ foreach ($aTargets as $oTarget) {
+ $oTarget->Delete();
+ $aRemovedCategory[] = $oTarget->getCategoryId();
+ }
+ return $aRemovedCategory;
+ }
+
+ /**
+ * Возвращает список категорий по категории
+ *
+ * @param $oCategory
+ * @param bool $bIncludeChild Возвращать все дочернии категории
+ *
+ * @return array|null
+ */
+ public function GetCategoriesIdByCategory($oCategory, $bIncludeChild = false)
+ {
+ if (is_object($oCategory)) {
+ $iCategoryId = $oCategory->getId();
+ } else {
+ $iCategoryId = $oCategory;
+ }
+ $aCategoryId = array($iCategoryId);
+ if ($bIncludeChild) {
+ /**
+ * Сначала получаем полный список категорий текущего типа
+ */
+ if (!is_object($oCategory)) {
+ $oCategory = $this->GetCategoryById($iCategoryId);
+ }
+ if ($oCategory) {
+ if ($aChildren = $oCategory->getDescendants()) {
+ foreach ($aChildren as $oCategoryChild) {
+ $aCategoryId[] = $oCategoryChild->getId();
+ }
+ }
+ }
+ }
+ return $aCategoryId;
+ }
+
+ /**
+ * Пересобирает полные URL дочерних категорий
+ *
+ * @param $oCategoryStart
+ * @param bool $bStart
+ */
+ public function RebuildCategoryUrlFull($oCategoryStart, $bStart = true)
+ {
+ static $aRebuildIds;
+ if ($bStart) {
+ $aRebuildIds = array();
+ }
+
+ if (is_null($oCategoryStart->getId())) {
+ $aCategories = $this->GetCategoryItemsByFilter(array(
+ '#where' => array('pid is null' => array()),
+ 'type_id' => $oCategoryStart->getTypeId()
+ ));
+ } else {
+ $aCategories = $this->GetCategoryItemsByFilter(array(
+ 'pid' => $oCategoryStart->getId(),
+ 'type_id' => $oCategoryStart->getTypeId()
+ ));
+ }
+
+ foreach ($aCategories as $oCategory) {
+ if ($oCategory->getId() == $oCategoryStart->getId()) {
+ continue;
+ }
+ if (in_array($oCategory->getId(), $aRebuildIds)) {
+ continue;
+ }
+ $aRebuildIds[] = $oCategory->getId();
+ $oCategory->setUrlFull($oCategoryStart->getUrlFull() . '/' . $oCategory->getUrl());
+ $oCategory->Update();
+ $this->RebuildCategoryUrlFull($oCategory, false);
+ }
+ }
+
+ /**
+ * Возвращает список ID таргетов по списку категорий
+ *
+ * @param $aCategoryId
+ * @param $sTargetType
+ * @param $iPage
+ * @param $iPerPage
+ *
+ * @return array
+ */
+ public function GetTargetIdsByCategoriesId($aCategoryId, $sTargetType, $iPage, $iPerPage)
+ {
+ if (!is_array($aCategoryId)) {
+ $aCategoryId = array($aCategoryId);
+ }
+ if (!count($aCategoryId)) {
+ return array();
+ }
+ $aTargetItems = $this->GetTargetItemsByFilter(array(
+ 'category_id in' => $aCategoryId,
+ 'target_type' => $sTargetType,
+ '#page' => array($iPage, $iPerPage),
+ '#index-from' => 'target_id'
+ ));
+ return array_keys($aTargetItems['collection']);
+ }
+
+ /**
+ * Возвращает список ID таргетов по категории
+ *
+ * @param $oCategory
+ * @param $sTargetType
+ * @param $iPage
+ * @param $iPerPage
+ * @param bool $bIncludeChild
+ *
+ * @return array
+ */
+ public function GetTargetIdsByCategory($oCategory, $sTargetType, $iPage, $iPerPage, $bIncludeChild = false)
+ {
+ $aCategoryId = $this->GetCategoriesIdByCategory($oCategory, $bIncludeChild);
+
+ return $this->GetTargetIdsByCategoriesId($aCategoryId, $sTargetType, $iPage, $iPerPage);
+ }
+
+ /**
+ * Создает новый тип объекта в БД для категорий
+ *
+ * @param string $sType
+ * @param string $sTitle
+ * @param array $aParams
+ * @param bool $bRewrite
+ *
+ * @return bool|ModuleCategory_EntityType
+ */
+ public function CreateTargetType($sType, $sTitle, $aParams = array(), $bRewrite = false)
+ {
+ /**
+ * Проверяем есть ли уже такой тип
+ */
+ if ($oType = $this->GetTypeByTargetType($sType)) {
+ if (!$bRewrite) {
+ return false;
+ }
+ } else {
+ $oType = Engine::GetEntity('ModuleCategory_EntityType');
+ $oType->setTargetType($sType);
+ }
+ $oType->setState(self::TARGET_STATE_ACTIVE);
+ $oType->setTitle(htmlspecialchars($sTitle));
+ $oType->setParams($aParams);
+ if ($oType->Save()) {
+ return $oType;
+ }
+ return false;
+ }
+
+ /**
+ * Отключает тип объекта для категорий
+ *
+ * @param string $sType
+ * @param int $iState self::TARGET_STATE_NOT_ACTIVE или self::TARGET_STATE_REMOVE
+ */
+ public function RemoveTargetType($sType, $iState = self::TARGET_STATE_NOT_ACTIVE)
+ {
+ if ($oType = $this->GetTypeByTargetType($sType)) {
+ $oType->setState($iState);
+ $oType->Save();
+ }
+ }
+
+ /**
+ * Парсинг текста с учетом конкретной категории
+ *
+ * @param string $sText
+ * @param ModuleCategory_EntityCategory $oCategory
+ *
+ * @return string
+ */
+ public function ParserText($sText, $oCategory)
+ {
+ $this->Text_AddParams(array('oCategory' => $oCategory));
+ $sResult = $this->Text_Parser($sText);
+ $this->Text_RemoveParams(array('oCategory'));
+ return $sResult;
+ }
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/category/behavior/Entity.behavior.class.php b/application/classes/modules/category/behavior/Entity.behavior.class.php
new file mode 100644
index 0000000..6b1f6ad
--- /dev/null
+++ b/application/classes/modules/category/behavior/Entity.behavior.class.php
@@ -0,0 +1,237 @@
+
+ *
+ */
+
+/**
+ * Поведение, которое необходимо добавлять к сущности (entity) у которой добавляются категории
+ *
+ * @package application.modules.category
+ * @since 2.0
+ */
+class ModuleCategory_BehaviorEntity extends Behavior
+{
+ /**
+ * Дефолтные параметры
+ *
+ * @var array
+ */
+ protected $aParams = array(
+ // Уникальный код
+ 'target_type' => '',
+ // Имя инпута (select) на форме, который содержит список категорий
+ 'form_field' => 'categories',
+ // Автоматически брать текущую категорию из реквеста
+ 'form_fill_current_from_request' => true,
+ // Возможность выбирать несколько категорий
+ 'multiple' => false,
+ // Автоматическая валидация категорий (актуально при ORM)
+ 'validate_enable' => true,
+ // Поле сущности, в котором хранятся категории. Если null, то используется имя из form_field
+ 'validate_field' => null,
+ // Обязательное заполнение категории
+ 'validate_require' => false,
+ // Получать значение валидации не из сущности, а из реквеста (используется поле form_field)
+ 'validate_from_request' => false,
+ // Минимальное количество категорий, доступное для выбора
+ 'validate_min' => 1,
+ // Максимальное количество категорий, доступное для выбора
+ 'validate_max' => 5,
+ // Возможность выбрать только те категории, у которых нет дочерних
+ 'validate_only_without_children' => false,
+ // Колбек для подсчета количества объектов у категории. Необходим, например, если необходимо учитывать объекты только с определенным статусом (доступен для публикации).
+ // Указывать можно строкой с полным вызовом метода модуля, например, "PluginArticle_Main_GetCountArticle"
+ // В качестве параметров передается список ID категорий и тип
+ 'callback_count_target' => null,
+ );
+ /**
+ * Список хуков
+ *
+ * @var array
+ */
+ protected $aHooks = array(
+ 'validate_after' => 'CallbackValidateAfter',
+ 'after_save' => 'CallbackAfterSave',
+ 'after_delete' => 'CallbackAfterDelete',
+ );
+
+ /**
+ * Инициализация
+ */
+ protected function Init()
+ {
+ parent::Init();
+ if (!$this->getParam('validate_field')) {
+ $this->aParams['validate_field'] = $this->getParam('form_field');
+ }
+ }
+
+ /**
+ * Коллбэк
+ * Выполняется при инициализации сущности
+ *
+ * @param $aParams
+ */
+ public function CallbackValidateAfter($aParams)
+ {
+ if ($aParams['bResult'] and $this->getParam('validate_enable')) {
+ $aFields = $aParams['aFields'];
+ if (is_null($aFields) or in_array($this->getParam('validate_field'), $aFields)) {
+ $oValidator = $this->Validate_CreateValidator('categories_check', $this,
+ $this->getParam('validate_field'));
+ $oValidator->validateEntity($this->oObject, $aFields);
+ $aParams['bResult'] = !$this->oObject->_hasValidateErrors();
+ }
+ }
+ }
+
+ /**
+ * Коллбэк
+ * Выполняется после сохранения сущности
+ */
+ public function CallbackAfterSave()
+ {
+ $this->Category_SaveCategories($this->oObject, $this->getParam('target_type'),
+ $this->getParam('callback_count_target'));
+ }
+
+ /**
+ * Коллбэк
+ * Выполняется после удаления сущности
+ */
+ public function CallbackAfterDelete()
+ {
+ $this->Category_RemoveCategories($this->oObject, $this->getParam('target_type'),
+ $this->getParam('callback_count_target'));
+ }
+
+ /**
+ * Дополнительный метод для сущности
+ * Запускает валидацию дополнительных полей
+ *
+ * @param $mValue
+ *
+ * @return bool|string
+ */
+ public function ValidateCategoriesCheck($mValue)
+ {
+ /**
+ * Проверяем тип категрий
+ */
+ if (!$oTypeCategory = $this->Category_GetTypeByTargetType($this->getParam('target_type'))) {
+ return 'Неверный тип категорий';
+ }
+
+ if ($this->getParam('validate_from_request')) {
+ $mValue = getRequest($this->getParam('form_field'));
+ }
+ /**
+ * Значение может быть числом, массивом, строкой с разделением через запятую
+ */
+ if (!is_array($mValue)) {
+ if ($this->getParam('multiple')) {
+ $mValue = explode(',', $mValue);
+ } else {
+ $mValue = array($mValue);
+ }
+ }
+ /**
+ * Проверяем наличие категорий в БД
+ */
+ $aCategories = $this->Category_ValidateCategoryArray($mValue, $oTypeCategory->getId(), true);
+ if (!$aCategories) {
+ $aCategories = array();
+ }
+
+ if ($this->getParam('validate_require') and !$aCategories) {
+ return $this->Lang_Get('category.notices.validate_require');
+ }
+ if (!$this->getParam('multiple') and count($aCategories) > 1) {
+ $aCategories = array_slice($aCategories, 0, 1);
+ }
+ if ($this->getParam('multiple') and $aCategories and (count($aCategories) < $this->getParam('validate_min') or count($aCategories) > $this->getParam('validate_max'))) {
+ return $this->Lang_Get('category.notices.validate_count',
+ array('min' => $this->getParam('validate_min'), 'max' => $this->getParam('validate_max')));
+ }
+ if ($this->getParam('validate_only_without_children')) {
+ foreach ($aCategories as $oCategory) {
+ if ($oCategory->getChildren()) {
+ return $this->Lang_Get('category.notices.validate_children');
+ }
+ }
+ }
+ /**
+ * Сохраняем необходимый список категорий для последующего сохранения в БД
+ */
+ $this->oObject->_setData(array('_categories_for_save' => array_keys($aCategories)));
+ return true;
+ }
+
+ /**
+ * Возвращает список категорий сущности
+ *
+ * @return array
+ */
+ public function getCategories()
+ {
+ return $this->Category_GetEntityCategories($this->oObject, $this->getCategoryTargetType());
+ }
+
+ /**
+ * Возвращает количество категорий
+ *
+ * @return array
+ */
+ public function getCountCategories()
+ {
+ return count($this->getCategories());
+ }
+
+ /**
+ * Возвращает одну категорию сущности
+ * Если объект может иметь несколько категорий, то вернется первая
+ *
+ * @return ModuleCategory_EntityCategory|null
+ */
+ public function getCategory()
+ {
+ $aCategories = $this->getCategories();
+ $oCategory = reset($aCategories);
+ return $oCategory ? $oCategory : null;
+ }
+
+ /**
+ * Возвращает тип объекта для категорий
+ *
+ * @return string
+ */
+ public function getCategoryTargetType()
+ {
+ if ($sType = $this->getParam('target_type')) {
+ return $sType;
+ }
+ /**
+ * Иначе дополнительно смотрим на наличие данного метода у сущности
+ * Это необходимо, если тип вычисляется динамически по какой-то своей логике
+ */
+ if (func_method_exists($this->oObject, 'getCategoryTargetType', 'public')) {
+ return call_user_func(array($this->oObject, 'getCategoryTargetType'));
+ }
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/category/behavior/Module.behavior.class.php b/application/classes/modules/category/behavior/Module.behavior.class.php
new file mode 100644
index 0000000..7018576
--- /dev/null
+++ b/application/classes/modules/category/behavior/Module.behavior.class.php
@@ -0,0 +1,107 @@
+
+ *
+ */
+
+/**
+ * Поведение, которое необходимо добавлять к ORM модулю сущности у которой добавляются категории
+ *
+ * @package application.modules.category
+ * @since 2.0
+ */
+class ModuleCategory_BehaviorModule extends Behavior
+{
+ /**
+ * Дефолтные параметры
+ *
+ * @var array
+ */
+ protected $aParams = array(
+ 'target_type' => '',
+ );
+ /**
+ * Список хуков
+ *
+ * @var array
+ */
+ protected $aHooks = array(
+ 'module_orm_GetItemsByFilter_after' => array(
+ 'CallbackGetItemsByFilterAfter',
+ 1000
+ ),
+ 'module_orm_GetItemsByFilter_before' => array(
+ 'CallbackGetItemsByFilterBefore',
+ 1000
+ ),
+ 'module_orm_GetByFilter_before' => array(
+ 'CallbackGetItemsByFilterBefore',
+ 1000
+ ),
+ );
+
+ /**
+ * Модифицирует фильтр в ORM запросе
+ *
+ * @param $aParams
+ */
+ public function CallbackGetItemsByFilterAfter($aParams)
+ {
+ $aEntities = $aParams['aEntities'];
+ $aFilter = $aParams['aFilter'];
+ $this->Category_RewriteGetItemsByFilter($aEntities, $aFilter, $this->getParam('target_type'));
+ }
+
+ /**
+ * Модифицирует результат ORM запроса
+ *
+ * @param $aParams
+ */
+ public function CallbackGetItemsByFilterBefore($aParams)
+ {
+ $aFilter = $this->Category_RewriteFilter($aParams['aFilter'], $aParams['sEntityFull'],
+ $this->getParam('target_type'));
+ $aParams['aFilter'] = $aFilter;
+ }
+
+ /**
+ * Возвращает дерево категорий
+ *
+ * @return mixed
+ */
+ public function GetCategoriesTree()
+ {
+ return $this->Category_GetCategoriesTreeByTargetType($this->getParam('target_type'));
+ }
+
+ /**
+ * Возвращает список ID объектов (элементов), которые принадлежат категории
+ *
+ * @param $oCategory
+ * @param $iPage
+ * @param $iPerPage
+ * @param bool $bIncludeChild
+ *
+ * @return mixed
+ */
+ public function GetTargetIdsByCategory($oCategory, $iPage, $iPerPage, $bIncludeChild = false)
+ {
+ return $this->Category_GetTargetIdsByCategory($oCategory, $this->getParam('target_type'), $iPage, $iPerPage,
+ $bIncludeChild);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/category/entity/Category.entity.class.php b/application/classes/modules/category/entity/Category.entity.class.php
new file mode 100644
index 0000000..fb54948
--- /dev/null
+++ b/application/classes/modules/category/entity/Category.entity.class.php
@@ -0,0 +1,280 @@
+
+ *
+ */
+
+/**
+ * Сущность категории
+ *
+ * @package application.modules.category
+ * @since 2.0
+ */
+class ModuleCategory_EntityCategory extends EntityORM
+{
+
+ /**
+ * Определяем правила валидации
+ *
+ * @var array
+ */
+ protected $aValidateRules = array(
+ array('title', 'string', 'max' => 200, 'min' => 1, 'allowEmpty' => false),
+ array('description', 'string', 'max' => 5000, 'min' => 1, 'allowEmpty' => true),
+ array('url', 'regexp', 'pattern' => '/^[\w\-_]+$/i', 'allowEmpty' => false),
+ array('order', 'number', 'integerOnly' => true),
+ array('pid', 'parent_category'),
+ array('order', 'order_check'),
+ );
+
+ protected $aRelations = array(
+ 'type' => array(self::RELATION_TYPE_BELONGS_TO, 'ModuleCategory_EntityType', 'type_id'),
+ self::RELATION_TYPE_TREE
+ );
+
+ /**
+ * Проверка родительской категории
+ *
+ * @param string $sValue Валидируемое значение
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function ValidateParentCategory($sValue, $aParams)
+ {
+ if ($this->getPid()) {
+ if ($oCategory = $this->Category_GetCategoryById($this->getPid())) {
+ if ($oCategory->getId() == $this->getId()) {
+ return $this->Lang_Get('category.notices.validate_recursion');
+ }
+ if ($oCategory->getTypeId() != $this->getTypeId()) {
+ return $this->Lang_Get('category.notices.validate_parent');
+ }
+ $this->setUrlFull($oCategory->getUrlFull() . '/' . $this->getUrl());
+ } else {
+ return $this->Lang_Get('category.notices.validate_wrong');
+ }
+ } else {
+ $this->setPid(null);
+ $this->setUrlFull($this->getUrl());
+ }
+ return true;
+ }
+
+ /**
+ * Установка дефолтной сортировки
+ *
+ * @param string $sValue Валидируемое значение
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function ValidateOrderCheck($sValue, $aParams)
+ {
+ if (!$this->getSort()) {
+ $this->setSort(100);
+ }
+ return true;
+ }
+
+ /**
+ * Выполняется перед удалением
+ *
+ * @return bool
+ */
+ protected function beforeDelete()
+ {
+ if ($bResult = parent::beforeDelete()) {
+ /**
+ * Запускаем удаление дочерних категорий
+ */
+ if ($aCildren = $this->getChildren()) {
+ foreach ($aCildren as $oChildren) {
+ $oChildren->Delete();
+ }
+ }
+ /**
+ * Удаляем связь с таргетом
+ */
+ if ($aTargets = $this->Category_GetTargetItemsByCategoryId($this->getId())) {
+ foreach ($aTargets as $oTarget) {
+ $oTarget->Delete();
+ /**
+ * TODO: Нужно запустить хук, что мы удалили такую-то связь
+ */
+ }
+ }
+ }
+ return $bResult;
+ }
+
+ /**
+ * Переопределяем имя поля с родителем
+ * Т.к. по дефолту в деревьях используется поле parent_id
+ *
+ * @return string
+ */
+ public function _getTreeParentKey()
+ {
+ return 'pid';
+ }
+
+ /**
+ * Выполняется перед сохранением
+ *
+ * @return bool
+ */
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+ /**
+ * Возвращает URL категории
+ * Этот метод необходимо переопределить из плагина и возвращать свой URL для нужного типа категорий
+ *
+ * @return string
+ */
+ public function getWebUrl()
+ {
+ return null;
+ }
+
+ /**
+ * Возвращает объект типа категории с использованием кеширования на время сессии
+ *
+ * @return ModuleCategory_EntityType
+ */
+ public function getTypeByCacheLife()
+ {
+ $sKey = 'category_type_' . (string)$this->getTypeId();
+ if (false === ($oType = $this->Cache_GetLife($sKey))) {
+ $oType = $this->getType();
+ $this->Cache_SetLife($oType, $sKey);
+ }
+ return $oType;
+ }
+
+ /**
+ * Возвращает URL админки для редактирования
+ *
+ * @return string
+ */
+ public function getUrlAdminUpdate()
+ {
+ return Router::GetPath('admin/categories/' . $this->getTypeByCacheLife()->getTargetType() . '/update/' . $this->getId());
+ }
+
+ /**
+ * Возвращает URL админки для удаления
+ *
+ * @return string
+ */
+ public function getUrlAdminRemove()
+ {
+ return Router::GetPath('admin/categories/' . $this->getTypeByCacheLife()->getTargetType() . '/remove/' . $this->getId());
+ }
+
+ /**
+ * Возвращает список дополнительных данных
+ *
+ * @return array|mixed
+ */
+ public function getData()
+ {
+ $aData = @unserialize($this->_getDataOne('data'));
+ if (!$aData) {
+ $aData = array();
+ }
+ return $aData;
+ }
+
+ /**
+ * Устанавливает список дополнительня данных
+ *
+ * @param $aRules
+ */
+ public function setData($aRules)
+ {
+ $this->_aData['data'] = @serialize($aRules);
+ }
+
+ /**
+ * Возвращает данные по конкретному ключу
+ *
+ * @param $sKey
+ *
+ * @return null
+ */
+ public function getDataOne($sKey)
+ {
+ $aData = $this->getData();
+ if (isset($aData[$sKey])) {
+ return $aData[$sKey];
+ }
+ return null;
+ }
+
+ /**
+ * Устанваливает данные для конкретного ключа
+ *
+ * @param $sKey
+ * @param $mValue
+ */
+ public function setDataOne($sKey, $mValue)
+ {
+ $aData = $this->getData();
+ $aData[$sKey] = $mValue;
+ $this->setData($aData);
+ }
+
+ /**
+ * Возвращает сумму значений по ключу для всех потомков, включая себя
+ *
+ * @param $sKey
+ *
+ * @return null
+ */
+ public function getDataOneSumDescendants($sKey)
+ {
+ $iResult = $this->getDataOne($sKey);
+ $aChildren = $this->getDescendants();
+ foreach ($aChildren as $oItem) {
+ $iResult += $oItem->getDataOne($sKey);
+ }
+ return $iResult;
+ }
+
+ /**
+ * Возвращает количество таргетов (объектов) для всех потомков, включая себя
+ *
+ * @return mixed
+ */
+ public function getCountTargetOfDescendants()
+ {
+ $iCount = $this->getCountTarget();
+ $aChildren = $this->getDescendants();
+ foreach ($aChildren as $oItem) {
+ $iCount += $oItem->getCountTarget();
+ }
+ return $iCount;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/category/entity/Target.entity.class.php b/application/classes/modules/category/entity/Target.entity.class.php
new file mode 100644
index 0000000..2e82929
--- /dev/null
+++ b/application/classes/modules/category/entity/Target.entity.class.php
@@ -0,0 +1,47 @@
+
+ *
+ */
+
+/**
+ * Сущность связи категории с объектами
+ *
+ * @package application.modules.category
+ * @since 2.0
+ */
+class ModuleCategory_EntityTarget extends EntityORM
+{
+
+ protected $aRelations = array();
+
+ /**
+ * Выполняется перед сохранением
+ *
+ * @return bool
+ */
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/category/entity/Type.entity.class.php b/application/classes/modules/category/entity/Type.entity.class.php
new file mode 100644
index 0000000..a08a79a
--- /dev/null
+++ b/application/classes/modules/category/entity/Type.entity.class.php
@@ -0,0 +1,86 @@
+
+ *
+ */
+
+/**
+ * Сущность типа категории
+ *
+ * @package application.modules.category
+ * @since 2.0
+ */
+class ModuleCategory_EntityType extends EntityORM
+{
+
+ protected $aRelations = array();
+
+ /**
+ * Выполняется перед сохранением
+ *
+ * @return bool
+ */
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ } else {
+ $this->setDateUpdate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+ /**
+ * Возвращает список дополнительных параметров
+ *
+ * @return array|mixed
+ */
+ public function getParams()
+ {
+ $aData = @unserialize($this->_getDataOne('params'));
+ if (!$aData) {
+ $aData = array();
+ }
+ return $aData;
+ }
+
+ /**
+ * Устанавливает список дополнительных параметров
+ *
+ * @param $aParams
+ */
+ public function setParams($aParams)
+ {
+ $this->_aData['params'] = @serialize($aParams);
+ }
+
+ /**
+ * Возвращает конкретный параметр
+ *
+ * @param $sName
+ *
+ * @return null
+ */
+ public function getParam($sName)
+ {
+ $aParams = $this->getParams();
+ return isset($aParams[$sName]) ? $aParams[$sName] : null;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/comment/Comment.class.php b/application/classes/modules/comment/Comment.class.php
new file mode 100644
index 0000000..8ff0654
--- /dev/null
+++ b/application/classes/modules/comment/Comment.class.php
@@ -0,0 +1,1128 @@
+
+ *
+ */
+
+/**
+ * Модуль для работы с комментариями
+ *
+ * @package application.modules.comment
+ * @since 1.0
+ */
+class ModuleComment extends Module
+{
+ /**
+ * Объект маппера
+ *
+ * @var ModuleComment_MapperComment
+ */
+ protected $oMapper;
+ /**
+ * Объект текущего пользователя
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Получить коммент по айдишнику
+ *
+ * @param int $sId ID комментария
+ * @return ModuleComment_EntityComment|null
+ */
+ public function GetCommentById($sId)
+ {
+ if (!is_numeric($sId)) {
+ return null;
+ }
+ $aComments = $this->GetCommentsAdditionalData($sId);
+ if (isset($aComments[$sId])) {
+ return $aComments[$sId];
+ }
+ return null;
+ }
+
+ /**
+ * Получает уникальный коммент, это помогает спастись от дублей комментов
+ *
+ * @param int $sTargetId ID владельца комментария
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $sUserId ID пользователя
+ * @param int $sCommentPid ID родительского комментария
+ * @param string $sHash Хеш строка текста комментария
+ * @return ModuleComment_EntityComment|null
+ */
+ public function GetCommentUnique($sTargetId, $sTargetType, $sUserId, $sCommentPid, $sHash)
+ {
+ $sId = $this->oMapper->GetCommentUnique($sTargetId, $sTargetType, $sUserId, $sCommentPid, $sHash);
+ return $this->GetCommentById($sId);
+ }
+
+ /**
+ * Получить все комменты
+ *
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param array $aExcludeTarget Список ID владельцев, которые необходимо исключить из выдачи
+ * @param array $aExcludeParentTarget Список ID родителей владельцев, которые необходимо исключить из выдачи, например, исключить комментарии топиков к определенным блогам(закрытым)
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetCommentsAll(
+ $sTargetType,
+ $iPage,
+ $iPerPage,
+ $aExcludeTarget = array(),
+ $aExcludeParentTarget = array()
+ ) {
+ $s = serialize($aExcludeTarget) . serialize($aExcludeParentTarget);
+ if (false === ($data = $this->Cache_Get("comment_all_{$sTargetType}_{$iPage}_{$iPerPage}_{$s}"))) {
+ $data = array(
+ 'collection' => $this->oMapper->GetCommentsAll($sTargetType, $iCount, $iPage, $iPerPage,
+ $aExcludeTarget, $aExcludeParentTarget),
+ 'count' => $iCount
+ );
+ $this->Cache_Set($data, "comment_all_{$sTargetType}_{$iPage}_{$iPerPage}_{$s}",
+ array("comment_new_{$sTargetType}", "comment_update_status_{$sTargetType}"), 60 * 60 * 24 * 1);
+ }
+ $data['collection'] = $this->GetCommentsAdditionalData($data['collection'],
+ array('target', 'favourite', 'user' => array()));
+ return $data;
+ }
+
+ /**
+ * Получает дополнительные данные(объекты) для комментов по их ID
+ *
+ * @param array $aCommentId Список ID комментов
+ * @param array|null $aAllowData Список типов дополнительных данных, которые нужно получить для комментариев
+ * @return array
+ */
+ public function GetCommentsAdditionalData($aCommentId, $aAllowData = null)
+ {
+ if (is_null($aAllowData)) {
+ $aAllowData = array('vote', 'target', 'favourite', 'user' => array());
+ }
+ func_array_simpleflip($aAllowData);
+ if (!is_array($aCommentId)) {
+ $aCommentId = array($aCommentId);
+ }
+ /**
+ * Получаем комменты
+ */
+ $aComments = $this->GetCommentsByArrayId($aCommentId);
+ /**
+ * Формируем ID дополнительных данных, которые нужно получить
+ */
+ $aUserId = array();
+ $aTargetId = array('topic' => array(), 'talk' => array());
+ foreach ($aComments as $oComment) {
+ if (isset($aAllowData['user'])) {
+ $aUserId[] = $oComment->getUserId();
+ }
+ if (isset($aAllowData['target'])) {
+ $aTargetId[$oComment->getTargetType()][] = $oComment->getTargetId();
+ }
+ }
+ /**
+ * Получаем дополнительные данные
+ */
+ $aUsers = isset($aAllowData['user']) && is_array($aAllowData['user']) ? $this->User_GetUsersAdditionalData($aUserId,
+ $aAllowData['user']) : $this->User_GetUsersAdditionalData($aUserId);
+ /**
+ * В зависимости от типа target_type достаем данные
+ */
+ $aTargets = array();
+ //$aTargets['topic']=isset($aAllowData['target']) && is_array($aAllowData['target']) ? $this->Topic_GetTopicsAdditionalData($aTargetId['topic'],$aAllowData['target']) : $this->Topic_GetTopicsAdditionalData($aTargetId['topic']);
+ $aTargets['topic'] = $this->Topic_GetTopicsAdditionalData($aTargetId['topic'],
+ array('blog' => array('owner' => array()), 'user' => array()));
+ $aVote = array();
+ if (isset($aAllowData['vote']) and $this->oUserCurrent) {
+ $aVote = $this->Vote_GetVoteByArray($aCommentId, 'comment', $this->oUserCurrent->getId());
+ }
+ if (isset($aAllowData['favourite']) and $this->oUserCurrent) {
+ $aFavouriteComments = $this->Favourite_GetFavouritesByArray($aCommentId, 'comment',
+ $this->oUserCurrent->getId());
+ }
+ /**
+ * Добавляем данные к результату
+ */
+ foreach ($aComments as $oComment) {
+ if (isset($aUsers[$oComment->getUserId()])) {
+ $oComment->setUser($aUsers[$oComment->getUserId()]);
+ } else {
+ $oComment->setUser(null); // или $oComment->setUser(new ModuleUser_EntityUser());
+ }
+ if (isset($aTargets[$oComment->getTargetType()][$oComment->getTargetId()])) {
+ $oComment->setTarget($aTargets[$oComment->getTargetType()][$oComment->getTargetId()]);
+ } else {
+ $oComment->setTarget(null);
+ }
+ if (isset($aVote[$oComment->getId()])) {
+ $oComment->setVote($aVote[$oComment->getId()]);
+ } else {
+ $oComment->setVote(null);
+ }
+ if (isset($aFavouriteComments[$oComment->getId()])) {
+ $oComment->setIsFavourite(true);
+ } else {
+ $oComment->setIsFavourite(false);
+ }
+ }
+ return $aComments;
+ }
+
+ /**
+ * Список комментов по ID
+ *
+ * @param array $aCommentId Список ID комментариев
+ * @return array
+ */
+ public function GetCommentsByArrayId($aCommentId)
+ {
+ if (!$aCommentId) {
+ return array();
+ }
+ if (Config::Get('sys.cache.solid')) {
+ return $this->GetCommentsByArrayIdSolid($aCommentId);
+ }
+ if (!is_array($aCommentId)) {
+ $aCommentId = array($aCommentId);
+ }
+ $aCommentId = array_unique($aCommentId);
+ $aComments = array();
+ $aCommentIdNotNeedQuery = array();
+ /**
+ * Делаем мульти-запрос к кешу
+ */
+ $aCacheKeys = func_build_cache_keys($aCommentId, 'comment_');
+ if (false !== ($data = $this->Cache_Get($aCacheKeys))) {
+ /**
+ * Проверяем что досталось из кеша
+ */
+ foreach ($aCacheKeys as $sValue => $sKey) {
+ if (array_key_exists($sKey, $data)) {
+ if ($data[$sKey]) {
+ $aComments[$data[$sKey]->getId()] = $data[$sKey];
+ } else {
+ $aCommentIdNotNeedQuery[] = $sValue;
+ }
+ }
+ }
+ }
+ /**
+ * Смотрим каких комментов не было в кеше и делаем запрос в БД
+ */
+ $aCommentIdNeedQuery = array_diff($aCommentId, array_keys($aComments));
+ $aCommentIdNeedQuery = array_diff($aCommentIdNeedQuery, $aCommentIdNotNeedQuery);
+ $aCommentIdNeedStore = $aCommentIdNeedQuery;
+ if ($data = $this->oMapper->GetCommentsByArrayId($aCommentIdNeedQuery)) {
+ foreach ($data as $oComment) {
+ /**
+ * Добавляем к результату и сохраняем в кеш
+ */
+ $aComments[$oComment->getId()] = $oComment;
+ $this->Cache_Set($oComment, "comment_{$oComment->getId()}", array(), 60 * 60 * 24 * 4);
+ $aCommentIdNeedStore = array_diff($aCommentIdNeedStore, array($oComment->getId()));
+ }
+ }
+ /**
+ * Сохраняем в кеш запросы не вернувшие результата
+ */
+ foreach ($aCommentIdNeedStore as $sId) {
+ $this->Cache_Set(null, "comment_{$sId}", array(), 60 * 60 * 24 * 4);
+ }
+ /**
+ * Сортируем результат согласно входящему массиву
+ */
+ $aComments = func_array_sort_by_keys($aComments, $aCommentId);
+ return $aComments;
+ }
+
+ /**
+ * Получает список комментариев по ID используя единый кеш
+ *
+ * @param array $aCommentId Список ID комментариев
+ * @return array
+ */
+ public function GetCommentsByArrayIdSolid($aCommentId)
+ {
+ if (!is_array($aCommentId)) {
+ $aCommentId = array($aCommentId);
+ }
+ $aCommentId = array_unique($aCommentId);
+ $aComments = array();
+ $s = join(',', $aCommentId);
+ if (false === ($data = $this->Cache_Get("comment_id_{$s}"))) {
+ $data = $this->oMapper->GetCommentsByArrayId($aCommentId);
+ foreach ($data as $oComment) {
+ $aComments[$oComment->getId()] = $oComment;
+ }
+ $this->Cache_Set($aComments, "comment_id_{$s}", array("comment_update"), 60 * 60 * 24 * 1);
+ return $aComments;
+ }
+ return $data;
+ }
+
+ /**
+ * Получить все комменты сгрупированные по типу(для вывода прямого эфира)
+ *
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iLimit Количество элементов
+ * @return array
+ */
+ public function GetCommentsOnline($sTargetType, $iLimit)
+ {
+ /**
+ * Исключаем из выборки идентификаторы закрытых блогов (target_parent_id)
+ */
+ $aCloseBlogs = ($this->oUserCurrent)
+ ? $this->Blog_GetInaccessibleBlogsByUser($this->oUserCurrent)
+ : $this->Blog_GetInaccessibleBlogsByUser();
+
+ $s = serialize($aCloseBlogs);
+
+ if (false === ($data = $this->Cache_Get("comment_online_{$sTargetType}_{$s}_{$iLimit}"))) {
+ $data = $this->oMapper->GetCommentsOnline($sTargetType, $aCloseBlogs, $iLimit);
+ $this->Cache_Set($data, "comment_online_{$sTargetType}_{$s}_{$iLimit}",
+ array("comment_online_update_{$sTargetType}"), 60 * 60 * 24 * 1);
+ }
+ $data = $this->GetCommentsAdditionalData($data);
+ return $data;
+ }
+
+ /**
+ * Получить комменты по юзеру
+ *
+ * @param int $sId ID пользователя
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetCommentsByUserId($sId, $sTargetType, $iPage, $iPerPage)
+ {
+ /**
+ * Исключаем из выборки идентификаторы закрытых блогов
+ */
+ $aCloseBlogs = ($this->oUserCurrent && $sId == $this->oUserCurrent->getId())
+ ? array()
+ : $this->Blog_GetInaccessibleBlogsByUser();
+ $s = serialize($aCloseBlogs);
+
+ if (false === ($data = $this->Cache_Get("comment_user_{$sId}_{$sTargetType}_{$iPage}_{$iPerPage}_{$s}"))) {
+ $data = array(
+ 'collection' => $this->oMapper->GetCommentsByUserId($sId, $sTargetType, $iCount, $iPage, $iPerPage,
+ array(), $aCloseBlogs),
+ 'count' => $iCount
+ );
+ $this->Cache_Set($data, "comment_user_{$sId}_{$sTargetType}_{$iPage}_{$iPerPage}_{$s}",
+ array("comment_new_user_{$sId}_{$sTargetType}", "comment_update_status_{$sTargetType}"),
+ 60 * 60 * 24 * 2);
+ }
+ $data['collection'] = $this->GetCommentsAdditionalData($data['collection']);
+ return $data;
+ }
+
+ /**
+ * Получает количество комментариев одного пользователя
+ *
+ * @param id $sId ID пользователя
+ * @param string $sTargetType Тип владельца комментария
+ * @return int
+ */
+ public function GetCountCommentsByUserId($sId, $sTargetType)
+ {
+ /**
+ * Исключаем из выборки идентификаторы закрытых блогов
+ */
+ $aCloseBlogs = ($this->oUserCurrent && $sId == $this->oUserCurrent->getId())
+ ? array()
+ : $this->Blog_GetInaccessibleBlogsByUser();
+ $s = serialize($aCloseBlogs);
+
+ if (false === ($data = $this->Cache_Get("comment_count_user_{$sId}_{$sTargetType}_{$s}"))) {
+ $data = $this->oMapper->GetCountCommentsByUserId($sId, $sTargetType, array(), $aCloseBlogs);
+ $this->Cache_Set($data, "comment_count_user_{$sId}_{$sTargetType}_{$s}",
+ array("comment_new_user_{$sId}_{$sTargetType}", "comment_update_status_{$sTargetType}"),
+ 60 * 60 * 24 * 2);
+ }
+ return $data;
+ }
+
+ /**
+ * Получить комменты по рейтингу и дате
+ *
+ * @param string $sDate Дата за которую выводить рейтинг, т.к. кеширование происходит по дате, то дату лучше передавать с точностью до часа (минуты и секунды как 00:00)
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iLimit Количество элементов
+ * @return array
+ */
+ public function GetCommentsRatingByDate($sDate, $sTargetType, $iLimit = 20)
+ {
+ /**
+ * Выбираем топики, комметарии к которым являются недоступными для пользователя
+ */
+ $aCloseBlogs = ($this->oUserCurrent)
+ ? $this->Blog_GetInaccessibleBlogsByUser($this->oUserCurrent)
+ : $this->Blog_GetInaccessibleBlogsByUser();
+ $s = serialize($aCloseBlogs);
+ /**
+ * Т.к. время передаётся с точностью 1 час то можно по нему замутить кеширование
+ */
+ if (false === ($data = $this->Cache_Get("comment_rating_{$sDate}_{$sTargetType}_{$iLimit}_{$s}"))) {
+ $data = $this->oMapper->GetCommentsRatingByDate($sDate, $sTargetType, $iLimit, array(), $aCloseBlogs);
+ $this->Cache_Set($data, "comment_rating_{$sDate}_{$sTargetType}_{$iLimit}_{$s}", array(
+ "comment_new_{$sTargetType}",
+ "comment_update_status_{$sTargetType}",
+ "comment_update_rating_{$sTargetType}"
+ ), 60 * 60 * 24 * 2);
+ }
+ $data = $this->GetCommentsAdditionalData($data);
+ return $data;
+ }
+
+ /**
+ * Получить комменты по владельцу
+ *
+ * @param int $sId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param array|null $aAllowData Список доп данных, котрые нужно подгружать с комментариями
+ * @return array('comments'=>array,'iMaxIdComment'=>int)
+ */
+ public function GetCommentsByTargetId($sId, $sTargetType, $iPage = 1, $iPerPage = 0, $aAllowData = null)
+ {
+ if (Config::Get('module.comment.use_nested')) {
+ return $this->GetCommentsTreeByTargetId($sId, $sTargetType, $iPage, $iPerPage);
+ }
+
+ if (false === ($aCommentsRec = $this->Cache_Get("comment_target_{$sId}_{$sTargetType}"))) {
+ $aCommentsRow = $this->oMapper->GetCommentsByTargetId($sId, $sTargetType);
+ if (count($aCommentsRow)) {
+ $aCommentsRec = $this->BuildCommentsRecursive($aCommentsRow);
+ }
+ $this->Cache_Set($aCommentsRec, "comment_target_{$sId}_{$sTargetType}",
+ array("comment_new_{$sTargetType}_{$sId}"), 60 * 60 * 24 * 2);
+ }
+ if (!isset($aCommentsRec['comments'])) {
+ return array('comments' => array(), 'iMaxIdComment' => 0);
+ }
+ $aComments = $aCommentsRec;
+ $aComments['comments'] = $this->GetCommentsAdditionalData(array_keys($aCommentsRec['comments']), $aAllowData);
+ foreach ($aComments['comments'] as $oComment) {
+ $oComment->setLevel($aCommentsRec['comments'][$oComment->getId()]);
+ }
+ return $aComments;
+
+ }
+
+ /**
+ * Получает комменты используя nested set
+ *
+ * @param int $sId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('comments'=>array,'iMaxIdComment'=>int,'count'=>int)
+ */
+ public function GetCommentsTreeByTargetId($sId, $sTargetType, $iPage = 1, $iPerPage = 0)
+ {
+ if (!Config::Get('module.comment.nested_page_reverse') and $iPerPage and $iCountPage = ceil($this->GetCountCommentsRootByTargetId($sId,
+ $sTargetType) / $iPerPage)
+ ) {
+ $iPage = $iCountPage - $iPage + 1;
+ }
+ $iPage = $iPage < 1 ? 1 : $iPage;
+ if (false === ($aReturn = $this->Cache_Get("comment_tree_target_{$sId}_{$sTargetType}_{$iPage}_{$iPerPage}"))) {
+
+ /**
+ * Нужно или нет использовать постраничное разбиение комментариев
+ */
+ if ($iPerPage) {
+ $aComments = $this->oMapper->GetCommentsTreePageByTargetId($sId, $sTargetType, $iCount, $iPage,
+ $iPerPage);
+ } else {
+ $aComments = $this->oMapper->GetCommentsTreeByTargetId($sId, $sTargetType);
+ $iCount = count($aComments);
+ }
+ $iMaxIdComment = count($aComments) ? max($aComments) : 0;
+ $aReturn = array('comments' => $aComments, 'iMaxIdComment' => $iMaxIdComment, 'count' => $iCount);
+ $this->Cache_Set($aReturn, "comment_tree_target_{$sId}_{$sTargetType}_{$iPage}_{$iPerPage}",
+ array("comment_new_{$sTargetType}_{$sId}"), 60 * 60 * 24 * 2);
+ }
+ $aReturn['comments'] = $this->GetCommentsAdditionalData($aReturn['comments']);
+ return $aReturn;
+ }
+
+ /**
+ * Возвращает количество дочерних комментариев у корневого коммента
+ *
+ * @param int $sId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @return int
+ */
+ public function GetCountCommentsRootByTargetId($sId, $sTargetType)
+ {
+ return $this->oMapper->GetCountCommentsRootByTargetId($sId, $sTargetType);
+ }
+
+ /**
+ * Возвращает номер страницы, на которой расположен комментарий
+ *
+ * @param int $sId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @param ModuleComment_EntityComment $oComment Объект комментария
+ * @return bool|int
+ */
+ public function GetPageCommentByTargetId($sId, $sTargetType, $oComment)
+ {
+ if (!Config::Get('module.comment.nested_per_page')) {
+ return 1;
+ }
+ if (is_numeric($oComment)) {
+ if (!($oComment = $this->GetCommentById($oComment))) {
+ return false;
+ }
+ if ($oComment->getTargetId() != $sId or $oComment->getTargetType() != $sTargetType) {
+ return false;
+ }
+ }
+ /**
+ * Получаем корневого родителя
+ */
+ if ($oComment->getPid()) {
+ if (!($oCommentRoot = $this->oMapper->GetCommentRootByTargetIdAndChildren($sId, $sTargetType,
+ $oComment->getLeft()))
+ ) {
+ return false;
+ }
+ } else {
+ $oCommentRoot = $oComment;
+ }
+ $iCount = ceil($this->oMapper->GetCountCommentsAfterByTargetId($sId, $sTargetType,
+ $oCommentRoot->getLeft()) / Config::Get('module.comment.nested_per_page'));
+
+ if (!Config::Get('module.comment.nested_page_reverse') and $iCountPage = ceil($this->GetCountCommentsRootByTargetId($sId,
+ $sTargetType) / Config::Get('module.comment.nested_per_page'))
+ ) {
+ $iCount = $iCountPage - $iCount + 1;
+ }
+ return $iCount ? $iCount : 1;
+ }
+
+ /**
+ * Добавляет коммент
+ *
+ * @param ModuleComment_EntityComment $oComment Объект комментария
+ * @return bool|ModuleComment_EntityComment
+ */
+ public function AddComment(ModuleComment_EntityComment $oComment)
+ {
+ if (Config::Get('module.comment.use_nested')) {
+ $sId = $this->oMapper->AddCommentTree($oComment);
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("comment_update"));
+ } else {
+ $sId = $this->oMapper->AddComment($oComment);
+ }
+ if ($sId) {
+ if ($oComment->getTargetType() == 'topic') {
+ $this->Topic_increaseTopicCountComment($oComment->getTargetId());
+ }
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array(
+ "comment_new",
+ "comment_new_{$oComment->getTargetType()}",
+ "comment_new_user_{$oComment->getUserId()}_{$oComment->getTargetType()}",
+ "comment_new_{$oComment->getTargetType()}_{$oComment->getTargetId()}"
+ ));
+ $oComment->setId($sId);
+ return $oComment;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет коммент
+ *
+ * @param ModuleComment_EntityComment $oComment Объект комментария
+ * @return bool
+ */
+ public function UpdateComment(ModuleComment_EntityComment $oComment)
+ {
+ if ($this->oMapper->UpdateComment($oComment)) {
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("comment_update", "comment_update_{$oComment->getTargetType()}_{$oComment->getTargetId()}"));
+ $this->Cache_Delete("comment_{$oComment->getId()}");
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет рейтинг у коммента
+ *
+ * @param ModuleComment_EntityComment $oComment Объект комментария
+ * @return bool
+ */
+ public function UpdateCommentRating(ModuleComment_EntityComment $oComment)
+ {
+ if ($this->oMapper->UpdateComment($oComment)) {
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("comment_update_rating_{$oComment->getTargetType()}"));
+ $this->Cache_Delete("comment_{$oComment->getId()}");
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет статус у коммента - delete или publish
+ *
+ * @param ModuleComment_EntityComment $oComment Объект комментария
+ * @return bool
+ */
+ public function UpdateCommentStatus(ModuleComment_EntityComment $oComment)
+ {
+ if ($this->oMapper->UpdateComment($oComment)) {
+ /**
+ * Если комментарий удаляется, удаляем его из прямого эфира
+ */
+ if ($oComment->getDelete()) {
+ $this->DeleteCommentOnlineByArrayId($oComment->getId(), $oComment->getTargetType());
+ }
+ /**
+ * Обновляем избранное
+ */
+ $this->Favourite_SetFavouriteTargetPublish($oComment->getId(), 'comment', !$oComment->getDelete());
+ /**
+ * Чистим зависимые кеши
+ */
+ if (Config::Get('sys.cache.solid')) {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("comment_update"));
+ }
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("comment_update_status_{$oComment->getTargetType()}"));
+ $this->Cache_Delete("comment_{$oComment->getId()}");
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Устанавливает publish у коммента
+ *
+ * @param int $sTargetId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iPublish Статус отображать комментарии или нет
+ * @return bool
+ */
+ public function SetCommentsPublish($sTargetId, $sTargetType, $iPublish)
+ {
+ if (!$aComments = $this->GetCommentsByTargetId($sTargetId, $sTargetType)) {
+ return false;
+ }
+ if (!isset($aComments['comments']) or count($aComments['comments']) == 0) {
+ return;
+ }
+
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("comment_update_status_{$sTargetType}"));
+ /**
+ * Если статус публикации успешно изменен, то меняем статус в отметке "избранное".
+ * Если комментарии снимаются с публикации, удаляем их из прямого эфира.
+ */
+ if ($this->oMapper->SetCommentsPublish($sTargetId, $sTargetType, $iPublish)) {
+ $this->Favourite_SetFavouriteTargetPublish(array_keys($aComments['comments']), 'comment', $iPublish);
+ if ($iPublish != 1) {
+ $this->DeleteCommentOnlineByTargetId($sTargetId, $sTargetType);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Удаляет коммент из прямого эфира
+ *
+ * @param int $sTargetId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @return bool
+ */
+ public function DeleteCommentOnlineByTargetId($sTargetId, $sTargetType)
+ {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("comment_online_update_{$sTargetType}"));
+ return $this->oMapper->DeleteCommentOnlineByTargetId($sTargetId, $sTargetType);
+ }
+
+ /**
+ * Добавляет новый коммент в прямой эфир
+ *
+ * @param ModuleComment_EntityCommentOnline $oCommentOnline Объект онлайн комментария
+ * @return bool|int
+ */
+ public function AddCommentOnline(ModuleComment_EntityCommentOnline $oCommentOnline)
+ {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("comment_online_update_{$oCommentOnline->getTargetType()}"));
+ return $this->oMapper->AddCommentOnline($oCommentOnline);
+ }
+
+ /**
+ * Получить новые комменты для владельца
+ *
+ * @param int $sId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $sIdCommentLast ID последнего прочитанного комментария
+ * @return array('comments'=>array,'iMaxIdComment'=>int)
+ */
+ public function GetCommentsNewByTargetId($sId, $sTargetType, $sIdCommentLast)
+ {
+ if (false === ($aComments = $this->Cache_Get("comment_target_{$sId}_{$sTargetType}_{$sIdCommentLast}"))) {
+ $aComments = $this->oMapper->GetCommentsNewByTargetId($sId, $sTargetType, $sIdCommentLast);
+ $this->Cache_Set($aComments, "comment_target_{$sId}_{$sTargetType}_{$sIdCommentLast}",
+ array("comment_new_{$sTargetType}_{$sId}"), 60 * 60 * 24 * 1);
+ }
+ if (count($aComments) == 0) {
+ return array('comments' => array(), 'iMaxIdComment' => 0);
+ }
+
+ $iMaxIdComment = max($aComments);
+ $aCmts = $this->GetCommentsAdditionalData($aComments);
+ $oViewerLocal = $this->Viewer_GetLocalViewer();
+ $oViewerLocal->Assign('oUserCurrent', $this->User_GetUserCurrent());
+ $oViewerLocal->Assign('oneComment', true, true);
+ if ($sTargetType != 'topic') {
+ $oViewerLocal->Assign('bNoCommentFavourites', true);
+ } elseif ($sTargetType == 'topic') {
+ $oViewerLocal->Assign('useFavourite', true, true);
+ $oViewerLocal->Assign('useVote', true, true);
+ $oViewerLocal->Assign('useScroll', true, true);
+ $oViewerLocal->Assign('useEdit', true, true);
+ $oViewerLocal->Assign('dateReadLast', '1', true);
+ }
+ $aCmt = array();
+ foreach ($aCmts as $oComment) {
+ $oViewerLocal->Assign('comment', $oComment, true);
+ $oViewerLocal->Assign('isDeleted', $oComment->getDelete(), true);
+ $sText = $oViewerLocal->Fetch($this->GetTemplateCommentByTarget($sId, $sTargetType));
+ $aCmt[] = array(
+ 'html' => $sText,
+ 'obj' => $oComment,
+ );
+ }
+ return array('comments' => $aCmt, 'iMaxIdComment' => $iMaxIdComment);
+ }
+
+ /**
+ * Возвращает шаблон комментария для рендеринга
+ * Плагин может переопределить данный метод и вернуть свой шаблон в зависимости от типа
+ *
+ * @param int $iTargetId ID объекта комментирования
+ * @param string $sTargetType Типа объекта комментирования
+ * @return string
+ */
+ public function GetTemplateCommentByTarget($iTargetId, $sTargetType)
+ {
+ return "component@comment.comment";
+ }
+
+ /**
+ * Строит дерево комментариев
+ *
+ * @param array $aComments Список комментариев
+ * @param bool $bBegin Флаг начала построения дерева, для инициализации параметров внутри метода
+ * @return array('comments'=>array,'iMaxIdComment'=>int)
+ */
+ protected function BuildCommentsRecursive($aComments, $bBegin = true)
+ {
+ static $aResultCommnets;
+ static $iLevel;
+ static $iMaxIdComment;
+ if ($bBegin) {
+ $aResultCommnets = array();
+ $iLevel = 0;
+ $iMaxIdComment = 0;
+ }
+ foreach ($aComments as $aComment) {
+ $aTemp = $aComment;
+ if ($aComment['comment_id'] > $iMaxIdComment) {
+ $iMaxIdComment = $aComment['comment_id'];
+ }
+ $aTemp['level'] = $iLevel;
+ unset($aTemp['childNodes']);
+ $aResultCommnets[$aTemp['comment_id']] = $aTemp['level'];
+ if (isset($aComment['childNodes']) and count($aComment['childNodes']) > 0) {
+ $iLevel++;
+ $this->BuildCommentsRecursive($aComment['childNodes'], false);
+ }
+ }
+ $iLevel--;
+ return array('comments' => $aResultCommnets, 'iMaxIdComment' => $iMaxIdComment);
+ }
+
+ /**
+ * Получает привязку комментария к ибранному(добавлен ли коммент в избранное у юзера)
+ *
+ * @param int $sCommentId ID комментария
+ * @param int $sUserId ID пользователя
+ * @return ModuleFavourite_EntityFavourite|null
+ */
+ public function GetFavouriteComment($sCommentId, $sUserId)
+ {
+ return $this->Favourite_GetFavourite($sCommentId, 'comment', $sUserId);
+ }
+
+ /**
+ * Получить список избранного по списку айдишников
+ *
+ * @param array $aCommentId Список ID комментов
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetFavouriteCommentsByArray($aCommentId, $sUserId)
+ {
+ return $this->Favourite_GetFavouritesByArray($aCommentId, 'comment', $sUserId);
+ }
+
+ /**
+ * Получить список избранного по списку айдишников, но используя единый кеш
+ *
+ * @param array $aCommentId Список ID комментов
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetFavouriteCommentsByArraySolid($aCommentId, $sUserId)
+ {
+ return $this->Favourite_GetFavouritesByArraySolid($aCommentId, 'comment', $sUserId);
+ }
+
+ /**
+ * Получает список комментариев из избранного пользователя
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetCommentsFavouriteByUserId($sUserId, $iCurrPage, $iPerPage)
+ {
+ $aCloseTopics = array();
+ /**
+ * Получаем список идентификаторов избранных комментов
+ */
+ $data = ($this->oUserCurrent && $sUserId == $this->oUserCurrent->getId())
+ ? $this->Favourite_GetFavouritesByUserId($sUserId, 'comment', $iCurrPage, $iPerPage, $aCloseTopics)
+ : $this->Favourite_GetFavouriteOpenCommentsByUserId($sUserId, $iCurrPage, $iPerPage);
+ /**
+ * Получаем комменты по переданому массиву айдишников
+ */
+ $data['collection'] = $this->GetCommentsAdditionalData($data['collection']);
+ return $data;
+ }
+
+ /**
+ * Возвращает число комментариев в избранном
+ *
+ * @param int $sUserId ID пользователя
+ * @return int
+ */
+ public function GetCountCommentsFavouriteByUserId($sUserId)
+ {
+ return ($this->oUserCurrent && $sUserId == $this->oUserCurrent->getId())
+ ? $this->Favourite_GetCountFavouritesByUserId($sUserId, 'comment')
+ : $this->Favourite_GetCountFavouriteOpenCommentsByUserId($sUserId);
+ }
+
+ /**
+ * Добавляет комментарий в избранное
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavourite Объект избранного
+ * @return bool|ModuleFavourite_EntityFavourite
+ */
+ public function AddFavouriteComment(ModuleFavourite_EntityFavourite $oFavourite)
+ {
+ if (($oFavourite->getTargetType() == 'comment')
+ && ($oComment = $this->Comment_GetCommentById($oFavourite->getTargetId()))
+ && in_array($oComment->getTargetType(), Config::get('module.comment.favourite_target_allow'))
+ ) {
+ return $this->Favourite_AddFavourite($oFavourite);
+ }
+ return false;
+ }
+
+ /**
+ * Удаляет комментарий из избранного
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavourite Объект избранного
+ * @return bool
+ */
+ public function DeleteFavouriteComment(ModuleFavourite_EntityFavourite $oFavourite)
+ {
+ if (($oFavourite->getTargetType() == 'comment')
+ && ($oComment = $this->Comment_GetCommentById($oFavourite->getTargetId()))
+ && in_array($oComment->getTargetType(), Config::get('module.comment.favourite_target_allow'))
+ ) {
+ return $this->Favourite_DeleteFavourite($oFavourite);
+ }
+ return false;
+ }
+
+ /**
+ * Удаляет комментарии из избранного по списку
+ *
+ * @param array $aCommentId Список ID комментариев
+ * @return bool
+ */
+ public function DeleteFavouriteCommentsByArrayId($aCommentId)
+ {
+ return $this->Favourite_DeleteFavouriteByTargetId($aCommentId, 'comment');
+ }
+
+ /**
+ * Удаляет комментарии из базы данных
+ *
+ * @param array|int $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельцев
+ * @return bool
+ */
+ public function DeleteCommentByTargetId($aTargetId, $sTargetType)
+ {
+ if (!is_array($aTargetId)) {
+ $aTargetId = array($aTargetId);
+ }
+ /**
+ * Получаем список идентификаторов удаляемых комментариев
+ */
+ $aCommentsId = array();
+ foreach ($aTargetId as $sTargetId) {
+ $aComments = $this->GetCommentsByTargetId($sTargetId, $sTargetType);
+ $aCommentsId = array_merge($aCommentsId, array_keys($aComments['comments']));
+ }
+ /**
+ * Если ни одного комментария не найдено, выходим
+ */
+ if (!count($aCommentsId)) {
+ return true;
+ }
+ /**
+ * Чистим зависимые кеши
+ */
+ if (Config::Get('sys.cache.solid')) {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("comment_update", "comment_target_{$sTargetId}_{$sTargetType}"));
+ } else {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("comment_target_{$sTargetId}_{$sTargetType}"));
+ /**
+ * Удаляем кеш для каждого комментария
+ */
+ foreach ($aCommentsId as $iCommentId) {
+ $this->Cache_Delete("comment_{$iCommentId}");
+ }
+ }
+ if ($this->oMapper->DeleteCommentByTargetId($aTargetId, $sTargetType)) {
+ /**
+ * Удаляем комментарии из избранного
+ */
+ $this->DeleteFavouriteCommentsByArrayId($aCommentsId);
+ /**
+ * Удаляем комментарии к топику из прямого эфира
+ */
+ $this->DeleteCommentOnlineByArrayId($aCommentsId, $sTargetType);
+ /**
+ * Удаляем голосование за комментарии
+ */
+ $this->Vote_DeleteVoteByTarget($aCommentsId, 'comment');
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Удаляет коммент из прямого эфира по массиву переданных идентификаторов
+ *
+ * @param array|int $aCommentId
+ * @param string $sTargetType Тип владельцев
+ * @return bool
+ */
+ public function DeleteCommentOnlineByArrayId($aCommentId, $sTargetType)
+ {
+ if (!is_array($aCommentId)) {
+ $aCommentId = array($aCommentId);
+ }
+ /**
+ * Чистим кеш
+ */
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("comment_online_update_{$sTargetType}"));
+ return $this->oMapper->DeleteCommentOnlineByArrayId($aCommentId, $sTargetType);
+ }
+
+ /**
+ * Меняем target parent по массиву идентификаторов
+ *
+ * @param int $sParentId Новый ID родителя владельца
+ * @param string $sTargetType Тип владельца
+ * @param array|int $aTargetId Список ID владельцев
+ * @return bool
+ */
+ public function UpdateTargetParentByTargetId($sParentId, $sTargetType, $aTargetId)
+ {
+ if (!is_array($aTargetId)) {
+ $aTargetId = array($aTargetId);
+ }
+ // чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("comment_new_{$sTargetType}"));
+
+ return $this->oMapper->UpdateTargetParentByTargetId($sParentId, $sTargetType, $aTargetId);
+ }
+
+ /**
+ * Меняем target parent по массиву идентификаторов в таблице комментариев online
+ *
+ * @param int $sParentId Новый ID родителя владельца
+ * @param string $sTargetType Тип владельца
+ * @param array|int $aTargetId Список ID владельцев
+ * @return bool
+ */
+ public function UpdateTargetParentByTargetIdOnline($sParentId, $sTargetType, $aTargetId)
+ {
+ if (!is_array($aTargetId)) {
+ $aTargetId = array($aTargetId);
+ }
+ // чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("comment_online_update_{$sTargetType}"));
+
+ return $this->oMapper->UpdateTargetParentByTargetIdOnline($sParentId, $sTargetType, $aTargetId);
+ }
+
+ /**
+ * Меняет target parent на новый
+ *
+ * @param int $sParentId Прежний ID родителя владельца
+ * @param string $sTargetType Тип владельца
+ * @param int $sParentIdNew Новый ID родителя владельца
+ * @return bool
+ */
+ public function MoveTargetParent($sParentId, $sTargetType, $sParentIdNew)
+ {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("comment_new_{$sTargetType}"));
+ return $this->oMapper->MoveTargetParent($sParentId, $sTargetType, $sParentIdNew);
+ }
+
+ /**
+ * Меняет target parent на новый в прямом эфире
+ *
+ * @param int $sParentId Прежний ID родителя владельца
+ * @param string $sTargetType Тип владельца
+ * @param int $sParentIdNew Новый ID родителя владельца
+ * @return bool
+ */
+ public function MoveTargetParentOnline($sParentId, $sTargetType, $sParentIdNew)
+ {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("comment_online_update_{$sTargetType}"));
+ return $this->oMapper->MoveTargetParentOnline($sParentId, $sTargetType, $sParentIdNew);
+ }
+
+ /**
+ * Перестраивает дерево комментариев
+ * Восстанавливает значения left, right и level
+ *
+ * @param int $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ */
+ public function RestoreTree($aTargetId = null, $sTargetType = null)
+ {
+ // обработать конкретную сущность
+ if (!is_null($aTargetId) and !is_null($sTargetType)) {
+ $this->oMapper->RestoreTree(null, 0, -1, $aTargetId, $sTargetType);
+ return;
+ }
+ $aType = array();
+ // обработать все сущности конкретного типа
+ if (!is_null($sTargetType)) {
+ $aType[] = $sTargetType;
+ } else {
+ // обработать все сущности всех типов
+ $aType = $this->oMapper->GetCommentTypes();
+ }
+ foreach ($aType as $sTargetType) {
+ // для каждого типа получаем порциями ID сущностей
+ $iPage = 1;
+ $iPerPage = 50;
+ while ($aResult = $this->oMapper->GetTargetIdByType($sTargetType, $iPage, $iPerPage)) {
+ foreach ($aResult as $Row) {
+ $this->oMapper->RestoreTree(null, 0, -1, $Row['target_id'], $sTargetType);
+ }
+ $iPage++;
+ }
+ }
+ }
+
+ /**
+ * Пересчитывает счетчик избранных комментариев
+ *
+ * @return bool
+ */
+ public function RecalculateFavourite()
+ {
+ return $this->oMapper->RecalculateFavourite();
+ }
+
+ /**
+ * Получает список комментариев по фильтру
+ *
+ * @param array $aFilter Фильтр выборки
+ * @param array $aOrder Сортировка
+ * @param int $iCurrPage Номер текущей страницы
+ * @param int $iPerPage Количество элементов на одну страницу
+ * @param array $aAllowData Список типов данных, которые нужно подтянуть к списку комментов
+ * @return array
+ */
+ public function GetCommentsByFilter($aFilter, $aOrder, $iCurrPage, $iPerPage, $aAllowData = null)
+ {
+ if (is_null($aAllowData)) {
+ $aAllowData = array('target', 'user' => array());
+ }
+ $aCollection = $this->oMapper->GetCommentsByFilter($aFilter, $aOrder, $iCount, $iCurrPage, $iPerPage);
+ return array('collection' => $this->GetCommentsAdditionalData($aCollection, $aAllowData), 'count' => $iCount);
+ }
+
+ /**
+ * Алиас для корректной работы ORM
+ *
+ * @param array $aCommentId Список ID комментариев
+ * @return array
+ */
+ public function GetCommentItemsByArrayId($aCommentId)
+ {
+ return $this->GetCommentsByArrayId($aCommentId);
+ }
+}
diff --git a/application/classes/modules/comment/entity/Comment.entity.class.php b/application/classes/modules/comment/entity/Comment.entity.class.php
new file mode 100644
index 0000000..2c49512
--- /dev/null
+++ b/application/classes/modules/comment/entity/Comment.entity.class.php
@@ -0,0 +1,578 @@
+
+ *
+ */
+
+/**
+ * Объект сущности комментариев
+ *
+ * @package application.modules.comment
+ * @since 1.0
+ */
+class ModuleComment_EntityComment extends Entity
+{
+ /**
+ * Возвращает ID коммента
+ *
+ * @return int|null
+ */
+ public function getId()
+ {
+ return $this->_getDataOne('comment_id');
+ }
+
+ /**
+ * Возвращает ID родительского коммента
+ *
+ * @return int|null
+ */
+ public function getPid()
+ {
+ return $this->_getDataOne('comment_pid');
+ }
+
+ /**
+ * Возвращает значение left для дерева nested set
+ *
+ * @return int|null
+ */
+ public function getLeft()
+ {
+ return $this->_getDataOne('comment_left');
+ }
+
+ /**
+ * Возвращает значение right для дерева nested set
+ *
+ * @return int|null
+ */
+ public function getRight()
+ {
+ return $this->_getDataOne('comment_right');
+ }
+
+ /**
+ * Возвращает ID владельца
+ *
+ * @return int|null
+ */
+ public function getTargetId()
+ {
+ return $this->_getDataOne('target_id');
+ }
+
+ /**
+ * Возвращает тип владельца
+ *
+ * @return string|null
+ */
+ public function getTargetType()
+ {
+ return $this->_getDataOne('target_type');
+ }
+
+ /**
+ * Возвращет ID родителя владельца
+ *
+ * @return int|null
+ */
+ public function getTargetParentId()
+ {
+ return $this->_getDataOne('target_parent_id') ? $this->_getDataOne('target_parent_id') : 0;
+ }
+
+ /**
+ * Возвращает ID пользователя, автора комментария
+ *
+ * @return int|null
+ */
+ public function getUserId()
+ {
+ return $this->_getDataOne('user_id');
+ }
+
+ /**
+ * Возвращает текст комментария
+ *
+ * @return string|null
+ */
+ public function getText()
+ {
+ return $this->_getDataOne('comment_text');
+ }
+
+ /**
+ * Возвращает исходный текст комментария
+ *
+ * @return string|null
+ */
+ public function getTextSource()
+ {
+ return $this->_getDataOne('comment_text_source') ? $this->_getDataOne('comment_text_source') : '';
+ }
+
+ /**
+ * Возвращает дату комментария
+ *
+ * @return string|null
+ */
+ public function getDate()
+ {
+ return $this->_getDataOne('comment_date');
+ }
+
+ /**
+ * Возвращает дату последнего редактирования комментария
+ *
+ * @return string|null
+ */
+ public function getDateEdit()
+ {
+ return $this->_getDataOne('comment_date_edit');
+ }
+
+ /**
+ * Возвращает IP пользователя
+ *
+ * @return string|null
+ */
+ public function getUserIp()
+ {
+ return $this->_getDataOne('comment_user_ip');
+ }
+
+ /**
+ * Возвращает рейтинг комментария
+ *
+ * @return string
+ */
+ public function getRating()
+ {
+ return number_format(round($this->_getDataOne('comment_rating'), 2), 0, '.', '');
+ }
+
+ /**
+ * Возвращает количество проголосовавших
+ *
+ * @return int|null
+ */
+ public function getCountVote()
+ {
+ return $this->_getDataOne('comment_count_vote');
+ }
+
+ /**
+ * Возвращает количество редактирований комментария
+ *
+ * @return int|null
+ */
+ public function getCountEdit()
+ {
+ return $this->_getDataOne('comment_count_edit');
+ }
+
+ /**
+ * Возвращает флаг удаленного комментария
+ *
+ * @return int|null
+ */
+ public function getDelete()
+ {
+ return $this->_getDataOne('comment_delete');
+ }
+
+ /**
+ * Возвращает флаг опубликованного комментария
+ *
+ * @return int
+ */
+ public function getPublish()
+ {
+ return $this->_getDataOne('comment_publish') ? 1 : 0;
+ }
+
+ /**
+ * Возвращает хеш комментария
+ *
+ * @return string|null
+ */
+ public function getTextHash()
+ {
+ return $this->_getDataOne('comment_text_hash');
+ }
+
+ /**
+ * Возвращает уровень вложенности комментария
+ *
+ * @return int|null
+ */
+ public function getLevel()
+ {
+ return $this->_getDataOne('comment_level');
+ }
+
+ /**
+ * Проверяет является ли комментарий плохим
+ *
+ * @return bool
+ */
+ public function isBad()
+ {
+ if ($this->getRating() <= Config::Get('module.comment.bad')) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает объект пользователя
+ *
+ * @return ModuleUser_EntityUser|null
+ */
+ public function getUser()
+ {
+ return $this->_getDataOne('user');
+ }
+
+ /**
+ * Возвращает объект владельца
+ *
+ * @return mixed|null
+ */
+ public function getTarget()
+ {
+ return $this->_getDataOne('target');
+ }
+
+ /**
+ * Возвращает объект голосования
+ *
+ * @return ModuleVote_EntityVote|null
+ */
+ public function getVote()
+ {
+ return $this->_getDataOne('vote');
+ }
+
+ /**
+ * Проверяет является ли комментарий избранным у текущего пользователя
+ *
+ * @return bool|null
+ */
+ public function getIsFavourite()
+ {
+ return $this->_getDataOne('comment_is_favourite');
+ }
+
+ /**
+ * Возвращает количество избранного
+ *
+ * @return int|null
+ */
+ public function getCountFavourite()
+ {
+ return $this->_getDataOne('comment_count_favourite');
+ }
+
+ /**
+ * Проверка на разрешение редактировать комментарий
+ *
+ * @return mixed
+ */
+ public function isAllowEdit()
+ {
+ return $this->ACL_IsAllowEditComment($this, $this->User_GetUserCurrent());
+ }
+
+ /**
+ * Возвращает количество секунд в течении которых возможно редактирование
+ *
+ * @return int
+ */
+ public function getEditTimeRemaining()
+ {
+ $oUser = $this->User_GetUserCurrent();
+ if (($oUser and $oUser->isAdministrator()) or !Config::Get('acl.update.comment.limit_time')) {
+ return 0;
+ }
+ $iTime = Config::Get('acl.update.comment.limit_time') - (time() - strtotime($this->getDate()));
+ return $iTime > 0 ? $iTime : 0;
+ }
+
+ /**
+ * Проверка на разрешение удалить комментарий
+ *
+ * @return mixed
+ */
+ public function isAllowDelete()
+ {
+ return $this->ACL_IsAllowDeleteComment($this, $this->User_GetUserCurrent());
+ }
+
+
+ /**
+ * Устанавливает ID комментария
+ *
+ * @param int $data
+ */
+ public function setId($data)
+ {
+ $this->_aData['comment_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID родительского комментария
+ *
+ * @param int $data
+ */
+ public function setPid($data)
+ {
+ $this->_aData['comment_pid'] = $data;
+ }
+
+ /**
+ * Устанавливает значени left для дерева nested set
+ *
+ * @param int $data
+ */
+ public function setLeft($data)
+ {
+ $this->_aData['comment_left'] = $data;
+ }
+
+ /**
+ * Устанавливает значени right для дерева nested set
+ *
+ * @param int $data
+ */
+ public function setRight($data)
+ {
+ $this->_aData['comment_right'] = $data;
+ }
+
+ /**
+ * Устанавливает ID владельца
+ *
+ * @param int $data
+ */
+ public function setTargetId($data)
+ {
+ $this->_aData['target_id'] = $data;
+ }
+
+ /**
+ * Устанавливает тип владельца
+ *
+ * @param string $data
+ */
+ public function setTargetType($data)
+ {
+ $this->_aData['target_type'] = $data;
+ }
+
+ /**
+ * Устанавливает ID родителя владельца
+ *
+ * @param int $data
+ */
+ public function setTargetParentId($data)
+ {
+ $this->_aData['target_parent_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID пользователя
+ *
+ * @param int $data
+ */
+ public function setUserId($data)
+ {
+ $this->_aData['user_id'] = $data;
+ }
+
+ /**
+ * Устанавливает текст комментария
+ *
+ * @param string $data
+ */
+ public function setText($data)
+ {
+ $this->_aData['comment_text'] = $data;
+ }
+
+ /**
+ * Устанавливает исходный текст комментария
+ *
+ * @param string $data
+ */
+ public function setTextSource($data)
+ {
+ $this->_aData['comment_text_source'] = $data;
+ }
+
+ /**
+ * Устанавливает дату комментария
+ *
+ * @param string $data
+ */
+ public function setDate($data)
+ {
+ $this->_aData['comment_date'] = $data;
+ }
+
+ /**
+ * Устанавливает дату последнего редактирования комментария
+ *
+ * @param string $data
+ */
+ public function setDateEdit($data)
+ {
+ $this->_aData['comment_date_edit'] = $data;
+ }
+
+ /**
+ * Устанавливает IP пользователя
+ *
+ * @param string $data
+ */
+ public function setUserIp($data)
+ {
+ $this->_aData['comment_user_ip'] = $data;
+ }
+
+ /**
+ * Устанавливает рейтинг комментария
+ *
+ * @param float $data
+ */
+ public function setRating($data)
+ {
+ $this->_aData['comment_rating'] = $data;
+ }
+
+ /**
+ * Устанавливает количество проголосавших
+ *
+ * @param int $data
+ */
+ public function setCountVote($data)
+ {
+ $this->_aData['comment_count_vote'] = $data;
+ }
+
+ /**
+ * Устанавливает количество редактирований комментария
+ *
+ * @param int $data
+ */
+ public function setCountEdit($data)
+ {
+ $this->_aData['comment_count_edit'] = $data;
+ }
+
+ /**
+ * Устанавливает флаг удаленности комментария
+ *
+ * @param int $data
+ */
+ public function setDelete($data)
+ {
+ $this->_aData['comment_delete'] = $data;
+ }
+
+ /**
+ * Устанавливает флаг публикации
+ *
+ * @param int $data
+ */
+ public function setPublish($data)
+ {
+ $this->_aData['comment_publish'] = $data;
+ }
+
+ /**
+ * Устанавливает хеш комментария
+ *
+ * @param strign $data
+ */
+ public function setTextHash($data)
+ {
+ $this->_aData['comment_text_hash'] = $data;
+ }
+
+ /**
+ * Устанавливает уровень вложенности комментария
+ *
+ * @param int $data
+ */
+ public function setLevel($data)
+ {
+ $this->_aData['comment_level'] = $data;
+ }
+
+ /**
+ * Устаналвает объект пользователя
+ *
+ * @param ModuleUser_EntityUser $data
+ */
+ public function setUser($data)
+ {
+ $this->_aData['user'] = $data;
+ }
+
+ /**
+ * Устанавливает объект владельца
+ *
+ * @param mixed $data
+ */
+ public function setTarget($data)
+ {
+ $this->_aData['target'] = $data;
+ }
+
+ /**
+ * Устанавливает объект голосования
+ *
+ * @param ModuleVote_EntityVote $data
+ */
+ public function setVote($data)
+ {
+ $this->_aData['vote'] = $data;
+ }
+
+ /**
+ * Устанавливает факт нахождения комментария в избранном у текущего пользователя
+ *
+ * @param bool $data
+ */
+ public function setIsFavourite($data)
+ {
+ $this->_aData['comment_is_favourite'] = $data;
+ }
+
+ /**
+ * Устанавливает количество избранного
+ *
+ * @param int $data
+ */
+ public function setCountFavourite($data)
+ {
+ $this->_aData['comment_count_favourite'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/comment/entity/CommentOnline.entity.class.php b/application/classes/modules/comment/entity/CommentOnline.entity.class.php
new file mode 100644
index 0000000..2509854
--- /dev/null
+++ b/application/classes/modules/comment/entity/CommentOnline.entity.class.php
@@ -0,0 +1,109 @@
+
+ *
+ */
+
+/**
+ * Объект сущности прямого эфира
+ *
+ * @package application.modules.comment
+ * @since 1.0
+ */
+class ModuleComment_EntityCommentOnline extends Entity
+{
+ /**
+ * Возвращает ID владельца
+ *
+ * @return int|null
+ */
+ public function getTargetId()
+ {
+ return $this->_getDataOne('target_id');
+ }
+
+ /**
+ * Возвращает тип владельца
+ *
+ * @return string|null
+ */
+ public function getTargetType()
+ {
+ return $this->_getDataOne('target_type');
+ }
+
+ /**
+ * Возвращает ID комментария
+ *
+ * @return int|null
+ */
+ public function getCommentId()
+ {
+ return $this->_getDataOne('comment_id');
+ }
+
+ /**
+ * Возвращает ID родителя владельца
+ *
+ * @return int
+ */
+ public function getTargetParentId()
+ {
+ return $this->_getDataOne('target_parent_id') ? $this->_getDataOne('target_parent_id') : 0;
+ }
+
+ /**
+ * Устанавливает ID владельца
+ *
+ * @param int $data
+ */
+ public function setTargetId($data)
+ {
+ $this->_aData['target_id'] = $data;
+ }
+
+ /**
+ * Устанавливает тип владельца
+ *
+ * @param string $data
+ */
+ public function setTargetType($data)
+ {
+ $this->_aData['target_type'] = $data;
+ }
+
+ /**
+ * Устанавливает ID комментария
+ *
+ * @param int $data
+ */
+ public function setCommentId($data)
+ {
+ $this->_aData['comment_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID родителя владельца
+ *
+ * @param int $data
+ */
+ public function setTargetParentId($data)
+ {
+ $this->_aData['target_parent_id'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/comment/mapper/Comment.mapper.class.php b/application/classes/modules/comment/mapper/Comment.mapper.class.php
new file mode 100644
index 0000000..d459d5b
--- /dev/null
+++ b/application/classes/modules/comment/mapper/Comment.mapper.class.php
@@ -0,0 +1,1031 @@
+
+ *
+ */
+
+/**
+ * Маппер комментариев, работа с базой данных
+ *
+ * @package application.modules.comment
+ * @since 1.0
+ */
+class ModuleComment_MapperComment extends Mapper
+{
+
+ /**
+ * Получить комменты по рейтингу и дате
+ *
+ * @param string $sDate Дата за которую выводить рейтинг
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iLimit Количество элементов
+ * @param array $aExcludeTarget Список ID владельцев, которые необходимо исключить из выдачи
+ * @param array $aExcludeParentTarget Список ID родителей владельцев, которые необходимо исключить из выдачи
+ * @return array
+ */
+ public function GetCommentsRatingByDate(
+ $sDate,
+ $sTargetType,
+ $iLimit,
+ $aExcludeTarget = array(),
+ $aExcludeParentTarget = array()
+ ) {
+ $sql = "SELECT
+ comment_id
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ target_type = ?
+ AND
+ comment_date >= ?
+ AND
+ comment_rating >= 0
+ AND
+ comment_delete = 0
+ AND
+ comment_publish = 1
+ { AND target_id NOT IN(?a) }
+ { AND target_parent_id NOT IN (?a) }
+ ORDER by comment_rating desc, comment_id desc
+ LIMIT 0, ?d ";
+ $aComments = array();
+ if ($aRows = $this->oDb->select(
+ $sql, $sTargetType, $sDate,
+ (is_array($aExcludeTarget) && count($aExcludeTarget)) ? $aExcludeTarget : DBSIMPLE_SKIP,
+ (count($aExcludeParentTarget) ? $aExcludeParentTarget : DBSIMPLE_SKIP),
+ $iLimit
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aComments[] = $aRow['comment_id'];
+ }
+ }
+ return $aComments;
+ }
+
+ /**
+ * Получает уникальный коммент, это помогает спастись от дублей комментов
+ *
+ * @param int $sTargetId ID владельца комментария
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $sUserId ID пользователя
+ * @param int $sCommentPid ID родительского комментария
+ * @param string $sHash Хеш строка текста комментария
+ * @return int|null
+ */
+ public function GetCommentUnique($sTargetId, $sTargetType, $sUserId, $sCommentPid, $sHash)
+ {
+ $sql = "SELECT comment_id FROM " . Config::Get('db.table.comment') . "
+ WHERE
+ target_id = ?d
+ AND
+ target_type = ?
+ AND
+ user_id = ?d
+ AND
+ ((comment_pid = ?) or (? is NULL and comment_pid is NULL))
+ AND
+ comment_text_hash =?
+ ";
+ if ($aRow = $this->oDb->selectRow($sql, $sTargetId, $sTargetType, $sUserId, $sCommentPid, $sCommentPid, $sHash)
+ ) {
+ return $aRow['comment_id'];
+ }
+ return null;
+ }
+
+ /**
+ * Получить все комменты
+ *
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param array $aExcludeTarget Список ID владельцев, которые необходимо исключить из выдачи
+ * @param array $aExcludeParentTarget Список ID родителей владельцев, которые необходимо исключить из выдачи, например, исключить комментарии топиков к определенным блогам(закрытым)
+ * @return array
+ */
+ public function GetCommentsAll(
+ $sTargetType,
+ &$iCount,
+ $iCurrPage,
+ $iPerPage,
+ $aExcludeTarget = array(),
+ $aExcludeParentTarget = array()
+ ) {
+ $sql = "SELECT
+ comment_id
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ target_type = ?
+ AND
+ comment_delete = 0
+ AND
+ comment_publish = 1
+ { AND target_id NOT IN(?a) }
+ { AND target_parent_id NOT IN(?a) }
+ ORDER by comment_id desc
+ LIMIT ?d, ?d ";
+ $aComments = array();
+ if ($aRows = $this->oDb->selectPage(
+ $iCount, $sql, $sTargetType,
+ (count($aExcludeTarget) ? $aExcludeTarget : DBSIMPLE_SKIP),
+ (count($aExcludeParentTarget) ? $aExcludeParentTarget : DBSIMPLE_SKIP),
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aComments[] = $aRow['comment_id'];
+ }
+ }
+ return $aComments;
+ }
+
+ /**
+ * Список комментов по ID
+ *
+ * @param array $aArrayId Список ID комментариев
+ * @return array
+ */
+ public function GetCommentsByArrayId($aArrayId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ comment_id IN(?a)
+ ORDER by FIELD(comment_id,?a)";
+ $aComments = array();
+ if ($aRows = $this->oDb->select($sql, $aArrayId, $aArrayId)) {
+ foreach ($aRows as $aRow) {
+ $aComments[] = Engine::GetEntity('Comment', $aRow);
+ }
+ }
+ return $aComments;
+ }
+
+ /**
+ * Получить все комменты сгрупированные по типу(для вывода прямого эфира)
+ *
+ * @param string $sTargetType Тип владельца комментария
+ * @param array $aExcludeTargets Список ID владельцев для исключения
+ * @param int $iLimit Количество элементов
+ * @return array
+ */
+ public function GetCommentsOnline($sTargetType, $aExcludeTargets, $iLimit)
+ {
+ $sql = "SELECT
+ comment_id
+ FROM
+ " . Config::Get('db.table.comment_online') . "
+ WHERE
+ target_type = ?
+ { AND target_parent_id NOT IN(?a) }
+ ORDER by comment_online_id desc limit 0, ?d ; ";
+
+ $aComments = array();
+ if ($aRows = $this->oDb->select(
+ $sql, $sTargetType,
+ (count($aExcludeTargets) ? $aExcludeTargets : DBSIMPLE_SKIP),
+ $iLimit
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aComments[] = $aRow['comment_id'];
+ }
+ }
+ return $aComments;
+ }
+
+ /**
+ * Получить комменты по владельцу
+ *
+ * @param int $sId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @return array
+ */
+ public function GetCommentsByTargetId($sId, $sTargetType)
+ {
+ $sql = "SELECT
+ comment_id,
+ comment_id as ARRAY_KEY,
+ comment_pid as PARENT_KEY
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ target_id = ?d
+ AND
+ target_type = ?
+ ORDER by comment_id asc;
+ ";
+ if ($aRows = $this->oDb->select($sql, $sId, $sTargetType)) {
+ return $aRows;
+ }
+ return null;
+ }
+
+ /**
+ * Получает комменты используя nested set
+ *
+ * @param int $sId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @return array
+ */
+ public function GetCommentsTreeByTargetId($sId, $sTargetType)
+ {
+ $sql = "SELECT
+ comment_id
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ target_id = ?d
+ AND
+ target_type = ?
+ ORDER by comment_left asc;
+ ";
+ $aComments = array();
+ if ($aRows = $this->oDb->select($sql, $sId, $sTargetType)) {
+ foreach ($aRows as $aRow) {
+ $aComments[] = $aRow['comment_id'];
+ }
+ }
+ return $aComments;
+ }
+
+ /**
+ * Получает комменты используя nested set
+ *
+ * @param int $sId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetCommentsTreePageByTargetId($sId, $sTargetType, &$iCount, $iPage, $iPerPage)
+ {
+
+ /**
+ * Сначала получаем корни и определяем границы выборки веток
+ */
+ $sql = "SELECT
+ comment_left,
+ comment_right
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ target_id = ?d
+ AND
+ target_type = ?
+ AND
+ comment_pid IS NULL
+ ORDER by comment_left desc
+ LIMIT ?d , ?d ;";
+ $aComments = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql, $sId, $sTargetType, ($iPage - 1) * $iPerPage, $iPerPage)) {
+ $aCmt = array_pop($aRows);
+ $iLeft = $aCmt['comment_left'];
+ if ($aRows) {
+ $aCmt = array_shift($aRows);
+ }
+ $iRight = $aCmt['comment_right'];
+ } else {
+ return array();
+ }
+
+ /**
+ * Теперь получаем полный список комментов
+ */
+ $sql = "SELECT
+ comment_id
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ target_id = ?d
+ AND
+ target_type = ?
+ AND
+ comment_left >= ?d
+ AND
+ comment_right <= ?d
+ ORDER by comment_left asc;
+ ";
+ $aComments = array();
+ if ($aRows = $this->oDb->select($sql, $sId, $sTargetType, $iLeft, $iRight)) {
+ foreach ($aRows as $aRow) {
+ $aComments[] = $aRow['comment_id'];
+ }
+ }
+
+ return $aComments;
+ }
+
+ /**
+ * Возвращает количество дочерних комментариев у корневого коммента
+ *
+ * @param int $sId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @return int
+ */
+ public function GetCountCommentsRootByTargetId($sId, $sTargetType)
+ {
+ $sql = "SELECT
+ count(comment_id) as c
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ target_id = ?d
+ AND
+ target_type = ?
+ AND
+ comment_pid IS NULL ;";
+
+ if ($aRow = $this->oDb->selectRow($sql, $sId, $sTargetType)) {
+ return $aRow['c'];
+ }
+ }
+
+ /**
+ * Возвращает количество комментариев
+ *
+ * @param int $sId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iLeft Значение left для дерева nested set
+ * @return int
+ */
+ public function GetCountCommentsAfterByTargetId($sId, $sTargetType, $iLeft)
+ {
+ $sql = "SELECT
+ count(comment_id) as c
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ target_id = ?d
+ AND
+ target_type = ?
+ AND
+ comment_pid IS NULL
+ AND
+ comment_left >= ?d ;";
+
+ if ($aRow = $this->oDb->selectRow($sql, $sId, $sTargetType, $iLeft)) {
+ return $aRow['c'];
+ }
+ }
+
+ /**
+ * Возвращает корневой комментарий
+ *
+ * @param int $sId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iLeft Значение left для дерева nested set
+ * @return ModuleComment_EntityComment|null
+ */
+ public function GetCommentRootByTargetIdAndChildren($sId, $sTargetType, $iLeft)
+ {
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ target_id = ?d
+ AND
+ target_type = ?
+ AND
+ comment_pid IS NULL
+ AND
+ comment_left < ?d
+ AND
+ comment_right > ?d
+ LIMIT 0,1 ;";
+
+ if ($aRow = $this->oDb->selectRow($sql, $sId, $sTargetType, $iLeft, $iLeft)) {
+ return Engine::GetEntity('Comment', $aRow);
+ }
+ return null;
+ }
+
+ /**
+ * Получить новые комменты для владельца
+ *
+ * @param int $sId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $sIdCommentLast ID последнего прочитанного комментария
+ * @return array
+ */
+ public function GetCommentsNewByTargetId($sId, $sTargetType, $sIdCommentLast)
+ {
+ $sql = "SELECT
+ comment_id
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ target_id = ?d
+ AND
+ target_type = ?
+ AND
+ comment_id > ?d
+ ORDER by comment_id asc;
+ ";
+ $aComments = array();
+ if ($aRows = $this->oDb->select($sql, $sId, $sTargetType, $sIdCommentLast)) {
+ foreach ($aRows as $aRow) {
+ $aComments[] = $aRow['comment_id'];
+ }
+ }
+ return $aComments;
+ }
+
+ /**
+ * Получить комменты по юзеру
+ *
+ * @param int $sId ID пользователя
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iCount Возращает общее количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param array $aExcludeTarget Список ID владельцев, которые необходимо исключить из выдачи
+ * @param array $aExcludeParentTarget Список ID родителей владельцев, которые необходимо исключить из выдачи
+ * @return array
+ */
+ public function GetCommentsByUserId(
+ $sId,
+ $sTargetType,
+ &$iCount,
+ $iCurrPage,
+ $iPerPage,
+ $aExcludeTarget = array(),
+ $aExcludeParentTarget = array()
+ ) {
+ $sql = "SELECT
+ comment_id
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ user_id = ?d
+ AND
+ target_type= ?
+ AND
+ comment_delete = 0
+ AND
+ comment_publish = 1
+ { AND target_id NOT IN (?a) }
+ { AND target_parent_id NOT IN (?a) }
+ ORDER by comment_id desc
+ LIMIT ?d, ?d ";
+ $aComments = array();
+ if ($aRows = $this->oDb->selectPage(
+ $iCount, $sql, $sId,
+ $sTargetType,
+ (count($aExcludeTarget) ? $aExcludeTarget : DBSIMPLE_SKIP),
+ (count($aExcludeParentTarget) ? $aExcludeParentTarget : DBSIMPLE_SKIP),
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aComments[] = $aRow['comment_id'];
+ }
+ }
+ return $aComments;
+ }
+
+ /**
+ * Получает количество комментариев одного пользователя
+ *
+ * @param id $sId ID пользователя
+ * @param string $sTargetType Тип владельца комментария
+ * @param array $aExcludeTarget Список ID владельцев, которые необходимо исключить из выдачи
+ * @param array $aExcludeParentTarget Список ID родителей владельцев, которые необходимо исключить из выдачи
+ * @return int
+ */
+ public function GetCountCommentsByUserId(
+ $sId,
+ $sTargetType,
+ $aExcludeTarget = array(),
+ $aExcludeParentTarget = array()
+ ) {
+ $sql = "SELECT
+ count(comment_id) as count
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ user_id = ?d
+ AND
+ target_type= ?
+ AND
+ comment_delete = 0
+ AND
+ comment_publish = 1
+ { AND target_id NOT IN (?a) }
+ { AND target_parent_id NOT IN (?a) }
+ ";
+ if ($aRow = $this->oDb->selectRow(
+ $sql, $sId, $sTargetType,
+ (count($aExcludeTarget) ? $aExcludeTarget : DBSIMPLE_SKIP),
+ (count($aExcludeParentTarget) ? $aExcludeParentTarget : DBSIMPLE_SKIP)
+ )
+ ) {
+ return $aRow['count'];
+ }
+ return false;
+ }
+
+ /**
+ * Добавляет коммент
+ *
+ * @param ModuleComment_EntityComment $oComment Объект комментария
+ * @return bool|int
+ */
+ public function AddComment(ModuleComment_EntityComment $oComment)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.comment') . "
+ (comment_pid,
+ target_id,
+ target_type,
+ target_parent_id,
+ user_id,
+ comment_text,
+ comment_text_source,
+ comment_date,
+ comment_user_ip,
+ comment_publish,
+ comment_text_hash
+ )
+ VALUES(?, ?d, ?, ?d, ?d, ?, ?, ?, ?, ?d, ?)
+ ";
+ if ($iId = $this->oDb->query($sql, $oComment->getPid(), $oComment->getTargetId(), $oComment->getTargetType(),
+ $oComment->getTargetParentId(), $oComment->getUserId(), $oComment->getText(), $oComment->getTextSource(),
+ $oComment->getDate(), $oComment->getUserIp(), $oComment->getPublish(), $oComment->getTextHash())
+ ) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Добавляет коммент в дерево nested set
+ *
+ * @param ModuleComment_EntityComment $oComment Объект комментария
+ * @return bool|int
+ */
+ public function AddCommentTree(ModuleComment_EntityComment $oComment)
+ {
+ $this->oDb->transaction();
+
+ if ($oComment->getPid() and $oCommentParent = $this->GetCommentsByArrayId(array($oComment->getPid()))) {
+ $oCommentParent = $oCommentParent[0];
+ $iLeft = $oCommentParent->getRight();
+ $iLevel = $oCommentParent->getLevel() + 1;
+
+ $sql = "UPDATE " . Config::Get('db.table.comment') . " SET comment_left=comment_left+2 WHERE target_id=?d and target_type=? and comment_left>? ;";
+ $this->oDb->query($sql, $oComment->getTargetId(), $oComment->getTargetType(), $iLeft - 1);
+ $sql = "UPDATE " . Config::Get('db.table.comment') . " SET comment_right=comment_right+2 WHERE target_id=?d and target_type=? and comment_right>? ;";
+ $this->oDb->query($sql, $oComment->getTargetId(), $oComment->getTargetType(), $iLeft - 1);
+ } else {
+ if ($oCommentLast = $this->GetCommentLast($oComment->getTargetId(), $oComment->getTargetType())) {
+ $iLeft = $oCommentLast->getRight() + 1;
+ } else {
+ $iLeft = 1;
+ }
+ $iLevel = 0;
+ }
+
+ if ($iId = $this->AddComment($oComment)) {
+ $sql = "UPDATE " . Config::Get('db.table.comment') . " SET comment_left = ?d, comment_right = ?d, comment_level = ?d WHERE comment_id = ? ;";
+ $this->oDb->query($sql, $iLeft, $iLeft + 1, $iLevel, $iId);
+ $this->oDb->commit();
+ return $iId;
+ }
+
+ if (strtolower(Config::Get('db.tables.engine')) == 'innodb') {
+ $this->oDb->rollback();
+ }
+
+ return false;
+ }
+
+ /**
+ * Возвращает последний комментарий
+ *
+ * @param int $sTargetId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @return ModuleComment_EntityComment|null
+ */
+ public function GetCommentLast($sTargetId, $sTargetType)
+ {
+ $sql = "SELECT * FROM " . Config::Get('db.table.comment') . "
+ WHERE
+ target_id = ?d
+ AND
+ target_type = ?
+ ORDER BY comment_right DESC
+ LIMIT 0,1
+ ";
+ if ($aRow = $this->oDb->selectRow($sql, $sTargetId, $sTargetType)) {
+ return Engine::GetEntity('Comment', $aRow);
+ }
+ return null;
+ }
+
+ /**
+ * Добавляет новый коммент в прямой эфир
+ *
+ * @param ModuleComment_EntityCommentOnline $oCommentOnline Объект онлайн комментария
+ * @return bool|int
+ */
+ public function AddCommentOnline(ModuleComment_EntityCommentOnline $oCommentOnline)
+ {
+ $sql = "REPLACE INTO " . Config::Get('db.table.comment_online') . "
+ SET
+ target_id= ?d ,
+ target_type= ? ,
+ target_parent_id = ?d,
+ comment_id= ?d
+ ";
+ if ($iId = $this->oDb->query($sql, $oCommentOnline->getTargetId(), $oCommentOnline->getTargetType(),
+ $oCommentOnline->getTargetParentId(), $oCommentOnline->getCommentId())
+ ) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Удаляет коммент из прямого эфира
+ *
+ * @param int $sTargetId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @return bool
+ */
+ public function DeleteCommentOnlineByTargetId($sTargetId, $sTargetType)
+ {
+ $sql = "DELETE FROM " . Config::Get('db.table.comment_online') . " WHERE target_id = ?d and target_type = ? ";
+ $res = $this->oDb->query($sql, $sTargetId, $sTargetType);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Обновляет коммент
+ *
+ * @param ModuleComment_EntityComment $oComment Объект комментария
+ * @return bool
+ */
+ public function UpdateComment(ModuleComment_EntityComment $oComment)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.comment') . "
+ SET
+ comment_text= ?,
+ comment_text_source= ?,
+ comment_rating= ?f,
+ comment_count_vote= ?d,
+ comment_count_favourite= ?d,
+ comment_count_edit= ?d,
+ comment_date_edit= ?,
+ comment_delete = ?d ,
+ comment_publish = ?d ,
+ comment_text_hash = ?
+ WHERE
+ comment_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oComment->getText(), $oComment->getTextSource(), $oComment->getRating(),
+ $oComment->getCountVote(), $oComment->getCountFavourite(), $oComment->getCountEdit(),
+ $oComment->getDateEdit(), $oComment->getDelete(), $oComment->getPublish(), $oComment->getTextHash(),
+ $oComment->getId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Устанавливает publish у коммента
+ *
+ * @param int $sTargetId ID владельца коммента
+ * @param string $sTargetType Тип владельца комментария
+ * @param int $iPublish Статус отображать комментарии или нет
+ * @return bool
+ */
+ public function SetCommentsPublish($sTargetId, $sTargetType, $iPublish)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.comment') . "
+ SET
+ comment_publish= ?
+ WHERE
+ target_id = ?d AND target_type = ?
+ ";
+ $res = $this->oDb->query($sql, $iPublish, $sTargetId, $sTargetType);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Удаляет комментарии из базы данных
+ *
+ * @param array|int $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельцев
+ * @return bool
+ */
+ public function DeleteCommentByTargetId($aTargetId, $sTargetType)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.comment') . "
+ WHERE
+ target_id IN (?a)
+ AND
+ target_type = ?
+ ORDER BY comment_id DESC
+ ";
+ $res = $this->oDb->query($sql, $aTargetId, $sTargetType);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Удаляет коммент из прямого эфира по массиву переданных идентификаторов
+ *
+ * @param array|int $aCommentId
+ * @param string $sTargetType Тип владельцев
+ * @return bool
+ */
+ public function DeleteCommentOnlineByArrayId($aCommentId, $sTargetType)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.comment_online') . "
+ WHERE
+ comment_id IN (?a)
+ AND
+ target_type = ?
+ ";
+ $res = $this->oDb->query($sql, $aCommentId, $sTargetType);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Меняем target parent по массиву идентификаторов
+ *
+ * @param int $sParentId Новый ID родителя владельца
+ * @param string $sTargetType Тип владельца
+ * @param array|int $aTargetId Список ID владельцев
+ * @return bool
+ */
+ public function UpdateTargetParentByTargetId($sParentId, $sTargetType, $aTargetId)
+ {
+ $sql = "
+ UPDATE " . Config::Get('db.table.comment') . "
+ SET
+ target_parent_id = ?d
+ WHERE
+ target_id IN (?a)
+ AND
+ target_type = ?
+ ";
+ $res = $this->oDb->query($sql, $sParentId, $aTargetId, $sTargetType);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Меняем target parent по массиву идентификаторов в таблице комментариев online
+ *
+ * @param int $sParentId Новый ID родителя владельца
+ * @param string $sTargetType Тип владельца
+ * @param array|int $aTargetId Список ID владельцев
+ * @return bool
+ */
+ public function UpdateTargetParentByTargetIdOnline($sParentId, $sTargetType, $aTargetId)
+ {
+ $sql = "
+ UPDATE " . Config::Get('db.table.comment_online') . "
+ SET
+ target_parent_id = ?d
+ WHERE
+ target_id IN (?a)
+ AND
+ target_type = ?
+ ";
+ $res = $this->oDb->query($sql, $sParentId, $aTargetId, $sTargetType);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Меняет target parent на новый
+ *
+ * @param int $sParentId Прежний ID родителя владельца
+ * @param string $sTargetType Тип владельца
+ * @param int $sParentIdNew Новый ID родителя владельца
+ * @return bool
+ */
+ public function MoveTargetParent($sParentId, $sTargetType, $sParentIdNew)
+ {
+ $sql = "
+ UPDATE " . Config::Get('db.table.comment') . "
+ SET
+ target_parent_id = ?d
+ WHERE
+ target_parent_id = ?d
+ AND
+ target_type = ?
+ ";
+ $res = $this->oDb->query($sql, $sParentIdNew, $sParentId, $sTargetType);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Меняет target parent на новый в прямом эфире
+ *
+ * @param int $sParentId Прежний ID родителя владельца
+ * @param string $sTargetType Тип владельца
+ * @param int $sParentIdNew Новый ID родителя владельца
+ * @return bool
+ */
+ public function MoveTargetParentOnline($sParentId, $sTargetType, $sParentIdNew)
+ {
+ $sql = "
+ UPDATE " . Config::Get('db.table.comment_online') . "
+ SET
+ target_parent_id = ?d
+ WHERE
+ target_parent_id = ?d
+ AND
+ target_type = ?
+ ";
+ $res = $this->oDb->query($sql, $sParentIdNew, $sParentId, $sTargetType);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Перестраивает дерево комментариев
+ * Восстанавливает значения left, right и level
+ *
+ * @param int $iPid ID родителя
+ * @param int $iLft Значение left для дерева nested set
+ * @param int $iLevel Уровень
+ * @param int $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @return int
+ */
+ public function RestoreTree($iPid, $iLft, $iLevel, $aTargetId, $sTargetType)
+ {
+ $iRgt = $iLft + 1;
+ $iLevel++;
+ $sql = "SELECT comment_id FROM " . Config::Get('db.table.comment') . " WHERE target_id = ? and target_type = ? { and comment_pid = ? } { and comment_pid IS NULL and 1=?d}
+ ORDER BY comment_id ASC";
+
+ if ($aRows = $this->oDb->select($sql, $aTargetId, $sTargetType, !is_null($iPid) ? $iPid : DBSIMPLE_SKIP,
+ is_null($iPid) ? 1 : DBSIMPLE_SKIP)
+ ) {
+ foreach ($aRows as $aRow) {
+ $iRgt = $this->RestoreTree($aRow['comment_id'], $iRgt, $iLevel, $aTargetId, $sTargetType);
+ }
+ }
+ $iLevel--;
+ if (!is_null($iPid)) {
+ $sql = "UPDATE " . Config::Get('db.table.comment') . "
+ SET comment_left=?d, comment_right=?d , comment_level =?d
+ WHERE comment_id = ? ";
+ $this->oDb->query($sql, $iLft, $iRgt, $iLevel, $iPid);
+ }
+
+ return $iRgt + 1;
+ }
+
+ /**
+ * Возвращает список всех используемых типов владельца
+ *
+ * @return array
+ */
+ public function GetCommentTypes()
+ {
+ $sql = "SELECT target_type FROM " . Config::Get('db.table.comment') . "
+ GROUP BY target_type ";
+ $aTypes = array();
+ if ($aRows = $this->oDb->select($sql)) {
+ foreach ($aRows as $aRow) {
+ $aTypes[] = $aRow['target_type'];
+ }
+ }
+ return $aTypes;
+ }
+
+ /**
+ * Возвращает список ID владельцев
+ *
+ * @param string $sTargetType Тип владельца
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на одну старницу
+ * @return array
+ */
+ public function GetTargetIdByType($sTargetType, $iPage, $iPerPage)
+ {
+ $sql = "SELECT target_id FROM " . Config::Get('db.table.comment') . "
+ WHERE target_type = ? GROUP BY target_id ORDER BY target_id LIMIT ?d, ?d ";
+ if ($aRows = $this->oDb->select($sql, $sTargetType, ($iPage - 1) * $iPerPage, $iPerPage)) {
+ return $aRows;
+ }
+ return array();
+ }
+
+ /**
+ * Пересчитывает счетчик избранных комментариев
+ *
+ * @return bool
+ */
+ public function RecalculateFavourite()
+ {
+ $sql = "
+ UPDATE " . Config::Get('db.table.comment') . " c
+ SET c.comment_count_favourite = (
+ SELECT count(f.user_id)
+ FROM " . Config::Get('db.table.favourite') . " f
+ WHERE
+ f.target_id = c.comment_id
+ AND
+ f.target_publish = 1
+ AND
+ f.target_type = 'comment'
+ )
+ ";
+ $res = $this->oDb->query($sql);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получает список комментариев по фильтру
+ *
+ * @param array $aFilter Фильтр выборки
+ * @param array $aOrder Сортировка
+ * @param int $iCount Возвращает общее количество элментов
+ * @param int $iCurrPage Номер текущей страницы
+ * @param int $iPerPage Количество элементов на одну страницу
+ * @return array
+ */
+ public function GetCommentsByFilter($aFilter, $aOrder, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $aOrderAllow = array('comment_id', 'comment_pid', 'comment_rating', 'comment_date');
+ $sOrder = '';
+ foreach ($aOrder as $key => $value) {
+ if (!in_array($key, $aOrderAllow)) {
+ unset($aOrder[$key]);
+ } elseif (in_array($value, array('asc', 'desc'))) {
+ $sOrder .= " {$key} {$value},";
+ }
+ }
+ $sOrder = trim($sOrder, ',');
+ if ($sOrder == '') {
+ $sOrder = ' comment_id desc ';
+ }
+
+ if (isset($aFilter['target_type']) and !is_array($aFilter['target_type'])) {
+ $aFilter['target_type'] = array($aFilter['target_type']);
+ }
+
+ $sql = "SELECT
+ comment_id
+ FROM
+ " . Config::Get('db.table.comment') . "
+ WHERE
+ 1 = 1
+ { AND comment_id = ?d }
+ { AND user_id = ?d }
+ { AND target_parent_id = ?d }
+ { AND target_id = ?d }
+ { AND target_type IN (?a) }
+ { AND comment_delete = ?d }
+ { AND comment_publish = ?d }
+ ORDER by {$sOrder}
+ LIMIT ?d, ?d ;
+ ";
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql,
+ isset($aFilter['id']) ? $aFilter['id'] : DBSIMPLE_SKIP,
+ isset($aFilter['user_id']) ? $aFilter['user_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['target_parent_id']) ? $aFilter['target_parent_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['target_id']) ? $aFilter['target_id'] : DBSIMPLE_SKIP,
+ (isset($aFilter['target_type']) and count($aFilter['target_type'])) ? $aFilter['target_type'] : DBSIMPLE_SKIP,
+ isset($aFilter['delete']) ? $aFilter['delete'] : DBSIMPLE_SKIP,
+ isset($aFilter['publish']) ? $aFilter['publish'] : DBSIMPLE_SKIP,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = $aRow['comment_id'];
+ }
+ }
+ return $aResult;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/favourite/Favourite.class.php b/application/classes/modules/favourite/Favourite.class.php
new file mode 100644
index 0000000..0e9cc10
--- /dev/null
+++ b/application/classes/modules/favourite/Favourite.class.php
@@ -0,0 +1,534 @@
+
+ *
+ */
+
+/**
+ * Модуль для работы с избранным
+ *
+ * @package application.modules.favourite
+ * @since 1.0
+ */
+class ModuleFavourite extends Module
+{
+ /**
+ * Объект маппера
+ *
+ * @var ModuleFavourite_MapperFavourite
+ */
+ protected $oMapper;
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ }
+
+ /**
+ * Получает информацию о том, найден ли таргет в избранном или нет
+ *
+ * @param int $sTargetId ID владельца
+ * @param string $sTargetType Тип владельца
+ * @param int $sUserId ID пользователя
+ * @return ModuleFavourite_EntityFavourite|null
+ */
+ public function GetFavourite($sTargetId, $sTargetType, $sUserId)
+ {
+ if (!is_numeric($sTargetId) or !is_string($sTargetType)) {
+ return null;
+ }
+ $data = $this->GetFavouritesByArray($sTargetId, $sTargetType, $sUserId);
+ return (isset($data[$sTargetId]))
+ ? $data[$sTargetId]
+ : null;
+ }
+
+ /**
+ * Получить список избранного по списку айдишников
+ *
+ * @param array $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetFavouritesByArray($aTargetId, $sTargetType, $sUserId)
+ {
+ if (!$aTargetId) {
+ return array();
+ }
+ if (Config::Get('sys.cache.solid')) {
+ return $this->GetFavouritesByArraySolid($aTargetId, $sTargetType, $sUserId);
+ }
+ if (!is_array($aTargetId)) {
+ $aTargetId = array($aTargetId);
+ }
+ $aTargetId = array_unique($aTargetId);
+ $aFavourite = array();
+ $aIdNotNeedQuery = array();
+ /**
+ * Делаем мульти-запрос к кешу
+ */
+ $aCacheKeys = func_build_cache_keys($aTargetId, "favourite_{$sTargetType}_", '_' . $sUserId);
+ if (false !== ($data = $this->Cache_Get($aCacheKeys))) {
+ /**
+ * проверяем что досталось из кеша
+ */
+ foreach ($aCacheKeys as $sValue => $sKey) {
+ if (array_key_exists($sKey, $data)) {
+ if ($data[$sKey]) {
+ $aFavourite[$data[$sKey]->getTargetId()] = $data[$sKey];
+ } else {
+ $aIdNotNeedQuery[] = $sValue;
+ }
+ }
+ }
+ }
+ /**
+ * Смотрим чего не было в кеше и делаем запрос в БД
+ */
+ $aIdNeedQuery = array_diff($aTargetId, array_keys($aFavourite));
+ $aIdNeedQuery = array_diff($aIdNeedQuery, $aIdNotNeedQuery);
+ $aIdNeedStore = $aIdNeedQuery;
+ if ($data = $this->oMapper->GetFavouritesByArray($aIdNeedQuery, $sTargetType, $sUserId)) {
+ foreach ($data as $oFavourite) {
+ /**
+ * Добавляем к результату и сохраняем в кеш
+ */
+ $aFavourite[$oFavourite->getTargetId()] = $oFavourite;
+ $this->Cache_Set($oFavourite,
+ "favourite_{$oFavourite->getTargetType()}_{$oFavourite->getTargetId()}_{$sUserId}", array(),
+ 60 * 60 * 24 * 7);
+ $aIdNeedStore = array_diff($aIdNeedStore, array($oFavourite->getTargetId()));
+ }
+ }
+ /**
+ * Сохраняем в кеш запросы не вернувшие результата
+ */
+ foreach ($aIdNeedStore as $sId) {
+ $this->Cache_Set(null, "favourite_{$sTargetType}_{$sId}_{$sUserId}", array(), 60 * 60 * 24 * 7);
+ }
+ /**
+ * Сортируем результат согласно входящему массиву
+ */
+ $aFavourite = func_array_sort_by_keys($aFavourite, $aTargetId);
+ return $aFavourite;
+ }
+
+ /**
+ * Получить список избранного по списку айдишников, но используя единый кеш
+ *
+ * @param array $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetFavouritesByArraySolid($aTargetId, $sTargetType, $sUserId)
+ {
+ if (!is_array($aTargetId)) {
+ $aTargetId = array($aTargetId);
+ }
+ $aTargetId = array_unique($aTargetId);
+ $aFavourites = array();
+ $s = join(',', $aTargetId);
+ if (false === ($data = $this->Cache_Get("favourite_{$sTargetType}_{$sUserId}_id_{$s}"))) {
+ $data = $this->oMapper->GetFavouritesByArray($aTargetId, $sTargetType, $sUserId);
+ foreach ($data as $oFavourite) {
+ $aFavourites[$oFavourite->getTargetId()] = $oFavourite;
+ }
+ $this->Cache_Set($aFavourites, "favourite_{$sTargetType}_{$sUserId}_id_{$s}",
+ array("favourite_{$sTargetType}_change_user_{$sUserId}"), 60 * 60 * 24 * 1);
+ return $aFavourites;
+ }
+ return $data;
+ }
+
+ /**
+ * Получает список таргетов из избранного
+ *
+ * @param int $sUserId ID пользователя
+ * @param string $sTargetType Тип владельца
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param array $aExcludeTarget Список ID владельцев для исклчения
+ * @return array
+ */
+ public function GetFavouritesByUserId($sUserId, $sTargetType, $iCurrPage, $iPerPage, $aExcludeTarget = array())
+ {
+ $s = serialize($aExcludeTarget);
+ if (false === ($data = $this->Cache_Get("{$sTargetType}_favourite_user_{$sUserId}_{$iCurrPage}_{$iPerPage}_{$s}"))) {
+ $data = array(
+ 'collection' => $this->oMapper->GetFavouritesByUserId($sUserId, $sTargetType, $iCount, $iCurrPage,
+ $iPerPage, $aExcludeTarget),
+ 'count' => $iCount
+ );
+ $this->Cache_Set(
+ $data,
+ "{$sTargetType}_favourite_user_{$sUserId}_{$iCurrPage}_{$iPerPage}_{$s}",
+ array(
+ "favourite_{$sTargetType}_change",
+ "favourite_{$sTargetType}_change_user_{$sUserId}"
+ ),
+ 60 * 60 * 24 * 1
+ );
+ }
+ return $data;
+ }
+
+ /**
+ * Возвращает число таргетов определенного типа в избранном по ID пользователя
+ *
+ * @param int $sUserId ID пользователя
+ * @param string $sTargetType Тип владельца
+ * @param array $aExcludeTarget Список ID владельцев для исклчения
+ * @return array
+ */
+ public function GetCountFavouritesByUserId($sUserId, $sTargetType, $aExcludeTarget = array())
+ {
+ $s = serialize($aExcludeTarget);
+ if (false === ($data = $this->Cache_Get("{$sTargetType}_count_favourite_user_{$sUserId}_{$s}"))) {
+ $data = $this->oMapper->GetCountFavouritesByUserId($sUserId, $sTargetType, $aExcludeTarget);
+ $this->Cache_Set(
+ $data,
+ "{$sTargetType}_count_favourite_user_{$sUserId}_{$s}",
+ array(
+ "favourite_{$sTargetType}_change",
+ "favourite_{$sTargetType}_change_user_{$sUserId}"
+ ),
+ 60 * 60 * 24 * 1
+ );
+ }
+ return $data;
+ }
+
+ /**
+ * Получает список комментариев к записям открытых блогов
+ * из избранного указанного пользователя
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetFavouriteOpenCommentsByUserId($sUserId, $iCurrPage, $iPerPage)
+ {
+ if (false === ($data = $this->Cache_Get("comment_favourite_user_{$sUserId}_{$iCurrPage}_{$iPerPage}_open"))) {
+ $data = array(
+ 'collection' => $this->oMapper->GetFavouriteOpenCommentsByUserId($sUserId, $iCount, $iCurrPage,
+ $iPerPage),
+ 'count' => $iCount
+ );
+ $this->Cache_Set(
+ $data,
+ "comment_favourite_user_{$sUserId}_{$iCurrPage}_{$iPerPage}_open",
+ array(
+ "favourite_comment_change",
+ "favourite_comment_change_user_{$sUserId}"
+ ),
+ 60 * 60 * 24 * 1
+ );
+ }
+ return $data;
+ }
+
+ /**
+ * Возвращает число комментариев к открытым блогам в избранном по ID пользователя
+ *
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetCountFavouriteOpenCommentsByUserId($sUserId)
+ {
+ if (false === ($data = $this->Cache_Get("comment_count_favourite_user_{$sUserId}_open"))) {
+ $data = $this->oMapper->GetCountFavouriteOpenCommentsByUserId($sUserId);
+ $this->Cache_Set(
+ $data,
+ "comment_count_favourite_user_{$sUserId}_open",
+ array(
+ "favourite_comment_change",
+ "favourite_comment_change_user_{$sUserId}"
+ ),
+ 60 * 60 * 24 * 1
+ );
+ }
+ return $data;
+ }
+
+ /**
+ * Получает список топиков из открытых блогов
+ * из избранного указанного пользователя
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetFavouriteOpenTopicsByUserId($sUserId, $iCurrPage, $iPerPage)
+ {
+ if (false === ($data = $this->Cache_Get("topic_favourite_user_{$sUserId}_{$iCurrPage}_{$iPerPage}_open"))) {
+ $data = array(
+ 'collection' => $this->oMapper->GetFavouriteOpenTopicsByUserId($sUserId, $iCount, $iCurrPage,
+ $iPerPage),
+ 'count' => $iCount
+ );
+ $this->Cache_Set(
+ $data,
+ "topic_favourite_user_{$sUserId}_{$iCurrPage}_{$iPerPage}_open",
+ array(
+ "favourite_topic_change",
+ "favourite_topic_change_user_{$sUserId}"
+ ),
+ 60 * 60 * 24 * 1
+ );
+ }
+ return $data;
+ }
+
+ /**
+ * Возвращает число топиков в открытых блогах из избранного по ID пользователя
+ *
+ * @param string $sUserId ID пользователя
+ * @return array
+ */
+ public function GetCountFavouriteOpenTopicsByUserId($sUserId)
+ {
+ if (false === ($data = $this->Cache_Get("topic_count_favourite_user_{$sUserId}_open"))) {
+ $data = $this->oMapper->GetCountFavouriteOpenTopicsByUserId($sUserId);
+ $this->Cache_Set(
+ $data,
+ "topic_count_favourite_user_{$sUserId}_open",
+ array(
+ "favourite_topic_change",
+ "favourite_topic_change_user_{$sUserId}"
+ ),
+ 60 * 60 * 24 * 1
+ );
+ }
+ return $data;
+ }
+
+ /**
+ * Добавляет таргет в избранное
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavourite Объект избранного
+ * @return bool
+ */
+ public function AddFavourite(ModuleFavourite_EntityFavourite $oFavourite)
+ {
+ if (!$oFavourite->getTags()) {
+ $oFavourite->setTags('');
+ }
+ $this->SetFavouriteTags($oFavourite);
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("favourite_{$oFavourite->getTargetType()}_change_user_{$oFavourite->getUserId()}"));
+ $this->Cache_Delete("favourite_{$oFavourite->getTargetType()}_{$oFavourite->getTargetId()}_{$oFavourite->getUserId()}");
+ return $this->oMapper->AddFavourite($oFavourite);
+ }
+
+ /**
+ * Обновляет запись об избранном
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavourite Объект избранного
+ * @return bool
+ */
+ public function UpdateFavourite(ModuleFavourite_EntityFavourite $oFavourite)
+ {
+ if (!$oFavourite->getTags()) {
+ $oFavourite->setTags('');
+ }
+ $this->SetFavouriteTags($oFavourite);
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("favourite_{$oFavourite->getTargetType()}_change_user_{$oFavourite->getUserId()}"));
+ $this->Cache_Delete("favourite_{$oFavourite->getTargetType()}_{$oFavourite->getTargetId()}_{$oFavourite->getUserId()}");
+ return $this->oMapper->UpdateFavourite($oFavourite);
+ }
+
+ /**
+ * Устанавливает список тегов для избранного
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavourite Объект избранного
+ * @param bool $bAddNew Добавлять новые теги или нет
+ */
+ public function SetFavouriteTags($oFavourite, $bAddNew = true)
+ {
+ /**
+ * Удаляем все теги
+ */
+ $this->oMapper->DeleteTags($oFavourite);
+ /**
+ * Добавляем новые
+ */
+ if ($bAddNew) {
+ /**
+ * Добавляем теги объекта избранного, если есть
+ */
+ if ($aTags = $this->GetTagsTarget($oFavourite->getTargetType(), $oFavourite->getTargetId())) {
+ foreach ($aTags as $sTag) {
+ $oTag = Engine::GetEntity('ModuleFavourite_EntityTag', $oFavourite->_getData());
+ $oTag->setText(htmlspecialchars($sTag));
+ $oTag->setIsUser(0);
+ $this->oMapper->AddTag($oTag);
+ }
+ }
+ if ($oFavourite->getTags()) {
+ /**
+ * Добавляем пользовательские теги
+ */
+ $aTags = $oFavourite->getTagsArray();
+ foreach ($aTags as $sTag) {
+ $oTag = Engine::GetEntity('ModuleFavourite_EntityTag', $oFavourite->_getData());
+ $oTag->setText($sTag); // htmlspecialchars уже используется при установке тегов
+ $oTag->setIsUser(1);
+ $this->oMapper->AddTag($oTag);
+ }
+ }
+ }
+ }
+
+ /**
+ * Удаляет таргет из избранного
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavourite Объект избранного
+ * @return bool
+ */
+ public function DeleteFavourite(ModuleFavourite_EntityFavourite $oFavourite)
+ {
+ $this->SetFavouriteTags($oFavourite, false);
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("favourite_{$oFavourite->getTargetType()}_change_user_{$oFavourite->getUserId()}"));
+ $this->Cache_Delete("favourite_{$oFavourite->getTargetType()}_{$oFavourite->getTargetId()}_{$oFavourite->getUserId()}");
+ return $this->oMapper->DeleteFavourite($oFavourite);
+ }
+
+ /**
+ * Меняет параметры публикации у таргета
+ *
+ * @param array|int $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @param int $iPublish Флаг публикации
+ * @return bool
+ */
+ public function SetFavouriteTargetPublish($aTargetId, $sTargetType, $iPublish)
+ {
+ if (!is_array($aTargetId)) {
+ $aTargetId = array($aTargetId);
+ }
+
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("favourite_{$sTargetType}_change"));
+ return $this->oMapper->SetFavouriteTargetPublish($aTargetId, $sTargetType, $iPublish);
+ }
+
+ /**
+ * Удаляет избранное по списку идентификаторов таргетов
+ *
+ * @param array|int $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @return bool
+ */
+ public function DeleteFavouriteByTargetId($aTargetId, $sTargetType)
+ {
+ if (!is_array($aTargetId)) {
+ $aTargetId = array($aTargetId);
+ }
+ /**
+ * Чистим зависимые кеши
+ */
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("favourite_{$sTargetType}_change"));
+ $this->DeleteTagByTarget($aTargetId, $sTargetType);
+ return $this->oMapper->DeleteFavouriteByTargetId($aTargetId, $sTargetType);
+ }
+
+ /**
+ * Удаление тегов по таргету
+ *
+ * @param array $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @return bool
+ */
+ public function DeleteTagByTarget($aTargetId, $sTargetType)
+ {
+ return $this->oMapper->DeleteTagByTarget($aTargetId, $sTargetType);
+ }
+
+ /**
+ * Возвращает список тегов для объекта избранного
+ *
+ * @param string $sTargetType Тип владельца
+ * @param int $iTargetId ID владельца
+ * @return bool|array
+ */
+ public function GetTagsTarget($sTargetType, $iTargetId)
+ {
+ $sMethod = 'GetTagsTarget' . func_camelize($sTargetType);
+ if (method_exists($this, $sMethod)) {
+ return $this->$sMethod($iTargetId);
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает наиболее часто используемые теги
+ *
+ * @param int $iUserId ID пользователя
+ * @param string $sTargetType Тип владельца
+ * @param bool $bIsUser Возвращает все теги ли только пользовательские
+ * @param int $iLimit Количество элементов
+ * @return array
+ */
+ public function GetGroupTags($iUserId, $sTargetType, $bIsUser, $iLimit)
+ {
+ return $this->oMapper->GetGroupTags($iUserId, $sTargetType, $bIsUser, $iLimit);
+ }
+
+ /**
+ * Возвращает список тегов по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetTags($aFilter, $aOrder, $iCurrPage, $iPerPage)
+ {
+ return array(
+ 'collection' => $this->oMapper->GetTags($aFilter, $aOrder, $iCount, $iCurrPage, $iPerPage),
+ 'count' => $iCount
+ );
+ }
+
+ /**
+ * Возвращает список тегов для топика, название метода формируется автоматически из GetTagsTarget()
+ * @see GetTagsTarget
+ *
+ * @param int $iTargetId ID владельца
+ * @return bool|array
+ */
+ public function GetTagsTargetTopic($iTargetId)
+ {
+ if ($oTopic = $this->Topic_GetTopicById($iTargetId)) {
+ return $oTopic->getTagsArray();
+ }
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/favourite/entity/Favourite.entity.class.php b/application/classes/modules/favourite/entity/Favourite.entity.class.php
new file mode 100644
index 0000000..795282b
--- /dev/null
+++ b/application/classes/modules/favourite/entity/Favourite.entity.class.php
@@ -0,0 +1,145 @@
+
+ *
+ */
+
+/**
+ * Объект сущности избрнного
+ *
+ * @package application.modules.favourite
+ * @since 1.0
+ */
+class ModuleFavourite_EntityFavourite extends Entity
+{
+ /**
+ * Возвращает ID владельца
+ *
+ * @return int|null
+ */
+ public function getTargetId()
+ {
+ return $this->_getDataOne('target_id');
+ }
+
+ /**
+ * Возвращает ID пользователя
+ *
+ * @return int|null
+ */
+ public function getUserId()
+ {
+ return $this->_getDataOne('user_id');
+ }
+
+ /**
+ * Возвращает флаг публикации владельца
+ *
+ * @return int|null
+ */
+ public function getTargetPublish()
+ {
+ return $this->_getDataOne('target_publish');
+ }
+
+ /**
+ * Возвращает тип владельца
+ *
+ * @return string|null
+ */
+ public function getTargetType()
+ {
+ return $this->_getDataOne('target_type');
+ }
+
+ /**
+ * Возващает список тегов
+ *
+ * @return array
+ */
+ public function getTagsArray()
+ {
+ if ($this->getTags()) {
+ return explode(',', $this->getTags());
+ }
+ return array();
+ }
+
+ /**
+ * Возвращает массив тегов в виде объектов
+ *
+ * @return array
+ */
+ public function getTagsObjects()
+ {
+ $aReturn = array();
+ if ($aTags = $this->getTagsArray()) {
+ foreach ($aTags as $sTag) {
+ if ($sTag) {
+ $aReturn[] = Engine::GetEntity('ModuleFavourite_EntityTag', array(
+ 'target_type' => $this->getTargetType(),
+ 'target_id' => $this->getTargetId(),
+ 'user_id' => $this->getUserId(),
+ 'text' => $sTag,
+ ));
+ }
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Устанавливает ID владельца
+ *
+ * @param int $data
+ */
+ public function setTargetId($data)
+ {
+ $this->_aData['target_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID пользователя
+ *
+ * @param int $data
+ */
+ public function setUserId($data)
+ {
+ $this->_aData['user_id'] = $data;
+ }
+
+ /**
+ * Устанавливает статус публикации для владельца
+ *
+ * @param int $data
+ */
+ public function setTargetPublish($data)
+ {
+ $this->_aData['target_publish'] = $data;
+ }
+
+ /**
+ * Устанавливает тип владельца
+ *
+ * @param string $data
+ */
+ public function setTargetType($data)
+ {
+ $this->_aData['target_type'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/favourite/entity/Tag.entity.class.php b/application/classes/modules/favourite/entity/Tag.entity.class.php
new file mode 100644
index 0000000..1a2be81
--- /dev/null
+++ b/application/classes/modules/favourite/entity/Tag.entity.class.php
@@ -0,0 +1,49 @@
+
+ *
+ */
+
+/**
+ * Объект сущности тега для избранного
+ *
+ * @package application.modules.favourite
+ * @since 1.0
+ */
+class ModuleFavourite_EntityTag extends Entity
+{
+ /**
+ * Возвращает URL страницы тега
+ * todo: на странице списка топиков получение пользователя может стать узким местом
+ *
+ * @return string
+ */
+ public function getUrl()
+ {
+ $_this = $this;
+ $oUser = $this->Cache_Remember("favourite_tag_user_{$this->getUserId()}",
+ function () use ($_this) {
+ return $_this->User_GetUserById($_this->getUserId());
+ }, false, array(), 'life', true);
+
+ if ($oUser) {
+ return $oUser->getUserWebPath() . 'favourites/topics/tag/' . urlencode($this->getText()) . '/';
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/favourite/mapper/Favourite.mapper.class.php b/application/classes/modules/favourite/mapper/Favourite.mapper.class.php
new file mode 100644
index 0000000..ef3e87a
--- /dev/null
+++ b/application/classes/modules/favourite/mapper/Favourite.mapper.class.php
@@ -0,0 +1,596 @@
+
+ *
+ */
+
+/**
+ * Объект маппера для работы с БД
+ *
+ * @package application.modules.favourite
+ * @since 1.0
+ */
+class ModuleFavourite_MapperFavourite extends Mapper
+{
+ /**
+ * Добавляет таргет в избранное
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavourite Объект избранного
+ * @return bool
+ */
+ public function AddFavourite(ModuleFavourite_EntityFavourite $oFavourite)
+ {
+ $sql = "
+ INSERT INTO " . Config::Get('db.table.favourite') . "
+ ( target_id, target_type, user_id, tags )
+ VALUES
+ (?d, ?, ?d, ?)
+ ";
+ if ($this->oDb->query(
+ $sql,
+ $oFavourite->getTargetId(),
+ $oFavourite->getTargetType(),
+ $oFavourite->getUserId(),
+ $oFavourite->getTags()
+ ) === 0
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет запись об избранном
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavourite Объект избранного
+ * @return bool
+ */
+ public function UpdateFavourite(ModuleFavourite_EntityFavourite $oFavourite)
+ {
+ $sql = "
+ UPDATE " . Config::Get('db.table.favourite') . "
+ SET tags = ? WHERE user_id = ?d and target_id = ?d and target_type = ?
+ ";
+ if ($this->oDb->query(
+ $sql,
+ $oFavourite->getTags(),
+ $oFavourite->getUserId(),
+ $oFavourite->getTargetId(),
+ $oFavourite->getTargetType()
+ ) !== false
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Получить список избранного по списку айдишников
+ *
+ * @param array $aArrayId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetFavouritesByArray($aArrayId, $sTargetType, $sUserId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+ $sql = "SELECT *
+ FROM " . Config::Get('db.table.favourite') . "
+ WHERE
+ user_id = ?d
+ AND
+ target_id IN(?a)
+ AND
+ target_type = ? ";
+ $aFavourites = array();
+ if ($aRows = $this->oDb->select($sql, $sUserId, $aArrayId, $sTargetType)) {
+ foreach ($aRows as $aRow) {
+ $aFavourites[] = Engine::GetEntity('Favourite', $aRow);
+ }
+ }
+ return $aFavourites;
+ }
+
+ /**
+ * Удаляет таргет из избранного
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavourite Объект избранного
+ * @return bool
+ */
+ public function DeleteFavourite(ModuleFavourite_EntityFavourite $oFavourite)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.favourite') . "
+ WHERE
+ user_id = ?d
+ AND
+ target_id = ?d
+ AND
+ target_type = ?
+ ";
+ $res = $this->oDb->query(
+ $sql,
+ $oFavourite->getUserId(),
+ $oFavourite->getTargetId(),
+ $oFavourite->getTargetType()
+ );
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Удаляет теги
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavourite Объект избранного
+ * @return bool
+ */
+ public function DeleteTags($oFavourite)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.favourite_tag') . "
+ WHERE
+ user_id = ?d
+ AND
+ target_type = ?
+ AND
+ target_id = ?d
+ ";
+ $res = $this->oDb->query(
+ $sql,
+ $oFavourite->getUserId(),
+ $oFavourite->getTargetType(),
+ $oFavourite->getTargetId()
+ );
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Добавляет тег
+ *
+ * @param ModuleFavourite_EntityTag $oTag Объект тега
+ * @return bool
+ */
+ public function AddTag($oTag)
+ {
+ $sql = "
+ INSERT INTO " . Config::Get('db.table.favourite_tag') . "
+ SET target_id = ?d, target_type = ?, user_id = ?d, is_user = ?d, text =?
+ ";
+ if ($this->oDb->query(
+ $sql,
+ $oTag->getTargetId(),
+ $oTag->getTargetType(),
+ $oTag->getUserId(),
+ $oTag->getIsUser(),
+ $oTag->getText()
+ ) === 0
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Меняет параметры публикации у таргета
+ *
+ * @param array|int $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @param int $iPublish Флаг публикации
+ * @return bool
+ */
+ public function SetFavouriteTargetPublish($aTargetId, $sTargetType, $iPublish)
+ {
+ $sql = "
+ UPDATE " . Config::Get('db.table.favourite') . "
+ SET
+ target_publish = ?d
+ WHERE
+ target_id IN(?a)
+ AND
+ target_type = ?
+ ";
+ $res = $this->oDb->query($sql, $iPublish, $aTargetId, $sTargetType);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получает список таргетов из избранного
+ *
+ * @param int $sUserId ID пользователя
+ * @param string $sTargetType Тип владельца
+ * @param int $iCount Возвращает количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param array $aExcludeTarget Список ID владельцев для исклчения
+ * @return array
+ */
+ public function GetFavouritesByUserId(
+ $sUserId,
+ $sTargetType,
+ &$iCount,
+ $iCurrPage,
+ $iPerPage,
+ $aExcludeTarget = array()
+ ) {
+ $sql = "
+ SELECT target_id
+ FROM " . Config::Get('db.table.favourite') . "
+ WHERE
+ user_id = ?
+ AND
+ target_publish = 1
+ AND
+ target_type = ?
+ { AND target_id NOT IN (?a) }
+ ORDER BY target_id DESC
+ LIMIT ?d, ?d ";
+
+ $aFavourites = array();
+ if ($aRows = $this->oDb->selectPage(
+ $iCount,
+ $sql,
+ $sUserId,
+ $sTargetType,
+ (count($aExcludeTarget) ? $aExcludeTarget : DBSIMPLE_SKIP),
+ ($iCurrPage - 1) * $iPerPage,
+ $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aFavourite) {
+ $aFavourites[] = $aFavourite['target_id'];
+ }
+ }
+ return $aFavourites;
+ }
+
+ /**
+ * Возвращает число таргетов определенного типа в избранном по ID пользователя
+ *
+ * @param int $sUserId ID пользователя
+ * @param string $sTargetType Тип владельца
+ * @param array $aExcludeTarget Список ID владельцев для исклчения
+ * @return array
+ */
+ public function GetCountFavouritesByUserId($sUserId, $sTargetType, $aExcludeTarget)
+ {
+ $sql = "SELECT
+ count(target_id) as count
+ FROM
+ " . Config::Get('db.table.favourite') . "
+ WHERE
+ user_id = ?
+ AND
+ target_publish = 1
+ AND
+ target_type = ?
+ { AND target_id NOT IN (?a) }
+ ;";
+ return ($aRow = $this->oDb->selectRow(
+ $sql, $sUserId,
+ $sTargetType,
+ (count($aExcludeTarget) ? $aExcludeTarget : DBSIMPLE_SKIP)
+ )
+ )
+ ? $aRow['count']
+ : false;
+ }
+
+ /**
+ * Получает список комментариев к записям открытых блогов
+ * из избранного указанного пользователя
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iCount Возвращает количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetFavouriteOpenCommentsByUserId($sUserId, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $sql = "
+ SELECT f.target_id
+ FROM
+ " . Config::Get('db.table.favourite') . " AS f,
+ " . Config::Get('db.table.comment') . " AS c,
+ " . Config::Get('db.table.topic') . " AS t,
+ " . Config::Get('db.table.blog') . " AS b
+ WHERE
+ f.user_id = ?d
+ AND
+ f.target_publish = 1
+ AND
+ f.target_type = 'comment'
+ AND
+ f.target_id = c.comment_id
+ AND
+ c.target_id = t.topic_id
+ AND
+ t.blog_id = b.blog_id
+ AND
+ b.blog_type IN ('open', 'personal')
+ ORDER BY target_id DESC
+ LIMIT ?d, ?d ";
+
+ $aFavourites = array();
+ if ($aRows = $this->oDb->selectPage(
+ $iCount, $sql, $sUserId,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aFavourite) {
+ $aFavourites[] = $aFavourite['target_id'];
+ }
+ }
+ return $aFavourites;
+ }
+
+ /**
+ * Возвращает число комментариев к открытым блогам в избранном по ID пользователя
+ *
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetCountFavouriteOpenCommentsByUserId($sUserId)
+ {
+ $sql = "SELECT
+ count(f.target_id) as count
+ FROM
+ " . Config::Get('db.table.favourite') . " AS f,
+ " . Config::Get('db.table.comment') . " AS c,
+ " . Config::Get('db.table.topic') . " AS t,
+ " . Config::Get('db.table.blog') . " AS b
+ WHERE
+ f.user_id = ?d
+ AND
+ f.target_publish = 1
+ AND
+ f.target_type = 'comment'
+ AND
+ f.target_id = c.comment_id
+ AND
+ c.target_id = t.topic_id
+ AND
+ t.blog_id = b.blog_id
+ AND
+ b.blog_type IN ('open', 'personal')
+ ;";
+ return ($aRow = $this->oDb->selectRow($sql, $sUserId))
+ ? $aRow['count']
+ : false;
+ }
+
+ /**
+ * Получает список топиков из открытых блогов
+ * из избранного указанного пользователя
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iCount Возвращает количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetFavouriteOpenTopicsByUserId($sUserId, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $sql = "
+ SELECT f.target_id
+ FROM
+ " . Config::Get('db.table.favourite') . " AS f,
+ " . Config::Get('db.table.topic') . " AS t,
+ " . Config::Get('db.table.blog') . " AS b
+ WHERE
+ f.user_id = ?d
+ AND
+ f.target_publish = 1
+ AND
+ f.target_type = 'topic'
+ AND
+ f.target_id = t.topic_id
+ AND
+ t.blog_id = b.blog_id
+ AND
+ b.blog_type IN ('open', 'personal')
+ ORDER BY target_id DESC
+ LIMIT ?d, ?d ";
+
+ $aFavourites = array();
+ if ($aRows = $this->oDb->selectPage(
+ $iCount, $sql, $sUserId,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aFavourite) {
+ $aFavourites[] = $aFavourite['target_id'];
+ }
+ }
+ return $aFavourites;
+ }
+
+ /**
+ * Возвращает число топиков в открытых блогах из избранного по ID пользователя
+ *
+ * @param string $sUserId ID пользователя
+ * @return array
+ */
+ public function GetCountFavouriteOpenTopicsByUserId($sUserId)
+ {
+ $sql = "SELECT
+ count(f.target_id) as count
+ FROM
+ " . Config::Get('db.table.favourite') . " AS f,
+ " . Config::Get('db.table.topic') . " AS t,
+ " . Config::Get('db.table.blog') . " AS b
+ WHERE
+ f.user_id = ?d
+ AND
+ f.target_publish = 1
+ AND
+ f.target_type = 'topic'
+ AND
+ f.target_id = t.topic_id
+ AND
+ t.blog_id = b.blog_id
+ AND
+ b.blog_type IN ('open', 'personal')
+ ;";
+ return ($aRow = $this->oDb->selectRow($sql, $sUserId))
+ ? $aRow['count']
+ : false;
+ }
+
+ /**
+ * Удаляет избранное по списку идентификаторов таргетов
+ *
+ * @param array|int $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @return bool
+ */
+ public function DeleteFavouriteByTargetId($aTargetId, $sTargetType)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.favourite') . "
+ WHERE
+ target_id IN(?a)
+ AND
+ target_type = ? ";
+ $res = $this->oDb->query($sql, $aTargetId, $sTargetType);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Удаление тегов по таргету
+ *
+ * @param array $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @return bool
+ */
+ public function DeleteTagByTarget($aTargetId, $sTargetType)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.favourite_tag') . "
+ WHERE
+ target_type = ?
+ AND
+ target_id IN(?a)
+ ";
+ $res = $this->oDb->query($sql, $sTargetType, $aTargetId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Возвращает наиболее часто используемые теги
+ *
+ * @param int $iUserId ID пользователя
+ * @param string $sTargetType Тип владельца
+ * @param bool $bIsUser Возвращает все теги ли только пользовательские
+ * @param int $iLimit Количество элементов
+ * @return array
+ */
+ public function GetGroupTags($iUserId, $sTargetType, $bIsUser, $iLimit)
+ {
+ $sql = "SELECT
+ text,
+ user_id,
+ count(text) as count
+ FROM
+ " . Config::Get('db.table.favourite_tag') . "
+ WHERE
+ 1=1
+ {AND user_id = ?d }
+ {AND target_type = ? }
+ {AND is_user = ?d }
+ GROUP BY
+ text
+ ORDER BY
+ count desc
+ LIMIT 0, ?d
+ ";
+ $aReturn = array();
+ $aReturnSort = array();
+ if ($aRows = $this->oDb->select($sql, $iUserId, $sTargetType, is_null($bIsUser) ? DBSIMPLE_SKIP : $bIsUser,
+ $iLimit)
+ ) {
+ foreach ($aRows as $aRow) {
+ $aReturn[mb_strtolower($aRow['text'], 'UTF-8')] = $aRow;
+ }
+ ksort($aReturn);
+ foreach ($aReturn as $aRow) {
+ $aReturnSort[] = Engine::GetEntity('ModuleFavourite_EntityTag', $aRow);
+ }
+ }
+ return $aReturnSort;
+ }
+
+ /**
+ * Возвращает список тегов по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCount Возвращает количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetTags($aFilter, $aOrder, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $aOrderAllow = array('target_id', 'user_id', 'is_user');
+ $sOrder = '';
+ foreach ($aOrder as $key => $value) {
+ if (!in_array($key, $aOrderAllow)) {
+ unset($aOrder[$key]);
+ } elseif (in_array($value, array('asc', 'desc'))) {
+ $sOrder .= " {$key} {$value},";
+ }
+ }
+ $sOrder = trim($sOrder, ',');
+ if ($sOrder == '') {
+ $sOrder = ' target_id desc ';
+ }
+
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.favourite_tag') . "
+ WHERE
+ 1 = 1
+ { AND user_id = ?d }
+ { AND target_type = ? }
+ { AND target_id = ?d }
+ { AND is_user = ?d }
+ { AND text = ? }
+ ORDER by {$sOrder}
+ LIMIT ?d, ?d ;
+ ";
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql,
+ isset($aFilter['user_id']) ? $aFilter['user_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['target_type']) ? $aFilter['target_type'] : DBSIMPLE_SKIP,
+ isset($aFilter['target_id']) ? $aFilter['target_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['is_user']) ? $aFilter['is_user'] : DBSIMPLE_SKIP,
+ isset($aFilter['text']) ? $aFilter['text'] : DBSIMPLE_SKIP,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('ModuleFavourite_EntityTag', $aRow);
+ }
+ }
+ return $aResult;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/geo/Geo.class.php b/application/classes/modules/geo/Geo.class.php
new file mode 100644
index 0000000..122d11f
--- /dev/null
+++ b/application/classes/modules/geo/Geo.class.php
@@ -0,0 +1,547 @@
+
+ *
+ */
+
+/**
+ * Модуль Geo - привязка объектов к географии (страна/регион/город)
+ * Терминология:
+ * объект - который привязываем к гео-объекту
+ * гео-объект - географический объект(страна/регион/город)
+ *
+ * @package application.modules.geo
+ * @since 1.0
+ */
+class ModuleGeo extends Module
+{
+ /**
+ * Объект маппера
+ *
+ * @var ModuleGeo_MapperGeo
+ */
+ protected $oMapper;
+ /**
+ * Список доступных типов объектов
+ * На данный момент доступен параметр allow_multi=>1 - указывает на возможность создавать несколько связей для одного объекта
+ *
+ * @var array
+ */
+ protected $aTargetTypes = array(
+ 'user' => array(),
+ );
+ /**
+ * Список доступных типов гео-объектов
+ *
+ * @var array
+ */
+ protected $aGeoTypes = array(
+ 'country',
+ 'region',
+ 'city',
+ );
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ }
+
+ /**
+ * Возвращает список типов объектов
+ *
+ * @return array
+ */
+ public function GetTargetTypes()
+ {
+ return $this->aTargetTypes;
+ }
+
+ /**
+ * Добавляет в разрешенные новый тип
+ * @param string $sTargetType Тип владельца
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function AddTargetType($sTargetType, $aParams = array())
+ {
+ if (!array_key_exists($sTargetType, $this->aTargetTypes)) {
+ $this->aTargetTypes[$sTargetType] = $aParams;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет разрешен ли данный тип
+ *
+ * @param string $sTargetType Тип владельца
+ * @return bool
+ */
+ public function IsAllowTargetType($sTargetType)
+ {
+ return in_array($sTargetType, array_keys($this->aTargetTypes));
+ }
+
+ /**
+ * Проверяет разрешен ли данный гео-тип
+ *
+ * @param string $sGeoType Тип владельца
+ * @return bool
+ */
+ public function IsAllowGeoType($sGeoType)
+ {
+ return in_array($sGeoType, $this->aGeoTypes);
+ }
+
+ /**
+ * Проверка объекта
+ *
+ * @param string $sTargetType Тип владельца
+ * @param int $iTargetId ID владельца
+ * @return bool
+ */
+ public function CheckTarget($sTargetType, $iTargetId)
+ {
+ if (!$this->IsAllowTargetType($sTargetType)) {
+ return false;
+ }
+ $sMethod = 'CheckTarget' . func_camelize($sTargetType);
+ if (method_exists($this, $sMethod)) {
+ return $this->$sMethod($iTargetId);
+ }
+ return false;
+ }
+
+ /**
+ * Проверка на возможность нескольких связей
+ *
+ * @param string $sTargetType Тип владельца
+ * @return bool
+ */
+ public function IsAllowTargetMulti($sTargetType)
+ {
+ if ($this->IsAllowTargetType($sTargetType)) {
+ if (isset($this->aTargetTypes[$sTargetType]['allow_multi']) and $this->aTargetTypes[$sTargetType]['allow_multi']) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Добавляет связь объекта с гео-объектом в БД
+ *
+ * @param ModuleGeo_EntityTarget $oTarget Объект связи с владельцем
+ * @return ModuleGeo_EntityTarget|bool
+ */
+ public function AddTarget($oTarget)
+ {
+ if ($this->oMapper->AddTarget($oTarget)) {
+ return $oTarget;
+ }
+ return false;
+ }
+
+ /**
+ * Создание связи
+ *
+ * @param ModuleGeo_EntityGeo $oGeoObject
+ * @param string $sTargetType Тип владельца
+ * @param int $iTargetId ID владельца
+ * @return bool|ModuleGeo_EntityTarget
+ */
+ public function CreateTarget($oGeoObject, $sTargetType, $iTargetId)
+ {
+ /**
+ * Проверяем объект на валидность
+ */
+ if (!$this->CheckTarget($sTargetType, $iTargetId)) {
+ return false;
+ }
+ /**
+ * Проверяем есть ли уже у этого объекта другие связи
+ */
+ $aTargets = $this->GetTargets(array('target_type' => $sTargetType, 'target_id' => $iTargetId), 1, 1);
+ if ($aTargets['count']) {
+ if ($this->IsAllowTargetMulti($sTargetType)) {
+ /**
+ * Разрешено несколько связей
+ * Проверяем есть ли уже связь с данным гео-объектом, если есть то возвращаем его
+ */
+ $aTargetSelf = $this->GetTargets(array(
+ 'target_type' => $sTargetType,
+ 'target_id' => $iTargetId,
+ 'geo_type' => $oGeoObject->getType(),
+ 'geo_id' => $oGeoObject->getId()
+ ), 1, 1);
+ if ($oTargetSelf = array_shift($aTargetSelf['collection'])) {
+ return $oTargetSelf;
+ }
+ } else {
+ /**
+ * Есть другие связи и несколько связей запрещено - удаляем имеющиеся связи
+ */
+ $this->DeleteTargets(array('target_type' => $sTargetType, 'target_id' => $iTargetId));
+ }
+ }
+ /**
+ * Создаем связь
+ */
+ $oTarget = Engine::GetEntity('ModuleGeo_EntityTarget');
+ $oTarget->setGeoType($oGeoObject->getType());
+ $oTarget->setGeoId($oGeoObject->getId());
+ $oTarget->setTargetType($sTargetType);
+ $oTarget->setTargetId($iTargetId);
+ if ($oGeoObject->getType() == 'city') {
+ $oTarget->setCountryId($oGeoObject->getCountryId());
+ $oTarget->setRegionId($oGeoObject->getRegionId());
+ $oTarget->setCityId($oGeoObject->getId());
+ } elseif ($oGeoObject->getType() == 'region') {
+ $oTarget->setCountryId($oGeoObject->getCountryId());
+ $oTarget->setRegionId($oGeoObject->getId());
+ } elseif ($oGeoObject->getType() == 'country') {
+ $oTarget->setCountryId($oGeoObject->getId());
+ }
+ return $this->AddTarget($oTarget);
+ }
+
+ /**
+ * Возвращает список связей по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetTargets($aFilter, $iCurrPage, $iPerPage)
+ {
+ return array(
+ 'collection' => $this->oMapper->GetTargets($aFilter, $iCount, $iCurrPage, $iPerPage),
+ 'count' => $iCount
+ );
+ }
+
+ /**
+ * Возвращает первый объект связи по объекту
+ *
+ * @param string $sTargetType Тип владельца
+ * @param int $iTargetId ID владельца
+ * @return null|ModuleGeo_EntityTarget
+ */
+ public function GetTargetByTarget($sTargetType, $iTargetId)
+ {
+ $aTargets = $this->GetTargets(array('target_type' => $sTargetType, 'target_id' => $iTargetId), 1, 1);
+ if ($oTarget = array_shift($aTargets['collection'])) {
+ return $oTarget;
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает список связей для списка объектов одного типа.
+ *
+ * @param string $sTargetType Тип владельца
+ * @param array $aTargetId Список ID владельцев
+ * @return array В качестве ключей используется ID объекта, в качестве значений массив связей этого объекта
+ */
+ public function GetTargetsByTargetArray($sTargetType, $aTargetId)
+ {
+ if (!is_array($aTargetId)) {
+ $aTargetId = array($aTargetId);
+ }
+ if (!count($aTargetId)) {
+ return array();
+ }
+ $aResult = array();
+ $aTargets = $this->GetTargets(array('target_type' => $sTargetType, 'target_id' => $aTargetId), 1,
+ count($aTargetId));
+ if ($aTargets['count']) {
+ foreach ($aTargets['collection'] as $oTarget) {
+ $aResult[$oTarget->getTargetId()][] = $oTarget;
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Удаляет связи по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @return bool|int
+ */
+ public function DeleteTargets($aFilter)
+ {
+ return $this->oMapper->DeleteTargets($aFilter);
+ }
+
+ /**
+ * Удаление всех связей объекта
+ *
+ * @param string $sTargetType Тип владельца
+ * @param int $iTargetId ID владельца
+ * @return bool|int
+ */
+ public function DeleteTargetsByTarget($sTargetType, $iTargetId)
+ {
+ return $this->DeleteTargets(array('target_type' => $sTargetType, 'target_id' => $iTargetId));
+ }
+
+ /**
+ * Возвращает список стран по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetCountries($aFilter, $aOrder, $iCurrPage, $iPerPage)
+ {
+ return array(
+ 'collection' => $this->oMapper->GetCountries($aFilter, $aOrder, $iCount, $iCurrPage, $iPerPage),
+ 'count' => $iCount
+ );
+ }
+
+ /**
+ * Возвращает список регионов по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetRegions($aFilter, $aOrder, $iCurrPage, $iPerPage)
+ {
+ return array(
+ 'collection' => $this->oMapper->GetRegions($aFilter, $aOrder, $iCount, $iCurrPage, $iPerPage),
+ 'count' => $iCount
+ );
+ }
+
+ /**
+ * Возвращает список городов по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetCities($aFilter, $aOrder, $iCurrPage, $iPerPage)
+ {
+ return array(
+ 'collection' => $this->oMapper->GetCities($aFilter, $aOrder, $iCount, $iCurrPage, $iPerPage),
+ 'count' => $iCount
+ );
+ }
+
+ /**
+ * Возвращает страну по ID
+ *
+ * @param int $iId ID страны
+ * @return ModuleGeo_EntityCountry|null
+ */
+ public function GetCountryById($iId)
+ {
+ $aRes = $this->GetCountries(array('id' => $iId), array(), 1, 1);
+ if ($oCountry = array_shift($aRes['collection'])) {
+ return $oCountry;
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает регион по ID
+ *
+ * @param int $iId ID региона
+ * @return ModuleGeo_EntityRegion|null
+ */
+ public function GetRegionById($iId)
+ {
+ $aRes = $this->GetRegions(array('id' => $iId), array(), 1, 1);
+ if ($oRegion = array_shift($aRes['collection'])) {
+ return $oRegion;
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает регион по ID
+ *
+ * @param int $iId ID города
+ * @return ModuleGeo_EntityCity|null
+ */
+ public function GetCityById($iId)
+ {
+ $aRes = $this->GetCities(array('id' => $iId), array(), 1, 1);
+ if ($oCity = array_shift($aRes['collection'])) {
+ return $oCity;
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает гео-объект
+ *
+ * @param string $sType Тип гео-объекта
+ * @param int $iId ID гео-объекта
+ * @return ModuleGeo_EntityGeo|null
+ */
+ public function GetGeoObject($sType, $iId)
+ {
+ $sType = strtolower($sType);
+ if (!$this->IsAllowGeoType($sType)) {
+ return null;
+ }
+ switch ($sType) {
+ case 'country':
+ return $this->GetCountryById($iId);
+ break;
+ case 'region':
+ return $this->GetRegionById($iId);
+ break;
+ case 'city':
+ return $this->GetCityById($iId);
+ break;
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Возвращает первый гео-объект для объекта
+ *
+ * @param string $sTargetType Тип владельца
+ * @param int $iTargetId ID владельца
+ * @return ModuleGeo_EntityCity|ModuleGeo_EntityCountry|ModuleGeo_EntityRegion|null
+ */
+ public function GetGeoObjectByTarget($sTargetType, $iTargetId)
+ {
+ $aTargets = $this->GetTargets(array('target_type' => $sTargetType, 'target_id' => $iTargetId), 1, 1);
+ if ($oTarget = array_shift($aTargets['collection']) ) {
+ $oTarget = $oTarget;
+ return $this->GetGeoObject($oTarget->getGeoType(), $oTarget->getGeoId());
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает список стран сгруппированных по количеству использований в данном типе объектов
+ *
+ * @param string $sTargetType Тип владельца
+ * @param int $iLimit Количество элементов
+ * @return array
+ */
+ public function GetGroupCountriesByTargetType($sTargetType, $iLimit)
+ {
+ return $this->oMapper->GetGroupCountriesByTargetType($sTargetType, $iLimit);
+ }
+
+ /**
+ * Возвращает список городов сгруппированных по количеству использований в данном типе объектов
+ *
+ * @param string $sTargetType Тип владельца
+ * @param int $iLimit Количество элементов
+ * @return array
+ */
+ public function GetGroupCitiesByTargetType($sTargetType, $iLimit)
+ {
+ return $this->oMapper->GetGroupCitiesByTargetType($sTargetType, $iLimit);
+ }
+
+ /**
+ * Возвращает список использованых стран для типа
+ *
+ * @param string $sTargetType Тип владельца
+ * @return array
+ */
+ public function GetCountriesUsedByTargetType($sTargetType)
+ {
+ return $this->oMapper->GetCountriesUsedByTargetType($sTargetType);
+ }
+
+ /**
+ * Возвращает список использованых регионов для типа
+ *
+ * @param int $iCountryId
+ * @param string $sTargetType Тип владельца
+ * @return array
+ */
+ public function GetRegionsUsedByTargetType($iCountryId, $sTargetType)
+ {
+ return $this->oMapper->GetRegionsUsedByTargetType($iCountryId, $sTargetType);
+ }
+
+ /**
+ * Возвращает список использованых городов для типа
+ *
+ * @param int $iRegionId
+ * @param string $sTargetType Тип владельца
+ * @return array
+ */
+ public function GetCitiesUsedByTargetType($iRegionId, $sTargetType)
+ {
+ return $this->oMapper->GetCitiesUsedByTargetType($iRegionId, $sTargetType);
+ }
+
+ /**
+ * Проверка объекта с типом "user"
+ * Название метода формируется автоматически
+ *
+ * @param int $iTargetId ID пользователя
+ * @return bool
+ */
+ public function CheckTargetUser($iTargetId)
+ {
+ if ($oUser = $this->User_GetUserById($iTargetId)) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Получение всех обьектов по таргетам (для загрузки в шаблон)
+ *
+ * @param arr $aTargets массив таргетов
+ * @return arr
+ */
+ public function GetGeoObjectsByTargets($aTargets)
+ {
+ $aGeoCountryIds = [];
+ $aGeoRegoinIds = [];
+ $aGeoCityIds = [];
+
+ foreach ($aTargets as $oTarget) {
+ $aGeoCountryIds[] = $oTarget->getCountryId();
+ $aGeoRegoinIds[] = $oTarget->getRegionId();
+ $aGeoCityIds[] = $oTarget->getCityId();
+ }
+
+ return [
+ 'countries' => $this->GetCountries(['id' => $aGeoCountryIds], [], 1, 1000)['collection'],
+ 'regions' => $this->GetRegions(['id' => $aGeoRegoinIds], [], 1, 1000)['collection'],
+ 'cities' => $this->GetCities(['id' => $aGeoCityIds], [], 1, 1000)['collection']
+ ];
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/geo/entity/City.entity.class.php b/application/classes/modules/geo/entity/City.entity.class.php
new file mode 100644
index 0000000..29b7e4b
--- /dev/null
+++ b/application/classes/modules/geo/entity/City.entity.class.php
@@ -0,0 +1,31 @@
+
+ *
+ */
+
+/**
+ * Объект сущности города
+ *
+ * @package application.modules.geo
+ * @since 1.0
+ */
+class ModuleGeo_EntityCity extends ModuleGeo_EntityGeo
+{
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/geo/entity/Country.entity.class.php b/application/classes/modules/geo/entity/Country.entity.class.php
new file mode 100644
index 0000000..626aec8
--- /dev/null
+++ b/application/classes/modules/geo/entity/Country.entity.class.php
@@ -0,0 +1,31 @@
+
+ *
+ */
+
+/**
+ * Объект сущности страны
+ *
+ * @package application.modules.geo
+ * @since 1.0
+ */
+class ModuleGeo_EntityCountry extends ModuleGeo_EntityGeo
+{
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/geo/entity/Geo.entity.class.php b/application/classes/modules/geo/entity/Geo.entity.class.php
new file mode 100644
index 0000000..9594c7c
--- /dev/null
+++ b/application/classes/modules/geo/entity/Geo.entity.class.php
@@ -0,0 +1,131 @@
+
+ *
+ */
+
+/**
+ * Объект сущности гео-объекта
+ *
+ * @package application.modules.geo
+ * @since 1.0
+ */
+class ModuleGeo_EntityGeo extends Entity
+{
+
+ /**
+ * Возвращает имя гео-объекта в зависимости от языка
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ $sName = '';
+ $sLangDef = Config::get('lang.default');
+ if ($sLangDef == 'ru') {
+ $sName = $this->getNameRu();
+ } elseif ($sLangDef == 'en') {
+ $sName = $this->getNameEn();
+ }
+
+ $sLang = Config::get('lang.current');
+ if ($sLang == 'ru' and $this->getNameRu()) {
+ $sName = $this->getNameRu();
+ } elseif ($sLang == 'en' and $this->getNameEn()) {
+ $sName = $this->getNameEn();
+ }
+ return $sName;
+ }
+
+ /**
+ * Возвращает тип гео-объекта
+ *
+ * @return null|string
+ */
+ public function getType()
+ {
+ if ($this instanceof ModuleGeo_EntityCity) {
+ return 'city';
+ } elseif ($this instanceof ModuleGeo_EntityRegion) {
+ return 'region';
+ } elseif ($this instanceof ModuleGeo_EntityCountry) {
+ return 'country';
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает гео-объект страны
+ *
+ * @return ModuleGeo_EntityGeo|null
+ */
+ public function getCountry()
+ {
+ if ($this->getType() == 'country') {
+ return $this;
+ }
+ if ($oCountry = $this->_getDataOne('country')) {
+ return $oCountry;
+ }
+ if ($this->getCountryId()) {
+ $oCountry = $this->Geo_GetCountryById($this->getCountryId());
+ return $this->_aData['country'] = $oCountry;
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает гео-объект региона
+ *
+ * @return ModuleGeo_EntityGeo|null
+ */
+ public function getRegion()
+ {
+ if ($this->getType() == 'region') {
+ return $this;
+ }
+ if ($oRegion = $this->_getDataOne('region')) {
+ return $oRegion;
+ }
+ if ($this->getRegionId()) {
+ $oRegion = $this->Geo_GetRegionById($this->getRegionId());
+ return $this->_aData['region'] = $oRegion;
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает гео-объект города
+ *
+ * @return ModuleGeo_EntityGeo|null
+ */
+ public function getCity()
+ {
+ if ($this->getType() == 'city') {
+ return $this;
+ }
+ if ($oCity = $this->_getDataOne('city')) {
+ return $oCity;
+ }
+ if ($this->getCityId()) {
+ $oCity = $this->Geo_GetCityById($this->getCityId());
+ return $this->_aData['city'] = $oCity;
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/geo/entity/Region.entity.class.php b/application/classes/modules/geo/entity/Region.entity.class.php
new file mode 100644
index 0000000..d5ed982
--- /dev/null
+++ b/application/classes/modules/geo/entity/Region.entity.class.php
@@ -0,0 +1,31 @@
+
+ *
+ */
+
+/**
+ * Объект сущности региона
+ *
+ * @package application.modules.geo
+ * @since 1.0
+ */
+class ModuleGeo_EntityRegion extends ModuleGeo_EntityGeo
+{
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/geo/entity/Target.entity.class.php b/application/classes/modules/geo/entity/Target.entity.class.php
new file mode 100644
index 0000000..6a47ea8
--- /dev/null
+++ b/application/classes/modules/geo/entity/Target.entity.class.php
@@ -0,0 +1,31 @@
+
+ *
+ */
+
+/**
+ * Объект связи гео-объекта с владельцем
+ *
+ * @package application.modules.geo
+ * @since 1.0
+ */
+class ModuleGeo_EntityTarget extends Entity
+{
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/geo/mapper/Geo.mapper.class.php b/application/classes/modules/geo/mapper/Geo.mapper.class.php
new file mode 100644
index 0000000..7136b8d
--- /dev/null
+++ b/application/classes/modules/geo/mapper/Geo.mapper.class.php
@@ -0,0 +1,467 @@
+
+ *
+ */
+
+/**
+ * Объект маппера для работы с БД
+ *
+ * @package application.modules.geo
+ * @since 1.0
+ */
+class ModuleGeo_MapperGeo extends Mapper
+{
+ /**
+ * Добавляет связь объекта с гео-объектом в БД
+ *
+ * @param ModuleGeo_EntityTarget $oTarget Объект связи с владельцем
+ * @return ModuleGeo_EntityTarget|bool
+ */
+ public function AddTarget($oTarget)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.geo_target') . " SET ?a ";
+ if ($this->oDb->query($sql, $oTarget->_getData())) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает список связей по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param int $iCount Возвращает количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetTargets($aFilter, &$iCount, $iCurrPage, $iPerPage)
+ {
+ if (isset($aFilter['target_id']) and !is_array($aFilter['target_id'])) {
+ $aFilter['target_id'] = array($aFilter['target_id']);
+ }
+
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.geo_target') . "
+ WHERE
+ 1 = 1
+ { AND geo_type = ? }
+ { AND geo_id = ?d }
+ { AND target_type = ? }
+ { AND target_id IN ( ?a ) }
+ { AND country_id = ?d }
+ { AND region_id = ?d }
+ { AND city_id = ?d }
+ ORDER BY target_id DESC
+ LIMIT ?d, ?d ;
+ ";
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql,
+ isset($aFilter['geo_type']) ? $aFilter['geo_type'] : DBSIMPLE_SKIP,
+ isset($aFilter['geo_id']) ? $aFilter['geo_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['target_type']) ? $aFilter['target_type'] : DBSIMPLE_SKIP,
+ (isset($aFilter['target_id']) and count($aFilter['target_id'])) ? $aFilter['target_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['country_id']) ? $aFilter['country_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['region_id']) ? $aFilter['region_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['city_id']) ? $aFilter['city_id'] : DBSIMPLE_SKIP,
+
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('ModuleGeo_EntityTarget', $aRow);
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Возвращает список стран сгруппированных по количеству использований в данном типе объектов
+ *
+ * @param string $sTargetType Тип владельца
+ * @param int $iLimit Количество элементов
+ * @return array
+ */
+ public function GetGroupCountriesByTargetType($sTargetType, $iLimit)
+ {
+ $sql = "
+ SELECT
+ t.count,
+ g.*
+ FROM (
+ SELECT
+ count(*) as count,
+ country_id
+ FROM
+ " . Config::Get('db.table.geo_target') . "
+ WHERE target_type = ? and country_id IS NOT NULL
+ GROUP BY country_id ORDER BY count DESC LIMIT 0, ?d
+ ) as t
+ JOIN " . Config::Get('db.table.geo_country') . " as g on t.country_id=g.id
+ ORDER BY g.name_ru
+ ";
+ $aResult = array();
+ if ($aRows = $this->oDb->select($sql, $sTargetType, $iLimit)) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('ModuleGeo_EntityCountry', $aRow);
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Возвращает список городов сгруппированных по количеству использований в данном типе объектов
+ *
+ * @param string $sTargetType Тип владельца
+ * @param int $iLimit Количество элементов
+ * @return array
+ */
+ public function GetGroupCitiesByTargetType($sTargetType, $iLimit)
+ {
+ $sql = "
+ SELECT
+ t.count,
+ g.*
+ FROM (
+ SELECT
+ count(*) as count,
+ city_id
+ FROM
+ " . Config::Get('db.table.geo_target') . "
+ WHERE target_type = ? and city_id IS NOT NULL
+ GROUP BY city_id ORDER BY count DESC LIMIT 0, ?d
+ ) as t
+ JOIN " . Config::Get('db.table.geo_city') . " as g on t.city_id=g.id
+ ORDER BY g.name_ru
+ ";
+ $aResult = array();
+ if ($aRows = $this->oDb->select($sql, $sTargetType, $iLimit)) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('ModuleGeo_EntityCity', $aRow);
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Удаляет связи по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @return bool|int
+ */
+ public function DeleteTargets($aFilter)
+ {
+ if (!$aFilter) {
+ return false;
+ }
+ $sql = "DELETE
+ FROM
+ " . Config::Get('db.table.geo_target') . "
+ WHERE
+ 1 = 1
+ { AND geo_type = ? }
+ { AND geo_id = ?d }
+ { AND target_type = ? }
+ { AND target_id = ?d }
+ { AND country_id = ?d }
+ { AND region_id = ?d }
+ { AND city_id = ?d }
+ ";
+ $res = $this->oDb->query($sql,
+ isset($aFilter['geo_type']) ? $aFilter['geo_type'] : DBSIMPLE_SKIP,
+ isset($aFilter['geo_id']) ? $aFilter['geo_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['target_type']) ? $aFilter['target_type'] : DBSIMPLE_SKIP,
+ isset($aFilter['target_id']) ? $aFilter['target_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['country_id']) ? $aFilter['country_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['region_id']) ? $aFilter['region_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['city_id']) ? $aFilter['city_id'] : DBSIMPLE_SKIP
+ );
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Возвращает список стран по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCount Возвращает количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetCountries($aFilter, $aOrder, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $aOrderAllow = array('id', 'name_ru', 'name_en', 'sort');
+ $sOrder = '';
+ foreach ($aOrder as $key => $value) {
+ if (!in_array($key, $aOrderAllow)) {
+ unset($aOrder[$key]);
+ } elseif (in_array($value, array('asc', 'desc'))) {
+ $sOrder .= " {$key} {$value},";
+ }
+ }
+ $sOrder = trim($sOrder, ',');
+ if ($sOrder == '') {
+ $sOrder = ' id desc ';
+ }
+
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.geo_country') . "
+ WHERE
+ 1 = 1
+ { AND id = ?d }
+ { AND name_ru = ? }
+ { AND name_ru LIKE ? }
+ { AND name_en = ? }
+ { AND name_en LIKE ? }
+ { AND code = ? }
+
+ ORDER by {$sOrder}
+ LIMIT ?d, ?d ;
+ ";
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql,
+ isset($aFilter['id']) ? $aFilter['id'] : DBSIMPLE_SKIP,
+ isset($aFilter['name_ru']) ? $aFilter['name_ru'] : DBSIMPLE_SKIP,
+ isset($aFilter['name_ru_like']) ? $aFilter['name_ru_like'] : DBSIMPLE_SKIP,
+ isset($aFilter['name_en']) ? $aFilter['name_en'] : DBSIMPLE_SKIP,
+ isset($aFilter['name_en_like']) ? $aFilter['name_en_like'] : DBSIMPLE_SKIP,
+ isset($aFilter['code']) ? $aFilter['code'] : DBSIMPLE_SKIP,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('ModuleGeo_EntityCountry', $aRow);
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Возвращает список регионов по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCount Возвращает количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetRegions($aFilter, $aOrder, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $aOrderAllow = array('id', 'name_ru', 'name_en', 'sort', 'country_id');
+ $sOrder = '';
+ foreach ($aOrder as $key => $value) {
+ if (!in_array($key, $aOrderAllow)) {
+ unset($aOrder[$key]);
+ } elseif (in_array($value, array('asc', 'desc'))) {
+ $sOrder .= " {$key} {$value},";
+ }
+ }
+ $sOrder = trim($sOrder, ',');
+ if ($sOrder == '') {
+ $sOrder = ' id desc ';
+ }
+
+ if (isset($aFilter['country_id']) and !is_array($aFilter['country_id'])) {
+ $aFilter['country_id'] = array($aFilter['country_id']);
+ }
+
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.geo_region') . "
+ WHERE
+ 1 = 1
+ { AND id = ?d }
+ { AND name_ru = ? }
+ { AND name_ru LIKE ? }
+ { AND name_en = ? }
+ { AND name_en LIKE ? }
+ { AND country_id IN ( ?a ) }
+
+ ORDER by {$sOrder}
+ LIMIT ?d, ?d ;
+ ";
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql,
+ isset($aFilter['id']) ? $aFilter['id'] : DBSIMPLE_SKIP,
+ isset($aFilter['name_ru']) ? $aFilter['name_ru'] : DBSIMPLE_SKIP,
+ isset($aFilter['name_ru_like']) ? $aFilter['name_ru_like'] : DBSIMPLE_SKIP,
+ isset($aFilter['name_en']) ? $aFilter['name_en'] : DBSIMPLE_SKIP,
+ isset($aFilter['name_en_like']) ? $aFilter['name_en_like'] : DBSIMPLE_SKIP,
+ (isset($aFilter['country_id']) && count($aFilter['country_id'])) ? $aFilter['country_id'] : DBSIMPLE_SKIP,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('ModuleGeo_EntityRegion', $aRow);
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Возвращает список городов по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCount Возвращает количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetCities($aFilter, $aOrder, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $aOrderAllow = array('id', 'name_ru', 'name_en', 'sort', 'country_id', 'region_id');
+ $sOrder = '';
+ foreach ($aOrder as $key => $value) {
+ if (!in_array($key, $aOrderAllow)) {
+ unset($aOrder[$key]);
+ } elseif (in_array($value, array('asc', 'desc'))) {
+ $sOrder .= " {$key} {$value},";
+ }
+ }
+ $sOrder = trim($sOrder, ',');
+ if ($sOrder == '') {
+ $sOrder = ' id desc ';
+ }
+
+ if (isset($aFilter['country_id']) and !is_array($aFilter['country_id'])) {
+ $aFilter['country_id'] = array($aFilter['country_id']);
+ }
+ if (isset($aFilter['region_id']) and !is_array($aFilter['region_id'])) {
+ $aFilter['region_id'] = array($aFilter['region_id']);
+ }
+
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.geo_city') . "
+ WHERE
+ 1 = 1
+ { AND id = ?d }
+ { AND name_ru = ? }
+ { AND name_ru LIKE ? }
+ { AND name_en = ? }
+ { AND name_en LIKE ? }
+ { AND country_id IN ( ?a ) }
+ { AND region_id IN ( ?a ) }
+
+ ORDER by {$sOrder}
+ LIMIT ?d, ?d ;
+ ";
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql,
+ isset($aFilter['id']) ? $aFilter['id'] : DBSIMPLE_SKIP,
+ isset($aFilter['name_ru']) ? $aFilter['name_ru'] : DBSIMPLE_SKIP,
+ isset($aFilter['name_ru_like']) ? $aFilter['name_ru_like'] : DBSIMPLE_SKIP,
+ isset($aFilter['name_en']) ? $aFilter['name_en'] : DBSIMPLE_SKIP,
+ isset($aFilter['name_en_like']) ? $aFilter['name_en_like'] : DBSIMPLE_SKIP,
+ (isset($aFilter['country_id']) && count($aFilter['country_id'])) ? $aFilter['country_id'] : DBSIMPLE_SKIP,
+ (isset($aFilter['region_id']) && count($aFilter['region_id'])) ? $aFilter['region_id'] : DBSIMPLE_SKIP,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('ModuleGeo_EntityCity', $aRow);
+ }
+ }
+ return $aResult;
+ }
+
+ public function GetCountriesUsedByTargetType($sTargetType)
+ {
+ $sql = "
+ SELECT
+ c.*
+ FROM (
+ SELECT
+ DISTINCT country_id
+ FROM
+ " . Config::Get('db.table.geo_target') . "
+ WHERE target_type = ? and country_id IS NOT NULL
+ ) as t
+ JOIN " . Config::Get('db.table.geo_country') . " as c on t.country_id=c.id
+ ORDER BY c.name_ru
+ ";
+
+ $aResult = array();
+ if ($aRows = $this->oDb->select($sql, $sTargetType)) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('ModuleGeo_EntityCountry', $aRow);
+ }
+ }
+ return $aResult;
+ }
+
+ public function GetRegionsUsedByTargetType($iCountryId,$sTargetType)
+ {
+ $sql = "
+ SELECT
+ c.*
+ FROM (
+ SELECT
+ DISTINCT region_id
+ FROM
+ " . Config::Get('db.table.geo_target') . "
+ WHERE target_type = ? and region_id IS NOT NULL
+ ) as t
+ JOIN " . Config::Get('db.table.geo_region') . " as c on ( t.region_id=c.id and c.country_id = ? )
+ ORDER BY c.name_ru
+ ";
+
+ $aResult = array();
+ if ($aRows = $this->oDb->select($sql, $sTargetType, $iCountryId)) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('ModuleGeo_EntityRegion', $aRow);
+ }
+ }
+ return $aResult;
+ }
+
+ public function GetCitiesUsedByTargetType($iRegionId,$sTargetType)
+ {
+ $sql = "
+ SELECT
+ c.*
+ FROM (
+ SELECT
+ DISTINCT city_id
+ FROM
+ " . Config::Get('db.table.geo_target') . "
+ WHERE target_type = ? and city_id IS NOT NULL
+ ) as t
+ JOIN " . Config::Get('db.table.geo_city') . " as c on ( t.city_id=c.id and c.region_id = ? )
+ ORDER BY c.name_ru
+ ";
+
+ $aResult = array();
+ if ($aRows = $this->oDb->select($sql, $sTargetType, $iRegionId)) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('ModuleGeo_EntityCity', $aRow);
+ }
+ }
+ return $aResult;
+ }
+}
diff --git a/application/classes/modules/ifhub/Ifhub.class.php b/application/classes/modules/ifhub/Ifhub.class.php
new file mode 100644
index 0000000..4fa9689
--- /dev/null
+++ b/application/classes/modules/ifhub/Ifhub.class.php
@@ -0,0 +1,68 @@
+
+ * Текст спойлера
+ *
+ *
+ * @param string $sTag Тег на котором сработал колбэк
+ * @param array $aParams Список параметров тега
+ * @return string
+ */
+ public function CallbackParserTagSpoiler($sTag, $aParams, $sText)
+ {
+ $sTitle = "Спойлер";
+ if (isset($aParams['title'])) {
+ $sTitle = $aParams['title'];
+ }
+
+ return ''.
+ ''.$sTitle.' '.
+ $sText.' ';
+ }
+ /**
+ * Обработка тега aside в тексте
+ *
+ *
+ *
+ *
+ * @param string $sTag Тег на котором сработал колбэк
+ * @param array $aParams Список параметров тега
+ * @return string
+ */
+ public function CallbackParserTagAside($sTag, $aParams, $sText)
+ {
+ return ''.$sText.'
';
+ }
+ /**
+ * Обработка тега incut в тексте
+ *
+ * Текст врезки
+ *
+ *
+ * @param string $sTag Тег на котором сработал колбэк
+ * @param array $aParams Список параметров тега
+ * @return string
+ */
+ public function CallbackParserTagIncut($sTag, $aParams, $sText)
+ {
+ return ''.$sText.'
';
+ }
+}
diff --git a/application/classes/modules/invite/Invite.class.php b/application/classes/modules/invite/Invite.class.php
new file mode 100644
index 0000000..5f0070a
--- /dev/null
+++ b/application/classes/modules/invite/Invite.class.php
@@ -0,0 +1,279 @@
+
+ *
+ */
+
+/**
+ * Модуль управления инвайтами
+ *
+ * @package application.modules.invite
+ * @since 2.0
+ */
+class ModuleInvite extends ModuleORM
+{
+ /**
+ * Тип реферального инвайта, когда пользователь приглашает по своему реферальному коду
+ */
+ const INVITE_TYPE_REFERRAL = 1;
+ /**
+ * Тип инвайта по сгенерированному коду, когда пользователь генерирует для приглашения отдельный код (доступен в закрытом режиме сайта)
+ */
+ const INVITE_TYPE_CODE = 2;
+
+ /**
+ * Генерирует новый код инвайта
+ *
+ * @param int $iUserId
+ * @param string|null $sCode
+ * @param int $iCountAllowUse
+ * @param int|string|null $sDateExpired
+ * @return bool|ModuleInvite_EntityCode
+ */
+ public function GenerateInvite($iUserId, $sCode = null, $iCountAllowUse = 1, $sDateExpired = null)
+ {
+ $iUserId = is_scalar($iUserId) ? (int)$iUserId : $iUserId->getId();
+ $sDateExpired = is_int($sDateExpired) ? date('Y-m-d H:i:s', time() + $sDateExpired) : $sDateExpired;
+
+ $oInviteCode = Engine::GetEntity('ModuleInvite_EntityCode');
+ $oInviteCode->setUserId($iUserId);
+ $oInviteCode->setCode(is_null($sCode) ? $this->GenerateRandomCode() : $sCode);
+ $oInviteCode->setCountAllowUse($iCountAllowUse);
+ $oInviteCode->setDateExpired($sDateExpired);
+ $oInviteCode->setActive(1);
+ if ($oInviteCode->Add()) {
+ return $oInviteCode;
+ }
+ return false;
+ }
+
+ /**
+ * Фиксирует факт использования кода инвайта
+ *
+ * @param string $sCode
+ * @param int $iUserId
+ * @return bool
+ */
+ public function UseCode($sCode, $iUserId)
+ {
+ $iUserId = is_scalar($iUserId) ? (int)$iUserId : $iUserId->getId();
+ $iType = $this->GetInviteTypeByCode($sCode);
+
+ $oUse = Engine::GetEntity('ModuleInvite_EntityUse');
+ $oUse->setType($iType);
+ $oUse->setToUserId($iUserId);
+
+ if ($iType == self::INVITE_TYPE_CODE) {
+ $oCode = $this->GetCodeByCode($sCode);
+ $oCode->setCountUse($oCode->getCountUse() + 1);
+ $oCode->Update();
+
+ $oUse->setCodeId($oCode->getId());
+ $oUse->setFromUserId($oCode->getUserId());
+ } elseif ($iType == self::INVITE_TYPE_REFERRAL) {
+ $oUser = $this->User_GetUserByReferralCode($sCode);
+ $oUse->setFromUserId($oUser->getId());
+ } else {
+ return false;
+ }
+ return $oUse->Add();
+ }
+
+ /**
+ * Проверяет корректность кода инвайта с учетом его типа
+ *
+ * @param string $sCode
+ * @param int $iType Тип инвайта, смотри self::INVITE_TYPE_*
+ * @return bool
+ */
+ public function CheckCode($sCode, $iType = self::INVITE_TYPE_CODE)
+ {
+ if ($iType == self::INVITE_TYPE_CODE) {
+ if ($oCode = $this->GetCodeByCode($sCode)) {
+ if ($oCode->getActive()
+ and $oCode->getCountUse() < $oCode->getCountAllowUse()
+ and (!$oCode->getDateExpired() or strtotime($oCode->getDateExpired()) < time())
+ ) {
+ return true;
+ }
+ }
+ } elseif ($iType == self::INVITE_TYPE_REFERRAL) {
+ if ($oUser = $this->User_GetUserByReferralCode($sCode)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает тип инвайта по его коду
+ *
+ * @param string $sCode
+ * @return bool|int
+ */
+ public function GetInviteTypeByCode($sCode)
+ {
+ /**
+ * Приоритет отдаем сгенерированному коду
+ */
+ if ($this->CheckCode($sCode, self::INVITE_TYPE_CODE)) {
+ return self::INVITE_TYPE_CODE;
+ }
+ if ($this->CheckCode($sCode, self::INVITE_TYPE_REFERRAL)) {
+ return self::INVITE_TYPE_REFERRAL;
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает персональный реферальный код пользователя
+ *
+ * @param ModuleUser_EntityUser $oUser
+ * @return string|null
+ */
+ public function GetReferralCode($oUser)
+ {
+ if (is_scalar($oUser)) {
+ $oUser = $this->User_GetUserById($oUser);
+ }
+ if (is_object($oUser)) {
+ return $oUser->getReferralCode();
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает полную ссылку с реферальным кодом
+ *
+ * @param ModuleUser_EntityUser $oUser
+ * @param string|null $sCode
+ * @return null|string
+ */
+ public function GetReferralLink($oUser, $sCode = null)
+ {
+ if ($sCode or $sCode = $this->GetReferralCode($oUser)) {
+ return Router::GetPath('auth/referral') . urlencode($sCode) . '/';
+ }
+ return null;
+ }
+
+ /**
+ * Генерирует случайный код
+ *
+ * @return string
+ */
+ protected function GenerateRandomCode()
+ {
+ return func_generator(10);
+ }
+
+ /**
+ * Возвращает количество доступных инвайтов для пользователя в данный момент
+ *
+ * @param ModuleUser_EntityUser $oUser
+ * @return int
+ */
+ public function GetCountInviteAvailable($oUser)
+ {
+ if (is_scalar($oUser)) {
+ $oUser = $this->User_GetUserById($oUser);
+ }
+ /**
+ * Период в днях, за который выдаем инвайты
+ */
+ $sDay = 7;
+ /**
+ * Количество выданных инвайтов за эти дни
+ */
+ $iCountUsed = $this->GetCountFromCodeByFilter(array(
+ 'user_id' => $oUser->getId(),
+ 'date_create >' => date("Y-m-d 00:00:00", mktime(0, 0, 0, date("m"), date("d") - $sDay, date("Y")))
+ ));
+ /**
+ * Доступное число инвайтов период = рейтингу пользователя
+ */
+ $iCountAllAvailable = round($oUser->getRating());
+ $iCountAllAvailable = $iCountAllAvailable < 0 ? 0 : $iCountAllAvailable;
+ $iCountAvailable = $iCountAllAvailable - $iCountUsed;
+ $iCountAvailable = $iCountAvailable < 0 ? 0 : $iCountAvailable;
+ return $iCountAvailable;
+ }
+
+ /**
+ * Возвращает количество приглашенных пользователей (число использованных инвайтов)
+ *
+ * @param int $iUserId
+ * @return int
+ */
+ public function GetCountInviteUsed($iUserId)
+ {
+ $iUserId = is_scalar($iUserId) ? (int)$iUserId : $iUserId->getId();
+
+ return $this->GetCountFromUseByFilter(array('from_user_id' => $iUserId));
+ }
+
+ /**
+ * Возвращает пользователя, который пригласил текущего
+ *
+ * @param $iUserId
+ * @return ModuleUser_EntityUser|null
+ */
+ public function GetUserInviteFrom($iUserId)
+ {
+ if ($oUse = $this->GetUseByToUserId($iUserId) and $iUserFrom = $oUse->getFromUserId()) {
+ return $this->User_GetUserById($iUserFrom);
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает список приглашенных пользователей
+ *
+ * @param int $iUserId
+ * @return array
+ */
+ public function GetUsersInvite($iUserId)
+ {
+ if ($aUseItems = $this->GetUseItemsByFilter(array('from_user_id' => $iUserId, '#index-from' => 'to_user_id', '#limit' => 100))) {
+ return $this->User_GetUsersAdditionalData(array_keys($aUseItems));
+ }
+ return array();
+ }
+
+ /**
+ * Отправляет инвайт
+ *
+ * @param ModuleUser_EntityUser $oUserFrom Пароль пользователя, который отправляет инвайт
+ * @param string $sMailTo Емайл на который отправляем инвайт
+ * @param string $sRefCode Код приглашения
+ */
+ public function SendNotifyInvite(ModuleUser_EntityUser $oUserFrom, $sMailTo, $sRefCode)
+ {
+ $this->Notify_Send(
+ $sMailTo,
+ 'invite.tpl',
+ $this->Lang_Get('emails.invite.subject'),
+ array(
+ 'sMailTo' => $sMailTo,
+ 'oUserFrom' => $oUserFrom,
+ 'sRefCode' => $sRefCode,
+ 'sRefLink' => $this->GetReferralLink($oUserFrom, $sRefCode),
+ )
+ );
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/invite/entity/Code.entity.class.php b/application/classes/modules/invite/entity/Code.entity.class.php
new file mode 100644
index 0000000..5ecd4eb
--- /dev/null
+++ b/application/classes/modules/invite/entity/Code.entity.class.php
@@ -0,0 +1,39 @@
+
+ *
+ */
+
+/**
+ * Сущность инвайта
+ *
+ * @package application.modules.invite
+ * @since 2.0
+ */
+class ModuleInvite_EntityCode extends EntityORM
+{
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/invite/entity/Use.entity.class.php b/application/classes/modules/invite/entity/Use.entity.class.php
new file mode 100644
index 0000000..4d1e75b
--- /dev/null
+++ b/application/classes/modules/invite/entity/Use.entity.class.php
@@ -0,0 +1,39 @@
+
+ *
+ */
+
+/**
+ * Сущность факта использования инвайта
+ *
+ * @package application.modules.invite
+ * @since 2.0
+ */
+class ModuleInvite_EntityUse extends EntityORM
+{
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/media/Media.class.php b/application/classes/modules/media/Media.class.php
new file mode 100644
index 0000000..0eb36b2
--- /dev/null
+++ b/application/classes/modules/media/Media.class.php
@@ -0,0 +1,1518 @@
+
+ *
+ */
+
+/**
+ * Модуль управления медиа-данными (изображения, видео и т.п.)
+ *
+ * @package application.modules.media
+ * @since 2.0
+ */
+class ModuleMedia extends ModuleORM
+{
+ /**
+ * Список типов медиа
+ * Свои кастомные типы необходимо нумеровать с 1000
+ */
+ const MEDIA_TYPE_IMAGE = 1;
+ const MEDIA_TYPE_VIDEO = 2;
+ /**
+ * Список типов для проверки доступа
+ */
+ const TYPE_CHECK_ALLOW_ADD = 'add';
+ const TYPE_CHECK_ALLOW_REMOVE = 'remove';
+ const TYPE_CHECK_ALLOW_UPDATE = 'update';
+ const TYPE_CHECK_ALLOW_PREVIEW = 'preview'; // возможность создания превью для объекта
+ const TYPE_CHECK_ALLOW_VIEW_LIST = 'view_list'; // просмотр списка медиа у объекта
+ /**
+ * Объект текущего пользователя
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent;
+
+ protected $oMapper = null;
+
+ protected $aTargetTypes = array(
+ 'topic' => array(
+ 'allow_preview' => true,
+ ),
+ 'comment' => array(),
+ 'blog' => array(),
+ 'talk' => array(),
+ 'imageset' => array(
+ 'allow_preview' => true
+ )
+ );
+
+ /**
+ * Список доступных типов медиа
+ *
+ * @var array
+ */
+ protected $aMediaTypes = array(
+ self::MEDIA_TYPE_IMAGE,
+ self::MEDIA_TYPE_VIDEO
+ );
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ parent::Init();
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Возвращает список типов объектов
+ *
+ * @return array
+ */
+ public function GetTargetTypes()
+ {
+ return $this->aTargetTypes;
+ }
+
+ /**
+ * Добавляет в разрешенные новый тип
+ *
+ * @param string $sTargetType Тип
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function AddTargetType($sTargetType, $aParams = array())
+ {
+ if (!array_key_exists($sTargetType, $this->aTargetTypes)) {
+ $this->aTargetTypes[$sTargetType] = $aParams;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет разрешен ли данный тип
+ *
+ * @param string $sTargetType Тип
+ * @return bool
+ */
+ public function IsAllowTargetType($sTargetType)
+ {
+ return in_array($sTargetType, array_keys($this->aTargetTypes));
+ }
+
+ /**
+ * Возвращает парметры нужного типа
+ *
+ * @param string $sTargetType
+ *
+ * @return array|null
+ */
+ public function GetTargetTypeParams($sTargetType)
+ {
+ if ($this->IsAllowTargetType($sTargetType)) {
+ return $this->aTargetTypes[$sTargetType];
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает конкретный параметр нужного типа
+ *
+ * @param string $sTargetType
+ * @param string $sName
+ *
+ * @return mixed|null
+ */
+ public function GetTargetTypeParam($sTargetType, $sName)
+ {
+ $aParams = $this->GetTargetTypeParams($sTargetType);
+ if ($aParams and array_key_exists($sName, $aParams)) {
+ return $aParams[$sName];
+ }
+ return null;
+ }
+
+ /**
+ * Проверяет разрешен ли тип медиа
+ *
+ * @param string $sType
+ *
+ * @return bool
+ */
+ public function IsAllowMediaType($sType)
+ {
+ return in_array($sType, $this->aMediaTypes);
+ }
+
+ /**
+ * Возвращает тип медиа по имени файла (имя файла должно содержать его расширение)
+ *
+ * @param $sFile
+ * @return int|null
+ */
+ public function GetMediaTypeByFileName($sFile)
+ {
+ $aPathInfo = pathinfo($sFile);
+ $sExtension = isset($aPathInfo['extension']) ? $aPathInfo['extension'] : 'unknown';
+ return $this->GetMediaTypeByFileExtension($sExtension);
+ }
+
+ /**
+ * Возвращает тип медиа по расширению файла
+ *
+ * @param $sExtension
+ * @return int|null
+ */
+ public function GetMediaTypeByFileExtension($sExtension)
+ {
+ $sExtension = strtolower($sExtension);
+ if (in_array($sExtension, array('jpg', 'jpeg', 'gif', 'png'))) {
+ return self::MEDIA_TYPE_IMAGE;
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает название медиа типа по его значению
+ * Название получается автоматически из константы с определением типа
+ *
+ * @param $iType
+ * @return null|string
+ */
+ public function GetMediaTypeName($iType)
+ {
+ $oRefl = new ReflectionObject($this);
+ foreach ($oRefl->getConstants() as $sName => $mValue) {
+ if (strpos($sName, 'MEDIA_TYPE_') === 0 and $mValue == $iType) {
+ return strtolower(substr($sName, strlen('MEDIA_TYPE_')));
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Проверка объекта target - владелец медиа
+ *
+ * @param string $sTargetType Тип
+ * @param int $iTargetId ID владельца
+ * @param string $sAllowType
+ * @param array $aParams
+ * @param string|null $sTargetTmp
+ * @return bool
+ */
+ public function CheckTarget($sTargetType, $iTargetId = null, $sAllowType = null, $aParams = array(), $sTargetTmp = null)
+ {
+ if (!$this->IsAllowTargetType($sTargetType)) {
+ return false;
+ }
+
+ /**
+ * Проверка на максимальное количество файлов
+ */
+ if ($sAllowType == self::TYPE_CHECK_ALLOW_ADD and ($iTargetId or $sTargetTmp)) {
+ $iCountAllow = $this->GetConfigParam('max_count_files', $sTargetType);
+ if (is_numeric($iCountAllow)) {
+ $aFilterCount = array('target_type' => $sTargetType);
+ if ($iTargetId) {
+ $aFilterCount['target_id'] = $iTargetId;
+ } else {
+ $aFilterCount['target_tmp'] = $sTargetTmp;
+ }
+ $iCount = $this->getCountFromTargetByFilter($aFilterCount);
+ if ($iCount >= $iCountAllow) {
+ return $this->Lang_Get('media.error.max_count_files');
+ }
+ }
+ }
+ $sMethod = 'CheckTarget' . func_camelize($sTargetType);
+ if (method_exists($this, $sMethod)) {
+ if (!array_key_exists('user', $aParams)) {
+ $aParams['user'] = $this->oUserCurrent;
+ }
+ return $this->$sMethod($iTargetId, $sAllowType, $aParams);
+ }
+ return false;
+ }
+
+ public function NotifyCreatePreviewTarget($sTargetType, $iTargetId, $oRelationTarget)
+ {
+ if (!$this->IsAllowTargetType($sTargetType)) {
+ return false;
+ }
+ $sMethod = 'NotifyCreatePreviewTarget' . func_camelize($sTargetType);
+ if (method_exists($this, $sMethod)) {
+ return $this->$sMethod($iTargetId, $oRelationTarget);
+ }
+ return false;
+ }
+
+ public function NotifyRemovePreviewTarget($sTargetType, $iTargetId, $oRelationTarget)
+ {
+ if (!$this->IsAllowTargetType($sTargetType)) {
+ return false;
+ }
+ $sMethod = 'NotifyRemovePreviewTarget' . func_camelize($sTargetType);
+ if (method_exists($this, $sMethod)) {
+ return $this->$sMethod($iTargetId, $oRelationTarget);
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает параметр конфига с учетом текущего target_type
+ *
+ * @param string $sParam Ключ конфига относительно module.media
+ * @param string $sTargetType Тип
+ *
+ * @return mixed
+ */
+ public function GetConfigParam($sParam, $sTargetType)
+ {
+ $mValue = Config::Get("module.media.type.{$sTargetType}.{$sParam}");
+ if (!$mValue) {
+ $mValue = Config::Get("module.media.{$sParam}");
+ }
+ return $mValue;
+ }
+
+ /**
+ * Проверяем файл на максимальный размер с учетом типа медиа
+ *
+ * @param $iSize
+ * @param $iMediaType
+ * @param $sTargetType
+ * @return bool
+ */
+ public function CheckFileUploadSize($iSize, $iMediaType, $sTargetType)
+ {
+ $sConfigParam = 'max_size';
+ $sMediaTypeName = $this->Media_GetMediaTypeName($iMediaType);
+ if (is_null($iMaxSizeKb = $this->GetConfigParam($sMediaTypeName . '.' . $sConfigParam, $sTargetType))) {
+ $iMaxSizeKb = $this->GetConfigParam($sConfigParam, $sTargetType);
+ }
+ if ($iSize > $iMaxSizeKb * 1024) {
+ return $this->Lang_Get('media.error.too_large', array('size' => $iMaxSizeKb));
+ }
+ return true;
+ }
+
+ public function Upload($aFile, $sTargetType, $sTargetId, $sTargetTmp = null)
+ {
+ if (is_string($aFile)) {
+ return $this->UploadUrl($aFile, $sTargetType, $sTargetId, $sTargetTmp);
+ } else {
+ return $this->UploadLocal($aFile, $sTargetType, $sTargetId, $sTargetTmp);
+ }
+ }
+
+ public function UploadLocal($aFile, $sTargetType, $sTargetId, $sTargetTmp = null)
+ {
+ if (!is_array($aFile) || !isset($aFile['error']) || !isset($aFile['tmp_name']) || !is_string($aFile['tmp_name']) || !isset($aFile['name']) || !isset($aFile['size'])) {
+ return false;
+ }
+
+ if ($aFile['error'] != UPLOAD_ERR_OK) {
+ switch ($aFile['error']) {
+ case UPLOAD_ERR_INI_SIZE:
+ case UPLOAD_ERR_FORM_SIZE:
+ return $this->Lang_Get('media.error.too_large', array('size' => @func_ini_return_bytes(ini_get('upload_max_filesize')) / 1024));
+ default:
+ return $this->Lang_Get('media.error.upload');
+ }
+ }
+
+ $aPathInfo = pathinfo($aFile['name']);
+ $sExtension = isset($aPathInfo['extension']) ? $aPathInfo['extension'] : 'unknown';
+ $sFileName = $aPathInfo['filename'] . '.' . $sExtension;
+ /**
+ * Проверяем на размер файла
+ */
+ $iMediaType = $this->GetMediaTypeByFileExtension($sExtension);
+ if (true !== ($mRes = $this->CheckFileUploadSize($aFile['size'], $iMediaType, $sTargetType))) {
+ return $mRes;
+ }
+ /**
+ * Копируем загруженный файл
+ */
+ $sDirTmp = Config::Get('path.tmp.server') . '/media/';
+ if (!is_dir($sDirTmp)) {
+ @mkdir($sDirTmp, 0777, true);
+ }
+ $sFileTmp = $sDirTmp . $sFileName;
+ if (!move_uploaded_file($aFile['tmp_name'], $sFileTmp)) {
+ return $this->Lang_Get('media.error.upload');
+ }
+
+ return $this->ProcessingFile($sFileTmp, $sTargetType, $sTargetId, $sTargetTmp);
+ }
+
+ public function UploadUrl($sFileUrl, $sTargetType, $sTargetId, $sTargetTmp = null)
+ {
+ /**
+ * Проверяем, является ли файл изображением
+ * TODO: файл может быть не только изображением, поэтому требуется рефакторинг
+ */
+ if (!$aImageInfo = (@getimagesize($sFileUrl))) {
+ return $this->Lang_Get('media.error.not_image');
+ }
+ $aTypeImage = array(
+ 1 => 'gif',
+ 2 => 'jpg',
+ 3 => 'png'
+ ); // see http://php.net/manual/en/function.exif-imagetype.php
+ $sExtension = isset($aTypeImage[$aImageInfo[2]]) ? $aTypeImage[$aImageInfo[2]] : 'jpg';
+ /**
+ * Открываем файловый поток и считываем файл поблочно,
+ * контролируя максимальный размер изображения
+ */
+ $rFile = fopen($sFileUrl, 'r');
+ if (!$rFile) {
+ return $this->Lang_Get('media.error.upload');
+ }
+
+ $iMaxSizeKb = $this->GetConfigParam('max_size', $sTargetType);
+ $iSizeKb = 0;
+ $sContent = '';
+ while (!feof($rFile) and $iSizeKb < $iMaxSizeKb) {
+ $sContent .= fread($rFile, 1024 * 2);
+ $iSizeKb++;
+ }
+ /**
+ * Если конец файла не достигнут,
+ * значит файл имеет недопустимый размер
+ */
+ if (!feof($rFile)) {
+ return $this->Lang_Get('media.error.too_large', array('size' => $iMaxSizeKb));
+ }
+ fclose($rFile);
+ /**
+ * Копируем загруженный файл
+ */
+ $sDirTmp = Config::Get('path.tmp.server') . '/media/';
+ if (!is_dir($sDirTmp)) {
+ @mkdir($sDirTmp, 0777, true);
+ }
+ $sFileTmp = $sDirTmp . func_generator() . '.' . $sExtension;
+ $rFile = fopen($sFileTmp, 'w');
+ fwrite($rFile, $sContent);
+ fclose($rFile);
+
+ return $this->ProcessingFile($sFileTmp, $sTargetType, $sTargetId, $sTargetTmp);
+ }
+
+ public function ProcessingFile($sFileTmp, $sTargetType, $sTargetId, $sTargetTmp = null)
+ {
+ /**
+ * Определяем тип медиа по файлу и запускаем обработку
+ */
+ $iType = $this->GetMediaTypeByFileName($sFileTmp);
+ if ($iType == self::MEDIA_TYPE_IMAGE) {
+ return $this->ProcessingFileImage($sFileTmp, $sTargetType, $sTargetId, $sTargetTmp);
+ }
+ return $this->Lang_Get('media.error.incorrect_type');
+ }
+
+ public function ProcessingFileImage($sFileTmp, $sTargetType, $sTargetId, $sTargetTmp = null)
+ {
+ $aPathInfo = pathinfo($sFileTmp);
+ $aParams = $this->Image_BuildParams('media.' . $sTargetType);
+ /**
+ * Если объект изображения не создан, возвращаем ошибку
+ */
+ if (!$oImage = $this->Image_Open($sFileTmp, $aParams)) {
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ return $this->Image_GetLastError();
+ }
+ $iWidth = $oImage->getWidth();
+ $iHeight = $oImage->getHeight();
+
+ $sPath = $this->GetSaveDir($sTargetType, $sTargetId);
+ $iFileSize = $this->Fs_GetFileSize($sFileTmp);
+ /**
+ * Уникальное имя файла
+ */
+ $sFileName = func_generator(20);
+ /**
+ * Сохраняем оригинальную копию
+ * Оригинал храним без вотермарка
+ */
+ $sFileResult = null;
+ $mOriginalSize = $this->GetConfigParam('image.original', $sTargetType);
+ if ($mOriginalSize !== false && $oImage->getFormat() == 'gif') {
+ /**
+ * Если gif, то сохраняем без изменений
+ */
+ if (!$sFileResult = $oImage->saveOriginalSmart($sPath, $sFileName)) {
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ return $this->Image_GetLastError();
+ }
+ } else {
+ if ($mOriginalSize === true) {
+ if (!$sFileResult = $oImage->saveSmart($sPath, $sFileName, array('skip_watermark' => true))) {
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ return $this->Image_GetLastError();
+ }
+ } elseif (is_string($mOriginalSize)) {
+ /**
+ * Ресайзим оригинал
+ */
+ $aOriginalSize = $this->ParsedImageSize($mOriginalSize);
+ if ($aOriginalSize['crop']) {
+ $oImage->cropProportion($aOriginalSize['w'] / $aOriginalSize['h'], 'center');
+ }
+ if (!$sFileResult = $oImage->resize($aOriginalSize['w'], $aOriginalSize['h'], true)->saveSmart($sPath, $sFileName,
+ array('skip_watermark' => true))
+ ) {
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ return $this->Image_GetLastError();
+ }
+ $iFileSize = $this->Fs_GetFileSize($sFileResult);
+ }
+ }
+
+ $aSizes = $this->GetConfigParam('image.sizes', $sTargetType);
+ /**
+ * Перед запуском генерации подчищаем память
+ */
+ unset($oImage);
+ /**
+ * Генерируем варианты с необходимыми размерами
+ */
+ $sFileResultLast = $this->GenerateImageBySizes($sFileTmp, $sPath, $sFileName, $aSizes, $aParams);
+ if (!$sFileResult) {
+ /**
+ * Оригинала нет, поэтому получаем фейковый путь до основного файла (нужен для получения файлов других размеров)
+ */
+ $aPathInfoLast = pathinfo($sFileResultLast);
+ $aFileNamePart = explode('_', $aPathInfoLast['filename']);
+ $sFileResult = $aPathInfoLast['dirname'] . '/' . $aFileNamePart[0] . '.' . $aPathInfoLast['extension'];
+ }
+ /**
+ * Сохраняем медиа
+ */
+ $oMedia = Engine::GetEntity('ModuleMedia_EntityMedia');
+ $oMedia->setUserId($this->oUserCurrent ? $this->oUserCurrent->getId() : null);
+ $oMedia->setType(self::MEDIA_TYPE_IMAGE);
+ $oMedia->setTargetType($sTargetType);
+ $oMedia->setFilePath($sFileResult);
+ $oMedia->setFileName($aPathInfo['filename']);
+ $oMedia->setFileSize($iFileSize);
+ $oMedia->setWidth($iWidth);
+ $oMedia->setHeight($iHeight);
+ $oMedia->setDataOne('image_sizes', $aSizes);
+ /**
+ * Теперь можно удалить временный файл
+ */
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ /**
+ * Добавляем в БД
+ */
+ if ($oMedia->Add()) {
+ /**
+ * Создаем связь с владельцем
+ */
+ if ($oTarget = $this->AttachMediaToTarget($oMedia, $sTargetType, $sTargetId, $sTargetTmp)) {
+ $oMedia->_setData(array('_relation_entity' => $oTarget));
+ return $oMedia;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Присоединяет медиа файл к объекту
+ *
+ * @param $oMedia
+ * @param $sTargetType
+ * @param $sTargetId
+ * @param $sTargetTmp
+ * @return bool|Entity
+ */
+ public function AttachMediaToTarget($oMedia, $sTargetType, $sTargetId, $sTargetTmp)
+ {
+ /**
+ * Создаем связь с владельцем
+ */
+ $oTarget = Engine::GetEntity('ModuleMedia_EntityTarget');
+ $oTarget->setMediaId($oMedia->getId());
+ $oTarget->setTargetType($sTargetType);
+ $oTarget->setTargetId($sTargetId ?: null);
+ $oTarget->setTargetTmp($sTargetTmp ?: null);
+ if ($oTarget->Add()) {
+ return $oTarget;
+ }
+ return false;
+ }
+
+ /**
+ * Создает набор отресайзанных изображений
+ * Варианты наименований результирующих файлов в зависимости от размеров:
+ * file_100x150 - w=100 h=150 crop=false
+ * file_100x150crop - w=100 h=150 crop=true
+ * file_x150 - w=null h=150 crop=false
+ * file_100x - w=100 h=null crop=false
+ *
+ * @param $sFileSource
+ * @param $sDirDist
+ * @param $sFileName
+ * @param $aSizes
+ * @param null $aParams
+ */
+ public function GenerateImageBySizes($sFileSource, $sDirDist, $sFileName, $aSizes, $aParams = null)
+ {
+ if (!$aSizes) {
+ return;
+ }
+ /**
+ * Преобразуем упрощенную запись списка размеров в полную
+ */
+ foreach ($aSizes as $k => $v) {
+ if (!is_array($v)) {
+ $aSizes[$k] = $this->ParsedImageSize($v);
+ }
+ }
+ $sFileResult = null;
+ foreach ($aSizes as $aSize) {
+ /**
+ * Для каждого указанного в конфиге размера генерируем картинку
+ */
+ $sNewFileName = $sFileName . '_' . $aSize['w'] . 'x' . $aSize['h'];
+ if ($oImage = $this->Image_Open($sFileSource, $aParams)) {
+ if ($aSize['crop']) {
+ $oImage->cropProportion($aSize['w'] / $aSize['h'], 'center');
+ $sNewFileName .= 'crop';
+ }
+ if (!$sFileResult = $oImage->resize($aSize['w'], $aSize['h'], true)->saveSmart($sDirDist,
+ $sNewFileName)
+ ) {
+ // TODO: прерывать и возвращать false?
+ }
+ }
+ }
+ /**
+ * Возвращаем путь до последнего созданного файла
+ */
+ return $sFileResult;
+ }
+
+ public function RemoveImageBySizes($sPath, $aSizes, $bRemoveOriginal = true)
+ {
+ if ($aSizes) {
+ /**
+ * Преобразуем упрощенную запись списка размеров в полную
+ */
+ foreach ($aSizes as $k => $v) {
+ if (!is_array($v)) {
+ $aSizes[$k] = $this->ParsedImageSize($v);
+ }
+ }
+ foreach ($aSizes as $aSize) {
+ $sSize = $aSize['w'] . 'x' . $aSize['h'];
+ if ($aSize['crop']) {
+ $sSize .= 'crop';
+ }
+ $this->Image_RemoveFile($this->GetImagePathBySize($sPath, $sSize));
+ }
+ }
+ /**
+ * Удаляем оригинал
+ */
+ if ($bRemoveOriginal) {
+ $this->Image_RemoveFile($sPath);
+ }
+ }
+
+ /**
+ * Возвращает каталог для сохранения контента медиа
+ *
+ * @param string $sTargetType
+ * @param string|null $sTargetId Желательно для одного типа при формировании каталога для загрузки выбрать что-то одно - использовать $sTargetId или нет
+ * @param string $sPostfix Дополнительный каталог для сохранения в конце цепочки
+ *
+ * @return string
+ */
+ public function GetSaveDir($sTargetType, $sTargetId = null, $sPostfix = '')
+ {
+ $sPostfix = trim($sPostfix, '/');
+ return Config::Get('path.uploads.base') . "/media/{$sTargetType}/" . date('Y/m/d/H/') . ($sPostfix ? "{$sPostfix}/" : '');
+ }
+
+ public function BuildCodeForEditor($oMedia, $aParams)
+ {
+ $sCode = '';
+ if ($oMedia->getType() == self::MEDIA_TYPE_IMAGE) {
+ $aSizes = (array)$oMedia->getDataOne('image_sizes');
+
+ $sSizeParam = isset($aParams['size']) ? (string)$aParams['size'] : '';
+ $sSize = 'original';
+ $bNeedHref = false;
+ /**
+ * Проверяем корректность размера
+ */
+ foreach ($aSizes as $aSizeAllow) {
+ $sSizeKey = $aSizeAllow['w'] . 'x' . $aSizeAllow['h'] . ($aSizeAllow['crop'] ? 'crop' : '');
+ if ($sSizeKey == $sSizeParam) {
+ $sSize = $sSizeKey;
+ /**
+ * Необходимость лайтбокса
+ */
+ if ($aSizeAllow['w'] < $oMedia->getWidth()) {
+ $bNeedHref = true;
+ }
+ }
+ }
+
+ $sPath = $oMedia->getFileWebPath($sSize == 'original' ? null : $sSize);
+ $aParams['image_url'] = (isset($aParams['relative_web']) and $aParams['relative_web']) ? $this->GetPathRelativeWebFromWeb($sPath) : $sPath;
+ $aParams['href_url'] = (isset($aParams['relative_web']) and $aParams['relative_web']) ? $this->GetPathRelativeWebFromWeb($oMedia->getFileWebPath()) : $oMedia->getFileWebPath();
+ $aParams['need_href'] = $bNeedHref;
+ if (!isset($aParams['title'])) {
+ $aParams['title'] = $oMedia->getDataOne('title');
+ }
+ /**
+ * Формируем HTML изображения
+ */
+ $sCode = $this->BuildImageCodeForEditor($aParams);
+ }
+
+ return $sCode;
+ }
+
+ /**
+ * Возвращает относительный веб путь до изображения
+ *
+ * @param string $sPath Полный веб путь до изображения
+ * @return string
+ */
+ public function GetPathRelativeWebFromWeb($sPath)
+ {
+ return '/' . ltrim(parse_url($sPath, PHP_URL_PATH), '/');
+ }
+
+ /**
+ * Формирует HTML изображения
+ *
+ * @param $aParams
+ * @return string
+ */
+ public function BuildImageCodeForEditor($aParams)
+ {
+ $sCode = ' $sDataValue) {
+ if ($sDataValue) {
+ $sDataParams .= ' data-' . $sDataName . '="' . htmlspecialchars($sDataValue) . '"';
+ }
+ }
+ }
+ if (isset($aParams['need_href']) and $aParams['need_href']) {
+ $sCode .= ' />';
+ $sLbxGroup = '';
+ if (isset($aParams['lbx_group'])) {
+ $sLbxGroup = ' data-rel="' . htmlspecialchars($aParams['lbx_group']) . '"';
+ }
+ $sCode = '' . $sCode . ' ';
+ } else {
+ $sCode .= $sDataParams . ' />';
+ }
+ return $sCode;
+ }
+
+ public function GetMediaByTarget($sTargetType, $iTargetId, $iUserId = null)
+ {
+ return $this->oMapper->GetMediaByTarget($sTargetType, $iTargetId, $iUserId);
+ }
+
+ public function GetMediaByTargetTmp($sTargetTmp, $iUserId = null)
+ {
+ return $this->oMapper->GetMediaByTargetTmp($sTargetTmp, $iUserId);
+ }
+
+ /**
+ * Выполняет удаление файлов медиа-объекта
+ *
+ * @param $oMedia
+ */
+ public function DeleteFiles($oMedia)
+ {
+ /**
+ * Сначала удаляем все файлы
+ */
+ if ($oMedia->getType() == self::MEDIA_TYPE_IMAGE) {
+ $aSizes = $oMedia->getDataOne('image_sizes');
+ $this->RemoveImageBySizes($oMedia->getFilePath(), $aSizes);
+ }
+ }
+
+ /**
+ * Возвращает список media с учетов прав доступа текущего пользователя
+ * В методе происходит проверка на права:
+ * 1. разрешаем объекты, которые создал пользователь
+ * 2. разрешаем объекты, которые связаны таргетом, к которому у пользователя есть доступ на редактирование
+ * @param array $aId
+ *
+ * @return array
+ */
+ public function GetAllowMediaItemsById($aId)
+ {
+ $aIdItems = array();
+ foreach ((array)$aId as $iId) {
+ $aIdItems[] = (int)$iId;
+ }
+ $aIdItemsAll = $aIdItems;
+
+ if (is_array($aIdItems) and count($aIdItems)) {
+ $iUserId = $this->oUserCurrent ? $this->oUserCurrent->getId() : null;
+ $aMediaItems = $this->Media_GetMediaItemsByFilter(array(
+ '#where' => array(
+ 'id in (?a) AND ( user_id is null OR user_id = ?d )' => array(
+ $aIdItems,
+ $iUserId
+ )
+ ),
+ '#index-from-primary'
+ )
+ );
+
+ /**
+ * Смотрим что осталось
+ */
+ if (!is_null($iUserId) and $aIdItems = array_diff($aIdItems, array_keys($aMediaItems))) {
+ $aMediaAllowIds = array();
+ $aTargetMediaGroup = $this->GetTargetItemsByFilter(array(
+ 'media_id in' => $aIdItems,
+ '#with' => 'media',
+ '#index-group' => 'media_id'
+ ));
+ $_this = $this;
+ foreach ($aTargetMediaGroup as $iMediaId => $aTargetMedia) {
+ /**
+ * Проверяем каждый таргет
+ */
+ foreach ($aTargetMedia as $oTargetMedia) {
+ if ($this->Cache_Remember("media_check_target_{$oTargetMedia->getTargetType()}_{$oTargetMedia->getTargetId()}",
+ function () use ($_this, $oTargetMedia, $iUserId) {
+ if ($oTargetMedia->getTargetId()) {
+ return $_this->CheckTarget($oTargetMedia->getTargetType(), $oTargetMedia->getTargetId(), ModuleMedia::TYPE_CHECK_ALLOW_ADD);
+ }
+ if ($oMedia = $oTargetMedia->getMedia() and $oMedia->getUserId() == $iUserId) {
+ return true;
+ }
+ return false;
+ }, false, array(), 'life', true)
+ ) {
+ $aMediaAllowIds[] = $oTargetMedia->getMediaId();
+ break;
+ }
+ }
+ }
+ if ($aMediaAllowIds) {
+ $aMediaItems = $aMediaItems + $this->GetMediaItemsByFilter(array(
+ 'id in' => $aMediaAllowIds,
+ '#index-from-primary'
+ ));
+ }
+ }
+ /**
+ * Нужно отсортировать по первоначальному массиву $aIdItems
+ */
+ $aReturn = array();
+ foreach ($aIdItemsAll as $iId) {
+ if (isset($aMediaItems[$iId])) {
+ $aReturn[$iId] = $aMediaItems[$iId];
+ }
+ }
+ return $aReturn;
+ }
+ return array();
+ }
+
+ /**
+ * Обработка тега gallery в тексте
+ *
+ *
+ *
+ *
+ * @param string $sTag Тег на ктором сработал колбэк
+ * @param array $aParams Список параметров тега
+ * @return string
+ */
+ public function CallbackParserTagGallery($sTag, $aParams)
+ {
+ if (isset($aParams['items'])) {
+ $aItems = explode(',', $aParams['items']);
+ $aItems = array_unique($aItems);
+ } else {
+ return '';
+ }
+
+ if (!($aMediaItems = $this->GetAllowMediaItemsById($aItems))) {
+ return '';
+ }
+
+ $aParamsMedia = array(
+ 'size' => '100crop',
+ 'skip_title' => true,
+ 'relative_web' => true
+ );
+ $sProperties = '';
+ if (isset($aParams['nav']) and in_array($aParams['nav'], array('thumbs'))) {
+ $sProperties .= ' data-nav="' . $aParams['nav'] . '" ';
+ }
+ $sTextResult = '' . "\r\n";
+ foreach ($aMediaItems as $oMedia) {
+ if (isset($aParams['caption']) and $aParams['caption']) {
+ $aParamsMedia['data']['caption'] = htmlspecialchars($oMedia->getDataOne('title'));
+ }
+ $sTextResult .= "\t" . $this->BuildCodeForEditor($oMedia, $aParamsMedia) . "\r\n";
+ }
+ $sTextResult .= "
\r\n";
+ return $sTextResult;
+ }
+
+ /**
+ * Заменяет временный идентификатор на необходимый ID объекта
+ *
+ * @param string $sTargetType
+ * @param string $sTargetId
+ * @param null|string $sTargetTmp Если не задан, то берется их куки "media_target_tmp_{$sTargetType}"
+ * @param null|array $aMediaId Необязательный список конкретных media id
+ */
+ public function ReplaceTargetTmpById($sTargetType, $sTargetId, $sTargetTmp = null, $aMediaId = null)
+ {
+ $sCookieKey = 'media_target_tmp_' . $sTargetType;
+ if (is_null($sTargetTmp) and $this->Session_GetCookie($sCookieKey)) {
+ $sTargetTmp = $this->Session_GetCookie($sCookieKey);
+ if (is_null($aMediaId)) {
+ $this->Session_DropCookie($sCookieKey);
+ }
+ }
+ if (is_string($sTargetTmp)) {
+ $aFilter = array(
+ 'target_tmp' => $sTargetTmp,
+ 'target_type' => $sTargetType,
+ );
+ if (!is_null($aMediaId)) {
+ $aNeedId = array(-1);
+ foreach ($aMediaId as $sId) {
+ if (is_numeric($sId)) {
+ $aNeedId[] = $sId;
+ }
+ }
+ $aFilter['media_id in'] = $aNeedId;
+ }
+ $aTargetItems = $this->Media_GetTargetItemsByFilter($aFilter);
+ foreach ($aTargetItems as $oTarget) {
+ $oTarget->setTargetTmp(null);
+ $oTarget->setTargetId($sTargetId);
+ $oTarget->Update();
+ /**
+ * Уведомляем объект о создании превью
+ */
+ if ($oTarget->getIsPreview()) {
+ $this->NotifyCreatePreviewTarget($oTarget->getTargetType(), $oTarget->getTargetId(), $oTarget);
+ }
+ }
+ }
+ }
+
+ /**
+ * Удаляет связь с медиа данными + при необходимости удаляет сами медиа данные
+ *
+ * @param string $sTargetType
+ * @param int $sTargetId
+ * @param bool $bMediaRemove Удалять медиа данные оставшиеся без связей
+ */
+ public function RemoveTarget($sTargetType, $sTargetId, $bMediaRemove = true)
+ {
+ /**
+ * Получаем прикрепленные медиа
+ */
+ $aMediaItems = $this->GetMediaByTarget($sTargetType, $sTargetId);
+ /**
+ * Удаляем все связи текущего таргета
+ */
+ $this->RemoveTargetByTypeAndId($sTargetType, $sTargetId);
+ if ($bMediaRemove and $aMediaItems) {
+ /**
+ * Проверяем с какими медиа данными еще остались связи
+ */
+ $aMediaIds = array();
+ foreach ($aMediaItems as $oMediaItem) {
+ $aMediaIds[] = $oMediaItem->getId();
+ }
+ $aTargetItems = $this->GetTargetItemsByFilter(array(
+ 'media_id in' => $aMediaIds,
+ '#index-group' => 'media_id'
+ ));
+ /**
+ * Удаляем медиа данные без оставшихся связей
+ */
+ foreach ($aMediaItems as $oMediaItem) {
+ if (!isset($aTargetItems[$oMediaItem->getId()])) {
+ $oMediaItem->Delete();
+ }
+ }
+ }
+ }
+
+ public function RemoveTargetByTypeAndId($sTargetType, $iTargetId)
+ {
+ $bRes = $this->oMapper->RemoveTargetByTypeAndId($sTargetType, $iTargetId);
+ /**
+ * Сбрасываем кеши
+ */
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array(
+ 'ModuleMedia_EntityTarget_delete'
+ ));
+ return $bRes;
+ }
+
+ public function GetFileWebPath($oMedia, $sSize = null)
+ {
+ if ($oMedia->getType() == self::MEDIA_TYPE_IMAGE) {
+ /**
+ * Проверяем необходимость автоматического создания превью нужного размера - если разрешено настройками и файл НЕ существует
+ */
+ if ($sSize and $this->GetConfigParam('image.autoresize',
+ $oMedia->getTargetType()) and !$this->Image_IsExistsFile($this->GetImagePathBySize($oMedia->getFilePath(),
+ $sSize))
+ ) {
+ /**
+ * Запускаем генерацию изображения нужного размера
+ */
+ $aSize = $this->ParsedImageSize($sSize);
+
+ $aParams = $this->Image_BuildParams('media.' . $oMedia->getTargetType());
+ $sNewFileName = $this->GetImagePathBySize($oMedia->getFilePath(), $sSize);
+ if ($oImage = $this->Image_OpenFrom($oMedia->getFilePath(), $aParams)) {
+ if ($aSize['crop']) {
+ $oImage->cropProportion($aSize['w'] / $aSize['h'], 'center');
+ }
+ $oImage->resize($aSize['w'], $aSize['h'], true)->save($sNewFileName);
+ /**
+ * Обновляем список размеров
+ */
+ $aSizeOld = (array)$oMedia->getDataOne('image_sizes');
+ $aSizeOld[] = $aSize;
+ $oMedia->setDataOne('image_sizes', $aSizeOld);
+ $oMedia->Update();
+ }
+ }
+ return $this->GetImageWebPath($oMedia->getFilePath(), $sSize);
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает веб путь до файла изображения
+ *
+ * @param $sPath
+ * @param null $sSize
+ * @return string
+ */
+ public function GetImageWebPath($sPath, $sSize = null)
+ {
+ $sPath = $this->Fs_GetPathWeb($sPath);
+ if ($sSize) {
+ return $this->GetImagePathBySize($sPath, $sSize);
+ } else {
+ return $sPath;
+ }
+ }
+
+ /**
+ * Возвращает путь до изображения конкретного размера
+ * Варианты преобразования размеров в имя файла:
+ * 100 - file_100x100
+ * 100crop - file_100x100crop
+ * 100x150 - file_100x150
+ * 100x150crop - file_100x150crop
+ * x150 - file_x150
+ * 100x - file_100x
+ *
+ * @param string $sPath
+ * @param string $sSize
+ *
+ * @return string
+ */
+ public function GetImagePathBySize($sPath, $sSize)
+ {
+ $aPathInfo = pathinfo($sPath);
+ if (is_array($sSize)) {
+ $aSize = $sSize;
+ $sSize = $aSize['w'] . 'x' . $aSize['h'];
+ if ($aSize['crop']) {
+ $sSize .= 'crop';
+ }
+ } else {
+ if (preg_match('#^(\d+)([a-z]{2,10})?$#i', $sSize, $aMatch)) {
+ $sSize = $aMatch[1] . 'x' . $aMatch[1];
+ if (isset($aMatch[2])) {
+ $sSize .= strtolower($aMatch[2]);
+ }
+ }
+ }
+ return $aPathInfo['dirname'] . '/' . $aPathInfo['filename'] . '_' . $sSize . '.' . $aPathInfo['extension'];
+ }
+
+ /**
+ * Парсит строку с размером изображения
+ * Варианты входной строки:
+ * 100
+ * 100crop
+ * 100x150
+ * 100x150crop
+ * x150
+ * 100x
+ *
+ * @param string $sSize
+ *
+ * @return array Массив вида array('w'=>100,'h'=>150,'crop'=>true)
+ */
+ public function ParsedImageSize($sSize)
+ {
+ $aSize = array(
+ 'w' => null,
+ 'h' => null,
+ 'crop' => false,
+ );
+
+ if (preg_match('#^(\d+)?(x)?(\d+)?([a-z]{2,10})?$#Ui', $sSize, $aMatch)) {
+ $iW = (isset($aMatch[1]) and $aMatch[1]) ? $aMatch[1] : null;
+ $iH = (isset($aMatch[3]) and $aMatch[3]) ? $aMatch[3] : null;
+ $bDelim = (isset($aMatch[2]) and $aMatch[2]) ? true : false;
+ $sMod = (isset($aMatch[4]) and $aMatch[4]) ? $aMatch[4] : '';
+
+ if (!$bDelim) {
+ $iW = $iH;
+ }
+ $aSize['w'] = $iW;
+ $aSize['h'] = $iH;
+ if ($sMod) {
+ $aSize[$sMod] = true;
+ }
+ }
+ return $aSize;
+ }
+
+ /**
+ * Производит стандартнуе проверку на определенное действие с конкретным объектом Media
+ *
+ * @param $sAllowType
+ * @param $aParams
+ *
+ * @return bool
+ */
+ public function CheckStandartMediaAllow($sAllowType, $aParams)
+ {
+ if (!$oUser = $aParams['user']) {
+ return false;
+ }
+ $oMedia = isset($aParams['media']) ? $aParams['media'] : null;
+ if (!$oMedia) {
+ return false;
+ }
+ if (in_array($sAllowType, array(self::TYPE_CHECK_ALLOW_REMOVE, self::TYPE_CHECK_ALLOW_UPDATE))) {
+ if ($oMedia->getUserId() == $oUser->getId() or $oUser->isAdministrator()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Перегенерация всех превью
+ *
+ * @param array|string|null $aTypes Список типов для перегенерации
+ */
+ public function ReCreateFilePreviewAll($aTypes = null)
+ {
+ $iPage = 1;
+ $aFilter = array(
+ 'is_preview' => 1,
+ 'target_id <>' => null,
+ '#with' => array('media'),
+ '#page' => array($iPage, 100)
+ );
+ if ($aTypes) {
+ if (!is_array($aTypes)) {
+ $aTypes = array($aTypes);
+ }
+ $aFilter['target_type in'] = $aTypes;
+ }
+
+ while ($aRes = $this->GetTargetItemsByFilter($aFilter) and $aRes['collection']) {
+ foreach ($aRes['collection'] as $oTarget) {
+ if ($oMedia = $oTarget->getMedia()) {
+ $this->CreateFilePreview($oMedia, $oTarget);
+ }
+ }
+ $iPage++;
+ $aFilter['#page'][0] = $iPage;
+ }
+ }
+
+ /**
+ * Создает превью у файла для определенного типа
+ *
+ * @param $oMedia
+ * @param $oTarget
+ *
+ * @return bool|string
+ */
+ public function CreateFilePreview($oMedia, $oTarget)
+ {
+ if (!$this->GetTargetTypeParam($oTarget->getTargetType(), 'allow_preview')) {
+ return false;
+ }
+
+ /**
+ * Нужно удалить прошлое превью (если оно есть)
+ */
+ $this->RemoveFilePreview($oMedia, $oTarget);
+
+ if ($oMedia->getType() == self::MEDIA_TYPE_IMAGE) {
+ $aParams = $this->Image_BuildParams('media.preview_' . $oTarget->getTargetType());
+
+ if (!$oImage = $this->Image_OpenFrom($oMedia->getFilePath(), $aParams)) {
+ return $this->Image_GetLastError();
+ }
+ /**
+ * Сохраняем во временный файл
+ */
+ if (!$sFileTmp = $oImage->saveTmp()) {
+ return $this->Image_GetLastError();
+ }
+ unset($oImage);
+ /**
+ * Получаем список необходимых размеров превью
+ */
+ $aSizes = $this->GetConfigParam('image.preview.sizes', $oTarget->getTargetType());
+ /**
+ * Каталог для сохранения превью
+ */
+ $sPath = $this->GetSaveDir($oTarget->getTargetType(), $oTarget->getTargetId(), 'preview');
+ /**
+ * Уникальное имя файла
+ */
+ $sFileName = func_generator(20);
+ /**
+ * Генерируем варианты с необходимыми размерами
+ */
+ $sFileLast = $this->GenerateImageBySizes($sFileTmp, $sPath, $sFileName, $aSizes, $aParams);
+ $aSizeLast = end($aSizes);
+ $sReplaceSize = '_' . $aSizeLast['w'] . 'x' . $aSizeLast['h'];
+ if ($aSizeLast['crop']) {
+ $sReplaceSize .= 'crop';
+ }
+ $sFileLast = str_replace($sReplaceSize, '', $sFileLast);
+ /**
+ * Теперь можно удалить временный файл
+ */
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ /**
+ * Сохраняем данные во связи
+ */
+ $oTarget->setDataOne('image_preview_sizes', $aSizes);
+ $oTarget->setDataOne('image_preview', $sFileLast);
+ $oTarget->setIsPreview(1);
+ $oTarget->Update();
+
+ /**
+ * Уведомляем объект о создании нового превью
+ */
+ if ($oTarget->getTargetId()) {
+ $this->NotifyCreatePreviewTarget($oTarget->getTargetType(), $oTarget->getTargetId(), $oTarget);
+ }
+
+ return true;
+ }
+ }
+
+ public function RemoveFilePreview($oMedia, $oTarget)
+ {
+ if ($oMedia->getType() == self::MEDIA_TYPE_IMAGE) {
+ if ($oTarget->getDataOne('image_preview')) {
+ /**
+ * Уведомляем объект о удалении превью
+ */
+ if ($oTarget->getTargetId()) {
+ $this->NotifyRemovePreviewTarget($oTarget->getTargetType(), $oTarget->getTargetId(), $oTarget);
+ }
+ $this->RemoveImageBySizes($oTarget->getDataOne('image_preview'),
+ $oTarget->getDataOne('image_preview_sizes'));
+ $oTarget->setDataOne('image_preview', null);
+ $oTarget->setDataOne('image_preview_sizes', array());
+ $oTarget->setIsPreview(0);
+ $oTarget->Update();
+ return true;
+ }
+ }
+ }
+
+ /**
+ * Удаляет все превью у конкретного объекта
+ *
+ * @param $sTargetType
+ * @param $sTargetId
+ * @param null $sTargetTmp
+ */
+ public function RemoveAllPreviewByTarget($sTargetType, $sTargetId, $sTargetTmp = null)
+ {
+ $aFilter = array(
+ 'target_type' => $sTargetType,
+ 'is_preview' => 1,
+ '#with' => array('media')
+ );
+ if ($sTargetId) {
+ $aFilter['target_id'] = $sTargetId;
+ } else {
+ $aFilter['target_tmp'] = $sTargetTmp;
+ }
+ $aTargetItems = $this->Media_GetTargetItemsByFilter($aFilter);
+ foreach ($aTargetItems as $oTarget) {
+ $this->RemoveFilePreview($oTarget->getMedia(), $oTarget);
+ }
+ }
+
+
+ /**
+ * Обработка создания превью для типа 'topic'
+ * Название метода формируется автоматически
+ *
+ * @param int $iTargetId
+ * @param ModuleMedia_EntityTarget $oRelationTarget
+ */
+ public function NotifyCreatePreviewTargetTopic($iTargetId, $oRelationTarget)
+ {
+ if ($oTopic = $this->Topic_GetTopicById($iTargetId)) {
+ $oTopic->setPreviewImage($oRelationTarget->getDataOne('image_preview'));
+ $this->Topic_UpdateTopic($oTopic);
+ }
+ }
+
+ /**
+ * Обработка удаления превью для типа 'topic'
+ * Название метода формируется автоматически
+ *
+ * @param int $iTargetId
+ * @param ModuleMedia_EntityTarget $oRelationTarget
+ */
+ public function NotifyRemovePreviewTargetTopic($iTargetId, $oRelationTarget)
+ {
+ if ($oTopic = $this->Topic_GetTopicById($iTargetId)) {
+ $oTopic->setPreviewImage(null);
+ $this->Topic_UpdateTopic($oTopic);
+ }
+ }
+
+ /**
+ * Проверка владельца с типом "topic"
+ * Название метода формируется автоматически
+ *
+ * @param int|null $iTargetId ID владельца, для новых объектов может быть не определен
+ * @param string $sAllowType Тип доступа, константа self::TYPE_CHECK_ALLOW_*
+ * @param array $aParams Дополнительные параметры, всегда есть ключ 'user'
+ *
+ * @return bool
+ */
+ public function CheckTargetTopic($iTargetId, $sAllowType, $aParams)
+ {
+ if (!$oUser = $aParams['user']) {
+ return false;
+ }
+ if (in_array($sAllowType,
+ array(self::TYPE_CHECK_ALLOW_ADD, self::TYPE_CHECK_ALLOW_PREVIEW, self::TYPE_CHECK_ALLOW_VIEW_LIST))) {
+ if (is_null($iTargetId)) {
+ /**
+ * Разрешаем для всех новых топиков
+ */
+ return true;
+ }
+ if ($oTopic = $this->Topic_GetTopicById($iTargetId)) {
+ /**
+ * Проверяем права на редактирование топика
+ */
+ if ($this->ACL_IsAllowEditTopic($oTopic, $oUser)) {
+ /**
+ * Дополнительно возможность исползования превью (настраивается отдельно для каждого типа топика)
+ */
+ if ($sAllowType == self::TYPE_CHECK_ALLOW_PREVIEW) {
+ if ($oTopicType = $this->Topic_GetTopicType($oTopic->getType())) {
+ if (!$oTopicType->getParam('allow_preview')) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+ }
+ } else {
+ return $this->CheckStandartMediaAllow($sAllowType, $aParams);
+ }
+ return false;
+ }
+
+ /**
+ * Проверка владельца с типом "Imageset"
+ * Название метода формируется автоматически
+ *
+ * @param int|null $iTargetId ID владельца, для новых объектов может быть не определен
+ * @param string $sAllowType Тип доступа, константа self::TYPE_CHECK_ALLOW_*
+ * @param array $aParams Дополнительные параметры, всегда есть ключ 'user'
+ *
+ * @return bool
+ */
+ public function CheckTargetImageset($iTargetId, $sAllowType, $aParams)
+ {
+ if (!$oUser = $aParams['user']) {
+ return false;
+ }
+ if (in_array($sAllowType,
+ array(self::TYPE_CHECK_ALLOW_ADD, self::TYPE_CHECK_ALLOW_PREVIEW, self::TYPE_CHECK_ALLOW_VIEW_LIST))) {
+
+ return true;
+
+ } else {
+ return $this->CheckStandartMediaAllow($sAllowType, $aParams);
+ }
+ return false;
+ }
+
+ /**
+ * Проверка владельца с типом "comment"
+ * Название метода формируется автоматически
+ *
+ * @param int|null $iTargetId ID владельца, для новых объектов может быть не определен
+ * @param string $sAllowType Тип доступа, константа self::TYPE_CHECK_ALLOW_*
+ * @param array $aParams Дополнительные параметры, всегда есть ключ 'user'
+ *
+ * @return bool
+ */
+ public function CheckTargetComment($iTargetId, $sAllowType, $aParams)
+ {
+ if (!$oUser = $aParams['user']) {
+ return false;
+ }
+ if (in_array($sAllowType, array(self::TYPE_CHECK_ALLOW_ADD, self::TYPE_CHECK_ALLOW_VIEW_LIST))) {
+ if (is_null($iTargetId)) {
+ /**
+ * Разрешаем для всех новых комментариев
+ */
+ return true;
+ }
+ if ($oComment = $this->Comment_GetCommentById($iTargetId)) {
+ /**
+ * Проверяем права на редактирование комментария
+ */
+ if ($this->ACL_IsAllowEditComment($oComment, $oUser)) {
+ return true;
+ }
+ }
+ } else {
+ return $this->CheckStandartMediaAllow($sAllowType, $aParams);
+ }
+ return false;
+ }
+
+ /**
+ * Проверка владельца с типом "blog"
+ * Название метода формируется автоматически
+ *
+ * @param int|null $iTargetId ID владельца, для новых объектов может быть не определен
+ * @param string $sAllowType Тип доступа, константа self::TYPE_CHECK_ALLOW_*
+ * @param array $aParams Дополнительные параметры, всегда есть ключ 'user'
+ *
+ * @return bool
+ */
+ public function CheckTargetBlog($iTargetId, $sAllowType, $aParams)
+ {
+ if (!$oUser = $aParams['user']) {
+ return false;
+ }
+ if (in_array($sAllowType, array(self::TYPE_CHECK_ALLOW_ADD, self::TYPE_CHECK_ALLOW_VIEW_LIST))) {
+ if (is_null($iTargetId)) {
+ /**
+ * Разрешаем для всех новых блогов
+ */
+ return true;
+ }
+ if ($oBlog = $this->Blog_GetBlogById($iTargetId)) {
+ /**
+ * Проверяем права на редактирование блога
+ */
+ if ($this->ACL_IsAllowEditBlog($oBlog, $oUser)) {
+ return true;
+ }
+ }
+ } else {
+ return $this->CheckStandartMediaAllow($sAllowType, $aParams);
+ }
+ return false;
+ }
+
+ /**
+ * Проверка владельца с типом "talk"
+ * Название метода формируется автоматически
+ *
+ * @param int|null $iTargetId ID владельца, для новых объектов может быть не определен
+ * @param string $sAllowType Тип доступа, константа self::TYPE_CHECK_ALLOW_*
+ * @param array $aParams Дополнительные параметры, всегда есть ключ 'user'
+ *
+ * @return bool
+ */
+ public function CheckTargetTalk($iTargetId, $sAllowType, $aParams)
+ {
+ if (!$oUser = $aParams['user']) {
+ return false;
+ }
+ if (in_array($sAllowType, array(self::TYPE_CHECK_ALLOW_ADD, self::TYPE_CHECK_ALLOW_VIEW_LIST))) {
+ if (is_null($iTargetId)) {
+ /**
+ * Разрешаем для всех новых сообщений
+ */
+ return true;
+ }
+ /**
+ * Редактировать сообщения нельзя
+ */
+ return false;
+ } else {
+ return $this->CheckStandartMediaAllow($sAllowType, $aParams);
+ }
+ return false;
+ }
+}
diff --git a/application/classes/modules/media/entity/Media.entity.class.php b/application/classes/modules/media/entity/Media.entity.class.php
new file mode 100644
index 0000000..b6896ea
--- /dev/null
+++ b/application/classes/modules/media/entity/Media.entity.class.php
@@ -0,0 +1,115 @@
+
+ *
+ */
+
+/**
+ * Сущность медиа данных (изображение, видео и т.п.)
+ *
+ * @package application.modules.media
+ * @since 2.0
+ */
+class ModuleMedia_EntityMedia extends EntityORM
+{
+
+ protected $aValidateRules = array();
+
+ protected $aRelations = array(
+ 'targets' => array(self::RELATION_TYPE_HAS_MANY, 'ModuleMedia_EntityTarget', 'media_id'),
+ );
+
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateAdd(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+ protected function beforeDelete()
+ {
+ if ($bResult = parent::beforeDelete()) {
+ /**
+ * Удаляем все связи
+ */
+ $aTargets = $this->getTargets();
+ foreach ($aTargets as $oTarget) {
+ $oTarget->Delete();
+ }
+ /**
+ * Удаляем все файлы медиа
+ */
+ $this->Media_DeleteFiles($this);
+ }
+ return $bResult;
+ }
+
+ /**
+ * Возвращает URL до файла нужного размера, в основном используется для изображений
+ *
+ * @param null $sSize
+ *
+ * @return null
+ */
+ public function getFileWebPath($sSize = null)
+ {
+ if ($this->getFilePath()) {
+ return $this->Media_GetFileWebPath($this, $sSize);
+ } else {
+ return null;
+ }
+ }
+
+ public function getData()
+ {
+ $aData = @unserialize($this->_getDataOne('data'));
+ if (!$aData) {
+ $aData = array();
+ }
+ return $aData;
+ }
+
+ public function setData($aRules)
+ {
+ $this->_aData['data'] = @serialize($aRules);
+ }
+
+ public function getDataOne($sKey)
+ {
+ $aData = $this->getData();
+ if (isset($aData[$sKey])) {
+ return $aData[$sKey];
+ }
+ return null;
+ }
+
+ public function setDataOne($sKey, $mValue)
+ {
+ $aData = $this->getData();
+ $aData[$sKey] = $mValue;
+ $this->setData($aData);
+ }
+
+ public function getRelationTarget()
+ {
+ return $this->_getDataOne('_relation_entity');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/media/entity/Target.entity.class.php b/application/classes/modules/media/entity/Target.entity.class.php
new file mode 100644
index 0000000..40e13c4
--- /dev/null
+++ b/application/classes/modules/media/entity/Target.entity.class.php
@@ -0,0 +1,102 @@
+
+ *
+ */
+
+/**
+ * Сущность связи медиа данных с объектами
+ *
+ * @package application.modules.media
+ * @since 2.0
+ */
+class ModuleMedia_EntityTarget extends EntityORM
+{
+
+ protected $aValidateRules = array();
+
+ protected $aRelations = array(
+ 'media' => array(self::RELATION_TYPE_BELONGS_TO, 'ModuleMedia_EntityMedia', 'media_id'),
+ );
+
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateAdd(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+ protected function beforeDelete()
+ {
+ if ($bResult = parent::beforeDelete()) {
+ /**
+ * Удаляем превью
+ */
+ if ($this->getIsPreview() and $oMedia = $this->getMedia()) {
+ $this->Media_RemoveFilePreview($oMedia, $this);
+ }
+ }
+ return $bResult;
+ }
+
+ public function getData()
+ {
+ $aData = @unserialize($this->_getDataOne('data'));
+ if (!$aData) {
+ $aData = array();
+ }
+ return $aData;
+ }
+
+ public function setData($aRules)
+ {
+ $this->_aData['data'] = @serialize($aRules);
+ }
+
+ public function getDataOne($sKey)
+ {
+ $aData = $this->getData();
+ if (isset($aData[$sKey])) {
+ return $aData[$sKey];
+ }
+ return null;
+ }
+
+ public function setDataOne($sKey, $mValue)
+ {
+ $aData = $this->getData();
+ $aData[$sKey] = $mValue;
+ $this->setData($aData);
+ }
+
+ public function getPreviewImageItemsWebPath()
+ {
+ $aPreviewItems = array();
+ $sPathbase = $this->getDataOne('image_preview');
+ $aSizes = $this->getDataOne('image_preview_sizes');
+ if ($sPathbase and $aSizes) {
+ foreach ($aSizes as $aSize) {
+ $aPreviewItems[] = $this->Media_GetImageWebPath($sPathbase, $aSize);
+ }
+ }
+ return $aPreviewItems;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/media/mapper/Media.mapper.class.php b/application/classes/modules/media/mapper/Media.mapper.class.php
new file mode 100644
index 0000000..8cc51b9
--- /dev/null
+++ b/application/classes/modules/media/mapper/Media.mapper.class.php
@@ -0,0 +1,129 @@
+
+ *
+ */
+
+/**
+ * Маппер для работы с БД
+ *
+ * @package application.modules.media
+ * @since 2.0
+ */
+class ModuleMedia_MapperMedia extends Mapper
+{
+
+ public function GetMediaByTarget($sTargetType, $iTargetId, $iUserId = null)
+ {
+ $sFieldsJoinReturn = $this->GetFieldsRelationTarget();
+ $sql = "SELECT
+ {$sFieldsJoinReturn},
+ m.*
+ FROM " . Config::Get('db.table.media_target') . " AS t
+ JOIN " . Config::Get('db.table.media') . " as m on ( m.id=t.media_id { and m.user_id = ?d } )
+ WHERE
+ t.target_id = ?d
+ AND
+ t.target_type = ?
+ ORDER BY
+ m.id desc
+ limit 0,500";
+
+ $aResult = array();
+ if ($aRows = $this->oDb->select($sql, $iUserId ? $iUserId : DBSIMPLE_SKIP, $iTargetId, $sTargetType)) {
+ $aResult = $this->PrepareResultTarget($aRows);
+ }
+ return $aResult;
+ }
+
+ public function GetMediaByTargetTmp($sTargetTmp, $iUserId = null)
+ {
+ $sFieldsJoinReturn = $this->GetFieldsRelationTarget();
+ $sql = "SELECT
+ {$sFieldsJoinReturn},
+ m.*
+ FROM " . Config::Get('db.table.media_target') . " AS t
+ JOIN " . Config::Get('db.table.media') . " as m on ( m.id=t.media_id { and m.user_id = ?d } )
+ WHERE
+ t.target_tmp = ?
+ ORDER BY
+ m.id desc
+ limit 0,500";
+
+ $aResult = array();
+ if ($aRows = $this->oDb->select($sql, $iUserId ? $iUserId : DBSIMPLE_SKIP, $sTargetTmp)) {
+ $aResult = $this->PrepareResultTarget($aRows);
+ }
+ return $aResult;
+ }
+
+ public function RemoveTargetByTypeAndId($sTargetType, $iTargetId)
+ {
+ $sql = "DELETE
+ FROM " . Config::Get('db.table.media_target') . "
+ WHERE
+ target_id = ?d
+ AND
+ target_type = ?
+ ";
+ if ($this->oDb->query($sql, $iTargetId, $sTargetType) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ protected function GetFieldsRelationTarget()
+ {
+ $oEntityJoinSample = Engine::GetEntity('ModuleMedia_EntityTarget');
+ /**
+ * Формируем список полей для возврата у таблице связей
+ */
+ $aFieldsJoinReturn = $oEntityJoinSample->_getFields();
+ foreach ($aFieldsJoinReturn as $k => $sField) {
+ if (!is_numeric($k)) {
+ // Удаляем служебные (примари) поля
+ unset($aFieldsJoinReturn[$k]);
+ continue;
+ }
+ $aFieldsJoinReturn[$k] = "t.`{$sField}` as t_join_{$sField}";
+ }
+ $sFieldsJoinReturn = join(', ', $aFieldsJoinReturn);
+ return $sFieldsJoinReturn;
+ }
+
+ protected function PrepareResultTarget($aRows)
+ {
+ $aResult = array();
+ foreach ($aRows as $aRow) {
+ $aData = array();
+ $aDataRelation = array();
+ foreach ($aRow as $k => $v) {
+ if (strpos($k, 't_join_') === 0) {
+ $aDataRelation[str_replace('t_join_', '', $k)] = $v;
+ } else {
+ $aData[$k] = $v;
+ }
+ }
+ $aData['_relation_entity'] = Engine::GetEntity('ModuleMedia_EntityTarget', $aDataRelation);
+ $oEntity = Engine::GetEntity('ModuleMedia_EntityMedia', $aData);
+ $oEntity->_SetIsNew(false);
+ $aResult[] = $oEntity;
+ }
+ return $aResult;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/menu/Menu.class.php b/application/classes/modules/menu/Menu.class.php
new file mode 100644
index 0000000..27ce266
--- /dev/null
+++ b/application/classes/modules/menu/Menu.class.php
@@ -0,0 +1,79 @@
+
+ *
+ */
+
+/**
+ * Description of Menu
+ *
+ * @author oleg
+ */
+class ModuleMenu extends ModuleORM {
+
+ const STATE_ITEM_ENABLE = 1;
+ const STATE_ITEM_DISABLE = 0;
+ const STATE_ITEM_ACTIVE = 2;
+
+ private $aMenus = [];
+
+ public function Init() {
+ parent::Init();
+ }
+
+ /**
+ * Возвращает дерево пунктов
+ *
+ * @param int $sId MenuId
+ *
+ * @return array
+ */
+ public function GetItemsTreeByMenuId($sId)
+ {
+ $aItems = $this->LoadTreeOfItem(array('menu_id' => $sId));
+ return ModuleORM::buildTree($aItems);
+ }
+
+ public function Get($sName) {
+ if( !isset($this->aMenus[$sName]) ){
+ $this->aMenus[$sName] = $this->GetMenuByName($sName);
+ }
+
+ return $this->aMenus[$sName];
+ }
+
+ public function GetMenuByName($sName) {
+ if(!$oMenu = $this->GetMenuByFilter(['name' => $sName])){
+ return null;
+ }
+ $aItemsTree = $this->LoadTreeOfItem([
+ 'menu_id' => $oMenu->getId(),
+ '#order' => ['priority' => 'asc']
+ ]);
+
+ if(is_array($aItemsTree)){
+ foreach ($aItemsTree as $oItem) {
+ $oItem->setParent($oMenu);
+ }
+ }
+
+ $oMenu->setChildren($aItemsTree);
+ return $oMenu;
+ }
+}
diff --git a/application/classes/modules/menu/entity/AbstractItem.entity.class.php b/application/classes/modules/menu/entity/AbstractItem.entity.class.php
new file mode 100644
index 0000000..8fea3d6
--- /dev/null
+++ b/application/classes/modules/menu/entity/AbstractItem.entity.class.php
@@ -0,0 +1,154 @@
+
+ *
+ */
+
+/**
+ * Description of Item
+ *
+ * @author oleg
+ */
+class ModuleMenu_EntityAbstractItem extends EntityORM{
+
+
+ public function find($sName) {
+ return $this->recursiveSearch($sName, $this->getChildren());
+ }
+
+ public function recursiveSearch($sName, $aItems) {
+ if(!is_array($aItems)){
+ return null;
+ }
+ foreach ($aItems as $oItem) {
+ if($oItem->getName() == $sName){
+ return $oItem;
+ }
+ if($mResult = $this->recursiveSearch($sName, $oItem->getChildren())){
+ return $mResult;
+ }
+ }
+ return null;
+ }
+
+ private function findIndex($aItems, $sName){
+ if(!is_array($aItems)){
+ return false;
+ }
+
+ foreach ($aItems as $key => $oItem) {
+ if($oItem->getName() == $sName){
+ return $key;
+ }
+ }
+ return false;
+ }
+
+ public function after($oItem) {
+ if(!is_array($oItem)){
+ $oItem = [$oItem];
+ }
+
+ if(get_class($this) == "ModuleMenu_EntityMenu"){
+ return $this;
+ }
+
+ if(!$oParent = $this->getParent()){
+ return $this;
+ }
+
+ $oParent->spliceChild($this->getName(), 1, $oItem);
+
+ return $this;
+ }
+
+ public function before($oItem) {
+ if(get_class($this) == "ModuleMenu_EntityMenu"){
+ return $this;
+ }
+
+ if(!is_array($oItem)){
+ $oItem = [$oItem];
+ }
+
+ if(!$oParent = $this->getParent()){
+ return $this;
+ }
+
+ $oParent->spliceChild($this->getName(), 0, $oItem);
+
+ return $this;
+ }
+
+ public function remove() {
+ if(get_class($this) == "ModuleMenu_EntityMenu"){
+ return $this;
+ }
+
+ if(!$oParent = $this->getParent()){
+ return $this;
+ }
+
+ $oParent->spliceChild($this->getName(), 0, [], 1);
+ }
+
+ public function spliceChild($sName, $iOffset, $aItems, $iRemove=0){
+
+ $aChildrens = $this->getChildren();
+
+ if(!is_array($aChildrens)){
+ return $this;
+ }
+
+ if(($iKey = $this->findIndex($aChildrens, $sName)) === false){
+ return $this;
+ }
+
+ array_splice($aChildrens, $iKey?($iKey+$iOffset):$iKey, $iRemove, $aItems);
+
+ $this->setChildren($aChildrens);
+
+ return $this;
+ }
+
+
+ public function appendChild($oItem) {
+ $aItems = $this->getChildren();
+
+ if(!is_array($aItems)){
+ $aItems= [];
+ }
+
+ $aItems[] = $oItem;
+ $this->setChildren($aItems);
+ return $this;
+ }
+
+ public function prependChild($oItem) {
+ $aItems = $this->getChildren();
+
+ if(!is_array($aItems)){
+ $aItems= [];
+ }
+
+ array_unshift($aItems, $oItem);
+ $this->setChildren($aItems);
+ return $this;
+ }
+}
diff --git a/application/classes/modules/menu/entity/Item.entity.class.php b/application/classes/modules/menu/entity/Item.entity.class.php
new file mode 100644
index 0000000..7b7ee87
--- /dev/null
+++ b/application/classes/modules/menu/entity/Item.entity.class.php
@@ -0,0 +1,128 @@
+
+ *
+ */
+
+/**
+ * Description of Item
+ *
+ * @author oleg
+ */
+class ModuleMenu_EntityItem extends ModuleMenu_EntityAbstractItem{
+
+ protected $aRelations = [
+ 'menu' => [self::RELATION_TYPE_BELONGS_TO, "ModuleMenu_EntityMenu", 'menu_id'],
+ self::RELATION_TYPE_TREE
+ ];
+
+ public function __construct($aData) {
+ parent::__construct($aData);
+ $this->setState(ModuleMenu::STATE_ITEM_ENABLE);
+ }
+
+ /**
+ * Определяем правила валидации
+ *
+ * @var array
+ */
+ public $aValidateRules = array(
+ array('title', 'string', 'max' => 250, 'min' => 1, 'allowEmpty' => false),
+ array('name', 'string', 'max' => 30, 'min' => 1, 'allowEmpty' => true),
+ array('url', 'string', 'max' => 1000, 'min' => 1, 'allowEmpty' => false),
+ array('enable', 'number'),
+ array('active', 'number'),
+ array('pid', 'parent_item'),
+ array('priority', 'number'),
+ array('menu_id', 'menu_id'),
+ );
+
+ public function _getTreeParentKey()
+ {
+ return 'pid';
+ }
+
+ public function beforeSave()
+ {
+ if(!parent::beforeSave()){
+ return false;
+ }
+
+ if(!$this->_getDataOne('enable')){
+ $this->setState(ModuleMenu::STATE_ITEM_DISABLE);
+ return true;
+ }
+ $this->setState(ModuleMenu::STATE_ITEM_ENABLE);
+
+ if($this->_getDataOne('active')){
+ $this->setState(ModuleMenu::STATE_ITEM_ACTIVE);
+ }
+ return true;
+ }
+
+ public function afterDelete() {
+ parent::afterDelete();
+
+ $aChildrenItems = $this->getChildren();
+ foreach ($aChildrenItems as $oItem) {
+ $oItem->setState(ModuleMenu::STATE_ITEM_DISABLE);
+ $oItem->setPid(null);
+ $oItem->Save();
+ }
+ }
+
+ public function ValidateParentItem($sValue, $aParams)
+ {
+ if(!$sValue){
+ return true;
+ }
+ if (!$oItem = $this->Menu_GetItemById($this->getPid())) {
+ return $this->Lang_Get('menu.message.no_find_parent_item');
+ }
+
+ return true;
+ }
+
+
+ public function ValidateMenuId($sValue, $aParams)
+ {
+
+ if (!$oMenu = $this->Menu_GetMenuById($this->getMenuId())) {
+ return $this->Lang_Get('menu.message.no_find_menu');
+ }
+
+ return true;
+ }
+
+ public function getEnable() {
+ if($this->getState()){
+ return 1;
+ }
+ return 0;
+ }
+
+ public function getActive() {
+ if($this->getState() == ModuleMenu::STATE_ITEM_ACTIVE){
+ return 1;
+ }
+ return 0;
+ }
+
+
+}
diff --git a/application/classes/modules/menu/entity/Menu.entity.class.php b/application/classes/modules/menu/entity/Menu.entity.class.php
new file mode 100644
index 0000000..550ca20
--- /dev/null
+++ b/application/classes/modules/menu/entity/Menu.entity.class.php
@@ -0,0 +1,34 @@
+
+ *
+ */
+
+/**
+ * Description of Menu
+ *
+ * @author oleg
+ */
+class ModuleMenu_EntityMenu extends ModuleMenu_EntityAbstractItem {
+
+ public function getItems() {
+ return $this->getChildren();
+ }
+
+}
diff --git a/application/classes/modules/poll/Poll.class.php b/application/classes/modules/poll/Poll.class.php
new file mode 100644
index 0000000..019d541
--- /dev/null
+++ b/application/classes/modules/poll/Poll.class.php
@@ -0,0 +1,287 @@
+
+ *
+ */
+
+/**
+ * Модуль опросов
+ *
+ * @package application.modules.poll
+ * @since 2.0
+ */
+class ModulePoll extends ModuleORM
+{
+
+ /**
+ * Объект текущего пользователя
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent;
+
+ protected $aTargetTypes = array(
+ 'topic' => array(),
+ );
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ parent::Init();
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Возвращает список типов объектов
+ *
+ * @return array
+ */
+ public function GetTargetTypes()
+ {
+ return $this->aTargetTypes;
+ }
+
+ /**
+ * Добавляет в разрешенные новый тип
+ *
+ * @param string $sTargetType Тип
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function AddTargetType($sTargetType, $aParams = array())
+ {
+ if (!array_key_exists($sTargetType, $this->aTargetTypes)) {
+ $this->aTargetTypes[$sTargetType] = $aParams;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет разрешен ли данный тип
+ *
+ * @param string $sTargetType Тип
+ * @return bool
+ */
+ public function IsAllowTargetType($sTargetType)
+ {
+ return in_array($sTargetType, array_keys($this->aTargetTypes));
+ }
+
+ /**
+ * Возвращает парметры нужного типа
+ *
+ * @param string $sTargetType
+ *
+ * @return mixed
+ */
+ public function GetTargetTypeParams($sTargetType)
+ {
+ if ($this->IsAllowTargetType($sTargetType)) {
+ return $this->aTargetTypes[$sTargetType];
+ }
+ }
+
+ /**
+ * Проверка объекта target - владелец медиа
+ *
+ * @param string $sTargetType Тип
+ * @param int $iTargetId ID владельца
+ * @return bool
+ */
+ public function CheckTarget($sTargetType, $iTargetId)
+ {
+ if (!$this->IsAllowTargetType($sTargetType)) {
+ return false;
+ }
+ $sMethod = 'CheckTarget' . func_camelize($sTargetType);
+ if (method_exists($this, $sMethod)) {
+ return $this->$sMethod($iTargetId);
+ }
+ return false;
+ }
+
+ /**
+ * Заменяет временный идентификатор на необходимый ID объекта
+ *
+ * @param string $sTargetType
+ * @param string $sTargetId
+ * @param null|string $sTargetTmp Если не задан, то берется их куки "poll_target_tmp_{$sTargetType}"
+ */
+ public function ReplaceTargetTmpById($sTargetType, $sTargetId, $sTargetTmp = null)
+ {
+ $sCookieKey = 'poll_target_tmp_' . $sTargetType;
+ if (is_null($sTargetTmp) and $this->Session_GetCookie($sCookieKey)) {
+ $sTargetTmp = $this->Session_GetCookie($sCookieKey);
+ $this->Session_DropCookie($sCookieKey);
+ }
+ if (is_string($sTargetTmp)) {
+ $aPollItems = $this->Poll_GetPollItemsByTargetTmpAndTargetType($sTargetTmp, $sTargetType);
+ foreach ($aPollItems as $oPoll) {
+ $oPoll->setTargetTmp(null);
+ $oPoll->setTargetId($sTargetId);
+ $oPoll->Update();
+ }
+ }
+ }
+
+ /**
+ * Возвращает список опросов для объекта
+ *
+ * @param string $sTargetType
+ * @param string $sTargetId
+ *
+ * @return mixed
+ */
+ public function GetPollItemsByTarget($sTargetType, $sTargetId)
+ {
+ $aFilter = array(
+ 'target_type' => $sTargetType,
+ 'target_id' => $sTargetId,
+ '#with' => array('answers')
+ );
+ if ($this->oUserCurrent) {
+ $aFilter['#with']['vote_current'] = array(
+ 'user_id' => $this->oUserCurrent->getId(),
+ '#value-default' => false
+ );
+ } else {
+ $_this = $this;
+ $aFilter['#with']['vote_current'] = array(
+ '#value-default' => false,
+ '#callback-filter' => function ($aPollItems, &$aRelationFilter) use ($_this) {
+ $aWhere = array();
+ $aWhereBind = array();
+ foreach ($aPollItems as $oPoll) {
+ /**
+ * Смотрим по IP
+ */
+ if($oPoll->getIsGuestCheckIp()) {
+ $aWhere[] = ' ( t.poll_id = ?d and t.ip = ? ) ';
+ $aWhereBind[] = $oPoll->getId();
+ $aWhereBind[] = func_getIp();
+ }
+ /**
+ * Смотрим в куках
+ */
+ if ($sKey = $_this->Session_GetCookie($_this->GetCookieVoteName($oPoll->getId()))) {
+ $aWhere[] = ' ( t.poll_id = ?d and t.guest_key = ? ) ';
+ $aWhereBind[] = $oPoll->getId();
+ $aWhereBind[] = $sKey;
+ }
+ }
+ if ($aWhere) {
+ $aRelationFilter['#where'] = array(
+ ' ( ' . join(' or ', $aWhere) . ' ) ' => $aWhereBind
+ );
+ } else {
+ $aRelationFilter['#value-set'] = false;
+ }
+ }
+ );
+ }
+ $aPollItems = $this->Poll_GetPollItemsByFilter($aFilter);
+ return $aPollItems;
+ }
+
+
+ /**
+ * Проверка владельца с типом "topic"
+ * Название метода формируется автоматически
+ *
+ * @param int $iTargetId ID владельца
+ * @return bool
+ */
+ public function CheckTargetTopic($iTargetId)
+ {
+ if ($oTopic = $this->Topic_GetTopicById($iTargetId)) {
+ if (!$oTopicType = $this->Topic_GetTopicType($oTopic->getType()) or !$oTopicType->getParam('allow_poll')) {
+ return false;
+ }
+ /**
+ * Проверяем права на редактирование топика
+ */
+ if ($this->ACL_IsAllowEditTopic($oTopic, $this->oUserCurrent)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Голосовал ли пользователь в опросе
+ *
+ * @param ModulePoll_EntityPoll $oPoll
+ * @param int|null $iUserId Если null, то проверяется для гостя
+ * @return bool
+ */
+ public function CheckUserAlreadyVote($oPoll, $iUserId)
+ {
+ return $this->GetVoteByUser($oPoll, $iUserId) ? true : false;
+ }
+
+ /**
+ * Возвращает объект голосования текущего пользователя за конкретный опрос
+ *
+ * @param ModulePoll_EntityPoll $oPoll
+ * @param int|null $iUserId Если null, то проверяется для гостя
+ * @return ModulePoll_EntityVote
+ */
+ public function GetVoteByUser($oPoll, $iUserId)
+ {
+ $iUserId = is_object($iUserId) ? $iUserId->getId() : $iUserId;
+ if (is_null($iUserId)) {
+ /**
+ * Для гостя
+ * Два варианта - проверка по IP и по кукам
+ */
+ if ($oPoll->getIsGuestCheckIp()) {
+ if ($oVote = $this->Poll_GetVoteByIpAndPollId(func_getIp(), $oPoll->getId())) {
+ return $oVote;
+ }
+ }
+ /**
+ * По кукам
+ */
+ if ($sKey = $this->Session_GetCookie($this->GetCookieVoteName($oPoll))) {
+ return $this->Poll_GetVoteByGuestKeyAndPollId($sKey, $oPoll->getId());
+ }
+ return false;
+ } else {
+ /**
+ * Для авторизованного
+ */
+ return $this->Poll_GetVoteByUserIdAndPollId($iUserId, $oPoll->getId());
+ }
+ }
+
+ /**
+ * Возвращает название куки для хранения факта голосования
+ *
+ * @param $oPoll
+ * @return string
+ */
+ public function GetCookieVoteName($oPoll)
+ {
+ $iPollId = is_object($oPoll) ? $oPoll->getId() : $oPoll;
+ return "poll-vote-{$iPollId}";
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/poll/entity/Answer.entity.class.php b/application/classes/modules/poll/entity/Answer.entity.class.php
new file mode 100644
index 0000000..1e7daae
--- /dev/null
+++ b/application/classes/modules/poll/entity/Answer.entity.class.php
@@ -0,0 +1,56 @@
+
+ *
+ */
+
+/**
+ * Сущность ответа в опросе
+ *
+ * @package application.modules.poll
+ * @since 2.0
+ */
+class ModulePoll_EntityAnswer extends EntityORM
+{
+
+ protected $aValidateRules = array(
+ array('title', 'string', 'allowEmpty' => false, 'min' => 1, 'max' => 250),
+ array('title', 'check_title'),
+ );
+
+ protected $aRelations = array(
+ 'poll' => array(self::RELATION_TYPE_BELONGS_TO, 'ModulePoll_EntityPoll', 'poll_id'),
+ );
+
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+ public function ValidateCheckTitle()
+ {
+ $this->setTitle(htmlspecialchars($this->getTitle()));
+ return true;
+ }
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/poll/entity/Poll.entity.class.php b/application/classes/modules/poll/entity/Poll.entity.class.php
new file mode 100644
index 0000000..25b49ed
--- /dev/null
+++ b/application/classes/modules/poll/entity/Poll.entity.class.php
@@ -0,0 +1,296 @@
+
+ *
+ */
+
+/**
+ * Сущность опроса
+ *
+ * @package application.modules.poll
+ * @since 2.0
+ */
+class ModulePoll_EntityPoll extends EntityORM
+{
+
+ protected $aValidateRules = array(
+ array('title', 'string', 'allowEmpty' => false, 'min' => 3, 'max' => 250, 'on' => array('create', 'update')),
+ array(
+ 'count_answer_max',
+ 'number',
+ 'allowEmpty' => true,
+ 'integerOnly' => true,
+ 'min' => 0,
+ 'on' => array('create', 'update')
+ ),
+ array('type', 'check_type', 'on' => array('create', 'update')),
+ array('answers_raw', 'check_answers_raw', 'on' => array('create', 'update')),
+ array('target_raw', 'check_target_raw', 'on' => array('create')),
+ array('title', 'check_title', 'on' => array('create', 'update')),
+ array('is_guest_allow', 'check_is_guest_allow', 'on' => array('create', 'update')),
+ array('is_guest_check_ip', 'check_is_guest_check_ip', 'on' => array('create', 'update')),
+ );
+
+ protected $aRelations = array(
+ 'answers' => array(self::RELATION_TYPE_HAS_MANY, 'ModulePoll_EntityAnswer', 'poll_id'),
+ 'vote_current' => array(self::RELATION_TYPE_HAS_ONE, 'ModulePoll_EntityVote', 'poll_id'),
+ );
+
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+ protected function afterSave()
+ {
+ parent::afterSave();
+ /**
+ * Сохраняем варианты
+ */
+ if ($aAnswers = $this->getAnswersObject()) {
+ foreach ($aAnswers as $oAnswer) {
+ $oAnswer->setPollId($this->getId());
+ $oAnswer->Save();
+ }
+ }
+ /**
+ * Удаляем варианты
+ */
+ if ($aAnswers = $this->getAnswersObjectForRemove()) {
+ foreach ($aAnswers as $oAnswer) {
+ $oAnswer->Delete();
+ }
+ }
+ }
+
+ protected function afterDelete()
+ {
+ parent::afterDelete();
+ /**
+ * Удаляем варианты ответов
+ */
+ $aAnswerItems = $this->Poll_GetAnswerItemsByPollId($this->getId());
+ foreach ($aAnswerItems as $oAnswer) {
+ $oAnswer->Delete();
+ }
+ /**
+ * Удаляем голосования
+ */
+ $aVoteItems = $this->Poll_GetVoteItemsByPollId($this->getId());
+ foreach ($aVoteItems as $oVote) {
+ $oVote->Delete();
+ }
+ }
+
+ public function ValidateCheckTitle()
+ {
+ $this->setTitle(htmlspecialchars($this->getTitle()));
+ return true;
+ }
+
+ public function ValidateCheckIsGuestAllow()
+ {
+ $this->setIsGuestAllow($this->getIsGuestAllow() ? 1 : 0);
+ return true;
+ }
+
+ public function ValidateCheckIsGuestCheckIp()
+ {
+ $this->setIsGuestCheckIp($this->getIsGuestCheckIp() ? 1 : 0);
+ return true;
+ }
+
+ public function ValidateCheckType()
+ {
+ if (!$this->_isNew() and $this->getCountVote()) {
+ /**
+ * Запрещаем смену типа
+ */
+ $this->setCountAnswerMax($this->_getOriginalDataOne('count_answer_max'));
+ return true;
+ }
+ $iCount = $this->getCountAnswerMax();
+ if ($this->getType() == 'one') {
+ $this->setCountAnswerMax(1);
+ return true;
+ } else {
+ if ($iCount < 2) {
+ return $this->Lang_Get('poll.notices.error_answers_max_wrong');
+ }
+ }
+ return true;
+ }
+
+ public function ValidateCheckAnswersRaw()
+ {
+ if (!$this->_isNew() and !$this->isAllowUpdate()) {
+ return true;
+ }
+
+ $aAnswersRaw = $this->getAnswersRaw();
+ if (!is_array($aAnswersRaw) or count($aAnswersRaw) < 2) {
+ return $this->Lang_Get('poll.notices.error_answers_count');
+ }
+ /**
+ * Здесь может быть два варианта - создание опроса или редактирование, при редактирование могут передаваться ID ответов
+ */
+ if (!$this->_isNew()) {
+ $aAnswersOld = $this->Poll_GetAnswerItemsByFilter(array(
+ 'poll_id' => $this->getId(),
+ '#index-from-primary'
+ ));
+ } else {
+ $aAnswersOld = array();
+ }
+ $aAnswers = array();
+ foreach ($aAnswersRaw as $aAnswer) {
+ if ($this->_isNew() or !(isset($aAnswer['id']) and isset($aAnswersOld[$aAnswer['id']]) and $oAnswer = $aAnswersOld[$aAnswer['id']])) {
+ $oAnswer = Engine::GetEntity('ModulePoll_EntityAnswer');
+ }
+ if ($oAnswer->getId()) {
+ /**
+ * Фильтруем список старых ответов для будущего удаления оставшихся
+ */
+ unset($aAnswersOld[$oAnswer->getId()]);
+ }
+ $oAnswer->setTitle(isset($aAnswer['title']) ? $aAnswer['title'] : '');
+ if (!$oAnswer->_Validate()) {
+ return $oAnswer->_getValidateError();
+ }
+ $aAnswers[] = $oAnswer;
+ }
+ $this->setAnswersObject($aAnswers);
+
+ foreach ($aAnswersOld as $oAnswer) {
+ if ($oAnswer->getCountVote()) {
+ return $this->Lang_Get('poll.notices.error_answer_remove');
+ }
+ }
+
+ $this->setAnswersObjectForRemove($aAnswersOld);
+ return true;
+ }
+
+ public function ValidateCheckTargetRaw()
+ {
+ $aTarget = $this->getTargetRaw();
+
+ $sTargetType = isset($aTarget['type']) ? $aTarget['type'] : '';
+ $sTargetId = isset($aTarget['id']) ? $aTarget['id'] : '';
+ $sTargetTmp = isset($aTarget['tmp']) ? $aTarget['tmp'] : '';
+
+ if ($sTargetId) {
+ $sTargetTmp = null;
+ if (!$this->Poll_CheckTarget($sTargetType, $sTargetId)) {
+ return $this->Lang_Get('poll.notices.error_target_type');
+ }
+ } else {
+ $sTargetId = null;
+ if (!$sTargetTmp or !$this->Poll_IsAllowTargetType($sTargetType)) {
+ return $this->Lang_Get('poll.notices.error_target_type');
+ }
+ if ($this->Poll_GetPollByFilter(array('target_tmp' => $sTargetTmp, 'target_type <>' => $sTargetType))) {
+ return $this->Lang_Get('poll.notices.error_target_tmp');
+ }
+ }
+
+ $this->setTargetType($sTargetType);
+ $this->setTargetId($sTargetId);
+ $this->setTargetTmp($sTargetTmp);
+ return true;
+ }
+
+ /**
+ * Проверяет доступность опроса для изменения
+ * Важно понимать, что здесь нет проверки на права доступа
+ *
+ * @return bool
+ */
+ public function isAllowUpdate()
+ {
+ $iTime = $this->getDateCreate();
+ if ((time() - strtotime($iTime)) > Config::Get('module.poll.time_limit_update')) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Проверяет возможность удаления опроса, не пользователем, а в принципе
+ * Важно понимать, что здесь нет проверки на права доступа
+ *
+ * @return bool
+ */
+ public function isAllowRemove()
+ {
+ if ($this->getCountVote() || $this->getCountAbstain()) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Проверяет возможность голосования в опросе, не пользователем, а в принципе
+ * Важно понимать, что здесь нет проверки на права доступа
+ *
+ * @return bool
+ */
+ public function isAllowVote()
+ {
+ $sDateEnd = $this->getDateEnd();
+ if ($sDateEnd and (time() - strtotime($sDateEnd)) > 0) {
+ return false;
+ }
+ return true;
+ }
+
+ public function getAnswerPercent($oAnswer)
+ {
+ $iCountAll = $this->getCountVote();
+ if ($iCountAll == 0) {
+ return 0;
+ } else {
+ return number_format(round($oAnswer->getCountVote() * 100 / $iCountAll, 1), 1, '.', '');
+ }
+ }
+
+ public function getCountVoteAnswerMax()
+ {
+ $iMax = 0;
+ $aAnswers = $this->getAnswers();
+ foreach ($aAnswers as $oAnswer) {
+ if ($oAnswer->getCountVote() > $iMax) {
+ $iMax = $oAnswer->getCountVote();
+ }
+ }
+ return $iMax;
+ }
+
+ public function getVoteCurrent()
+ {
+ if (array_key_exists('vote_current', $this->aRelationsData)) {
+ return $this->aRelationsData['vote_current'];
+ }
+ return $this->Poll_GetVoteByUser($this, $this->User_GetUserCurrent());
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/poll/entity/Vote.entity.class.php b/application/classes/modules/poll/entity/Vote.entity.class.php
new file mode 100644
index 0000000..05c864a
--- /dev/null
+++ b/application/classes/modules/poll/entity/Vote.entity.class.php
@@ -0,0 +1,93 @@
+
+ *
+ */
+
+/**
+ * Сущность голосования в опросе
+ *
+ * @package application.modules.poll
+ * @since 2.0
+ */
+class ModulePoll_EntityVote extends EntityORM
+{
+
+ protected $aValidateRules = array();
+
+ protected $aRelations = array(
+ 'poll' => array(self::RELATION_TYPE_BELONGS_TO, 'ModulePoll_EntityPoll', 'poll_id'),
+ );
+
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ $this->setIp(func_getIp());
+ }
+ }
+ return $bResult;
+ }
+
+ protected function afterSave()
+ {
+ parent::afterSave();
+ if ($this->_isNew()) {
+ /**
+ * Отмечаем факт голосования в опросе и вариантах
+ */
+ $oPoll = $this->getPoll();
+ $aAnswerItems = $this->getAnswersObject();
+ if ($aAnswerItems) {
+ foreach ($aAnswerItems as $oAnswer) {
+ $oAnswer->setCountVote($oAnswer->getCountVote() + 1);
+ $oAnswer->Update();
+ }
+ $oPoll->setCountVote($oPoll->getCountVote() + 1);
+ } else {
+ $oPoll->setCountAbstain($oPoll->getCountAbstain() + 1);
+ }
+ $oPoll->Update(0);
+ }
+ }
+
+ /**
+ * Возвращает список вариантов, за которые голосовали
+ *
+ * @return array|mixed
+ */
+ public function getAnswers()
+ {
+ $aData = @unserialize($this->_getDataOne('answers'));
+ if (!$aData) {
+ $aData = array();
+ }
+ return $aData;
+ }
+
+ /**
+ * Устанавливает список вариантов, за которые голосовали
+ *
+ * @param $aParams
+ */
+ public function setAnswers($aParams)
+ {
+ $this->_aData['answers'] = @serialize($aParams);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/Property.class.php b/application/classes/modules/property/Property.class.php
new file mode 100644
index 0000000..3cbdb6e
--- /dev/null
+++ b/application/classes/modules/property/Property.class.php
@@ -0,0 +1,971 @@
+
+ *
+ */
+
+/**
+ * Модуль управления дополнительными полями
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty extends ModuleORM
+{
+ /**
+ * Список возможных типов свойств/полей
+ */
+ const PROPERTY_TYPE_INT = 'int';
+ const PROPERTY_TYPE_FLOAT = 'float';
+ const PROPERTY_TYPE_VARCHAR = 'varchar';
+ const PROPERTY_TYPE_TEXT = 'text';
+ const PROPERTY_TYPE_CHECKBOX = 'checkbox';
+ const PROPERTY_TYPE_TAGS = 'tags';
+ const PROPERTY_TYPE_VIDEO_LINK = 'video_link';
+ const PROPERTY_TYPE_SELECT = 'select';
+ const PROPERTY_TYPE_DATE = 'date';
+ const PROPERTY_TYPE_FILE = 'file';
+ const PROPERTY_TYPE_IMAGE = 'image';
+ const PROPERTY_TYPE_IMAGESET = 'imageset';
+ /**
+ * Список состояний типов объектов
+ */
+ const TARGET_STATE_ACTIVE = 1;
+ const TARGET_STATE_NOT_ACTIVE = 2;
+ const TARGET_STATE_REMOVE = 3;
+
+ protected $oMapper = null;
+ /**
+ * Список доступных типов полей
+ *
+ * @var array
+ */
+ protected $aPropertyTypes = array(
+ self::PROPERTY_TYPE_INT,
+ self::PROPERTY_TYPE_FLOAT,
+ self::PROPERTY_TYPE_VARCHAR,
+ self::PROPERTY_TYPE_TEXT,
+ self::PROPERTY_TYPE_CHECKBOX,
+ self::PROPERTY_TYPE_TAGS,
+ self::PROPERTY_TYPE_VIDEO_LINK,
+ self::PROPERTY_TYPE_SELECT,
+ self::PROPERTY_TYPE_DATE,
+ self::PROPERTY_TYPE_FILE,
+ self::PROPERTY_TYPE_IMAGE,
+ self::PROPERTY_TYPE_IMAGESET
+ );
+ /**
+ * Список разрешенных типов
+ * На данный момент допустимы параметры entity=>ModuleTest_EntityTest - указывает на класс сущности
+ * name=>Статьи
+ *
+ * @var array
+ */
+ protected $aTargetTypes = array();
+
+ public function Init()
+ {
+ parent::Init();
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+
+ /**
+ * Получаем типы из БД и активируем их
+ */
+ if ($aTargetItems = $this->GetTargetItemsByFilter(array('state' => self::TARGET_STATE_ACTIVE))) {
+ foreach ($aTargetItems as $oTarget) {
+ $this->Property_AddTargetType($oTarget->getType(), $oTarget->getParams());
+ }
+ }
+ }
+
+ /**
+ * Возвращает список типов объектов
+ *
+ * @return array
+ */
+ public function GetTargetTypes()
+ {
+ return $this->aTargetTypes;
+ }
+
+ /**
+ * Добавляет в разрешенные новый тип
+ *
+ * @param string $sTargetType Тип
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function AddTargetType($sTargetType, $aParams = array())
+ {
+ if (!array_key_exists($sTargetType, $this->aTargetTypes)) {
+ $this->aTargetTypes[$sTargetType] = $aParams;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет разрешен ли данный тип
+ *
+ * @param string $sTargetType Тип
+ * @return bool
+ */
+ public function IsAllowTargetType($sTargetType)
+ {
+ return in_array($sTargetType, array_keys($this->aTargetTypes));
+ }
+
+ /**
+ * Возвращает парметры нужного типа
+ *
+ * @param string $sTargetType
+ *
+ * @return mixed
+ */
+ public function GetTargetTypeParams($sTargetType)
+ {
+ if ($this->IsAllowTargetType($sTargetType)) {
+ return $this->aTargetTypes[$sTargetType];
+ }
+ }
+
+ /**
+ * Проверяет разрешен ли тип поля
+ *
+ * @param string $sType
+ *
+ * @return bool
+ */
+ public function IsAllowPropertyType($sType)
+ {
+ return in_array($sType, $this->aPropertyTypes);
+ }
+
+ /**
+ * Для каждого из свойств получает значение
+ *
+ * @param array $aProperties Список свойств
+ * @param string $sTargetType Тип объекта
+ * @param int $iTargetId ID объекта
+ *
+ * @return bool
+ */
+ public function AttachValueForProperties($aProperties, $sTargetType, $iTargetId)
+ {
+ if (!$aProperties) {
+ return false;
+ }
+ /**
+ * Формируем список ID свойств
+ */
+ $aPropertyIds = array();
+ foreach ($aProperties as $oProperty) {
+ $aPropertyIds[] = $oProperty->getId();
+ }
+ /**
+ * Получаем список значений
+ */
+ $aValues = $this->Property_GetValueItemsByFilter(array(
+ 'target_id' => $iTargetId,
+ 'target_type' => $sTargetType,
+ 'property_id in' => $aPropertyIds,
+ '#index-from' => 'property_id'
+ ));
+ /**
+ * Аттачим значения к свойствам
+ */
+ foreach ($aProperties as $oProperty) {
+ if (isset($aValues[$oProperty->getId()])) {
+ $oProperty->setValue($aValues[$oProperty->getId()]);
+ } else {
+ $oProperty->setValue(Engine::GetEntity('ModuleProperty_EntityValue', array(
+ 'property_id' => $oProperty->getId(),
+ 'property_type' => $oProperty->getType(),
+ 'target_type' => $sTargetType,
+ 'target_id' => $iTargetId
+ )));
+ }
+ $oProperty->getValue()->setProperty($oProperty);
+ }
+ return true;
+ }
+
+ /**
+ * Сохраняет текущие значения свойств
+ *
+ * @param array $aProperties
+ * @param Entity|int $oTarget Объект сущности или ID сущности
+ */
+ public function UpdatePropertiesValue($aProperties, $oTarget)
+ {
+ if ($aProperties) {
+ foreach ($aProperties as $oProperty) {
+ $oValue = $oProperty->getValue();
+ $oValue->setTargetId(is_object($oTarget) ? $oTarget->getId() : $oTarget);
+ $oValue->setPropertyType($oProperty->getType());
+ $oValue->Save();
+ }
+ }
+ }
+
+ /**
+ * Удаление всех свойств у конкретного объекта/сущности
+ *
+ * @param Entity $oTarget
+ */
+ public function RemovePropertiesValue($oTarget)
+ {
+ $aProperties = $this->Property_GetPropertyItemsByFilter(array('target_type' => $oTarget->property->getPropertyTargetType()));
+ if ($aProperties) {
+ $this->AttachValueForProperties($aProperties, $oTarget->property->getPropertyTargetType(),
+ $oTarget->getId());
+ foreach ($aProperties as $oProperty) {
+ $oValue = $oProperty->getValue();
+ if ($oValue and $oValue->getId()) {
+ $oValueType = $oValue->getValueTypeObject();
+ /**
+ * Кастомное удаление
+ */
+ $oValueType->removeValue();
+ /**
+ * Удаляем основные данные
+ */
+ $oValue->Delete();
+ }
+ }
+ }
+ }
+
+ /**
+ * Валидирует значение свойств у объекта
+ *
+ * @param Entity $oTarget
+ *
+ * @return bool|string
+ */
+ public function ValidateEntityPropertiesCheck($oTarget)
+ {
+ /**
+ * Пробуем получить свойства из реквеста
+ */
+ $oTarget->setProperties($oTarget->getProperties() ? $oTarget->getProperties() : getRequest('property'));
+ $aPropertiesValue = $oTarget->getProperties();
+ $aPropertiesResult = array();
+ /**
+ * Получаем весь список свойств у объекта
+ */
+ $aPropertiesObject = $this->Property_GetPropertyItemsByFilter(array('target_type' => $oTarget->property->getPropertyTargetType()));
+ $this->Property_AttachValueForProperties($aPropertiesObject, $oTarget->property->getPropertyTargetType(),
+ $oTarget->getId());
+ foreach ($aPropertiesObject as $oProperty) {
+ $oValue = $oProperty->getValue();
+ $sValue = isset($aPropertiesValue[$oProperty->getId()]) ? $aPropertiesValue[$oProperty->getId()] : null;
+ /**
+ * Валидируем значение
+ */
+ $oValueType = $oValue->getValueTypeObject();
+ $oValueType->setValueForValidate($sValue);
+ if (true === ($sRes = $oValueType->validate())) {
+ $oValueType->setValue($oValueType->getValueForValidate());
+ $aPropertiesResult[$oProperty->getId()] = $oProperty;
+ } else {
+ return $this->Lang_Get('property.notices.validate_value_wrong',
+ array('field' => $oProperty->getTitle())) . ($sRes ? $sRes : $this->Lang_Get('property.notices.validate_value_wrong_base'));
+ }
+ }
+ $oTarget->setPropertiesObject($aPropertiesResult);
+ return true;
+ }
+
+ /**
+ * Возвращает значение свойсва у объекта
+ *
+ * @param Entity $oTarget Объект сущности
+ * @param int $sPropertyId ID свойства
+ *
+ * @return null|mixed
+ */
+ public function GetEntityPropertyValue($oTarget, $sPropertyId)
+ {
+ if ($oProperty = $this->GetEntityPropertyValueObject($oTarget, $sPropertyId)) {
+ return $oProperty->getValue()->getValueForDisplay();
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает объект свойства сущности
+ *
+ * @param Entity $oTarget Объект сущности
+ * @param int $sPropertyId ID свойства
+ *
+ * @return null|ModuleProperty_EntityProperty
+ */
+ public function GetEntityProperty($oTarget, $sPropertyId)
+ {
+ if ($oProperty = $this->GetEntityPropertyValueObject($oTarget, $sPropertyId)) {
+ return $oProperty;
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает список свойств сущности
+ *
+ * @param Entity $oTarget Объект сущности
+ *
+ * @return array
+ */
+ public function GetEntityPropertyList($oTarget)
+ {
+ $sTargetType = $oTarget->property->getPropertyTargetType();
+ /**
+ * Проверяем зарегистрирован ли такой тип
+ */
+ if (!$this->IsAllowTargetType($sTargetType)) {
+ return array();
+ }
+ if (!$oTarget->getPropertyIsLoadAll()) {
+ $aProperties = $this->oMapper->GetPropertiesValueByTarget($oTarget->property->getPropertyTargetType(),
+ $oTarget->getId());
+ $this->AttachPropertiesForTarget($oTarget, $aProperties);
+ }
+ return $oTarget->_getDataOne('property_list');
+ }
+
+ /**
+ * Служебный метод для аттача свойст к сущности
+ *
+ * @param Entity $oTarget Объект сущности
+ * @param array $aProperties Список свойств
+ */
+ public function AttachPropertiesForTarget($oTarget, $aProperties)
+ {
+ $oTarget->setPropertyList($aProperties);
+ $oTarget->setPropertyIsLoadAll(true);
+ $aMapperCode = array();
+ foreach ($aProperties as $oProperty) {
+ $aMapperCode[$oProperty->getCode()] = $oProperty->getId();
+ }
+ $oTarget->setPropertyMapperCode($aMapperCode);
+ }
+
+ /**
+ * Возвращает объект свойства
+ *
+ * @param Entity $oTarget Объект сущности
+ * @param array $sPropertyId ID свойства
+ *
+ * @return null
+ */
+ public function GetEntityPropertyValueObject($oTarget, $sPropertyId)
+ {
+ if (!$oTarget->getPropertyIsLoadAll()) {
+ /**
+ * Загружаем все свойства
+ */
+ $aProperties = $this->oMapper->GetPropertiesValueByTarget($oTarget->property->getPropertyTargetType(),
+ $oTarget->getId());
+ $this->AttachPropertiesForTarget($oTarget, $aProperties);
+ }
+
+ if (!is_numeric($sPropertyId)) {
+ $aMapperCode = $oTarget->getPropertyMapperCode();
+ if (isset($aMapperCode[$sPropertyId])) {
+ $sPropertyId = $aMapperCode[$sPropertyId];
+ } else {
+ return null;
+ }
+ }
+ $aProperties = $oTarget->property->getPropertyList();
+ if (isset($aProperties[$sPropertyId])) {
+ return $aProperties[$sPropertyId];
+ }
+ return null;
+ }
+
+ /**
+ * Переопределяем метод для возможности цеплять свои кастомные данные при ORM запросах - свойства
+ *
+ * @param array $aResult
+ * @param array $aFilter
+ * @param null|string $sEntityFull
+ */
+ public function RewriteGetItemsByFilter($aResult, $aFilter = array(), $sEntityFull = null)
+ {
+ if (!$aResult) {
+ return;
+ }
+ /**
+ * Список на входе может быть двух видом:
+ * 1 - одномерный массив
+ * 2 - двумерный, если применялась группировка (использование '#index-group')
+ *
+ * Поэтому сначала сформируем линейный список
+ */
+ if (isset($aFilter['#index-group']) and $aFilter['#index-group']) {
+ $aEntitiesWork = array();
+ foreach ($aResult as $aItems) {
+ foreach ($aItems as $oItem) {
+ $aEntitiesWork[] = $oItem;
+ }
+ }
+ } else {
+ $aEntitiesWork = $aResult;
+ }
+
+ if (!$aEntitiesWork) {
+ return;
+ }
+ $oEntityFirst = reset($aEntitiesWork);
+ if (!$oEntityFirst->property) {
+ return;
+ }
+ /**
+ * Проверяем необходимость цеплять свойства
+ */
+ if (isset($aFilter['#properties']) and $aFilter['#properties']) {
+ $aEntitiesId = array();
+ $aTargetTypes = array();
+ foreach ($aEntitiesWork as $oEntity) {
+ $sTargetType = $oEntity->property->getPropertyTargetType();
+ if ($this->IsAllowTargetType($sTargetType)) {
+ $aEntitiesId[] = $oEntity->getId();
+ $aTargetTypes[] = $sTargetType;
+ }
+ }
+ $aTargetTypes = array_unique($aTargetTypes);
+ /**
+ * Получаем все свойства со значениями для всех объектов
+ */
+ $aResult = $this->oMapper->GetPropertiesValueByTargetArray($aTargetTypes, $aEntitiesId);
+ if ($aResult) {
+ /**
+ * Формируем список свойств и значений
+ */
+ $aProperties = array();
+ $aValues = array();
+ foreach ($aResult as $aRow) {
+ $aPropertyData = array();
+ $aValueData = array();
+ foreach ($aRow as $k => $v) {
+ if (strpos($k, 'prop_') === 0) {
+ $aPropertyData[str_replace('prop_', '', $k)] = $v;
+ } else {
+ $aValueData[$k] = $v;
+ }
+ }
+
+ if (!isset($aProperties[$aRow['prop_id']])) {
+ $oProperty = Engine::GetEntity('ModuleProperty_EntityProperty', $aPropertyData);
+ $aProperties[$aRow['prop_id']] = $oProperty;
+ }
+ if ($aRow['target_id']) {
+ $sKey = $aRow['property_id'] . '_' . $aRow['target_id'];
+ $aValues[$sKey] = Engine::GetEntity('ModuleProperty_EntityValue', $aValueData);
+ }
+ }
+ /**
+ * Собираем данные
+ */
+ foreach ($aEntitiesWork as $oEntity) {
+ $aPropertiesClone = array();
+ foreach ($aProperties as $oProperty) {
+ if ($oEntity->property->getPropertyTargetType() != $oProperty->getTargetType()) {
+ continue;
+ }
+ $oPropertyNew = clone $oProperty;
+ $sKey = $oProperty->getId() . '_' . $oEntity->getId();
+ if (isset($aValues[$sKey])) {
+ $oValue = $aValues[$sKey];
+ } else {
+ $oValue = Engine::GetEntity('ModuleProperty_EntityValue', array(
+ 'property_type' => $oProperty->getType(),
+ 'property_id' => $oProperty->getId(),
+ 'target_type' => $oProperty->getTargetType(),
+ 'target_id' => $oEntity->getId()
+ ));
+ }
+ $oPropertyNew->setValue($oValue);
+ $oValue->setProperty($oPropertyNew);
+ $aPropertiesClone[$oPropertyNew->getId()] = $oPropertyNew;
+ }
+ $this->AttachPropertiesForTarget($oEntity, $aPropertiesClone);
+ }
+ }
+ }
+ }
+
+ /**
+ * Обработка фильтра ORM запросов
+ *
+ * @param array $aFilter
+ * @param array $sEntityFull
+ *
+ * @return array
+ */
+ public function RewriteFilter($aFilter, $sEntityFull)
+ {
+ $oEntitySample = Engine::GetEntity($sEntityFull);
+ if (!$oEntitySample->property) {
+ return $aFilter;
+ }
+
+ if (!isset($aFilter['#join'])) {
+ $aFilter['#join'] = array();
+ }
+ $aPropFields = array();
+ foreach ($aFilter as $k => $v) {
+ if (preg_match('@^#prop:(.+)$@i', $k, $aMatch)) {
+ /**
+ * Сначала формируем список полей с операндами
+ */
+ $aK = explode(' ', trim($aMatch[1]), 2);
+ $sPropCurrent = $aK[0];
+ $sConditionCurrent = ' = ';
+ if (count($aK) > 1) {
+ $sConditionCurrent = strtolower($aK[1]);
+ }
+ $aPropFields[$sPropCurrent] = array('value' => $v, 'condition' => $sConditionCurrent);
+ }
+ }
+ /**
+ * Проверяем на наличие сортировки по полям
+ */
+ $aOrders = array();
+ if (isset($aFilter['#order'])) {
+ if (!is_array($aFilter['#order'])) {
+ $aFilter['#order'] = array($aFilter['#order']);
+ }
+ foreach ($aFilter['#order'] as $key => $value) {
+ $aKeys = explode(':', $key);
+ if (count($aKeys) == 2 and strtolower($aKeys[0]) == 'prop') {
+ $aOrders[$aKeys[1]] = array('way' => $value, 'replace' => $key);
+ }
+ }
+ }
+ /**
+ * Получаем данные по полям
+ */
+ if ($aPropFields) {
+ $sTargetType = $oEntitySample->property->getPropertyTargetType();
+ $aProperties = $this->Property_GetPropertyItemsByFilter(array(
+ 'code in' => array_keys($aPropFields),
+ 'target_type' => $sTargetType
+ ));
+ $iPropNum = 0;
+ foreach ($aProperties as $oProperty) {
+ /**
+ * По каждому полю строим JOIN запрос
+ */
+ $sCondition = $aPropFields[$oProperty->getCode()]['condition'];
+ $bIsArray = in_array(strtolower($sCondition), array('in', 'not in')) ? true : false;
+ if (in_array($oProperty->getType(),
+ array(ModuleProperty::PROPERTY_TYPE_INT, ModuleProperty::PROPERTY_TYPE_CHECKBOX))) {
+ $sFieldValue = "value_int";
+ $sConditionFull = $sCondition . ($bIsArray ? ' (?a) ' : ' ?d ');
+ } elseif ($oProperty->getType() == ModuleProperty::PROPERTY_TYPE_FLOAT) {
+ $sFieldValue = "value_float";
+ $sConditionFull = $sCondition . ($bIsArray ? ' (?a) ' : ' ?f ');
+ } elseif (in_array($oProperty->getType(), array(
+ ModuleProperty::PROPERTY_TYPE_VARCHAR,
+ ModuleProperty::PROPERTY_TYPE_TAGS,
+ ModuleProperty::PROPERTY_TYPE_VIDEO_LINK
+ ))) {
+ $sFieldValue = "value_varchar";
+ $sConditionFull = $sCondition . ($bIsArray ? ' (?a) ' : ' ? ');
+ } elseif ($oProperty->getType() == ModuleProperty::PROPERTY_TYPE_TEXT) {
+ $sFieldValue = "value_text";
+ $sConditionFull = $sCondition . ($bIsArray ? ' (?a) ' : ' ? ');
+ } else {
+ $sFieldValue = "value_varchar";
+ $sConditionFull = $sCondition . ($bIsArray ? ' (?a) ' : ' ? ');
+ }
+ $iPropNum++;
+ $sJoin = "JOIN " . Config::Get('db.table.property_value') . " propv{$iPropNum} ON
+ t.`{$oEntitySample->_getPrimaryKey()}` = propv{$iPropNum}.target_id and
+ propv{$iPropNum}.target_type = '{$sTargetType}' and
+ propv{$iPropNum}.property_id = {$oProperty->getId()} and
+ propv{$iPropNum}.{$sFieldValue} {$sConditionFull}";
+ $aFilter['#join'][$sJoin] = array($aPropFields[$oProperty->getCode()]['value']);
+ /**
+ * Проверяем на сортировку по текущему полю
+ */
+ if (isset($aOrders[$oProperty->getCode()])) {
+ $aOrders[$oProperty->getCode()]['field'] = "propv{$iPropNum}.{$sFieldValue}";
+ }
+ }
+ }
+ /**
+ * Подменяем сортировку
+ */
+ foreach ($aOrders as $aItem) {
+ if (isset($aFilter['#order'][$aItem['replace']])) {
+ $aFilter['#order'] = $this->ArrayReplaceKey($aFilter['#order'], $aItem['replace'], $aItem['field']);
+ }
+ }
+ return $aFilter;
+ }
+
+ /**
+ * Служебный метод для замены ключа в массиве
+ *
+ * @param array $aArray
+ * @param string $sKeyOld
+ * @param string $sKeyNew
+ *
+ * @return array|bool
+ */
+ protected function ArrayReplaceKey($aArray, $sKeyOld, $sKeyNew)
+ {
+ $aKeys = array_keys($aArray);
+ if (false === $iIndex = array_search($sKeyOld, $aKeys)) {
+ return false;
+ }
+ $aKeys[$iIndex] = $sKeyNew;
+ return array_combine($aKeys, array_values($aArray));
+ }
+
+ /**
+ * Удаляет теги свойства у сущности
+ *
+ * @param string $sTargetType Тип объекта сущности
+ * @param int $iTargetId ID объекта сущности
+ * @param int $iPropertyId ID свойства
+ *
+ * @return mixed
+ */
+ public function RemoveValueTagsByTarget($sTargetType, $iTargetId, $iPropertyId)
+ {
+ // сбрасываем кеш
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('ModuleProperty_EntityValueTag_delete'));
+ return $this->oMapper->RemoveValueTagsByTarget($sTargetType, $iTargetId, $iPropertyId);
+ }
+
+ /**
+ * Удаляет значения типа select
+ *
+ * @param string $sTargetType Тип объекта сущности
+ * @param int $iTargetId ID объекта сущности
+ * @param int $iPropertyId ID свойства
+ *
+ * @return mixed
+ */
+ public function RemoveValueSelectsByTarget($sTargetType, $iTargetId, $iPropertyId)
+ {
+ // сбрасываем кеш
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('ModuleProperty_EntityValueSelect_delete'));
+ return $this->oMapper->RemoveValueSelectsByTarget($sTargetType, $iTargetId, $iPropertyId);
+ }
+
+ /**
+ * Возвращает список тегов/знаяений свойства. Используется для авкомплиттера тегов.
+ *
+ * @param string $sTag
+ * @param int $iPropertyId
+ * @param int $iLimit
+ *
+ * @return mixed
+ */
+ public function GetPropertyTagsByLike($sTag, $iPropertyId, $iLimit)
+ {
+ return $this->oMapper->GetPropertyTagsByLike($sTag, $iPropertyId, $iLimit);
+ }
+
+ /**
+ * Возвращет список группированных тегов с их количеством для необходимого свойства
+ *
+ * @param int $iPropertyId
+ * @param int $iLimit
+ *
+ * @return mixed
+ */
+ public function GetPropertyTagsGroup($iPropertyId, $iLimit)
+ {
+ return $this->oMapper->GetPropertyTagsGroup($iPropertyId, $iLimit);
+ }
+
+ /**
+ * Формирует и возвращает облако тегов необходимого свойства
+ *
+ * @param int $iPropertyId
+ * @param int $iLimit
+ *
+ * @return mixed
+ */
+ public function GetPropertyTagsCloud($iPropertyId, $iLimit)
+ {
+ $aTags = $this->Property_GetPropertyTagsGroup($iPropertyId, $iLimit);
+ if ($aTags) {
+ $this->Tools_MakeCloud($aTags);
+ }
+ return $aTags;
+ }
+
+ /**
+ * Список ID сущностей по тегу конкретного свойства
+ *
+ * @param int $iPropertyId
+ * @param string $sTag
+ * @param int $iCurrPage
+ * @param int $iPerPage
+ *
+ * @return array
+ */
+ public function GetTargetsByTag($iPropertyId, $sTag, $iCurrPage, $iPerPage)
+ {
+ return array(
+ 'collection' => $this->oMapper->GetTargetsByTag($iPropertyId, $sTag, $iCount, $iCurrPage, $iPerPage),
+ 'count' => $iCount
+ );
+ }
+
+ /**
+ * Производит изменение названия типа объекта, например "article" меняем на "news"
+ *
+ * @param $sType
+ * @param $sTypeNew
+ */
+ public function ChangeTargetType($sType, $sTypeNew)
+ {
+ $this->oMapper->UpdatePropertyByTargetType($sType, $sTypeNew);
+ $this->oMapper->UpdatePropertyTargetByTargetType($sType, $sTypeNew);
+ $this->oMapper->UpdatePropertySelectByTargetType($sType, $sTypeNew);
+ $this->oMapper->UpdatePropertyValueByTargetType($sType, $sTypeNew);
+ $this->oMapper->UpdatePropertyValueSelectByTargetType($sType, $sTypeNew);
+ $this->oMapper->UpdatePropertyValueTagByTargetType($sType, $sTypeNew);
+ /**
+ * Сбрасываем кеши
+ */
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array(
+ 'ModuleProperty_EntityProperty_save',
+ 'ModuleProperty_EntityTarget_save',
+ 'ModuleProperty_EntitySelect_save',
+ 'ModuleProperty_EntityValue_save',
+ 'ModuleProperty_EntityValueSelect_save',
+ 'ModuleProperty_EntityValueTag_save',
+ ));
+ }
+
+ /**
+ * Создает новый тип объекта в БД для дополнительных полей
+ *
+ * @param string $sType
+ * @param array $aParams
+ * @param bool $bRewrite
+ *
+ * @return bool|ModuleProperty_EntityTarget
+ */
+ public function CreateTargetType($sType, $aParams, $bRewrite = false)
+ {
+ /**
+ * Проверяем есть ли уже такой тип
+ */
+ if ($oTarget = $this->GetTargetByType($sType)) {
+ if (!$bRewrite) {
+ return false;
+ }
+ } else {
+ $oTarget = Engine::GetEntity('ModuleProperty_EntityTarget');
+ $oTarget->setType($sType);
+ }
+ $oTarget->setState(self::TARGET_STATE_ACTIVE);
+ $oTarget->setParams($aParams);
+ if ($oTarget->Save()) {
+ return $oTarget;
+ }
+ return false;
+ }
+
+ /**
+ * Отключает тип объекта для дополнительных полей
+ *
+ * @param string $sType
+ * @param int $iState self::TARGET_STATE_NOT_ACTIVE или self::TARGET_STATE_REMOVE
+ */
+ public function RemoveTargetType($sType, $iState = self::TARGET_STATE_NOT_ACTIVE)
+ {
+ if ($oTarget = $this->GetTargetByType($sType)) {
+ $oTarget->setState($iState);
+ $oTarget->Save();
+ }
+ }
+
+ /**
+ * Возвращает набор полей/свойств для показа их на форме редактирования
+ *
+ * @param $sTargetType
+ * @param $iTargetId
+ *
+ * @return mixed
+ */
+ public function GetPropertiesForUpdate($sTargetType, $iTargetId)
+ {
+ /**
+ * Проверяем зарегистрирован ли такой тип
+ */
+ if (!$this->IsAllowTargetType($sTargetType)) {
+ return array();
+ }
+ /**
+ * Получаем набор свойств
+ */
+ $aProperties = $this->Property_GetPropertyItemsByFilter(array(
+ 'target_type' => $sTargetType,
+ '#order' => array('sort' => 'desc')
+ ));
+ $this->Property_AttachValueForProperties($aProperties, $sTargetType, $iTargetId);
+ return $aProperties;
+ }
+
+ /**
+ * Автоматическое создание дополнительного поля
+ * TODO: учитывать $aAdditional для создание вариантов в типе select
+ *
+ * @param string $sTargetType Тип объекта дял которого добавляем поле
+ * @param array $aData Данные поля: array('type'=>'int','title'=>'Название','code'=>'newfield','description'=>'Описание поля','sort'=>100);
+ * @param bool $bSkipErrorUniqueCode Пропускать ошибку при дублировании кода поля (такое поле уже существует)
+ * @param array $aValidateRules Данные валидатора поля, зависят от конкретного типа поля: array('allowEmpty'=>true,'max'=>1000)
+ * @param array $aParams Дополнительные параметры поля, зависят от типа поля
+ * @param array $aAdditional Дополнительные данные, которые нужно учитывать при создании поля, зависят от типа поля
+ *
+ * @return bool|ModuleProperty_EntityProperty
+ */
+ public function CreateTargetProperty(
+ $sTargetType,
+ $aData,
+ $bSkipErrorUniqueCode = true,
+ $aValidateRules = array(),
+ $aParams = array(),
+ $aAdditional = array()
+ ) {
+ /**
+ * Если необходимо и поле уже существует, то пропускаем создание
+ */
+ if ($bSkipErrorUniqueCode and isset($aData['code']) and $this->GetPropertyByTargetTypeAndCode($sTargetType,
+ $aData['code'])
+ ) {
+ return true;
+ }
+
+ $oProperty = Engine::GetEntity('ModuleProperty_EntityProperty');
+ $oProperty->_setValidateScenario('auto');
+ $oProperty->_setDataSafe($aData);
+ $oProperty->setValidateRulesRaw($aValidateRules);
+ $oProperty->setParamsRaw($aParams);
+ $oProperty->setTargetType($sTargetType);
+ if ($oProperty->_Validate()) {
+ if ($oProperty->Add()) {
+ return $oProperty;
+ } else {
+ return $this->Lang_Get('property.notices.create_error');
+ }
+ } else {
+ return $oProperty->_getValidateError();
+ }
+ return false;
+ }
+
+ /**
+ * Используется для создания дефолтных дополнительных полей при активации плагина
+ *
+ * @param array $aProperties Список полей
+ *
+ * array(
+ * array(
+ * 'data'=>array(
+ * 'type'=>ModuleProperty::PROPERTY_TYPE_INT,
+ * 'title'=>'Номер',
+ * 'code'=>'number',
+ * 'sort'=>100
+ * ),
+ * 'validate_rule'=>array(
+ * 'min'=>10
+ * ),
+ * 'params'=>array(),
+ * 'additional'=>array()
+ * )
+ * );
+ *
+ * @param string $sTargetType Тип объекта
+ *
+ * @return bool
+ */
+ public function CreateDefaultTargetPropertyFromPlugin($aProperties, $sTargetType)
+ {
+ foreach ($aProperties as $aProperty) {
+ $sResultMsg = $this->CreateTargetProperty($sTargetType, $aProperty['data'], true,
+ $aProperty['validate_rule'], $aProperty['params'], $aProperty['additional']);
+ if ($sResultMsg !== true and !is_object($sResultMsg)) {
+ if (is_string($sResultMsg)) {
+ $this->Message_AddErrorSingle($sResultMsg, $this->Lang_Get('common.error.error'), true);
+ }
+ /**
+ * Отменяем добавление типа
+ */
+ $this->RemoveTargetType($sTargetType, ModuleProperty::TARGET_STATE_NOT_ACTIVE);
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public function RemoveValueByPropertyId($iPropertyId)
+ {
+ $bRes = $this->oMapper->RemoveValueByPropertyId($iPropertyId);
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('ModuleProperty_EntityValue_delete'));
+ return $bRes;
+ }
+
+ public function RemoveValueTagByPropertyId($iPropertyId)
+ {
+ $bRes = $this->oMapper->RemoveValueTagByPropertyId($iPropertyId);
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('ModuleProperty_EntityValueTag_delete'));
+ return $bRes;
+ }
+
+ public function RemoveValueSelectByPropertyId($iPropertyId)
+ {
+ $bRes = $this->oMapper->RemoveValueSelectByPropertyId($iPropertyId);
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('ModuleProperty_EntityValueSelect_delete'));
+ return $bRes;
+ }
+
+ public function RemoveSelectByPropertyId($iPropertyId)
+ {
+ $bRes = $this->oMapper->RemoveSelectByPropertyId($iPropertyId);
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('ModuleProperty_EntitySelect_delete'));
+ return $bRes;
+ }
+
+ public function CheckAllowTargetObject($sTargetType, $iTargetId, $aParams = array())
+ {
+ $sMethod = 'CheckAllowTargetObject' . func_camelize($sTargetType);
+ if (method_exists($this, $sMethod)) {
+ if (!array_key_exists('user', $aParams)) {
+ $aParams['user'] = $this->oUserCurrent;
+ }
+ return $this->$sMethod($iTargetId, $aParams);
+ }
+ /**
+ * По умолчанию считаем доступ разрешен
+ */
+ return true;
+ }
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/behavior/Entity.behavior.class.php b/application/classes/modules/property/behavior/Entity.behavior.class.php
new file mode 100644
index 0000000..2a6cd38
--- /dev/null
+++ b/application/classes/modules/property/behavior/Entity.behavior.class.php
@@ -0,0 +1,149 @@
+
+ *
+ */
+
+/**
+ * Поведение для подключения функционала дополнительных полей к сущностям
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_BehaviorEntity extends Behavior
+{
+ /**
+ * Дефолтные параметры
+ *
+ * @var array
+ */
+ protected $aParams = array(
+ 'target_type' => '',
+ );
+ /**
+ * Список хуков
+ *
+ * @var array
+ */
+ protected $aHooks = array(
+ 'validate_after' => 'CallbackValidateAfter',
+ 'after_save' => 'CallbackAfterSave',
+ 'after_delete' => 'CallbackAfterDelete',
+ );
+
+ /**
+ * Коллбэк
+ * Выполняется при инициализации сущности
+ *
+ * @param $aParams
+ */
+ public function CallbackValidateAfter($aParams)
+ {
+ if ($aParams['bResult']) {
+ $aFields = $aParams['aFields'];
+ if (is_null($aFields) or in_array('properties', $aFields)) {
+ $oValidator = $this->Validate_CreateValidator('properties_check', $this, 'properties');
+ $oValidator->validateEntity($this->oObject, $aFields);
+ $aParams['bResult'] = !$this->oObject->_hasValidateErrors();
+ }
+ }
+ }
+
+ /**
+ * Коллбэк
+ * Выполняется после сохранения сущности
+ */
+ public function CallbackAfterSave()
+ {
+ $this->Property_UpdatePropertiesValue($this->oObject->getPropertiesObject(), $this->oObject);
+ }
+
+ /**
+ * Коллбэк
+ * Выполняется после удаления сущности
+ */
+ public function CallbackAfterDelete()
+ {
+ $this->Property_RemovePropertiesValue($this->oObject);
+ }
+
+ /**
+ * Дополнительный метод для сущности
+ * Запускает валидацию дополнительных полей
+ *
+ * @return mixed
+ */
+ public function ValidatePropertiesCheck()
+ {
+ return $this->Property_ValidateEntityPropertiesCheck($this->oObject);
+ }
+
+ /**
+ * Возвращает полный список свойств сущности
+ *
+ * @return mixed
+ */
+ public function getPropertyList()
+ {
+ return $this->Property_GetEntityPropertyList($this->oObject);
+ }
+
+ /**
+ * Возвращает значение конкретного свойства
+ * @see ModuleProperty_EntityValue::getValueForDisplay
+ *
+ * @param int|string $sPropertyId ID или код свойства
+ *
+ * @return mixed
+ */
+ public function getPropertyValue($sPropertyId)
+ {
+ return $this->Property_GetEntityPropertyValue($this->oObject, $sPropertyId);
+ }
+
+ /**
+ * Возвращает объект конкретного свойства сущности
+ *
+ * @param int|string $sPropertyId ID или код свойства
+ *
+ * @return ModuleProperty_EntityProperty|null
+ */
+ public function getProperty($sPropertyId)
+ {
+ return $this->Property_GetEntityProperty($this->oObject, $sPropertyId);
+ }
+
+ /**
+ * Возвращает тип объекта для дополнительных полей
+ *
+ * @return string
+ */
+ public function getPropertyTargetType()
+ {
+ if ($sType = $this->getParam('target_type')) {
+ return $sType;
+ }
+ /**
+ * Иначе дополнительно смотрим на наличие данного метода у сущности
+ * Это необходимо, если тип вычисляется динамически по какой-то своей логике
+ */
+ if (func_method_exists($this->oObject, 'getPropertyTargetType', 'public')) {
+ return call_user_func(array($this->oObject, 'getPropertyTargetType'));
+ }
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/behavior/Module.behavior.class.php b/application/classes/modules/property/behavior/Module.behavior.class.php
new file mode 100644
index 0000000..76a9993
--- /dev/null
+++ b/application/classes/modules/property/behavior/Module.behavior.class.php
@@ -0,0 +1,72 @@
+
+ *
+ */
+
+/**
+ * Поведение для подключения функционала дополнительных полей к модулям
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_BehaviorModule extends Behavior
+{
+ /**
+ * Список хуков
+ *
+ * @var array
+ */
+ protected $aHooks = array(
+ 'module_orm_GetItemsByFilter_after' => array(
+ 'CallbackGetItemsByFilterAfter',
+ 1000
+ ),
+ 'module_orm_GetItemsByFilter_before' => array(
+ 'CallbackGetItemsByFilterBefore',
+ 1000
+ ),
+ 'module_orm_GetByFilter_before' => array(
+ 'CallbackGetItemsByFilterBefore',
+ 1000
+ ),
+ );
+
+ /**
+ * Модифицирует фильтр в ORM запросе
+ *
+ * @param $aParams
+ */
+ public function CallbackGetItemsByFilterAfter($aParams)
+ {
+ $aEntities = $aParams['aEntities'];
+ $aFilter = $aParams['aFilter'];
+ $this->Property_RewriteGetItemsByFilter($aEntities, $aFilter);
+ }
+
+ /**
+ * Модифицирует результат ORM запроса
+ *
+ * @param $aParams
+ */
+ public function CallbackGetItemsByFilterBefore($aParams)
+ {
+ $aFilter = $this->Property_RewriteFilter($aParams['aFilter'], $aParams['sEntityFull']);
+ $aParams['aFilter'] = $aFilter;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/Property.entity.class.php b/application/classes/modules/property/entity/Property.entity.class.php
new file mode 100644
index 0000000..4a5a965
--- /dev/null
+++ b/application/classes/modules/property/entity/Property.entity.class.php
@@ -0,0 +1,345 @@
+
+ *
+ */
+
+/**
+ * Сущность дополнительного поля
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityProperty extends EntityORM
+{
+
+ protected $aValidateRules = array(
+ array('type', 'check_type', 'on' => array('create', 'auto')),
+ array(
+ 'code',
+ 'regexp',
+ 'allowEmpty' => false,
+ 'pattern' => '#^[a-z0-9\_]+$#i',
+ 'on' => array('create', 'update', 'auto')
+ ),
+ array(
+ 'title',
+ 'string',
+ 'allowEmpty' => false,
+ 'min' => 1,
+ 'max' => 250,
+ 'on' => array('create', 'update', 'auto')
+ ),
+ array('description', 'string', 'allowEmpty' => true, 'max' => 500, 'on' => array('update', 'auto')),
+ array('sort', 'number', 'allowEmpty' => false, 'integerOnly' => true, 'min' => 0, 'on' => array('auto')),
+ array('validate_rules_raw', 'check_validate_rules_raw', 'on' => array('create', 'update', 'auto')),
+ array('params_raw', 'check_params_raw', 'on' => array('update', 'auto')),
+ array('code', 'check_code', 'on' => array('create', 'update', 'auto')),
+ array('title', 'check_title', 'on' => array('create', 'update', 'auto')),
+ array('description', 'check_description', 'on' => array('update', 'auto')),
+ );
+
+ protected $aRelations = array(
+ 'selects' => array(
+ self::RELATION_TYPE_HAS_MANY,
+ 'ModuleProperty_EntitySelect',
+ 'property_id',
+ array('#order' => array('sort' => 'desc'))
+ ),
+ );
+
+ public function ValidateCheckType()
+ {
+ if ($this->Property_IsAllowPropertyType($this->getType())) {
+ return true;
+ }
+ return $this->Lang_Get('property.notices.validate_type');
+ }
+
+ public function ValidateCheckCode()
+ {
+ if ($oProperty = $this->Property_GetPropertyByTargetTypeAndCode($this->getTargetType(), $this->getCode())) {
+ if ($this->getId() != $oProperty->getId()) {
+ return $this->Lang_Get('property.notices.validate_code');
+ }
+ }
+ return true;
+ }
+
+ public function ValidateCheckTitle()
+ {
+ $this->setTitle(htmlspecialchars($this->getTitle()));
+ return true;
+ }
+
+ public function ValidateCheckDescription()
+ {
+ $this->setDescription(htmlspecialchars($this->getDescription()));
+ return true;
+ }
+
+ public function ValidateCheckValidateRulesRaw()
+ {
+ $aRulesRaw = $this->getValidateRulesRaw();
+ /**
+ * Валидация зависит от типа
+ */
+ $oValue = Engine::GetEntity('ModuleProperty_EntityValue', array(
+ 'property_type' => $this->getType(),
+ 'property_id' => $this->getId(),
+ 'target_type' => $this->getTargetType(),
+ 'target_id' => $this->getId()
+ ));
+ $oValueType = $oValue->getValueTypeObject();
+ $aRules = $oValueType->prepareValidateRulesRaw($aRulesRaw);
+ $this->setValidateRules($aRules);
+ return true;
+ }
+
+ public function ValidateCheckParamsRaw()
+ {
+ $aParamsRaw = $this->getParamsRaw();
+ /**
+ * Валидация зависит от типа
+ */
+ $oValue = Engine::GetEntity('ModuleProperty_EntityValue', array(
+ 'property_type' => $this->getType(),
+ 'property_id' => $this->getId(),
+ 'target_type' => $this->getTargetType(),
+ 'target_id' => $this->getId()
+ ));
+ $oValueType = $oValue->getValueTypeObject();
+ $aParams = $oValueType->prepareParamsRaw($aParamsRaw);
+ $this->setParams($aParams);
+ return true;
+ }
+
+ /**
+ * Выполняется перед сохранением сущности
+ *
+ * @return bool
+ */
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+
+ $oValue = Engine::GetEntity('ModuleProperty_EntityValue', array(
+ 'property_type' => $this->getType(),
+ 'property_id' => $this->getId(),
+ 'target_type' => $this->getTargetType(),
+ 'target_id' => $this->getId()
+ ));
+ $oValueType = $oValue->getValueTypeObject();
+ /**
+ * Выставляем дефолтные значения параметров
+ */
+ $this->setParams($oValueType->getParamsDefault());
+ /**
+ * Выставляем дефолтные значения параметров валидации
+ */
+ $this->setValidateRules($oValueType->getValidateRulesDefault());
+ }
+ }
+ return $bResult;
+ }
+
+ /**
+ * Выполняется перед удалением сущности
+ *
+ * @return bool
+ */
+ protected function beforeDelete()
+ {
+ if ($bResult = parent::beforeDelete()) {
+ /**
+ * Сначала удаляем стандартные значения
+ */
+ $this->Property_RemoveValueByPropertyId($this->getId());
+ /**
+ * Удаляем значения тегов
+ */
+ $this->Property_RemoveValueTagByPropertyId($this->getId());
+ /**
+ * Удаляем значения селектов
+ */
+ $this->Property_RemoveValueSelectByPropertyId($this->getId());
+ /**
+ * Удаляем сами варианты селектов
+ */
+ $this->Property_RemoveSelectByPropertyId($this->getId());
+ }
+ return $bResult;
+ }
+
+ /**
+ * Возвращает правила валидации поля
+ *
+ * @return array
+ */
+ public function getValidateRules()
+ {
+ $aData = @unserialize($this->_getDataOne('validate_rules'));
+ if (!$aData) {
+ $aData = array();
+ }
+ return $aData;
+ }
+
+ /**
+ * Возвращает экранированный список правил валидации
+ *
+ * @return array
+ */
+ public function getValidateRulesEscape()
+ {
+ $aRules = $this->getValidateRules();
+ func_htmlspecialchars($aRules);
+ return $aRules;
+ }
+
+ /**
+ * Возвращает конкретное правило валидации
+ *
+ * @param string $sRule
+ *
+ * @return null|mixed
+ */
+ public function getValidateRuleOne($sRule)
+ {
+ $aData = $this->getValidateRules();
+ if (isset($aData[$sRule])) {
+ return $aData[$sRule];
+ }
+ return null;
+ }
+
+ /**
+ * Устанавливает правила валидации поля
+ *
+ * @param array $aRules
+ */
+ public function setValidateRules($aRules)
+ {
+ $this->_aData['validate_rules'] = @serialize($aRules);
+ }
+
+ /**
+ * Возвращает список дополнительных параметров поля
+ *
+ * @return array|mixed
+ */
+ public function getParams()
+ {
+ $aData = @unserialize($this->_getDataOne('params'));
+ if (!$aData) {
+ $aData = array();
+ }
+ return $aData;
+ }
+
+ /**
+ * Возвращает экранированный список параметров
+ *
+ * @return array
+ */
+ public function getParamsEscape()
+ {
+ $aParams = $this->getParams();
+ func_htmlspecialchars($aParams);
+ return $aParams;
+ }
+
+ /**
+ * Устанавливает список дополнительных параметров поля
+ *
+ * @param $aParams
+ */
+ public function setParams($aParams)
+ {
+ $this->_aData['params'] = @serialize($aParams);
+ }
+
+ /**
+ * Возвращает конкретный параметр поля
+ *
+ * @param $sName
+ *
+ * @return null
+ */
+ public function getParam($sName)
+ {
+ $aParams = $this->getParams();
+ return isset($aParams[$sName]) ? $aParams[$sName] : null;
+ }
+
+ /**
+ * Возвращает URL админки для редактирования поля
+ *
+ * @return string
+ */
+ public function getUrlAdminUpdate()
+ {
+ return Router::GetPath('admin/properties/' . $this->getTargetType() . '/update/' . $this->getId());
+ }
+
+ /**
+ * Возвращает URL админки для редактирования поля
+ *
+ * @return string
+ */
+ public function getUrlAdminRemove()
+ {
+ return Router::GetPath('admin/properties/' . $this->getTargetType() . '/remove/' . $this->getId());
+ }
+
+ /**
+ * Возвращает описание типа поля
+ *
+ * @return mixed
+ */
+ public function getTypeTitle()
+ {
+ /**
+ * TODO: использовать текстовку из языкового
+ */
+ return $this->getType();
+ }
+
+ public function getSaveFileDir($sPostfix = '')
+ {
+ $sPostfix = trim($sPostfix, '/');
+ return Config::Get('path.uploads.base') . '/property/' . $this->getTargetType() . '/' . $this->getType() . '/' . date('Y/m/d/H/') . ($sPostfix ? "{$sPostfix}/" : '');
+ }
+
+ public function isEmpty()
+ {
+ if (!$oValue = $this->getValue()) {
+ return true;
+ }
+ return $oValue->isEmpty();
+ }
+
+ public function getValueTypeObject()
+ {
+ if ($oValue = $this->getValue()) {
+ return $oValue->getValueTypeObject();
+ }
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/Select.entity.class.php b/application/classes/modules/property/entity/Select.entity.class.php
new file mode 100644
index 0000000..737749f
--- /dev/null
+++ b/application/classes/modules/property/entity/Select.entity.class.php
@@ -0,0 +1,35 @@
+
+ *
+ */
+
+/**
+ * Сущность дополнительного поля
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntitySelect extends EntityORM
+{
+
+ protected $aValidateRules = array();
+
+ protected $aRelations = array();
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/Target.entity.class.php b/application/classes/modules/property/entity/Target.entity.class.php
new file mode 100644
index 0000000..c848987
--- /dev/null
+++ b/application/classes/modules/property/entity/Target.entity.class.php
@@ -0,0 +1,83 @@
+
+ *
+ */
+
+/**
+ * Сущность связи поля с объектом
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityTarget extends EntityORM
+{
+
+ protected $aValidateRules = array();
+
+ protected $aRelations = array();
+
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ } else {
+ $this->setDateUpdate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+ /**
+ * Возвращает список дополнительных параметров
+ *
+ * @return array|mixed
+ */
+ public function getParams()
+ {
+ $aData = @unserialize($this->_getDataOne('params'));
+ if (!$aData) {
+ $aData = array();
+ }
+ return $aData;
+ }
+
+ /**
+ * Устанавливает список дополнительных параметров
+ *
+ * @param $aParams
+ */
+ public function setParams($aParams)
+ {
+ $this->_aData['params'] = @serialize($aParams);
+ }
+
+ /**
+ * Возвращает конкретный параметр
+ *
+ * @param $sName
+ *
+ * @return null
+ */
+ public function getParam($sName)
+ {
+ $aParams = $this->getParams();
+ return isset($aParams[$sName]) ? $aParams[$sName] : null;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/Value.entity.class.php b/application/classes/modules/property/entity/Value.entity.class.php
new file mode 100644
index 0000000..551c82b
--- /dev/null
+++ b/application/classes/modules/property/entity/Value.entity.class.php
@@ -0,0 +1,101 @@
+
+ *
+ */
+
+/**
+ * Сущность значения поля
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValue extends EntityORM
+{
+
+ protected $aRelations = array(
+ 'property' => array(self::RELATION_TYPE_BELONGS_TO, 'ModuleProperty_EntityProperty', 'property_id'),
+ );
+
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ $oValueType = $this->getValueTypeObject();
+ $oValueType->beforeSaveValue();
+ }
+ return $bResult;
+ }
+
+ public function getValueForDisplay()
+ {
+ $oValueType = $this->getValueTypeObject();
+ return $oValueType->getValueForDisplay();
+ }
+
+ public function isEmpty()
+ {
+ $oValueType = $this->getValueTypeObject();
+ return $oValueType->isEmpty();
+ }
+
+ public function getValueForForm()
+ {
+ $oValueType = $this->getValueTypeObject();
+ return $oValueType->getValueForForm();
+ }
+
+ public function getValueTypeObject()
+ {
+ if (!$this->_getDataOne('value_type_object')) {
+ $oObject = Engine::GetEntity('ModuleProperty_EntityValueType' . func_camelize($this->getPropertyType()));
+ $oObject->setValueObject($this);
+ $this->setValueTypeObject($oObject);
+ }
+ return $this->_getDataOne('value_type_object');
+ }
+
+ public function getData()
+ {
+ $aData = @unserialize($this->_getDataOne('data'));
+ if (!$aData) {
+ $aData = array();
+ }
+ return $aData;
+ }
+
+ public function setData($aRules)
+ {
+ $this->_aData['data'] = @serialize($aRules);
+ }
+
+ public function getDataOne($sKey)
+ {
+ $aData = $this->getData();
+ if (isset($aData[$sKey])) {
+ return $aData[$sKey];
+ }
+ return null;
+ }
+
+ public function setDataOne($sKey, $mValue)
+ {
+ $aData = $this->getData();
+ $aData[$sKey] = $mValue;
+ $this->setData($aData);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueSelect.entity.class.php b/application/classes/modules/property/entity/ValueSelect.entity.class.php
new file mode 100644
index 0000000..84ec590
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueSelect.entity.class.php
@@ -0,0 +1,33 @@
+
+ *
+ */
+
+/**
+ * Сущность значений поля типа Select
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueSelect extends EntityORM
+{
+
+ protected $aRelations = array();
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTag.entity.class.php b/application/classes/modules/property/entity/ValueTag.entity.class.php
new file mode 100644
index 0000000..1b450bd
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTag.entity.class.php
@@ -0,0 +1,33 @@
+
+ *
+ */
+
+/**
+ * Сущность значений поля типа Tag
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTag extends EntityORM
+{
+
+ protected $aRelations = array();
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueType.entity.class.php b/application/classes/modules/property/entity/ValueType.entity.class.php
new file mode 100644
index 0000000..8ad3dc5
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueType.entity.class.php
@@ -0,0 +1,144 @@
+
+ *
+ */
+
+/**
+ * Базовый объект значения поля
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueType extends Entity
+{
+
+ protected $oValue = null;
+
+ public function getValueForDisplay()
+ {
+ // TODO: getValue() всегда вернет null
+ return $this->getValueObject()->getValue();
+ }
+
+ public function getValueForForm()
+ {
+ return htmlspecialchars($this->getValueObject()->getValue());
+ }
+
+ public function isEmpty()
+ {
+ return $this->getValueObject()->getValueVarchar() ? false : true;
+ }
+
+ public function validate()
+ {
+ return 'Неверное значение';
+ }
+
+ protected function validateStandart(
+ $sTypeValidator,
+ $aParamsAdditional = array(),
+ $sFieldForValidate = 'value_for_validate'
+ ) {
+ $oProperty = $this->getValueObject()->getProperty();
+ /**
+ * Получаем параметры валидации
+ */
+ $aParams = $oProperty->getValidateRules();
+ if (!isset($aParams['label'])) {
+ $aParams['label'] = '';
+ }
+ $aParams = array_merge($aParams, $aParamsAdditional);
+
+ $oValidator = $this->Validate_CreateValidator($sTypeValidator, $this, null, $aParams);
+ $oValidator->fields = array($sFieldForValidate);
+ $oValidator->validateEntity($this);
+ if ($this->_hasValidateErrors()) {
+ return $this->_getValidateError();
+ } else {
+ return true;
+ }
+ }
+
+ public function setValue($mValue)
+ {
+ $this->resetAllValue();
+ }
+
+ public function setValueObject($oValue)
+ {
+ $this->oValue = $oValue;
+ }
+
+ public function getValueObject()
+ {
+ return $this->oValue;
+ }
+
+ public function resetAllValue()
+ {
+ $oValue = $this->getValueObject();
+ $oValue->setValueInt(null);
+ $oValue->setValueFloat(null);
+ $oValue->setValueVarchar(null);
+ $oValue->setValueText(null);
+ $oValue->setValueDate(null);
+ $oValue->setData(null);
+ /**
+ * Удаляем из таблицы тегов
+ */
+ $this->Property_RemoveValueTagsByTarget($oValue->getTargetType(), $oValue->getTargetId(),
+ $oValue->getPropertyId());
+ /**
+ * Удаляем из таблицы селектов
+ */
+ $this->Property_RemoveValueSelectsByTarget($oValue->getTargetType(), $oValue->getTargetId(),
+ $oValue->getPropertyId());
+ }
+
+ public function prepareValidateRulesRaw($aRulesRaw)
+ {
+ return array();
+ }
+
+ public function getValidateRulesDefault()
+ {
+ return array();
+ }
+
+ public function prepareParamsRaw($aParamsRaw)
+ {
+ return array();
+ }
+
+ public function getParamsDefault()
+ {
+ return array();
+ }
+
+ public function beforeSaveValue()
+ {
+
+ }
+
+ public function removeValue()
+ {
+
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTypeCheckbox.entity.class.php b/application/classes/modules/property/entity/ValueTypeCheckbox.entity.class.php
new file mode 100644
index 0000000..9e5da18
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTypeCheckbox.entity.class.php
@@ -0,0 +1,81 @@
+
+ *
+ */
+
+/**
+ * Объект управления типом checkbox
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTypeCheckbox extends ModuleProperty_EntityValueType
+{
+
+ public function getValueForDisplay()
+ {
+ return $this->getValueObject()->getValueInt() ? 'да' : 'нет';
+ }
+
+ public function getValueForForm()
+ {
+ $oValue = $this->getValueObject();
+ $oProperty = $oValue->getProperty();
+ return $oValue->_isNew() ? $oProperty->getParam('default') : $oValue->getValueInt();
+ }
+
+ public function isEmpty()
+ {
+ return false;
+ }
+
+ public function validate()
+ {
+ $sValue = $this->getValueForValidate();
+ $this->setValueForValidate($sValue ? 1 : 0);
+ return true;
+ }
+
+ public function setValue($mValue)
+ {
+ $this->resetAllValue();
+ $oValue = $this->getValueObject();
+ $oProperty = $oValue->getProperty();
+ $oValue->setValueInt($mValue ? $oProperty->getParam('default_value') : 0);
+ }
+
+ public function prepareParamsRaw($aParamsRaw)
+ {
+ $aParams = array();
+
+ $aParams['default'] = isset($aParamsRaw['default']) ? true : false;
+ if (isset($aParamsRaw['default_value'])) {
+ $aParams['default_value'] = htmlspecialchars($aParamsRaw['default_value']);
+ }
+
+ return $aParams;
+ }
+
+ public function getParamsDefault()
+ {
+ return array(
+ 'default_value' => 1,
+ );
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTypeDate.entity.class.php b/application/classes/modules/property/entity/ValueTypeDate.entity.class.php
new file mode 100644
index 0000000..824b5b6
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTypeDate.entity.class.php
@@ -0,0 +1,159 @@
+
+ *
+ */
+
+/**
+ * Объект управления типом date
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTypeDate extends ModuleProperty_EntityValueType
+{
+
+ protected $sFormatDateInput = 'dd.MM.yyyy';
+ protected $sFormatDateTimeInput = 'dd.MM.yyyy HH:mm';
+
+ public function getValueForDisplay()
+ {
+ $oValue = $this->getValueObject();
+ $oProperty = $oValue->getProperty();
+
+ return $oValue->getValueDate() ? $this->Viewer_GetDateFormat(strtotime($oValue->getValueDate()), $oProperty->getParam('format_out')) : '';
+ }
+
+ public function isEmpty()
+ {
+ return $this->getValueObject()->getValueDate() ? false : true;
+ }
+
+ public function getValueForForm()
+ {
+ $oValue = $this->getValueObject();
+ $sDate = $oValue->getValueDate();
+ $iTime = strtotime($sDate);
+ // TODO: нужен конвертор формата дат вида Y в yyyy для учета $this->sFormatDateInput
+ return $sDate ? date('d.m.Y', $iTime) : '';
+ }
+
+ public function getValueTimeForForm()
+ {
+ $oValue = $this->getValueObject();
+ $sDate = $oValue->getValueDate();
+ return $sDate ? date('H:i', strtotime($sDate)) : '';
+ }
+
+ public function validate()
+ {
+ /**
+ * Данные поступают ввиде массива array( 'date'=>'..', 'time' => '..' )
+ */
+ $aValue = $this->getValueForValidate();
+ $oValueObject = $this->getValueObject();
+ $oProperty = $oValueObject->getProperty();
+ $this->setValueForValidateDate(isset($aValue['date']) ? $aValue['date'] : '');
+ /**
+ * Формируем формат для валидации даты
+ * В инпуте дата идет в формате d.m.Y и плюс H:i если используется время
+ */
+ if ($oProperty->getParam('use_time')) {
+ $sFormatValidate = $this->sFormatDateTimeInput;
+ if (isset($aValue['time'])) {
+ $this->setValueForValidateDate($this->getValueForValidateDate() . ' ' . $aValue['time']);
+ }
+ } else {
+ $sFormatValidate = $this->sFormatDateInput;
+ }
+
+ $mRes = $this->validateStandart('date', array('format' => $sFormatValidate), 'value_for_validate_date');
+ if ($mRes === true) {
+ /**
+ * Формируем полную дату
+ */
+ if ($this->getValueForValidateDate()) {
+ $sTimeFull = strtotime($this->getValueForValidateDate());
+ /**
+ * Проверка на ограничение даты
+ */
+ if ($oProperty->getValidateRuleOne('disallowFuture')) {
+ if ($sTimeFull > time()) {
+ return "{$oProperty->getTitle()}: " . $this->Lang_Get('property.notices.validate_value_date_future');
+ }
+ }
+ /**
+ * Проверка на ограничения только если это новая запись, либо старая с изменениями
+ */
+ if ($oValueObject->_isNew() or strtotime($oValueObject->getValueDate()) != $sTimeFull) {
+ if ($oProperty->getValidateRuleOne('disallowPast')) {
+ if ($sTimeFull < time()) {
+ return "{$oProperty->getTitle()}: " . $this->Lang_Get('property.notices.validate_value_date_past');
+ }
+ }
+ }
+ } else {
+ $sTimeFull = null;
+ }
+ /**
+ * Переопределяем результирующее значение
+ */
+ $this->setValueForValidate($sTimeFull ? date('Y-m-d H:i:00', $sTimeFull) : null);
+ return true;
+ } else {
+ return $mRes;
+ }
+ }
+
+ public function setValue($mValue)
+ {
+ $this->resetAllValue();
+ $oValue = $this->getValueObject();
+ $oValue->setValueDate($mValue ? $mValue : null);
+ }
+
+ public function prepareValidateRulesRaw($aRulesRaw)
+ {
+ $aRules = array();
+ $aRules['allowEmpty'] = isset($aRulesRaw['allowEmpty']) ? false : true;
+ $aRules['disallowFuture'] = isset($aRulesRaw['disallowFuture']) ? true : false;
+ $aRules['disallowPast'] = isset($aRulesRaw['disallowPast']) ? true : false;
+
+ return $aRules;
+ }
+
+ public function prepareParamsRaw($aParamsRaw)
+ {
+ $aParams = array();
+ $aParams['use_time'] = isset($aParamsRaw['use_time']) ? true : false;
+
+ if (isset($aParamsRaw['format_out'])) {
+ $aParams['format_out'] = $aParamsRaw['format_out'];
+ }
+
+ return $aParams;
+ }
+
+ public function getParamsDefault()
+ {
+ return array(
+ 'format_out' => 'Y-m-d H:i',
+ 'use_time' => true,
+ );
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTypeFile.entity.class.php b/application/classes/modules/property/entity/ValueTypeFile.entity.class.php
new file mode 100644
index 0000000..f900687
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTypeFile.entity.class.php
@@ -0,0 +1,305 @@
+
+ *
+ */
+
+/**
+ * Объект управления типом file
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTypeFile extends ModuleProperty_EntityValueType
+{
+
+ public function getValueForDisplay()
+ {
+ return $this->getFileFullName();
+ }
+
+ public function isEmpty()
+ {
+ return $this->getFileFullName() ? false : true;
+ }
+
+ public function validate()
+ {
+ $oValue = $this->getValueObject();
+ $oProperty = $oValue->getProperty();
+ $iPropertyId = $oProperty->getId();
+
+ $bNeedRemove = false;
+ $mValue = $this->getValueForValidate();
+ if (isset($mValue['remove']) and $mValue['remove']) {
+ $bNeedRemove = true;
+ $this->setValueForValidate(array('remove' => true));
+ }
+
+ $sFileName = $this->_getValueFromFiles($iPropertyId, 'name');
+ $sFileTmpName = $this->_getValueFromFiles($iPropertyId, 'tmp_name');
+ $sFileError = $this->_getValueFromFiles($iPropertyId, 'error');
+ $sFileSize = $this->_getValueFromFiles($iPropertyId, 'size');
+
+ if (!$sFileTmpName) {
+ if ($oProperty->getValidateRuleOne('allowEmpty')) {
+ return true;
+ } elseif ($aFilePrev = $oValue->getDataOne('file') and isset($aFilePrev['path']) and !$bNeedRemove) {
+ return true;
+ } else {
+ return $this->Lang_Get('property.notices.validate_value_file_empty');
+ }
+ }
+ /**
+ * Проверяем на ошибки
+ */
+ if ($sFileError and $sFileError != UPLOAD_ERR_NO_FILE) {
+ return $this->Lang_Get('property.notices.validate_value_file_upload') . " - {$sFileError}";
+ }
+ /**
+ * На корректность загрузки
+ */
+ if (!$sFileName or !$sFileTmpName) {
+ return false;
+ }
+ /**
+ * На ограничение по размеру файла
+ */
+ if ($iSizeKb = $oProperty->getValidateRuleOne('size_max') and $iSizeKb * 1024 < $sFileSize) {
+ return $this->Lang_Get('property.notices.validate_value_file_size_max', array('size' => $iSizeKb));
+ }
+ /**
+ * На допустимые типы файлов
+ */
+ $aPath = pathinfo($sFileName);
+ if (!isset($aPath['extension']) or !$aPath['extension']) {
+ return false;
+ }
+ if ($aTypes = $oProperty->getParam('types') and !in_array($aPath['extension'], $aTypes)) {
+ return $this->Lang_Get('property.notices.validate_value_file_type', array('types' => join(', ', $aTypes)));
+ }
+ /**
+ * Пробрасываем данные по файлу
+ */
+ $this->setValueForValidate(array(
+ 'name' => $sFileName,
+ 'tmp_name' => $sFileTmpName,
+ 'error' => $sFileError,
+ 'size' => $sFileSize,
+ ));
+ return true;
+ }
+
+ protected function _getValueFromFiles($iId, $sName)
+ {
+ if (isset($_FILES['property'][$sName][$iId]['file'])) {
+ return $_FILES['property'][$sName][$iId]['file'];
+ }
+ return null;
+ }
+
+ /**
+ * Устанавливает значение после валидации конкретного поля, а не всех полей
+ * Поэтому здесь нельзя сохранять файл, это нужно делать в beforeSaveValue()
+ *
+ * @param $aValue
+ */
+ public function setValue($aValue)
+ {
+ $oValue = $this->getValueObject();
+ /**
+ * Просто пробрасываем данные
+ */
+ if ($aValue) {
+ $oValue->setDataOne('file_raw', $aValue);
+ }
+ }
+
+ /**
+ * Дополнительная обработка перед сохранением значения
+ * Здесь нужно выполнять основную загрузку файла
+ */
+ public function beforeSaveValue()
+ {
+ $oValue = $this->getValueObject();
+ $oProperty = $oValue->getProperty();
+ if (!$aFile = $oValue->getDataOne('file_raw')) {
+ return true;
+ }
+ $oValue->setDataOne('file_raw', null);
+ /**
+ * Удаляем предыдущий файл
+ */
+ if (isset($aFile['remove']) or isset($aFile['name'])) {
+ if ($aFilePrev = $oValue->getDataOne('file')) {
+ $this->RemoveFile($aFilePrev['path']);
+ $oValue->setDataOne('file', array());
+ $oValue->setValueVarchar(null);
+ }
+ }
+
+ if (isset($aFile['name'])) {
+ /**
+ * Выполняем загрузку файла
+ */
+ $aPathInfo = pathinfo($aFile['name']);
+ $sExtension = isset($aPathInfo['extension']) ? $aPathInfo['extension'] : 'unknown';
+ $sFileName = func_generator(20) . '.' . $sExtension;
+ /**
+ * Копируем загруженный файл
+ */
+ $sDirTmp = Config::Get('path.tmp.server') . '/property/';
+ if (!is_dir($sDirTmp)) {
+ @mkdir($sDirTmp, 0777, true);
+ }
+ $sFileTmp = $sDirTmp . $sFileName;
+
+ if (move_uploaded_file($aFile['tmp_name'], $sFileTmp)) {
+ $sDirSave = Config::Get('path.root.server') . $oProperty->getSaveFileDir();
+ if (!is_dir($sDirSave)) {
+ @mkdir($sDirSave, 0777, true);
+ }
+ $sFilePath = $sDirSave . $sFileName;
+ /**
+ * Сохраняем файл
+ */
+ if ($sFilePathNew = $this->SaveFile($sFileTmp, $sFilePath, null, true)) {
+ /**
+ * Сохраняем данные о файле
+ */
+ $oValue->setDataOne('file', array(
+ 'path' => $sFilePathNew,
+ 'size' => filesize($sFilePath),
+ 'name' => htmlspecialchars($aPathInfo['filename']),
+ 'extension' => htmlspecialchars($aPathInfo['extension']),
+ ));
+ /**
+ * Сохраняем уникальный ключ для доступа к файлу
+ */
+ $oValue->setValueVarchar(func_generator(32));
+ return true;
+ }
+ }
+ }
+ }
+
+ public function prepareValidateRulesRaw($aRulesRaw)
+ {
+ $aRules = array();
+ $aRules['allowEmpty'] = isset($aRulesRaw['allowEmpty']) ? false : true;
+
+ if (isset($aRulesRaw['size_max']) and is_numeric($aRulesRaw['size_max'])) {
+ $aRules['size_max'] = (int)$aRulesRaw['size_max'];
+ }
+ return $aRules;
+ }
+
+ public function prepareParamsRaw($aParamsRaw)
+ {
+ $aParams = array();
+
+ $aParams['types'] = array();
+ if (isset($aParamsRaw['types']) and is_array($aParamsRaw['types'])) {
+ foreach ($aParamsRaw['types'] as $sType) {
+ if ($sType) {
+ $aParams['types'][] = htmlspecialchars($sType);
+ }
+ }
+ }
+ $aParams['access_only_auth'] = isset($aParamsRaw['access_only_auth']) ? true : false;
+
+ return $aParams;
+ }
+
+ public function getParamsDefault()
+ {
+ return array(
+ 'types' => array(
+ 'zip'
+ ),
+ );
+ }
+
+ public function removeValue()
+ {
+ $oValue = $this->getValueObject();
+ /**
+ * Удаляем файл
+ */
+ if ($aFilePrev = $oValue->getDataOne('file')) {
+ $this->RemoveFile($aFilePrev['path']);
+ }
+ }
+
+ public function getFileFullName()
+ {
+ $oValue = $this->getValueObject();
+ if ($aFilePrev = $oValue->getDataOne('file')) {
+ return $aFilePrev['name'] . '.' . $aFilePrev['extension'];
+ }
+ return null;
+ }
+
+ public function getCountDownloads()
+ {
+ $aStats = $this->oValue->getDataOne('stats');
+ return isset($aStats['count_download']) ? $aStats['count_download'] : 0;
+ }
+
+ /**
+ * Сохраняет(копирует) файл на сервер
+ * Если переопределить данный метод, то можно сохранять файл, например, на Amazon S3
+ *
+ * @param string $sFileSource Полный путь до исходного файла
+ * @param string $sFileDest Полный путь до файла для сохранения с типом, например, [server]/home/var/site.ru/book.pdf
+ * @param int|null $iMode Права chmod для файла, например, 0777
+ * @param bool $bRemoveSource Удалять исходный файл или нет
+ * @return bool | string При успешном сохранении возвращает относительный путь до файла с типом, например, [relative]/image.jpg
+ */
+ protected function SaveFile($sFileSource, $sFileDest, $iMode = null, $bRemoveSource = false)
+ {
+ if ($this->Fs_SaveFileLocal($sFileSource, $this->Fs_GetPathServer($sFileDest), $iMode, $bRemoveSource)) {
+ return $this->Fs_MakePath($this->Fs_GetPathRelativeFromServer($sFileDest), ModuleFs::PATH_TYPE_RELATIVE);
+ }
+ return false;
+ }
+
+ /**
+ * Удаляет файл
+ * Если переопределить данный метод, то можно удалять файл, например, с Amazon S3
+ *
+ * @param string $sPathFile Полный путь до файла с типом, например, [relative]/book.pdf
+ *
+ * @return mixed
+ */
+ protected function RemoveFile($sPathFile)
+ {
+ $sPathFile = $this->Fs_GetPathServer($sPathFile);
+ return $this->Fs_RemoveFileLocal($sPathFile);
+ }
+
+ public function DownloadFile()
+ {
+ $oValue = $this->getValueObject();
+ if ($aFilePrev = $oValue->getDataOne('file')) {
+ $this->Tools_DownloadFile($this->Fs_GetPathServer($aFilePrev['path']),
+ $aFilePrev['name'] . '.' . $aFilePrev['extension'], $aFilePrev['size']);
+ }
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTypeFloat.entity.class.php b/application/classes/modules/property/entity/ValueTypeFloat.entity.class.php
new file mode 100644
index 0000000..1ff536e
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTypeFloat.entity.class.php
@@ -0,0 +1,84 @@
+
+ *
+ */
+
+/**
+ * Объект управления типом float
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTypeFloat extends ModuleProperty_EntityValueType
+{
+
+ public function getValueForDisplay()
+ {
+ return $this->getValueObject()->getValueFloat();
+ }
+
+ public function isEmpty()
+ {
+ return is_null($this->getValueObject()->getValueFloat()) ? true : false;
+ }
+
+ public function getValueForForm()
+ {
+ $oValue = $this->getValueObject();
+ $oProperty = $oValue->getProperty();
+ return $oValue->_isNew() ? $oProperty->getParam('default') : $oValue->getValueFloat();
+ }
+
+ public function validate()
+ {
+ return $this->validateStandart('number', array('integerOnly' => false));
+ }
+
+ public function setValue($mValue)
+ {
+ $this->resetAllValue();
+ $oValue = $this->getValueObject();
+ $oValue->setValueFloat($mValue ? $mValue : null);
+ }
+
+ public function prepareValidateRulesRaw($aRulesRaw)
+ {
+ $aRules = array();
+ $aRules['allowEmpty'] = isset($aRulesRaw['allowEmpty']) ? false : true;
+
+ if (isset($aRulesRaw['max']) and is_numeric($aRulesRaw['max'])) {
+ $aRules['max'] = (int)$aRulesRaw['max'];
+ }
+ if (isset($aRulesRaw['min']) and is_numeric($aRulesRaw['min'])) {
+ $aRules['min'] = (int)$aRulesRaw['min'];
+ }
+ return $aRules;
+ }
+
+ public function prepareParamsRaw($aParamsRaw)
+ {
+ $aParams = array();
+
+ if (isset($aParamsRaw['default'])) {
+ $aParams['default'] = htmlspecialchars($aParamsRaw['default']);
+ }
+
+ return $aParams;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTypeImage.entity.class.php b/application/classes/modules/property/entity/ValueTypeImage.entity.class.php
new file mode 100644
index 0000000..4b2191c
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTypeImage.entity.class.php
@@ -0,0 +1,231 @@
+
+ *
+ */
+
+/**
+ * Объект управления типом image
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTypeImage extends ModuleProperty_EntityValueTypeFile
+{
+
+ public function getValueForDisplay()
+ {
+ /**
+ * Показываем превью, в качестве изображения берем первый ресайз из списка размеров
+ */
+ if ($aFile = $this->oValue->getDataOne('file') and isset($aFile['path'])) {
+ return ' ';
+ }
+ return $this->getFileFullName();
+ }
+
+
+ public function getImageWebPath($sSize = null)
+ {
+ if ($aFile = $this->oValue->getDataOne('file') and isset($aFile['path'])) {
+ return $this->Media_GetImageWebPath($aFile['path'], $sSize);
+ }
+ return null;
+ }
+
+ public function getImageSizes()
+ {
+ return $this->oValue->getDataOne('image_sizes');
+ }
+
+ public function getImageSizeFirst()
+ {
+ if ($aSizes = $this->getImageSizes()) {
+ return array_shift($aSizes);
+ }
+ return null;
+ }
+
+ public function validate()
+ {
+ /**
+ * Выполняем стандартные проверки для типа "Файл"
+ */
+ $bRes = parent::validate();
+
+ $oProperty = $this->oValue->getProperty();
+
+ $aValue = $this->getValueForValidate();
+ if (isset($aValue['tmp_name'])) {
+ if (!$aImageInfo = (@getimagesize($aValue['tmp_name']))) {
+ return $this->Lang_Get('property.notices.validate_value_image_wrong');
+ }
+ /**
+ * Проверяем на максимальную ширину
+ */
+ if ($iWMax = $oProperty->getValidateRuleOne('width_max') and $iWMax < $aImageInfo[0]) {
+ return $this->Lang_Get('property.notices.validate_value_image_width_max', array('size' => $iWMax));
+ }
+ /**
+ * Проверяем на максимальную высоту
+ */
+ if ($iHMax = $oProperty->getValidateRuleOne('height_max') and $iHMax < $aImageInfo[1]) {
+ return $this->Lang_Get('property.notices.validate_value_image_height_max', array('size' => $iHMax));
+ }
+ }
+
+ return $bRes;
+ }
+
+
+ public function beforeSaveValue()
+ {
+ $oProperty = $this->oValue->getProperty();
+ if (!$aFile = $this->oValue->getDataOne('file_raw')) {
+ return true;
+ }
+ $this->oValue->setDataOne('file_raw', null);
+ /**
+ * Удаляем предыдущий файл
+ */
+ if (isset($aFile['remove']) or isset($aFile['name'])) {
+ if ($aFilePrev = $this->oValue->getDataOne('file')) {
+ $this->Media_RemoveImageBySizes($aFilePrev['path'], $this->oValue->getDataOne('image_sizes'), true);
+
+ $this->oValue->setDataOne('file', array());
+ $this->oValue->setDataOne('image_sizes', array());
+ $this->oValue->setValueVarchar(null);
+ }
+ }
+
+ if (isset($aFile['name'])) {
+ /**
+ * Выполняем загрузку файла
+ */
+ $aPathInfo = pathinfo($aFile['name']);
+ $sFileName = func_generator(20);
+ /**
+ * Копируем загруженный файл
+ */
+ $sDirTmp = Config::Get('path.tmp.server') . '/property/';
+ if (!is_dir($sDirTmp)) {
+ @mkdir($sDirTmp, 0777, true);
+ }
+ $sFileTmp = $sDirTmp . $sFileName;
+
+ if (move_uploaded_file($aFile['tmp_name'], $sFileTmp)) {
+ $aParams = $this->Image_BuildParams('property.' . $oProperty->getTargetType() . '.' . $oProperty->getType() . '.' . $oProperty->getCode());
+ /**
+ * Если объект изображения не создан, возвращаем ошибку
+ */
+ if ($oImage = $this->Image_Open($sFileTmp, $aParams)) {
+ $sPath = $oProperty->getSaveFileDir();
+ /**
+ * Сохраняем оригинальную копию
+ */
+ if ($sFileResult = $oImage->saveSmart($sPath, $sFileName)) {
+ /**
+ * Сохраняем данные о файле
+ */
+ $this->oValue->setDataOne('file', array(
+ 'path' => $sFileResult,
+ 'size' => filesize($sFileTmp),
+ 'name' => htmlspecialchars($aPathInfo['filename']),
+ 'extension' => htmlspecialchars($oImage->getFormat()),
+ ));
+ $aSizes = $oProperty->getParam('sizes');
+ /**
+ * Сохраняем размеры
+ */
+ $this->oValue->setDataOne('image_sizes', $aSizes);
+ /**
+ * Сохраняем уникальный ключ для доступа к файлу
+ */
+ $this->oValue->setValueVarchar(func_generator(32));
+ unset($oImage);
+ /**
+ * Генерируем ресайзы
+ */
+ $this->Media_GenerateImageBySizes($sFileTmp, $sPath, $sFileName, $aSizes, $aParams);
+
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ return true;
+ }
+ }
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ }
+ }
+ }
+
+
+ public function prepareValidateRulesRaw($aRulesRaw)
+ {
+ $aRules = array();
+ $aRules['allowEmpty'] = isset($aRulesRaw['allowEmpty']) ? false : true;
+
+ if (isset($aRulesRaw['size_max']) and is_numeric($aRulesRaw['size_max'])) {
+ $aRules['size_max'] = (int)$aRulesRaw['size_max'];
+ }
+ if (isset($aRulesRaw['width_max']) and is_numeric($aRulesRaw['width_max'])) {
+ $aRules['width_max'] = (int)$aRulesRaw['width_max'];
+ }
+ if (isset($aRulesRaw['height_max']) and is_numeric($aRulesRaw['height_max'])) {
+ $aRules['height_max'] = (int)$aRulesRaw['height_max'];
+ }
+ return $aRules;
+ }
+
+ public function prepareParamsRaw($aParamsRaw)
+ {
+ $aParams = array();
+
+ $aParams['sizes'] = array();
+ if (isset($aParamsRaw['sizes']) and is_array($aParamsRaw['sizes'])) {
+ foreach ($aParamsRaw['sizes'] as $sSize) {
+ if ($sSize and preg_match('#^(\d+)?(x)?(\d+)?([a-z]{2,10})?$#Ui', $sSize)) {
+ $aParams['sizes'][] = htmlspecialchars($sSize);
+ }
+ }
+ }
+ $aParams['types'] = array();
+ if (isset($aParamsRaw['types']) and is_array($aParamsRaw['types'])) {
+ foreach ($aParamsRaw['types'] as $sType) {
+ if ($sType) {
+ $aParams['types'][] = htmlspecialchars($sType);
+ }
+ }
+ }
+
+ return $aParams;
+ }
+
+ public function getParamsDefault()
+ {
+ return array(
+ 'sizes' => array(
+ '150x150crop'
+ ),
+ 'types' => array(
+ 'jpg',
+ 'jpeg',
+ 'gif',
+ 'png'
+ )
+ );
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTypeImageset.entity.class.php b/application/classes/modules/property/entity/ValueTypeImageset.entity.class.php
new file mode 100644
index 0000000..4478c5d
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTypeImageset.entity.class.php
@@ -0,0 +1,176 @@
+
+ *
+ */
+
+/**
+ * Объект управления типом imageset
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTypeImageset extends ModuleProperty_EntityValueType
+{
+ public function getValueForDisplay($aFilter = [])
+ {
+ $aMedia = $this->Media_GetMediaItemsByFilter(array_merge([
+ '#join' => [
+ "JOIN ".Config::Get('db.table.media_target')." as mt ON mt.media_id = t.id "
+ . "AND mt.target_type = 'imageset' AND mt.target_id = ?d" =>
+ [$this->oValue->getId()]
+ ]
+ ], $aFilter));
+ return $aMedia;
+ }
+
+ public function getMedia($aFilter = []) {
+ return $this->getValueForDisplay($aFilter);
+ }
+
+ public function isEmpty()
+ {
+ $aData = $this->oValue->getData();
+ return !sizeof($aData);
+ }
+
+ public function beforeSaveValue() {
+ $mValue = $this->getValueForValidate();
+
+ $aMediaTargets = $this->getMediaTargetsImageset($mValue, 'media_id');
+
+ $this->Media_DeleteTargetItemsByFilter( $this->getMediaTargetsFilter($mValue) );
+
+ $aMediaTargetIds = [];
+ foreach($aMediaTargets as $oMediaTarget){
+ $oMediaTarget->Add();
+ $aMediaTargetIds[] = $oMediaTarget->getId();
+ }
+
+ $this->oValue->setData( $aMediaTargetIds );
+
+ $this->Media_ReplaceTargetTmpById('imageset', $this->oValue->getId());
+
+ return true;
+ }
+
+ public function getValueForForm()
+ {
+ return $this->oValue->getId();
+ }
+
+ public function getImageSize()
+ {
+ return $this->getDataOne('size');
+ }
+
+ public function getMediaTargetsImageset($iTargetForm, $indexFrom = 'id') {
+
+ $aFilter = $this->getMediaTargetsFilter($iTargetForm);
+
+ $aFilter['#index-from'] = $indexFrom;
+
+ return $this->Media_GetTargetItemsByFilter($aFilter);
+ }
+
+ public function getMediaTargetsFilter($iTargetForm) {
+ $aFilter = [
+ 'target_type' => 'imageset'
+ ];
+ if( $this->oValue->_isNew() ){
+ $aFilter['#where'] = [
+ "(t.target_id = ?d OR t.target_tmp = ?d)" => [$iTargetForm, $iTargetForm]
+ ];
+ }else{
+ $aFilter['#where'] = [
+ "(t.target_id = ?d OR t.target_id = ?d OR t.target_tmp = ?d)" => [$this->oValue->getId(), $iTargetForm, $iTargetForm]
+ ];
+ }
+
+ return $aFilter;
+ }
+
+ public function validate()
+ {
+ $mValue = $this->getValueForValidate();
+
+ $oProperty = $this->oValue->getProperty();
+ if( !$mValue and !$oProperty->getValidateRuleOne('allowEmpty')){
+ return $this->Lang_Get('property.notices.validate_value_file_empty');
+ }
+
+ $aMediaTargets = $this->getMediaTargetsImageset($mValue);
+
+ if($iMin = $oProperty->getValidateRuleOne('count_min') and $iMin > sizeof($aMediaTargets)){
+ return $this->Lang_Get('property.notices.validate_value_select_min', array('count' => $iMin));
+ }
+
+ if($iMax = $oProperty->getValidateRuleOne('count_max') and $iMax < sizeof($aMediaTargets)){
+ return $this->Lang_Get('property.notices.validate_value_select_max', array('count' => $iMax));
+ }
+
+ $this->oValue->setData( array_keys($aMediaTargets) );
+
+
+ return true;
+ }
+
+
+ public function prepareValidateRulesRaw($aRulesRaw)
+ {
+ $aRules = array();
+ $aRules['allowEmpty'] = isset($aRulesRaw['allowEmpty']) ? false : true;
+
+ if (isset($aRulesRaw['count_max']) and is_numeric($aRulesRaw['count_max'])) {
+ $aRules['count_max'] = (int)$aRulesRaw['count_max'];
+ }
+ if (isset($aRulesRaw['count_min']) and is_numeric($aRulesRaw['count_min'])) {
+ $aRules['count_min'] = (int)$aRulesRaw['count_min'];
+ }
+ return $aRules;
+ }
+
+ public function prepareParamsRaw($aParamsRaw)
+ {
+ $aParams = array();
+
+ if (isset($aParamsRaw['count_min'])) {
+ $aParams['count_min'] = htmlspecialchars($aParamsRaw['count_min']);
+ }
+
+ if (isset($aParamsRaw['count_max'])) {
+ $aParams['count_max'] = htmlspecialchars($aParamsRaw['count_max']);
+ }
+
+ if (isset($aParamsRaw['size']) and preg_match('#^(\d+)?(x)?(\d+)?([a-z]{2,10})?$#Ui', $aParamsRaw['size'])) {
+ $aParams['size'] = htmlspecialchars($aParamsRaw['size']);
+ }
+
+ return $aParams;
+ }
+
+ public function getParamsDefault()
+ {
+ return array(
+ 'count_min' => 1,
+ 'count_max' => 10,
+ 'size' => '100x100crop'
+ );
+ }
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTypeInt.entity.class.php b/application/classes/modules/property/entity/ValueTypeInt.entity.class.php
new file mode 100644
index 0000000..016668a
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTypeInt.entity.class.php
@@ -0,0 +1,84 @@
+
+ *
+ */
+
+/**
+ * Объект управления типом int
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTypeInt extends ModuleProperty_EntityValueType
+{
+
+ public function getValueForDisplay()
+ {
+ return $this->getValueObject()->getValueInt();
+ }
+
+ public function isEmpty()
+ {
+ return is_null($this->getValueObject()->getValueInt()) ? true : false;
+ }
+
+ public function getValueForForm()
+ {
+ $oValue = $this->getValueObject();
+ $oProperty = $oValue->getProperty();
+ return $oValue->_isNew() ? $oProperty->getParam('default') : $oValue->getValueInt();
+ }
+
+ public function validate()
+ {
+ return $this->validateStandart('number', array('integerOnly' => true));
+ }
+
+ public function setValue($mValue)
+ {
+ $this->resetAllValue();
+ $oValue = $this->getValueObject();
+ $oValue->setValueInt($mValue ? $mValue : null);
+ }
+
+ public function prepareValidateRulesRaw($aRulesRaw)
+ {
+ $aRules = array();
+ $aRules['allowEmpty'] = isset($aRulesRaw['allowEmpty']) ? false : true;
+
+ if (isset($aRulesRaw['max']) and is_numeric($aRulesRaw['max'])) {
+ $aRules['max'] = (int)$aRulesRaw['max'];
+ }
+ if (isset($aRulesRaw['min']) and is_numeric($aRulesRaw['min'])) {
+ $aRules['min'] = (int)$aRulesRaw['min'];
+ }
+ return $aRules;
+ }
+
+ public function prepareParamsRaw($aParamsRaw)
+ {
+ $aParams = array();
+
+ if (isset($aParamsRaw['default'])) {
+ $aParams['default'] = htmlspecialchars($aParamsRaw['default']);
+ }
+
+ return $aParams;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTypeSelect.entity.class.php b/application/classes/modules/property/entity/ValueTypeSelect.entity.class.php
new file mode 100644
index 0000000..515d373
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTypeSelect.entity.class.php
@@ -0,0 +1,180 @@
+
+ *
+ */
+
+/**
+ * Объект управления типом select
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTypeSelect extends ModuleProperty_EntityValueType
+{
+
+ public function getValueForDisplay()
+ {
+ $oValue = $this->getValueObject();
+ $aValues = $oValue->getDataOne('values');
+ return is_array($aValues) ? join(', ', $aValues) : '';
+ }
+
+ public function isEmpty()
+ {
+ $oValue = $this->getValueObject();
+ $aValues = $oValue->getDataOne('values');
+ return $aValues ? false : true;
+ }
+
+ public function getValueForForm()
+ {
+ $oValue = $this->getValueObject();
+ $aValues = $oValue->getDataOne('values');
+ return $aValues;
+ }
+
+ public function validate()
+ {
+ $oProperty = $this->getValueObject()->getProperty();
+
+ $iValue = $this->getValueForValidate();
+ if (is_array($iValue)) {
+ $iValue = array_filter($iValue);
+ }
+ if (!$iValue and $oProperty->getValidateRuleOne('allowEmpty')) {
+ return true;
+ }
+ if (is_array($iValue)) {
+ if ($oProperty->getValidateRuleOne('allowMany')) {
+ if ($oProperty->getValidateRuleOne('max') and count($iValue) > $oProperty->getValidateRuleOne('max')) {
+ return $this->Lang_Get('property.notices.validate_value_select_max', array('count' => $oProperty->getValidateRuleOne('max')));
+ }
+ if ($oProperty->getValidateRuleOne('min') and count($iValue) < $oProperty->getValidateRuleOne('min')) {
+ return $this->Lang_Get('property.notices.validate_value_select_min', array('count' => $oProperty->getValidateRuleOne('min')));
+ }
+ /**
+ * Для безопасности
+ */
+ $aValues = array();
+ foreach ($iValue as $iV) {
+ $aValues[] = (int)$iV;
+ }
+ if (count($aValues) == count($this->Property_GetSelectItemsByFilter(array(
+ 'property_id' => $oProperty->getId(),
+ 'id in' => $aValues
+ )))
+ ) {
+ $this->setValueForValidate($aValues);
+ return true;
+ } else {
+ return $this->Lang_Get('property.notices.validate_value_select_wrong');
+ }
+ } elseif (count($iValue) == 1) {
+ $iValue = (int)reset($iValue);
+ } else {
+ return $this->Lang_Get('property.notices.validate_value_select_only_one');
+ }
+ }
+ /**
+ * Проверяем значение
+ */
+ if ($oSelect = $this->Property_GetSelectByIdAndPropertyId($iValue, $oProperty->getId())) {
+ return true;
+ }
+ return 'Необходимо выбрать значение';
+ }
+
+ public function setValue($mValue)
+ {
+ $this->resetAllValue();
+ $oValue = $this->getValueObject();
+ $oProperty = $oValue->getProperty();
+
+ $aValues = array();
+ /**
+ * Сохраняем с data, т.к. может быть множественный выбор
+ */
+ if ($mValue) {
+ if (is_array($mValue)) {
+ $aSelectItems = $this->Property_GetSelectItemsByFilter(array(
+ 'property_id' => $oProperty->getId(),
+ 'id in' => $mValue
+ ));
+ foreach ($aSelectItems as $oSelect) {
+ $aValues[$oSelect->getId()] = $oSelect->getValue();
+ }
+ } else {
+ if ($oSelect = $this->Property_GetSelectByIdAndPropertyId($mValue, $oProperty->getId())) {
+ $aValues[$oSelect->getId()] = $oSelect->getValue();
+ }
+ }
+ }
+ $oValue->setData($aValues ? array('values' => $aValues) : array());
+ }
+
+ /**
+ * Дополнительная обработка перед сохранением значения
+ */
+ public function beforeSaveValue()
+ {
+ $oValue = $this->getValueObject();
+ if ($aValues = $oValue->getData()) {
+ foreach ($aValues['values'] as $k => $v) {
+ $oSelect = Engine::GetEntity('ModuleProperty_EntityValueSelect');
+ $oSelect->setPropertyId($oValue->getPropertyId());
+ $oSelect->setTargetType($oValue->getTargetType());
+ $oSelect->setTargetId($oValue->getTargetId());
+ $oSelect->setSelectId($k);
+ $oSelect->Add();
+ }
+ }
+ }
+
+ public function prepareValidateRulesRaw($aRulesRaw)
+ {
+ $aRules = array();
+ $aRules['allowEmpty'] = isset($aRulesRaw['allowEmpty']) ? false : true;
+ $aRules['allowMany'] = isset($aRulesRaw['allowMany']) ? true : false;
+
+ if (isset($aRulesRaw['max']) and is_numeric($aRulesRaw['max'])) {
+ $aRules['max'] = (int)$aRulesRaw['max'];
+ }
+ if (isset($aRulesRaw['min']) and is_numeric($aRulesRaw['min'])) {
+ $aRules['min'] = (int)$aRulesRaw['min'];
+ }
+ return $aRules;
+ }
+
+ public function removeValue()
+ {
+ $oValue = $this->getValueObject();
+ /**
+ * Удаляем значения select'а из дополнительной таблицы
+ */
+ if ($aSelects = $this->Property_GetValueSelectItemsByFilter(array(
+ 'property_id' => $oValue->getPropertyId(),
+ 'target_id' => $oValue->getTargetId()
+ ))
+ ) {
+ foreach ($aSelects as $oSelect) {
+ $oSelect->Delete();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTypeTags.entity.class.php b/application/classes/modules/property/entity/ValueTypeTags.entity.class.php
new file mode 100644
index 0000000..d7d6085
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTypeTags.entity.class.php
@@ -0,0 +1,108 @@
+
+ *
+ */
+
+/**
+ * Объект управления типом tags
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTypeTags extends ModuleProperty_EntityValueType
+{
+
+ public function getValueForDisplay()
+ {
+ return htmlspecialchars($this->getValueObject()->getValueVarchar());
+ }
+
+ public function validate()
+ {
+ return $this->validateStandart('tags');
+ }
+
+ public function setValue($mValue)
+ {
+ $this->resetAllValue();
+ $oValue = $this->getValueObject();
+ $oValue->setValueVarchar($mValue ? $mValue : null);
+ }
+
+ /**
+ * Дополнительная обработка перед сохранением значения
+ */
+ public function beforeSaveValue()
+ {
+ /**
+ * Заливаем теги в отдельную таблицу
+ */
+ if ($aTags = $this->getTagsArray()) {
+ $oValue = $this->getValueObject();
+ foreach ($aTags as $sTag) {
+ $oTag = Engine::GetEntity('ModuleProperty_EntityValueTag');
+ $oTag->setPropertyId($oValue->getPropertyId());
+ $oTag->setTargetType($oValue->getTargetType());
+ $oTag->setTargetId($oValue->getTargetId());
+ $oTag->setText($sTag);
+ $oTag->Add();
+ }
+ }
+ }
+
+ public function getTagsArray()
+ {
+ $sTags = $this->getValueObject()->getValueVarchar();
+ if ($sTags) {
+ return explode(',', $sTags);
+ }
+ return array();
+ }
+
+ public function prepareValidateRulesRaw($aRulesRaw)
+ {
+ $aRules = array();
+ $aRules['allowEmpty'] = isset($aRulesRaw['allowEmpty']) ? false : true;
+
+ if (isset($aRulesRaw['countMax']) and ($iCount = (int)$aRulesRaw['countMax']) > 0) {
+ $aRules['countMax'] = $iCount;
+ }
+ if (isset($aRulesRaw['countMin']) and ($iCount = (int)$aRulesRaw['countMin']) > 0) {
+ $aRules['countMin'] = $iCount;
+ }
+ return $aRules;
+ }
+
+ public function removeValue()
+ {
+ $oValue = $this->getValueObject();
+ /**
+ * Удаляем теги из дополнительной таблицы
+ */
+ if ($aTags = $this->Property_GetValueTagItemsByFilter(array(
+ 'property_id' => $oValue->getPropertyId(),
+ 'target_id' => $oValue->getTargetId()
+ ))
+ ) {
+ foreach ($aTags as $oTag) {
+ $oTag->Delete();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTypeText.entity.class.php b/application/classes/modules/property/entity/ValueTypeText.entity.class.php
new file mode 100644
index 0000000..5f571d6
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTypeText.entity.class.php
@@ -0,0 +1,88 @@
+
+ *
+ */
+
+/**
+ * Объект управления типом text
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTypeText extends ModuleProperty_EntityValueType
+{
+
+ public function getValueForDisplay()
+ {
+ return $this->getValueObject()->getValueText();
+ }
+
+ public function isEmpty()
+ {
+ return $this->getValueObject()->getValueText() ? false : true;
+ }
+
+ public function getValueForForm()
+ {
+ $oValue = $this->getValueObject();
+ return htmlspecialchars($oValue->getDataOne('text_source'));
+ }
+
+ public function validate()
+ {
+ return $this->validateStandart('string');
+ }
+
+ public function setValue($mValue)
+ {
+ $this->resetAllValue();
+ $oValue = $this->getValueObject();
+ $oProperty = $oValue->getProperty();
+
+ $oValue->setDataOne('text_source', $mValue);
+ if ($oProperty->getParam('use_html')) {
+ $mValue = $this->Text_Parser($mValue);
+ } else {
+ $mValue = htmlspecialchars($mValue);
+ }
+ $oValue->setValueText($mValue ? $mValue : null);
+ }
+
+ public function prepareValidateRulesRaw($aRulesRaw)
+ {
+ $aRules = array();
+ $aRules['allowEmpty'] = isset($aRulesRaw['allowEmpty']) ? false : true;
+
+ if (isset($aRulesRaw['max']) and is_numeric($aRulesRaw['max'])) {
+ $aRules['max'] = (int)$aRulesRaw['max'];
+ }
+ if (isset($aRulesRaw['min']) and is_numeric($aRulesRaw['min'])) {
+ $aRules['min'] = (int)$aRulesRaw['min'];
+ }
+ return $aRules;
+ }
+
+ public function prepareParamsRaw($aParamsRaw)
+ {
+ $aParams = array();
+ $aParams['use_html'] = isset($aParamsRaw['use_html']) ? true : false;
+
+ return $aParams;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTypeVarchar.entity.class.php b/application/classes/modules/property/entity/ValueTypeVarchar.entity.class.php
new file mode 100644
index 0000000..021e89f
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTypeVarchar.entity.class.php
@@ -0,0 +1,79 @@
+
+ *
+ */
+
+/**
+ * Объект управления типом varchar
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTypeVarchar extends ModuleProperty_EntityValueType
+{
+
+ public function getValueForDisplay()
+ {
+ return $this->getValueObject()->getValueVarchar();
+ }
+
+ public function getValueForForm()
+ {
+ $oValue = $this->getValueObject();
+ $oProperty = $oValue->getProperty();
+ return $oValue->_isNew() ? $oProperty->getParam('default') : $oValue->getValueVarchar();
+ }
+
+ public function validate()
+ {
+ return $this->validateStandart('string');
+ }
+
+ public function setValue($mValue)
+ {
+ $this->resetAllValue();
+ $oValue = $this->getValueObject();
+ $oValue->setValueVarchar($mValue ? htmlspecialchars($mValue) : null);
+ }
+
+ public function prepareValidateRulesRaw($aRulesRaw)
+ {
+ $aRules = array();
+ $aRules['allowEmpty'] = isset($aRulesRaw['allowEmpty']) ? false : true;
+
+ if (isset($aRulesRaw['max']) and is_numeric($aRulesRaw['max'])) {
+ $aRules['max'] = (int)$aRulesRaw['max'];
+ }
+ if (isset($aRulesRaw['min']) and is_numeric($aRulesRaw['min'])) {
+ $aRules['min'] = (int)$aRulesRaw['min'];
+ }
+ return $aRules;
+ }
+
+ public function prepareParamsRaw($aParamsRaw)
+ {
+ $aParams = array();
+
+ if (isset($aParamsRaw['default'])) {
+ $aParams['default'] = htmlspecialchars($aParamsRaw['default']);
+ }
+
+ return $aParams;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/entity/ValueTypeVideoLink.entity.class.php b/application/classes/modules/property/entity/ValueTypeVideoLink.entity.class.php
new file mode 100644
index 0000000..150133e
--- /dev/null
+++ b/application/classes/modules/property/entity/ValueTypeVideoLink.entity.class.php
@@ -0,0 +1,212 @@
+
+ *
+ */
+
+/**
+ * Объект управления типом video link
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_EntityValueTypeVideoLink extends ModuleProperty_EntityValueType
+{
+
+ const VIDEO_PROVIDER_YOUTUBE = 'youtube';
+ const VIDEO_PROVIDER_VIMEO = 'vimeo';
+ const VIDEO_PROVIDER_RUTUBE = 'rutube';
+
+ public function getValueForDisplay()
+ {
+ return $this->getVideoCodeFrame();
+ }
+
+ public function validate()
+ {
+ $mRes = $this->validateStandart('url', array('defaultScheme' => 'http'));
+ if ($mRes === true) {
+ /**
+ * Теперь проверяем на принадлежность к разным видео-хостингам
+ */
+ if ($this->getValueForValidate() and !$this->checkVideo($this->getValueForValidate())) {
+ return $this->Lang_Get('property.notices.validate_value_video_wrong');
+ }
+ return true;
+ } else {
+ return $mRes;
+ }
+ }
+
+ public function prepareValidateRulesRaw($aRulesRaw)
+ {
+ $aRules = array();
+ $aRules['allowEmpty'] = isset($aRulesRaw['allowEmpty']) ? false : true;
+ return $aRules;
+ }
+
+ public function setValue($mValue)
+ {
+ $this->resetAllValue();
+ $oValue = $this->getValueObject();
+ $oValue->setValueVarchar($mValue ? $mValue : null);
+ /**
+ * Получаем и сохраняем ссылку на превью
+ */
+ $this->retrievePreview($oValue);
+ }
+
+ protected function retrievePreview($oValue)
+ {
+ $sLink = $oValue->getValueVarchar();
+ $sProvider = $this->getVideoProvider($sLink);
+ $sId = $this->getVideoId($sLink);
+ if ($sProvider == self::VIDEO_PROVIDER_YOUTUBE) {
+ $oValue->setDataOne('preview_small', "http://img.youtube.com/vi/{$sId}/default.jpg");
+ $oValue->setDataOne('preview_normal', "http://img.youtube.com/vi/{$sId}/0.jpg");
+ } elseif ($sProvider == self::VIDEO_PROVIDER_VIMEO) {
+ $aRetrieveData = @json_decode(file_get_contents("http://vimeo.com/api/v2/video/{$sId}.json"), true);
+ if (isset($aRetrieveData[0]['thumbnail_medium'])) {
+ $oValue->setDataOne('preview_small', $aRetrieveData[0]['thumbnail_medium']);
+ $oValue->setDataOne('preview_normal', $aRetrieveData[0]['thumbnail_large']);
+ }
+ } elseif ($sProvider == self::VIDEO_PROVIDER_RUTUBE) {
+ $aRetrieveData = @json_decode(file_get_contents("http://rutube.ru/api/video/{$sId}/?format=json"), true);
+ if (isset($aRetrieveData['thumbnail_url'])) {
+ $oValue->setDataOne('preview_small', $aRetrieveData['thumbnail_url'] . '?size=s');
+ $oValue->setDataOne('preview_normal', $aRetrieveData['thumbnail_url']);
+ }
+ }
+ }
+
+ public function checkVideo($sLink)
+ {
+ return $this->getVideoId($sLink) ? true : false;
+ }
+
+ public function getVideoId($sLink = null)
+ {
+ if (is_null($sLink)) {
+ $sLink = $this->getValueObject()->getValueVarchar();
+ }
+ $sProvider = $this->getVideoProvider($sLink);
+ /**
+ * youtube
+ * http://www.youtube.com/watch?v=LZaCb5Y9SyM
+ * http://youtu.be/LZaCb5Y9SyM
+ */
+ if ($sProvider == self::VIDEO_PROVIDER_YOUTUBE) {
+ if (preg_match("#(?<=v=)[a-zA-Z0-9-]+(?=&)|(?<=v\/)[^&\n]+|(?<=v=)[^&\n]+|(?<=youtu.be/)[^&\n]+#", $sLink,
+ $aMatch)) {
+ return $aMatch[0];
+ }
+ }
+ /**
+ * vimeo
+ * http://vimeo.com/72359144
+ */
+ if ($sProvider == self::VIDEO_PROVIDER_VIMEO) {
+ return substr(parse_url($sLink, PHP_URL_PATH), 1);
+ }
+ /**
+ * rutube
+ * http://rutube.ru/video/ee523c9164c8f9fc8b267c66a0a3adae/
+ * http://rutube.ru/video/6fd81c1c212c002673280850a1c56415/#.UMQYln9yTWQ
+ * http://rutube.ru/tracks/6032725.html
+ * http://rutube.ru/video/embed/6032725
+ */
+ if ($sProvider == self::VIDEO_PROVIDER_RUTUBE) {
+ if (preg_match('/(?:http|https)+:\/\/(?:www\.|)rutube\.ru\/video\/embed\/([a-zA-Z0-9_\-]+)/i', $sLink,
+ $aMatch) || preg_match('/(?:http|https)+:\/\/(?:www\.|)rutube\.ru\/(?:tracks|video)\/([a-zA-Z0-9_\-]+)(&.+)?/i',
+ $sLink, $aMatch)
+ ) {
+ return $aMatch[1];
+ }
+ }
+ return null;
+ }
+
+ public function getVideoProvider($sLink)
+ {
+ if (preg_match("#(youtube\.)|(youtu\.be)#i", $sLink)) {
+ return self::VIDEO_PROVIDER_YOUTUBE;
+ }
+ if (preg_match("#(vimeo\.)#i", $sLink)) {
+ return self::VIDEO_PROVIDER_VIMEO;
+ }
+ if (preg_match("#(rutube\.ru)#i", $sLink)) {
+ return self::VIDEO_PROVIDER_RUTUBE;
+ }
+ return null;
+ }
+
+ public function getVideoCodeFrame()
+ {
+ $sLink = $this->getValueObject()->getValueVarchar();
+ $sProvider = $this->getVideoProvider($sLink);
+ $sId = $this->getVideoId($sLink);
+ if ($sProvider == self::VIDEO_PROVIDER_YOUTUBE) {
+ return '
+
+ ';
+ } elseif ($sProvider == self::VIDEO_PROVIDER_VIMEO) {
+ return '
+
+ ';
+ } elseif ($sProvider == self::VIDEO_PROVIDER_RUTUBE) {
+ return '
+
+ ';
+ }
+ return '';
+ }
+
+ public function getPreview($sType = 'small')
+ {
+ $oValue = $this->getValueObject();
+ return $oValue->getDataOne("preview_{$sType}");
+ }
+
+ public function getCountView()
+ {
+ $oValue = $this->getValueObject();
+ $sLink = $oValue->getValueVarchar();
+ $sProvider = $this->getVideoProvider($sLink);
+ $sId = $this->getVideoId($sLink);
+ if ($sProvider == self::VIDEO_PROVIDER_YOUTUBE) {
+ $iCount = (int)$oValue->getDataOne("count_view");
+ $iCountViewLastTime = (int)$oValue->getDataOne("count_view_last_time");
+ if (time() - $iCountViewLastTime > 60 * 60 * 1) {
+ $aData = @json_decode(file_get_contents("https://gdata.youtube.com/feeds/api/videos/{$sId}?v=2&alt=json"),
+ true);
+ if (isset($aData['entry']['yt$statistics']['viewCount'])) {
+ $iCount = $aData['entry']['yt$statistics']['viewCount'];
+ }
+ $oValue->setDataOne("count_view", $iCount);
+ $oValue->setDataOne("count_view_last_time", time());
+ $oValue->Update();
+ }
+ return $iCount;
+ } elseif ($sProvider == self::VIDEO_PROVIDER_VIMEO) {
+
+ } elseif ($sProvider == self::VIDEO_PROVIDER_RUTUBE) {
+
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/property/mapper/Property.mapper.class.php b/application/classes/modules/property/mapper/Property.mapper.class.php
new file mode 100644
index 0000000..76abe22
--- /dev/null
+++ b/application/classes/modules/property/mapper/Property.mapper.class.php
@@ -0,0 +1,364 @@
+
+ *
+ */
+
+/**
+ * Маппер для работы с БД
+ *
+ * @package application.modules.property
+ * @since 2.0
+ */
+class ModuleProperty_MapperProperty extends Mapper
+{
+
+ public function GetPropertiesValueByTarget($sTargetType, $iTargetId)
+ {
+ $sql = "SELECT
+ v.*,
+ p.id as prop_id,
+ p.target_type as prop_target_type,
+ p.type as prop_type ,
+ p.code as prop_code,
+ p.title as prop_title,
+ p.date_create as prop_date_create,
+ p.sort as prop_sort,
+ p.params as prop_params
+ FROM " . Config::Get('db.table.property') . " AS p
+ LEFT JOIN " . Config::Get('db.table.property_value') . " as v on ( v.property_id=p.id and v.target_id = ?d )
+ WHERE
+ p.target_type = ?
+ ORDER BY
+ p.sort desc
+ limit 0,100";
+
+ $aResult = array();
+ if ($aRows = $this->oDb->select($sql, $iTargetId, $sTargetType)) {
+ foreach ($aRows as $aRow) {
+ $aProperty = array();
+ $aValue = array();
+ foreach ($aRow as $k => $v) {
+ if (strpos($k, 'prop_') === 0) {
+ $aProperty[str_replace('prop_', '', $k)] = $v;
+ } else {
+ $aValue[$k] = $v;
+ }
+ }
+ $oProperty = Engine::GetEntity('ModuleProperty_EntityProperty', $aProperty);
+ /**
+ * На случай, если нет еще значения свойства в БД
+ */
+ $aValue['property_id'] = $oProperty->getId();
+ $aValue['property_type'] = $oProperty->getType();
+ $aValue['target_type'] = $sTargetType;
+ $aValue['target_id'] = $iTargetId;
+ $oProperty->setValue(Engine::GetEntity('ModuleProperty_EntityValue', $aValue));
+ $aResult[$oProperty->getId()] = $oProperty;
+ }
+ }
+ return $aResult;
+ }
+
+ public function GetPropertiesValueByTargetArray($aTargetType, $aTargetId)
+ {
+ if (!is_array($aTargetId)) {
+ $aTargetId = array($aTargetId);
+ }
+ if (!is_array($aTargetType)) {
+ $aTargetType = array($aTargetType);
+ }
+ if (!$aTargetId) {
+ return array();
+ }
+ $sql = "SELECT
+ v.*,
+ p.id as prop_id,
+ p.target_type as prop_target_type,
+ p.type as prop_type ,
+ p.code as prop_code,
+ p.title as prop_title,
+ p.date_create as prop_date_create,
+ p.sort as prop_sort,
+ p.params as prop_params
+ FROM " . Config::Get('db.table.property') . " AS p
+ LEFT JOIN " . Config::Get('db.table.property_value') . " as v on ( v.property_id=p.id and v.target_id IN ( ?a ) )
+ WHERE
+ p.target_type IN ( ?a )
+ ORDER BY
+ p.sort desc ";
+
+ $aResult = array();
+ if ($aRows = $this->oDb->select($sql, $aTargetId, $aTargetType)) {
+ return $aRows;
+ }
+ return $aResult;
+ }
+
+ public function RemoveValueTagsByTarget($sTargetType, $iTargetId, $iPropertyId)
+ {
+ $sql = "DELETE
+ FROM " . Config::Get('db.table.property_value_tag') . "
+ WHERE
+ target_id = ?d
+ and
+ target_type = ?
+ and
+ property_id = ?d
+ ";
+ if ($this->oDb->query($sql, $iTargetId, $sTargetType, $iPropertyId) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ public function RemoveValueSelectsByTarget($sTargetType, $iTargetId, $iPropertyId)
+ {
+ $sql = "DELETE
+ FROM " . Config::Get('db.table.property_value_select') . "
+ WHERE
+ target_id = ?d
+ and
+ target_type = ?
+ and
+ property_id = ?d
+ ";
+ if ($this->oDb->query($sql, $iTargetId, $sTargetType, $iPropertyId) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ public function RemoveValueByPropertyId($iPropertyId)
+ {
+ $sql = "DELETE
+ FROM " . Config::Get('db.table.property_value') . "
+ WHERE
+ property_id = ?d
+ ";
+ if ($this->oDb->query($sql, $iPropertyId) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ public function RemoveValueTagByPropertyId($iPropertyId)
+ {
+ $sql = "DELETE
+ FROM " . Config::Get('db.table.property_value_tag') . "
+ WHERE
+ property_id = ?d
+ ";
+ if ($this->oDb->query($sql, $iPropertyId) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ public function RemoveValueSelectByPropertyId($iPropertyId)
+ {
+ $sql = "DELETE
+ FROM " . Config::Get('db.table.property_value_select') . "
+ WHERE
+ property_id = ?d
+ ";
+ if ($this->oDb->query($sql, $iPropertyId) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ public function RemoveSelectByPropertyId($iPropertyId)
+ {
+ $sql = "DELETE
+ FROM " . Config::Get('db.table.property_select') . "
+ WHERE
+ property_id = ?d
+ ";
+ if ($this->oDb->query($sql, $iPropertyId) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ public function GetPropertyTagsByLike($sTag, $iPropertyId, $iLimit)
+ {
+ $sTag = mb_strtolower($sTag, "UTF-8");
+ $sql = "SELECT
+ text
+ FROM
+ " . Config::Get('db.table.property_value_tag') . "
+ WHERE
+ property_id = ?d and text LIKE ?
+ GROUP BY
+ text
+ LIMIT 0, ?d
+ ";
+ $aReturn = array();
+ if ($aRows = $this->oDb->select($sql, $iPropertyId, $sTag . '%', $iLimit)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = Engine::GetEntity('ModuleProperty_EntityValueTag', $aRow);
+ }
+ }
+ return $aReturn;
+ }
+
+ public function GetPropertyTagsGroup($iPropertyId, $iLimit)
+ {
+ $sql = "SELECT
+ text,
+ count(text) as count
+ FROM
+ " . Config::Get('db.table.property_value_tag') . "
+ WHERE
+ 1=1
+ property_id = ?d
+ GROUP BY
+ text
+ ORDER BY
+ count desc
+ LIMIT 0, ?d
+ ";
+ $aReturn = array();
+ $aReturnSort = array();
+ if ($aRows = $this->oDb->select(
+ $sql,
+ $iPropertyId,
+ $iLimit
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aReturn[mb_strtolower($aRow['text'], 'UTF-8')] = $aRow;
+ }
+ ksort($aReturn);
+ foreach ($aReturn as $aRow) {
+ $aReturnSort[] = Engine::GetEntity('ModuleProperty_EntityValueTag', $aRow);
+ }
+ }
+ return $aReturnSort;
+ }
+
+ public function GetTargetsByTag($iPropertyId, $sTag, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $sql = "
+ SELECT
+ target_id
+ FROM
+ " . Config::Get('db.table.property_value_tag') . "
+ WHERE
+ property_id = ?d
+ and
+ text = ?
+ ORDER BY target_id DESC
+ LIMIT ?d, ?d ";
+
+ $aReturn = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql, $iPropertyId, $sTag, ($iCurrPage - 1) * $iPerPage,
+ $iPerPage)
+ ) {
+ foreach ($aRows as $aTopic) {
+ $aReturn[] = $aTopic['target_id'];
+ }
+ }
+ return $aReturn;
+ }
+
+ public function UpdatePropertyByTargetType($sTargetType, $sTargetTypeNew)
+ {
+ $sql = "UPDATE
+ " . Config::Get('db.table.property') . "
+ SET target_type = ?
+ WHERE
+ target_type = ?
+ ";
+ if ($this->oDb->query($sql, $sTargetTypeNew, $sTargetType) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ public function UpdatePropertyTargetByTargetType($sTargetType, $sTargetTypeNew)
+ {
+ $sql = "UPDATE
+ " . Config::Get('db.table.property_target') . "
+ SET type = ?
+ WHERE
+ type = ?
+ ";
+ if ($this->oDb->query($sql, $sTargetTypeNew, $sTargetType) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ public function UpdatePropertySelectByTargetType($sTargetType, $sTargetTypeNew)
+ {
+ $sql = "UPDATE
+ " . Config::Get('db.table.property_select') . "
+ SET target_type = ?
+ WHERE
+ target_type = ?
+ ";
+ if ($this->oDb->query($sql, $sTargetTypeNew, $sTargetType) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ public function UpdatePropertyValueByTargetType($sTargetType, $sTargetTypeNew)
+ {
+ $sql = "UPDATE
+ " . Config::Get('db.table.property_value') . "
+ SET target_type = ?
+ WHERE
+ target_type = ?
+ ";
+ if ($this->oDb->query($sql, $sTargetTypeNew, $sTargetType) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ public function UpdatePropertyValueSelectByTargetType($sTargetType, $sTargetTypeNew)
+ {
+ $sql = "UPDATE
+ " . Config::Get('db.table.property_value_select') . "
+ SET target_type = ?
+ WHERE
+ target_type = ?
+ ";
+ if ($this->oDb->query($sql, $sTargetTypeNew, $sTargetType) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ public function UpdatePropertyValueTagByTargetType($sTargetType, $sTargetTypeNew)
+ {
+ $sql = "UPDATE
+ " . Config::Get('db.table.property_value_tag') . "
+ SET target_type = ?
+ WHERE
+ target_type = ?
+ ";
+ if ($this->oDb->query($sql, $sTargetTypeNew, $sTargetType) !== false) {
+ return true;
+ }
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/rating/Rating.class.php b/application/classes/modules/rating/Rating.class.php
new file mode 100644
index 0000000..892323b
--- /dev/null
+++ b/application/classes/modules/rating/Rating.class.php
@@ -0,0 +1,87 @@
+
+ *
+ */
+
+/**
+ * Модуль управления рейтингами и силой
+ *
+ * @package application.modules.rating
+ * @since 1.0
+ */
+class ModuleRating extends Module
+{
+
+ /**
+ * Инициализация модуля
+ *
+ */
+ public function Init()
+ {
+
+ }
+
+ /**
+ * Расчет рейтинга при голосовании за комментарий
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя, который голосует
+ * @param ModuleComment_EntityComment $oComment Объект комментария
+ * @param int $iValue
+ * @return int
+ */
+ public function VoteComment(ModuleUser_EntityUser $oUser, ModuleComment_EntityComment $oComment, $iValue)
+ {
+ /**
+ * Устанавливаем рейтинг комментария
+ */
+ $oComment->setRating($oComment->getRating() + $iValue);
+ /**
+ * Меняем рейтинг автора коммента
+ */
+ $fDeltaUser = ($iValue < 0 ? -1 : 1) * Config::Get('module.rating.comment_multiplier');
+ $oUserComment = $this->User_GetUserById($oComment->getUserId());
+ $oUserComment->setRating($oUserComment->getRating() + $fDeltaUser);
+ $this->User_Update($oUserComment);
+ return $iValue;
+ }
+
+ /**
+ * Расчет рейтинга и силы при гоосовании за топик
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя, который голосует
+ * @param ModuleTopic_EntityTopic $oTopic Объект топика
+ * @param int $iValue
+ * @return int
+ */
+ public function VoteTopic(ModuleUser_EntityUser $oUser, ModuleTopic_EntityTopic $oTopic, $iValue)
+ {
+ /**
+ * Устанавливаем рейтинг топика
+ */
+ $oTopic->setRating($oTopic->getRating() + $iValue);
+ /**
+ * Меняем рейтинг автора топика
+ */
+ $fDeltaUser = ($iValue < 0 ? -1 : 1) * Config::Get('module.rating.topic_multiplier');
+ $oUserTopic = $this->User_GetUserById($oTopic->getUserId());
+ $oUserTopic->setRating($oUserTopic->getRating() + $fDeltaUser);
+ $this->User_Update($oUserTopic);
+ return $iValue;
+ }
+}
diff --git a/application/classes/modules/rbac/Rbac.class.php b/application/classes/modules/rbac/Rbac.class.php
new file mode 100644
index 0000000..a7dfbb7
--- /dev/null
+++ b/application/classes/modules/rbac/Rbac.class.php
@@ -0,0 +1,589 @@
+
+ *
+ */
+
+/**
+ * Модуль управления правами на основе ролей и разрешений
+ * Для проверки прав доступны два метода - для текущего пользователя и для любого.
+ *
+ * // для текущего пользователя
+ * $this->Rbac_IsAllow('topic_create');
+ * // для конкретного пользователя с параметрами
+ * $this->Rbac_IsAllowUser($oUser,'topic_update',array('topic'=>$oTopic));
+ * // для плагина 'article', указывается код плагина
+ * $this->Rbac_IsAllow('article_create','article');
+ * // для плагина, где $this - любой текущий объект плагина (кроме Inherit классов)
+ * $this->Rbac_IsAllow('article_create',$this);
+ * // для плагина с параметрами
+ * $this->Rbac_IsAllow('article_update',$this,array('article'=>$oArticle));
+ *
+ *
+ * @package application.modules.rbac
+ * @since 2.0
+ */
+class ModuleRbac extends ModuleORM
+{
+ /**
+ * Код системной гостевой роли.
+ * Всем неавторизованным пользователям присваивается эта роль
+ */
+ const ROLE_CODE_GUEST = 'guest';
+ /**
+ * Статусы разрешений
+ */
+ const PERMISSION_STATE_ACTIVE = 1;
+ const PERMISSION_STATE_INACTIVE = 0;
+ /**
+ * Статусы ролей
+ */
+ const ROLE_STATE_ACTIVE = 1;
+ const ROLE_STATE_INACTIVE = 0;
+
+ /**
+ * Внутренний кеш ролей пользователя
+ *
+ * @var array
+ */
+ protected $aUserRoleCache = array();
+ /**
+ * Внутренний кеш всех ролей
+ *
+ * @var array
+ */
+ protected $aRoleCache = array();
+ /**
+ * Внутренний кеш разрешений для ролей
+ *
+ * @var array
+ */
+ protected $aRulePermissionCache = array();
+ /**
+ * Внутренний кеш всех используемых разрешений
+ *
+ * @var array
+ */
+ protected $aPermissionCache = array();
+ /**
+ * Хранит последнее сообщение о неудачной проверке прав
+ *
+ * @var null|string
+ */
+ protected $sMessageLast = null;
+ /**
+ * Объект маппера
+ *
+ * @var ModuleRbac_MapperRbac
+ */
+ protected $oMapper = null;
+
+ /**
+ * Инициализация модуля
+ */
+ public function Init()
+ {
+ parent::Init();
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ }
+
+ /**
+ * Проверяет разрешение для текущего авторизованного пользователя
+ *
+ * @param string $sPermissionCode Код разрешения
+ * @param mixed $aParamsOrPlugin Параметры или плагин
+ * @param mixed $sPluginOrParams Плагин или параметры
+ *
+ * @return bool
+ */
+ public function IsAllow($sPermissionCode, $aParamsOrPlugin = array(), $sPluginOrParams = null)
+ {
+ return $this->IsAllowUser($this->User_GetUserCurrent(), $sPermissionCode, $aParamsOrPlugin, $sPluginOrParams);
+ }
+
+ /**
+ * Проверяет разрешение для конкретного пользователя
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @param string $sPermissionCode Код разрешения
+ * @param mixed $aParamsOrPlugin Параметры или плагин
+ * @param mixed $sPluginOrParams Плагин или параметры
+ *
+ * @return bool
+ */
+ public function IsAllowUser($oUser, $sPermissionCode, $aParamsOrPlugin = array(), $sPluginOrParams = null)
+ {
+ $aParams = array();
+ $sPlugin = null;
+ if (!is_array($sPluginOrParams)) {
+ $sPlugin = $sPluginOrParams;
+ } else {
+ $aParams = $sPluginOrParams;
+ }
+ if (is_array($aParamsOrPlugin)) {
+ $aParams = $aParamsOrPlugin;
+ } else {
+ $sPlugin = $aParamsOrPlugin;
+ }
+ return $this->IsAllowUserFull($oUser, $sPermissionCode, $aParams, $sPlugin);
+ }
+
+ /**
+ * Проверяет разрешение для конкретного пользователя
+ *
+ * @param ModuleUser_EntityUser $oUser Пользователь
+ * @param string $sPermissionCode Код разрешения
+ * @param array $aParams Параметры
+ * @param mixed $sPlugin Плагин, можно указать код плагина, название класса или объект
+ *
+ * @return bool
+ */
+ protected function IsAllowUserFull($oUser, $sPermissionCode, $aParams = array(), $sPlugin = null)
+ {
+ if (!$sPermissionCode) {
+ return false;
+ }
+ $sPlugin = $sPlugin ? Plugin::GetPluginCode($sPlugin) : '';
+ /**
+ * Загружаем все роли и пермишены
+ */
+ $this->LoadRoleAndPermissions();
+ $sUserId = self::ROLE_CODE_GUEST;
+ if ($oUser) {
+ $sUserId = $oUser->getId();
+ }
+ /**
+ * Смотрим роли в кеше
+ */
+ if (!isset($this->aUserRoleCache[$sUserId])) {
+ if ($sUserId == self::ROLE_CODE_GUEST) {
+ $aRoles = $this->GetRoleByCodeAndState(self::ROLE_CODE_GUEST, self::ROLE_STATE_ACTIVE);
+ $aRoles = $aRoles ? array($aRoles) : array();
+ } else {
+ $aRoles = $this->GetRolesByUser($oUser);
+ }
+ $this->aUserRoleCache[$sUserId] = $aRoles;
+ } else {
+ $aRoles = $this->aUserRoleCache[$sUserId];
+ }
+ /**
+ * Получаем пермишены для ролей
+ */
+ $sPermissionCode = func_underscore($sPermissionCode);
+ $mResult = false;
+ foreach ($aRoles as $oRole) {
+ /**
+ * У роли есть необходимый пермишен, то проверим на возможную кастомную обработку с параметрами
+ */
+ if ($this->CheckPermissionByRole($oRole, $sPermissionCode, $sPlugin)) {
+ /**
+ * Проверяем на передачу коллбека
+ */
+ if (isset($aParams['callback']) and is_callable($aParams['callback'])) {
+ $mResult = call_user_func($aParams['callback'], $oUser, $aParams);
+ } else {
+ /**
+ * Для плагинов: CheckCustomPluginArticleCreate
+ * Для ядра: CheckCustomCreate
+ */
+ $sAdd = $sPlugin ? ('Plugin' . func_camelize($sPlugin)) : '';
+ $sMethod = 'CheckCustom' . $sAdd . func_camelize($sPermissionCode);
+ if (method_exists($this, $sMethod)) {
+ $mResult = call_user_func(array($this, $sMethod), $oUser, $aParams);
+ } else {
+ return true;
+ }
+ }
+ break;
+ }
+ }
+ /**
+ * Дефолтное сообщение об ошибке
+ */
+ $sMsg = $this->Lang_Get('rbac.notices.error_not_allow', array('permission' => $sPermissionCode));
+ /**
+ * Проверяем результат кастомной обработки
+ */
+ if ($mResult === true) {
+ return true;
+ } elseif (is_string($mResult)) {
+ /**
+ * Вернули кастомное сообщение об ошибке
+ */
+ $sMsg = $mResult;
+ } else {
+ /**
+ * Формируем сообщение об ошибке
+ */
+ if (isset($this->aPermissionCache[$sPlugin][$sPermissionCode])) {
+ $aPerm = $this->aPermissionCache[$sPlugin][$sPermissionCode];
+ if ($aPerm['msg_error']) {
+ $sMsg = $this->Lang_Get($aPerm['msg_error']);
+ } else {
+ $sMsg = $this->Lang_Get('rbac.notices.error_not_allow', array('permission' => $aPerm['title'] ? $aPerm['title'] : $aPerm['code']));
+ }
+ }
+ }
+ $this->sMessageLast = $sMsg;
+ return false;
+ }
+
+ /**
+ * Возвращает список ролей пользователя
+ * На самом деле этот метод можно было бы заменить на $oUser->getRolesActive(), если бы сущность User была ORM
+ *
+ * @param ModuleUser_EntityUser|int $oUser
+ * @param bool $bActiveOnly Учитывать только активные роли
+ *
+ * @return array
+ */
+ public function GetRolesByUser($oUser, $bActiveOnly = true)
+ {
+ if (!$oUser) {
+ return array();
+ }
+ if (is_object($oUser)) {
+ $iUserId = $oUser->getId();
+ } else {
+ $iUserId = $oUser;
+ }
+ /**
+ * Сначала получаем все связи
+ */
+ $aRoleUserItems = $this->GetRoleUserItemsByFilter(array('user_id' => $iUserId, '#index-from' => 'role_id'));
+ $aRoleIds = array_keys($aRoleUserItems);
+ /**
+ * Теперь получаем список ролей
+ */
+ if ($aRoleIds) {
+ $aFilter = array('id in' => $aRoleIds);
+ if ($bActiveOnly) {
+ $aFilter['state'] = self::ROLE_STATE_ACTIVE;
+ }
+ return $this->GetRoleItemsByFilter($aFilter);
+ }
+ return array();
+ }
+
+ /**
+ * Возвращает количество пользователей у роли
+ *
+ * @param ModuleRbac_EntityRole|int $oRole
+ *
+ * @return int
+ */
+ public function GetCountUsersByRole($oRole)
+ {
+ if (!$oRole) {
+ return 0;
+ }
+ if (is_object($oRole)) {
+ $iRoleId = $oRole->getId();
+ } else {
+ $iRoleId = $oRole;
+ }
+
+ return $this->GetCountItemsByFilter(array('role_id' => $iRoleId), 'ModuleRbac_EntityRoleUser');
+ }
+
+ /**
+ * Выполняет загрузку в кеш ролей и разрешений
+ */
+ protected function LoadRoleAndPermissions()
+ {
+ /**
+ * Роли
+ */
+ $this->LoadRoles();
+ /**
+ * Пермишены
+ */
+ $this->LoadPermissions();
+ }
+
+ /**
+ * Загружает в кеш разрешения
+ */
+ protected function LoadPermissions()
+ {
+ if ($this->aRulePermissionCache) {
+ return;
+ }
+ $aResult = $this->oMapper->GetRoleWithPermissions();
+ foreach ($aResult as $aRow) {
+ $this->aRulePermissionCache[$aRow['role_id']][$aRow['plugin']][] = $aRow['code'];
+ $this->aPermissionCache[$aRow['plugin']][$aRow['code']] = $aRow;
+ }
+ }
+
+ /**
+ * Загружает в кеш роли
+ */
+ protected function LoadRoles()
+ {
+ if ($this->aRoleCache) {
+ return;
+ }
+ $aRoles = $this->GetRoleItemsByState(self::ROLE_STATE_ACTIVE);
+ foreach ($aRoles as $oRole) {
+ $this->aRoleCache[$oRole->getId()] = $oRole;
+ }
+ }
+
+ /**
+ * Проверяет наличие разрешения у конкретной роли, учитывается наследование ролей
+ *
+ * @param ModuleRbac_EntityRole $oRole Объект роли
+ * @param string $sPermissionCode Код разрешения
+ * @param string $sPlugin Код плагина или пустая строка (разрешения ядра)
+ *
+ * @return bool
+ */
+ protected function CheckPermissionByRole($oRole, $sPermissionCode, $sPlugin = '')
+ {
+ /**
+ * Проверяем наличие пермишена в текущей роли
+ */
+ if (isset($this->aRulePermissionCache[$oRole->getId()][$sPlugin])) {
+ if (in_array($sPermissionCode, $this->aRulePermissionCache[$oRole->getId()][$sPlugin])) {
+ return true;
+ }
+ }
+ /**
+ * Смотрим родительскую роль
+ */
+ if ($oRole->getPid() and isset($this->aRoleCache[$oRole->getPid()])) {
+ return $this->CheckPermissionByRole($this->aRoleCache[$oRole->getPid()], $sPermissionCode, $sPlugin);
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает последнее сообщение о неудачной проверке прав
+ *
+ * @return null|string
+ */
+ public function GetMsgLast()
+ {
+ return $this->sMessageLast;
+ }
+
+ /**
+ * Добавляет роль к пользователю
+ *
+ * @param ModuleRbac_EntityRole|string $oRole Объект роли или код роли
+ * @param int|ModuleUser_EntityUser $iUserId Объект пользователя или его ID
+ *
+ * @return bool|Entity
+ */
+ public function AddRoleToUser($oRole, $iUserId)
+ {
+ if (is_string($oRole)) {
+ $oRole = $this->GetRoleByCode($oRole);
+ }
+ if (is_object($iUserId)) {
+ $iUserId = $iUserId->getId();
+ }
+ if (!$oRole or !$iUserId) {
+ return false;
+ }
+ if (!($oRoleUser = $this->Rbac_GetRoleUserByFilter(array(
+ 'role_id' => $oRole->getId(),
+ 'user_id' => $iUserId
+ )))
+ ) {
+ /**
+ * Добавляем
+ */
+ $oRoleUser = Engine::GetEntity('ModuleRbac_EntityRoleUser');
+ $oRoleUser->setRoleId($oRole->getId());
+ $oRoleUser->setUserId($iUserId);
+ $oRoleUser->Add();
+ }
+ return $oRoleUser;
+ }
+
+ /**
+ * Создает разрешений для управления правами
+ * В качестве основного параметра передается массив с данными, массив имеет тип корневых ключа: groups, roles и permissions.
+ *
+ * $aData=array(
+ * 'groups' => array(
+ * array('article','Статьи'),
+ * ),
+ * 'roles' => array(
+ * array('article_moderator','Модератор статей'),
+ * ),
+ * 'permissions' => array(
+ * array('view','Просмотр статьи','msg_error'=>'У вас нет прав на просмотр статьи','group'=>'article','roles'=>array('guest','user')),
+ * array('create','Создание статей','msg_error'=>'У вас нет прав на создание статьи','group'=>'article','roles'=>'user'),
+ * array('update_all','Правка всех статей','msg_error'=>'У вас нет прав на редактирование статьи','group'=>'article','roles'=>'article_moderator'),
+ * ),
+ * );
+ *
+ *
+ * @param array $aData Набор данных
+ * @param mixed $sPlugin Плагин, можно указать код плагина, название класса или объект
+ *
+ * @return bool
+ */
+ public function CreatePermissions($aData, $sPlugin = null)
+ {
+ $sPlugin = $sPlugin ? Plugin::GetPluginCode($sPlugin) : '';
+ /**
+ * Создаем группы
+ */
+ if (isset($aData['groups'])) {
+ foreach ($aData['groups'] as $aGroup) {
+ $sCode = $aGroup[0];
+ $sTitle = isset($aGroup[1]) ? $aGroup[1] : $sCode;
+ if (!$this->GetGroupByCode($sCode)) {
+ $oGroup = Engine::GetEntity('ModuleRbac_EntityGroup');
+ $oGroup->setCode($sCode);
+ $oGroup->setTitle($sTitle);
+ if ($oGroup->_Validate()) {
+ $oGroup->setTitle(htmlspecialchars($oGroup->getTitle()));
+ $oGroup->Add();
+ }
+ }
+ }
+ }
+ /**
+ * Создаем роли
+ */
+ if (isset($aData['roles'])) {
+ foreach ($aData['roles'] as $aRole) {
+ $sCode = $aRole[0];
+ $sTitle = isset($aRole[1]) ? $aRole[1] : $sCode;
+ if (!$this->GetRoleByCode($sCode)) {
+ $oRole = Engine::GetEntity('ModuleRbac_EntityRole');
+ $oRole->setCode($sCode);
+ $oRole->setTitle($sTitle);
+ if ($oRole->_Validate()) {
+ $oRole->setTitle(htmlspecialchars($oRole->getTitle()));
+ $oRole->Add();
+ }
+ }
+ }
+ }
+ /**
+ * Создаем разрешения
+ */
+ if (isset($aData['permissions'])) {
+ foreach ($aData['permissions'] as $aPermission) {
+ $sCode = $aPermission[0];
+ $sTitle = isset($aPermission[1]) ? $aPermission[1] : $sCode;
+ $aFilter = array(
+ 'code' => $sCode
+ );
+ if ($sPlugin) {
+ $aFilter['plugin'] = $sPlugin;
+ }
+ if (!$this->GetPermissionByFilter($aFilter)) {
+ $oPermission = Engine::GetEntity('ModuleRbac_EntityPermission');
+ $oPermission->setCode($sCode);
+ $oPermission->setTitle($sTitle);
+ $oPermission->setPlugin($sPlugin);
+ $oPermission->setMsgError(isset($aPermission['msg_error']) ? $aPermission['msg_error'] : '');
+ if (isset($aPermission['group']) and $oGroup = $this->GetGroupByCode($aPermission['group'])) {
+ $oPermission->setGroupId($oGroup->getId());
+ }
+ if ($oPermission->_Validate()) {
+ $oPermission->setTitle(htmlspecialchars($oPermission->getTitle()));
+ $oPermission->setMsgError(htmlspecialchars($oPermission->getMsgError()));
+ if ($oPermission->Add()) {
+ /**
+ * Создаем связь с ролями
+ */
+ if (isset($aPermission['roles'])) {
+ $aRoles = is_array($aPermission['roles']) ? $aPermission['roles'] : array($aPermission['roles']);
+ foreach ($aRoles as $sRoleCode) {
+ if ($oRole = $this->GetRoleByCode($sRoleCode)) {
+ $oRolePermission = Engine::GetEntity('ModuleRbac_EntityRolePermission');
+ $oRolePermission->setRoleId($oRole->getId());
+ $oRolePermission->setPermissionId($oPermission->getId());
+ $oRolePermission->Add();
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Удаляет разрешения - группы, роли, разрешения
+ *
+ *
+ * $aData=array(
+ * 'groups' => array('article'),
+ * 'roles' => array('article_moderator'),
+ * );
+ *
+ * @param array $aData Данные для удаления
+ * @param mixed $sPlugin Плагин, можно указать код плагина, название класса или объект
+ */
+ public function RemovePermissions($aData, $sPlugin)
+ {
+ if (isset($aData['groups'])) {
+ $aGroups = is_array($aData['groups']) ? $aData['groups'] : array($aData['groups']);
+ foreach ($aGroups as $sGroupCode) {
+ if ($oGroup = $this->GetGroupByCode($sGroupCode)) {
+ $oGroup->Delete();
+ }
+ }
+ }
+ if (isset($aData['roles'])) {
+ $aRoles = is_array($aData['roles']) ? $aData['roles'] : array($aData['roles']);
+ foreach ($aRoles as $sRoleCode) {
+ if ($oRole = $this->GetRoleByCode($sRoleCode)) {
+ $oRole->Delete();
+ }
+ }
+ }
+ /**
+ * Удаляем разрешения
+ */
+ $sPlugin = $sPlugin ? Plugin::GetPluginCode($sPlugin) : '';
+ if ($sPlugin and $aPermissions = $this->GetPermissionItemsByPlugin($sPlugin)) {
+ foreach ($aPermissions as $oPermission) {
+ $oPermission->Delete();
+ }
+ }
+ }
+
+ /**
+ * Алиас для перенаправления экшена на страницу ошибки с сообщением
+ *
+ * @param bool $bFromAdmin Необходимо указать true, если метод вызывается из стандартной админки
+ *
+ * @return string
+ */
+ public function ReturnActionError($bFromAdmin = false)
+ {
+ if ($bFromAdmin) {
+ $this->Message_AddErrorSingle($this->GetMsgLast());
+ return Router::Action('admin', 'error');
+ } else {
+ return Router::ActionError($this->GetMsgLast());
+ }
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/rbac/entity/Group.entity.class.php b/application/classes/modules/rbac/entity/Group.entity.class.php
new file mode 100644
index 0000000..6c05268
--- /dev/null
+++ b/application/classes/modules/rbac/entity/Group.entity.class.php
@@ -0,0 +1,118 @@
+
+ *
+ */
+
+/**
+ * Сущность группы для логического объединения разрешений
+ *
+ * @package application.modules.rbac
+ * @since 2.0
+ */
+class ModuleRbac_EntityGroup extends EntityORM
+{
+ /**
+ * Определяем правила валидации
+ *
+ * @var array
+ */
+ protected $aValidateRules = array(
+ array('title', 'string', 'max' => 200, 'min' => 1, 'allowEmpty' => false),
+ array('code', 'regexp', 'pattern' => '/^[\w\-_]+$/i', 'allowEmpty' => false),
+ array('code', 'check_code'),
+ );
+ /**
+ * Связи ORM
+ *
+ * @var array
+ */
+ protected $aRelations = array(
+ 'permissions' => array(self::RELATION_TYPE_HAS_MANY, 'ModuleRbac_EntityPermission', 'group_id'),
+ );
+
+ /**
+ * Валидация кода группы
+ *
+ * @return bool|string
+ */
+ public function ValidateCheckCode()
+ {
+ if ($oObject = $this->Rbac_GetGroupByCode($this->getCode())) {
+ if ($this->getId() != $oObject->getId()) {
+ return $this->Lang_Get('rbac.notices.validate_group_code');
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Выполняется перед сохранением
+ *
+ * @return bool
+ */
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+ /**
+ * Выполняется перед удалением сущности
+ *
+ * @return bool
+ */
+ protected function beforeDelete()
+ {
+ if ($bResult = parent::beforeDelete()) {
+ /**
+ * Нужно сбросить группу у разрешений
+ */
+ $aPermissionItems = $this->Rbac_GetPermissionItemsByGroupId($this->getId());
+ foreach ($aPermissionItems as $oPermission) {
+ $oPermission->setGroupId(null);
+ $oPermission->Update();
+ }
+ }
+ return $bResult;
+ }
+
+ /**
+ * Возвращает URL админки для редактирования
+ *
+ * @return string
+ */
+ public function getUrlAdminUpdate()
+ {
+ return Router::GetPath('admin/users/rbac/group-update/' . $this->getId());
+ }
+
+ /**
+ * Возвращает URL админки для удаления
+ *
+ * @return string
+ */
+ public function getUrlAdminRemove()
+ {
+ return Router::GetPath('admin/users/rbac/group-remove/' . $this->getId());
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/rbac/entity/Permission.entity.class.php b/application/classes/modules/rbac/entity/Permission.entity.class.php
new file mode 100644
index 0000000..05206c8
--- /dev/null
+++ b/application/classes/modules/rbac/entity/Permission.entity.class.php
@@ -0,0 +1,133 @@
+
+ *
+ */
+
+/**
+ * Сущность разрешения
+ *
+ * @package application.modules.rbac
+ * @since 2.0
+ */
+class ModuleRbac_EntityPermission extends EntityORM
+{
+ /**
+ * Определяем правила валидации
+ *
+ * @var array
+ */
+ protected $aValidateRules = array(
+ array('title', 'string', 'max' => 200, 'min' => 1, 'allowEmpty' => false),
+ array('msg_error', 'string', 'max' => 250, 'min' => 1, 'allowEmpty' => true),
+ array('code', 'regexp', 'pattern' => '/^[\w\-_]+$/i', 'allowEmpty' => false),
+ array('plugin', 'regexp', 'pattern' => '/^[\w\-_]+$/i', 'allowEmpty' => true),
+ array('code', 'check_code'),
+ array('group_id', 'check_group'),
+ );
+
+ /**
+ * Связи ORM
+ *
+ * @var array
+ */
+ protected $aRelations = array(
+ 'roles' => array(
+ self::RELATION_TYPE_MANY_TO_MANY,
+ 'ModuleRbac_EntityRole',
+ 'role_id',
+ 'ModuleRbac_EntityRolePermission',
+ 'permission_id'
+ ),
+ );
+
+ /**
+ * Валидация группы
+ *
+ * @return bool|string
+ */
+ public function ValidateCheckGroup()
+ {
+ if ($this->getGroupId()) {
+ if ($oObject = $this->Rbac_GetGroupById($this->getGroupId())) {
+ $this->setGroupId($oObject->getId());
+ } else {
+ return $this->Lang_Get('rbac.notices.validate_group_wrong');
+ }
+ } else {
+ $this->setGroupId(null);
+ }
+ return true;
+ }
+
+ /**
+ * Валидация кода
+ *
+ * @return bool|string
+ */
+ public function ValidateCheckCode()
+ {
+ $sPlugin = $this->getPlugin() ? $this->getPlugin() : '';
+ if ($oObject = $this->Rbac_GetPermissionByCodeAndPlugin($this->getCode(), $sPlugin)) {
+ if ($this->getId() != $oObject->getId()) {
+ return $this->Lang_Get('rbac.notices.validate_permission_code');
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Выполняется перед сохранением
+ *
+ * @return bool
+ */
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+ /**
+ * Возвращает URL админки для редактирования
+ *
+ * @return string
+ */
+ public function getUrlAdminUpdate()
+ {
+ return Router::GetPath('admin/users/rbac/permission-update/' . $this->getId());
+ }
+
+ /**
+ * Возвращает URL админки для удаления
+ *
+ * @return string
+ */
+ public function getUrlAdminRemove()
+ {
+ return Router::GetPath('admin/users/rbac/permission-remove/' . $this->getId());
+ }
+
+ public function getTitleLang()
+ {
+ return $this->Lang_Get($this->getTitle());
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/rbac/entity/Role.entity.class.php b/application/classes/modules/rbac/entity/Role.entity.class.php
new file mode 100644
index 0000000..2c7f6ba
--- /dev/null
+++ b/application/classes/modules/rbac/entity/Role.entity.class.php
@@ -0,0 +1,163 @@
+
+ *
+ */
+
+/**
+ * Сущность роли, которая назначается пользователям
+ *
+ * @package application.modules.rbac
+ * @since 2.0
+ */
+class ModuleRbac_EntityRole extends EntityORM
+{
+
+ /**
+ * Определяем правила валидации
+ *
+ * @var array
+ */
+ protected $aValidateRules = array(
+ array('title', 'string', 'max' => 200, 'min' => 1, 'allowEmpty' => false),
+ array('code', 'regexp', 'pattern' => '/^[\w\-_]+$/i', 'allowEmpty' => false),
+ array('code', 'check_code'),
+ array('pid', 'parent_role'),
+ );
+ /**
+ * Связи ORM
+ *
+ * @var array
+ */
+ protected $aRelations = array(
+ 'permissions' => array(
+ self::RELATION_TYPE_MANY_TO_MANY,
+ 'ModuleRbac_EntityPermission',
+ 'permission_id',
+ 'ModuleRbac_EntityRolePermission',
+ 'role_id'
+ ),
+ self::RELATION_TYPE_TREE,
+ );
+
+ /**
+ * Переопределяем имя поля с родителем
+ * Т.к. по дефолту в деревьях используется поле parent_id
+ *
+ * @return string
+ */
+ public function _getTreeParentKey()
+ {
+ return 'pid';
+ }
+
+ /**
+ * Выполняется перед сохранением
+ *
+ * @return bool
+ */
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+ /**
+ * Выполняется перед удалением
+ *
+ * @return bool
+ */
+ protected function beforeDelete()
+ {
+ if ($bResult = parent::beforeDelete()) {
+ /**
+ * Запускаем удаление дочерних ролей
+ */
+ if ($aCildren = $this->getChildren()) {
+ foreach ($aCildren as $oChildren) {
+ $oChildren->Delete();
+ }
+ }
+ }
+ return $bResult;
+ }
+
+ /**
+ * Валидация кода
+ *
+ * @return bool|string
+ */
+ public function ValidateCheckCode()
+ {
+ if ($oObject = $this->Rbac_GetRoleByCode($this->getCode())) {
+ if ($this->getId() != $oObject->getId()) {
+ return $this->Lang_Get('rbac.notices.validate_role_code');
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Проверка родительской роли
+ *
+ * @param string $sValue Валидируемое значение
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function ValidateParentRole($sValue, $aParams)
+ {
+ if ($this->getPid()) {
+ if ($oRole = $this->Rbac_GetRoleById($this->getPid())) {
+ if ($oRole->getId() == $this->getId()) {
+ return $this->Lang_Get('rbac.notices.validate_role_recursive');
+ }
+ } else {
+ return $this->Lang_Get('rbac.notices.validate_role_wrong');
+ }
+ } else {
+ $this->setPid(null);
+ }
+ return true;
+ }
+
+ /**
+ * Возвращает количество пользователей с данной ролью
+ *
+ * @return mixed
+ */
+ public function getCountUsers()
+ {
+ return $this->Rbac_GetCountUsersByRole($this);
+ }
+
+ /**
+ * Возвращает URL админки для различных действий над ролью, например, редактирование
+ *
+ * @param $sAction
+ *
+ * @return string
+ */
+ public function getUrlAdminAction($sAction)
+ {
+ return Router::GetPath('admin/users/rbac/role-' . $sAction . '/' . $this->getId());
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/rbac/entity/RolePermission.entity.class.php b/application/classes/modules/rbac/entity/RolePermission.entity.class.php
new file mode 100644
index 0000000..f3b61a0
--- /dev/null
+++ b/application/classes/modules/rbac/entity/RolePermission.entity.class.php
@@ -0,0 +1,45 @@
+
+ *
+ */
+
+/**
+ * Сущность связи роли с разрешениями
+ *
+ * @package application.modules.rbac
+ * @since 2.0
+ */
+class ModuleRbac_EntityRolePermission extends EntityORM
+{
+ /**
+ * Выполняется перед сохранением
+ *
+ * @return bool
+ */
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/rbac/entity/RoleUser.entity.class.php b/application/classes/modules/rbac/entity/RoleUser.entity.class.php
new file mode 100644
index 0000000..8efeede
--- /dev/null
+++ b/application/classes/modules/rbac/entity/RoleUser.entity.class.php
@@ -0,0 +1,45 @@
+
+ *
+ */
+
+/**
+ * Сущность связи роли с пользователям
+ *
+ * @package application.modules.rbac
+ * @since 2.0
+ */
+class ModuleRbac_EntityRoleUser extends EntityORM
+{
+ /**
+ * Выполняется перед сохранением
+ *
+ * @return bool
+ */
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/rbac/mapper/Rbac.mapper.class.php b/application/classes/modules/rbac/mapper/Rbac.mapper.class.php
new file mode 100644
index 0000000..f861623
--- /dev/null
+++ b/application/classes/modules/rbac/mapper/Rbac.mapper.class.php
@@ -0,0 +1,54 @@
+
+ *
+ */
+
+/**
+ * Маппер для работы с БД
+ *
+ * @package application.modules.rbac
+ * @since 2.0
+ */
+class ModuleRbac_MapperRbac extends Mapper
+{
+
+ /**
+ * Получает список всех задействованых в ролях разрешений
+ *
+ * @return array|null
+ */
+ public function GetRoleWithPermissions()
+ {
+ $sql = "SELECT
+ r.role_id,
+ p.code,
+ p.plugin,
+ p.title,
+ p.msg_error
+ FROM
+ " . Config::Get('db.table.rbac_role_permission') . " as r
+ LEFT JOIN " . Config::Get('db.table.rbac_permission') . " as p ON r.permission_id=p.id
+ WHERE
+ p.state = ?d ; ";
+ if ($aRows = $this->oDb->select($sql, ModuleRbac::PERMISSION_STATE_ACTIVE)) {
+ return $aRows;
+ }
+ return array();
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/search/Search.class.php b/application/classes/modules/search/Search.class.php
new file mode 100644
index 0000000..7f2546d
--- /dev/null
+++ b/application/classes/modules/search/Search.class.php
@@ -0,0 +1,241 @@
+
+ *
+ */
+
+/**
+ * Модуль поиска
+ *
+ * @package application.modules.search
+ * @since 2.0
+ */
+class ModuleSearch extends Module
+{
+
+ protected $oMapper;
+
+ /**
+ * Инициализация модуля
+ */
+ public function Init()
+ {
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ }
+
+ /**
+ * Выполняет поиск топиков по регулярному выражению
+ *
+ * @param $sRegexp
+ * @param $iCurrPage
+ * @param $iPerPage
+ *
+ * @return array
+ */
+ public function SearchTopics($sRegexp, $iCurrPage, $iPerPage)
+ {
+ $sCacheKey = "search_topics_{$sRegexp}_{$iCurrPage}_{$iPerPage}";
+ if (false === ($data = $this->Cache_Get($sCacheKey))) {
+ $data = array(
+ 'collection' => $this->oMapper->SearchTopics($sRegexp, $iCount, $iCurrPage, $iPerPage),
+ 'count' => $iCount
+ );
+ $this->Cache_Set($data, $sCacheKey, array('topic_update', 'topic_new'), 60 * 60 * 24 * 1);
+ }
+ if ($data['collection']) {
+ $data['collection'] = $this->Topic_GetTopicsAdditionalData($data['collection']);
+ }
+ return $data;
+ }
+
+ /**
+ * Выполняет поиск комментариев по регулярному выражению
+ *
+ * @param $sRegexp
+ * @param $iCurrPage
+ * @param $iPerPage
+ * @param $sTargetType
+ *
+ * @return array
+ */
+ public function SearchComments($sRegexp, $iCurrPage, $iPerPage, $sTargetType)
+ {
+ $sCacheKey = "search_comments_{$sRegexp}_{$iCurrPage}_{$iPerPage}_" . serialize($sTargetType);
+ if (false === ($data = $this->Cache_Get($sCacheKey))) {
+ $data = array(
+ 'collection' => $this->oMapper->SearchComments($sRegexp, $iCount, $iCurrPage, $iPerPage, $sTargetType),
+ 'count' => $iCount
+ );
+ $this->Cache_Set($data, $sCacheKey, array('comment_new'), 60 * 60 * 24 * 1);
+ }
+ if ($data['collection']) {
+ $data['collection'] = $this->Comment_GetCommentsAdditionalData($data['collection']);
+ }
+ return $data;
+ }
+
+ /**
+ * Выделяет отрывки из текста с необходимыми словами (делает сниппеты)
+ *
+ * @param string $sText Исходный текст
+ * @param array|string $aWords Список слов
+ * @param array $aParams Список параметром
+ *
+ * @return string
+ */
+ public function BuildExcerpts($sText, $aWords, $aParams = array())
+ {
+ $iMaxLengthBetweenWords = isset($aParams['iMaxLengthBetweenWords']) ? $aParams['iMaxLengthBetweenWords'] : 200;
+ $iLengthIndentSection = isset($aParams['iLengthIndentSection']) ? $aParams['iLengthIndentSection'] : 100;
+ $iMaxCountSections = isset($aParams['iMaxCountSections']) ? $aParams['iMaxCountSections'] : 3;
+ $sWordWrapBegin = isset($aParams['sWordWrapBegin']) ? $aParams['sWordWrapBegin'] : '';
+ $sWordWrapEnd = isset($aParams['sWordWrapEnd']) ? $aParams['sWordWrapEnd'] : ' ';
+ $sGlueSections = isset($aParams['sGlueSections']) ? $aParams['sGlueSections'] : "\r\n";
+
+ $sText = strip_tags($sText);
+ $sText = trim($sText);
+ if (is_string($aWords)) {
+ $aWords = preg_split('#[\W]+#u', $aWords);
+ }
+ $sPregWords = join('|', array_filter($aWords, 'preg_quote'));
+ $aSections = array();
+ if (preg_match_all("#{$sPregWords}#i", $sText, $aMatchAll, PREG_OFFSET_CAPTURE)) {
+ $aSectionItems = array();
+ $iCountDiff = -1;
+ foreach ($aMatchAll[0] as $aMatch) {
+ if ($iCountDiff == -1 or $aMatch[1] - $iCountDiff <= $iMaxLengthBetweenWords) {
+ $aSectionItems[] = $aMatch;
+ $iCountDiff = $aMatch[1];
+ } else {
+ $aSections[] = array('items' => $aSectionItems);
+ $aSectionItems = array();
+ $aSectionItems[] = $aMatch;
+ $iCountDiff = $aMatch[1];
+ }
+ }
+ if (count($aSectionItems)) {
+ $aSections[] = array('items' => $aSectionItems);
+ }
+ }
+
+ $aSections = array_slice($aSections, 0, $iMaxCountSections);
+
+ $sTextResult = '';
+ if ($aSections) {
+ foreach ($aSections as $aSection) {
+ /**
+ * Расчитываем дополнительные данные: начало и конец фрагмента, уникальный список слов
+ */
+ $aItem = reset($aSection['items']);
+ $aSection['begin'] = $aItem[1];
+ $aItem = end($aSection['items']);
+ $aSection['end'] = $aItem[1] + mb_strlen($aItem[0], 'utf-8');
+ $aSection['words'] = array();
+
+ foreach ($aSection['items'] as $aItem) {
+ $sKey = mb_strtolower($aItem[0], 'utf-8');
+ $aSection['words'][$sKey] = $aItem[0];
+ }
+
+ /**
+ * Формируем фрагменты текста
+ */
+
+ /**
+ * Определям правую границу текста по слову
+ */
+ $iEnd = $aSection['end'];
+ for ($i = $iEnd; ($i <= $aSection['end'] + $iLengthIndentSection) and $i < mb_strlen($sText,
+ 'utf-8'); $i++) {
+ if (preg_match('#^\s$#', mb_substr($sText, $i, 1, 'utf-8'))) {
+ $iEnd = $i;
+ }
+ }
+ /**
+ * Определям левую границу текста по слову
+ */
+ $iBegin = $aSection['begin'];
+ for ($i = $iBegin; ($i >= $aSection['begin'] - $iLengthIndentSection) and $i >= 0; $i--) {
+ if (preg_match('#^\s$#', mb_substr($sText, $i, 1, 'utf-8'))) {
+ $iBegin = $i;
+ }
+ }
+ /**
+ * Вырезаем фрагмент текста
+ */
+ $sTextSection = trim(mb_substr($sText, $iBegin, $iEnd - $iBegin, 'utf-8'));
+ if ($iBegin > 0) {
+ $sTextSection = '...' . $sTextSection;
+ }
+ if ($iEnd < mb_strlen($sText, 'utf-8')) {
+ $sTextSection .= '...';
+ }
+ $sTextSection = preg_replace("#{$sPregWords}#i", $sWordWrapBegin . '\\0' . $sWordWrapEnd,
+ $sTextSection);
+ $sTextResult .= $sTextSection . $sGlueSections;
+ }
+ } else {
+ $iLength = $iMaxLengthBetweenWords * 2;
+ if ($iLength > mb_strlen($sText, 'utf-8')) {
+ $iLength = mb_strlen($sText, 'utf-8');
+ }
+ $sTextResult = trim(mb_substr($sText, 0, $iLength - 1, 'utf-8'));
+ }
+ return $sTextResult;
+ }
+
+ /**
+ * Возвращает массив слов из поискового запроса
+ *
+ * @param $sQuery
+ *
+ * @return array
+ */
+ public function GetWordsForSearch($sQuery)
+ {
+ /**
+ * Удаляем запрещенные символы
+ */
+ $sQuery = preg_replace('#[^\w\sа-я\-]+#iu', ' ', $sQuery);
+ /**
+ * Разбиваем фразу на слова
+ */
+ $aWords = preg_split('#[\s]+#u', $sQuery);
+ foreach ($aWords as $k => $sWord) {
+ /**
+ * Короткие слова удаляем
+ */
+ if (mb_strlen($sWord, 'utf-8') < 3) {
+ unset($aWords[$k]);
+ }
+ }
+ return $aWords;
+ }
+
+ /**
+ * Возвращает регулярное выражение для поиска в БД по словам
+ *
+ * @param $aWords
+ *
+ * @return string
+ */
+ public function GetRegexpForWords($aWords)
+ {
+ return join('|', $aWords);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/search/mapper/Search.mapper.class.php b/application/classes/modules/search/mapper/Search.mapper.class.php
new file mode 100644
index 0000000..2d88429
--- /dev/null
+++ b/application/classes/modules/search/mapper/Search.mapper.class.php
@@ -0,0 +1,79 @@
+
+ *
+ */
+
+/**
+ * Маппер для работы с БД
+ *
+ * @package application.modules.search
+ * @since 2.0
+ */
+class ModuleSearch_MapperSearch extends Mapper
+{
+
+ public function SearchTopics($sRegexp, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $sql = "SELECT
+ DISTINCT t.topic_id,
+ CASE WHEN (LOWER(t.topic_title) REGEXP ?) THEN 1 ELSE 0 END +
+ CASE WHEN (LOWER(tc.topic_text) REGEXP ?) THEN 1 ELSE 0 END AS weight
+ FROM " . Config::Get('db.table.topic') . " AS t
+ INNER JOIN " . Config::Get('db.table.topic_content') . " AS tc ON tc.topic_id=t.topic_id
+ WHERE
+ (t.topic_publish=1) AND ((LOWER(t.topic_title) REGEXP ?) OR (LOWER(tc.topic_text) REGEXP ?))
+ ORDER BY
+ weight DESC, t.topic_id DESC
+ LIMIT ?d, ?d";
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql, $sRegexp, $sRegexp, $sRegexp, $sRegexp,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage)
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = $aRow['topic_id'];
+ }
+ }
+ return $aResult;
+ }
+
+ public function SearchComments($sRegexp, &$iCount, $iCurrPage, $iPerPage, $sTargetType)
+ {
+ if (!is_array($sTargetType)) {
+ $sTargetType = array($sTargetType);
+ }
+ $sql = "SELECT
+ DISTINCT c.comment_id
+ FROM " . Config::Get('db.table.comment') . " AS c
+ WHERE
+ (c.comment_delete=0 AND c.target_type IN ( ?a ) ) AND (LOWER(c.comment_text) REGEXP ?)
+ ORDER BY
+ c.comment_id DESC
+ LIMIT ?d, ?d";
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql, $sTargetType, $sRegexp, ($iCurrPage - 1) * $iPerPage,
+ $iPerPage)
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = $aRow['comment_id'];
+ }
+ }
+ return $aResult;
+ }
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/stream/Stream.class.php b/application/classes/modules/stream/Stream.class.php
new file mode 100644
index 0000000..27b3509
--- /dev/null
+++ b/application/classes/modules/stream/Stream.class.php
@@ -0,0 +1,613 @@
+
+ *
+ */
+
+/**
+ * Модуль потока событий на сайте
+ *
+ * @package application.modules.stream
+ * @since 1.0
+ */
+class ModuleStream extends Module
+{
+ /**
+ * Объект маппера
+ *
+ * @var ModuleStream_MapperStream
+ */
+ protected $oMapper = null;
+ /**
+ * Список дефолтных типов событий, они добавляются каждому пользователю при регистрации
+ *
+ * @var array
+ */
+ protected $aEventDefaultTypes = array(
+ 'add_wall',
+ 'add_topic',
+ 'add_comment',
+ 'add_blog',
+ 'vote_topic',
+ 'add_friend'
+ );
+ /**
+ * Типы событий
+ *
+ * @var array
+ */
+ protected $aEventTypes = array(
+ 'add_wall' => array('related' => 'wall', 'unique' => true),
+ 'add_topic' => array('related' => 'topic', 'unique' => true),
+ 'add_comment' => array('related' => 'comment', 'unique' => true),
+ 'add_blog' => array('related' => 'blog', 'unique' => true),
+ 'vote_topic' => array('related' => 'topic'),
+ 'vote_comment_topic' => array('related' => 'comment'),
+ 'add_friend' => array('related' => 'user', 'unique_user' => true),
+ 'join_blog' => array('related' => 'blog', 'unique_user' => true)
+ );
+
+ /**
+ * Инициализация модуля
+ */
+ public function Init()
+ {
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ }
+
+ /**
+ * Возвращает все типы событий
+ *
+ * @return array
+ */
+ public function getEventTypes()
+ {
+ return $this->aEventTypes;
+ }
+
+ /**
+ * Возвращает типы событий с учетом фильтра(доступности)
+ *
+ * @param array|null $aTypes Список типов
+ * @return array
+ */
+ public function getEventTypesFilter($aTypes = null)
+ {
+ if (is_null($aTypes)) {
+ $aTypes = array_keys($this->getEventTypes());
+ }
+ if (Config::Get('module.stream.disable_vote_events')) {
+ foreach ($aTypes as $i => $sType) {
+ if (substr($sType, 0, 4) == 'vote') {
+ unset ($aTypes[$i]);
+ }
+ }
+ }
+ return $aTypes;
+ }
+
+ /**
+ * Добавляет новый тип события, метод для расширения списка событий плагинами
+ *
+ * @param string $sName Название типа
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function AddEventType($sName, $aParams)
+ {
+ if (!array_key_exists($sName, $this->aEventTypes)) {
+ $this->aEventTypes[$sName] = $aParams;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверка допустимого типа событий
+ *
+ * @param string $sType Тип
+ * @return bool
+ */
+ public function IsAllowEventType($sType)
+ {
+ if (!is_string($sType)) {
+ return false;
+ }
+ return array_key_exists($sType, $this->aEventTypes);
+ }
+
+ /**
+ * Добавление события в БД
+ *
+ * @param ModuleStream_EntityEvent $oObject Объект события
+ * @return ModuleStream_EntityEvent|bool
+ */
+ public function AddEvent($oObject)
+ {
+ if ($iId = $this->oMapper->AddEvent($oObject)) {
+ $oObject->setId($iId);
+ return $oObject;
+ }
+ return false;
+ }
+
+ /**
+ * Обновление события
+ *
+ * @param ModuleStream_EntityEvent $oObject Объект события
+ * @return int
+ */
+ public function UpdateEvent($oObject)
+ {
+ return $this->oMapper->UpdateEvent($oObject);
+ }
+
+ /**
+ * Получает событие по типу и его ID
+ *
+ * @param string $sEventType Тип
+ * @param int $iTargetId ID владельца события
+ * @param int|null $iUserId ID пользователя
+ * @return ModuleStream_EntityEvent
+ */
+ public function GetEventByTarget($sEventType, $iTargetId, $iUserId = null)
+ {
+ return $this->oMapper->GetEventByTarget($sEventType, $iTargetId, $iUserId);
+ }
+
+ /**
+ * Запись события в ленту
+ *
+ * @param int $iUserId ID пользователя
+ * @param string $sEventType Тип события
+ * @param int $iTargetId ID владельца
+ * @param int $iPublish Статус
+ * @param string|int|null $sDateCreate Дата создания события
+ * @return bool
+ */
+ public function Write($iUserId, $sEventType, $iTargetId, $iPublish = 1, $sDateCreate = null)
+ {
+ $iPublish = (int)$iPublish;
+ if (!$this->IsAllowEventType($sEventType)) {
+ return false;
+ }
+ $aParams = $this->aEventTypes[$sEventType];
+ if (isset($aParams['unique']) and $aParams['unique']) {
+ /**
+ * Проверяем на уникальность
+ */
+ if ($oEvent = $this->GetEventByTarget($sEventType, $iTargetId)) {
+ /**
+ * Событие уже было
+ */
+ if ($oEvent->getPublish() != $iPublish) {
+ $oEvent->setPublish($iPublish);
+ $this->UpdateEvent($oEvent);
+ }
+ return $oEvent;
+ }
+ }
+ if (isset($aParams['unique_user']) and $aParams['unique_user']) {
+ /**
+ * Проверяем на уникальность для конкретного пользователя
+ */
+ if ($oEvent = $this->GetEventByTarget($sEventType, $iTargetId, $iUserId)) {
+ /**
+ * Событие уже было
+ */
+ if ($oEvent->getPublish() != $iPublish) {
+ $oEvent->setPublish($iPublish);
+ $this->UpdateEvent($oEvent);
+ }
+ return $oEvent;
+ }
+ }
+
+ if ($iPublish) {
+ if (is_null($sDateCreate)) {
+ $sDateCreate = date("Y-m-d H:i:s");
+ } elseif (is_numeric($sDateCreate)) {
+ $sDateCreate = date("Y-m-d H:i:s", $sDateCreate);
+ }
+ /**
+ * Создаем новое событие
+ */
+ $oEvent = Engine::GetEntity('Stream_Event');
+ $oEvent->setEventType($sEventType);
+ $oEvent->setUserId($iUserId);
+ $oEvent->setTargetId($iTargetId);
+ $oEvent->setDateAdded($sDateCreate);
+ $oEvent->setPublish($iPublish);
+ $this->AddEvent($oEvent);
+ }
+ return $oEvent;
+ }
+
+ /**
+ * Чтение потока пользователя
+ *
+ * @param int|null $iCount Количество
+ * @param int|null $iFromId ID события с которого начинать выборку
+ * @param int|null $iUserId ID пользователя
+ * @return array
+ */
+ public function Read($iCount = null, $iFromId = null, $iUserId = null)
+ {
+ if (!$iUserId) {
+ if ($this->User_getUserCurrent()) {
+ $iUserId = $this->User_getUserCurrent()->getId();
+ } else {
+ return array();
+ }
+ }
+ /**
+ * Получаем типы событий
+ */
+ $aEventTypes = $this->getTypesList($iUserId);
+ /**
+ * Получаем список тех на кого подписан
+ */
+ $aUsersList = $this->getUsersList($iUserId);
+
+ return $this->ReadEvents($aEventTypes, $aUsersList, $iCount, $iFromId);
+ }
+
+ /**
+ * Чтение всей активности на сайте
+ *
+ * @param int|null $iCount Количество
+ * @param int|null $iFromId ID события с которого начинать выборку
+ * @return array
+ */
+ public function ReadAll($iCount = null, $iFromId = null)
+ {
+ /**
+ * Получаем типы событий
+ */
+ $aEventTypes = array_keys($this->getEventTypes());
+
+ return $this->ReadEvents($aEventTypes, null, $iCount, $iFromId);
+ }
+
+ /**
+ * Чтение активности конкретного пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param int|null $iCount Количество
+ * @param int|null $iFromId ID события с которого начинать выборку
+ * @return array
+ */
+ public function ReadByUserId($iUserId, $iCount = null, $iFromId = null)
+ {
+ /**
+ * Получаем типы событий
+ */
+ $aEventTypes = array_keys($this->getEventTypes());
+ /**
+ * Получаем список тех на кого подписан
+ */
+ $aUsersList = array($iUserId);
+
+ return $this->ReadEvents($aEventTypes, $aUsersList, $iCount, $iFromId);
+ }
+
+ /**
+ * Количество событий конкретного пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @return int
+ */
+ public function GetCountByUserId($iUserId)
+ {
+ /**
+ * Получаем типы событий
+ */
+ $aEventTypes = $this->getEventTypesFilter();
+ if (!count($aEventTypes)) {
+ return 0;
+ }
+
+ return $this->oMapper->GetCount($aEventTypes, $iUserId);
+ }
+
+ /**
+ * Количество событий на которые подписан пользователь
+ *
+ * @param int $iUserId ID пользователя
+ * @return int
+ */
+ public function GetCountByReaderId($iUserId)
+ {
+ /**
+ * Получаем типы событий
+ */
+ $aEventTypes = $this->getEventTypesFilter($this->getTypesList($iUserId));
+ /**
+ * Получаем список тех на кого подписан
+ */
+ $aUsersList = $this->getUsersList($iUserId);
+ if (!count($aEventTypes)) {
+ return 0;
+ }
+
+ return $this->oMapper->GetCount($aEventTypes, $aUsersList);
+ }
+
+ /**
+ * Количество событий на всем сайте
+ *
+ * @return int
+ */
+ public function GetCountAll()
+ {
+ /**
+ * Получаем типы событий
+ */
+ $aEventTypes = $this->getEventTypesFilter();
+ if (!count($aEventTypes)) {
+ return 0;
+ }
+
+ return $this->oMapper->GetCount($aEventTypes, null);
+ }
+
+ /**
+ * Количество событий для пользователя
+ *
+ * @param array $aEventTypes Список типов событий
+ * @param array|null $aUserId ID пользователя
+ * @return int
+ */
+ public function GetCount($aEventTypes, $aUserId = null)
+ {
+ return $this->oMapper->GetCount($aEventTypes, $aUserId);
+ }
+
+ /**
+ * Чтение событий
+ *
+ * @param array $aEventTypes Список типов событий
+ * @param array|null $aUsersList Список пользователей, чьи события читать
+ * @param int $iCount Количество
+ * @param int $iFromId ID события с которого начинать выборку
+ * @return array
+ */
+ public function ReadEvents($aEventTypes, $aUsersList, $iCount = null, $iFromId = null)
+ {
+ if (!is_null($aUsersList) and !count($aUsersList)) {
+ return array();
+ }
+ if (!$iCount) {
+ $iCount = Config::Get('module.stream.count_default');
+ }
+
+ $aEventTypes = $this->getEventTypesFilter($aEventTypes);
+ if (!count($aEventTypes)) {
+ return array();
+ }
+ /**
+ * Получаем список событий
+ */
+ $aEvents = $this->oMapper->Read($aEventTypes, $aUsersList, $iCount, $iFromId);
+ /**
+ * Составляем список объектов для загрузки
+ */
+ $aNeedObjects = array();
+ foreach ($aEvents as $oEvent) {
+ if (isset($this->aEventTypes[$oEvent->getEventType()]['related'])) {
+ $aNeedObjects[$this->aEventTypes[$oEvent->getEventType()]['related']][] = $oEvent->getTargetId();
+ }
+ $aNeedObjects['user'][] = $oEvent->getUserId();
+ }
+ /**
+ * Получаем объекты
+ */
+ $aObjects = array();
+ foreach ($aNeedObjects as $sType => $aListId) {
+ if (count($aListId)) {
+ $aListId = array_unique($aListId);
+ $sMethod = 'loadRelated' . ucfirst($sType);
+ if (method_exists($this, $sMethod)) {
+ if ($aRes = $this->$sMethod($aListId)) {
+ foreach ($aRes as $oObject) {
+ $aObjects[$sType][$oObject->getId()] = $oObject;
+ }
+ }
+ }
+ }
+ }
+ /**
+ * Формируем результирующий поток
+ */
+ foreach ($aEvents as $key => $oEvent) {
+ /**
+ * Жестко вытаскиваем автора события
+ */
+ if (isset($aObjects['user'][$oEvent->getUserId()])) {
+ $oEvent->setUser($aObjects['user'][$oEvent->getUserId()]);
+ /**
+ * Аттачим объекты
+ */
+ if (isset($this->aEventTypes[$oEvent->getEventType()]['related'])) {
+ $sTypeObject = $this->aEventTypes[$oEvent->getEventType()]['related'];
+ if (isset($aObjects[$sTypeObject][$oEvent->getTargetId()])) {
+ $oEvent->setTarget($aObjects[$sTypeObject][$oEvent->getTargetId()]);
+ } else {
+ unset($aEvents[$key]);
+ }
+ } else {
+ unset($aEvents[$key]);
+ }
+ } else {
+ unset($aEvents[$key]);
+ }
+ }
+ return $aEvents;
+ }
+
+ /**
+ * Получение типов событий, на которые подписан пользователь
+ *
+ * @param int $iUserId ID пользователя
+ * @return array
+ */
+ public function getTypesList($iUserId)
+ {
+ return $this->oMapper->getTypesList($iUserId);
+ }
+
+ /**
+ * Получение списка id пользователей, на которых подписан пользователь
+ *
+ * @param int $iUserId ID пользователя
+ * @return array
+ */
+ protected function getUsersList($iUserId)
+ {
+ return $this->oMapper->getUserSubscribes($iUserId);
+ }
+
+ /**
+ * Получение списка пользователей, на которых подписан пользователь
+ *
+ * @param int $iUserId ID пользователя
+ * @return array
+ */
+ public function getUserSubscribes($iUserId)
+ {
+ $aIds = $this->oMapper->getUserSubscribes($iUserId);
+ return $this->User_GetUsersAdditionalData($aIds);
+ }
+
+ /**
+ * Проверяет подписан ли пользователь на конкретного пользователя
+ *
+ * @param $iUserId ID пользователя
+ * @param $iTargetUserId ID пользователя на которого подписан
+ * @return bool
+ */
+ public function IsSubscribe($iUserId, $iTargetUserId)
+ {
+ return $this->oMapper->IsSubscribe($iUserId, $iTargetUserId);
+ }
+
+ /**
+ * Редактирование списка событий, на которые подписан юзер
+ *
+ * @param int $iUserId ID пользователя
+ * @param string $sType Тип
+ * @return bool
+ */
+ public function switchUserEventType($iUserId, $sType)
+ {
+ if ($this->IsAllowEventType($sType)) {
+ return $this->oMapper->switchUserEventType($iUserId, $sType);
+ }
+ return false;
+ }
+
+ /**
+ * Переключает дефолтный список типов событий у пользователя
+ *
+ * @param int $iUserId ID пользователя
+ */
+ public function switchUserEventDefaultTypes($iUserId)
+ {
+ foreach ($this->aEventDefaultTypes as $sType) {
+ $this->switchUserEventType($iUserId, $sType);
+ }
+ }
+
+ /**
+ * Подписать пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param int $iTargetUserId ID пользователя на которого подписываем
+ */
+ public function subscribeUser($iUserId, $iTargetUserId)
+ {
+ $this->oMapper->subscribeUser($iUserId, $iTargetUserId);
+ }
+
+ /**
+ * Отписать пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param int $iTargetUserId ID пользователя на которого подписываем
+ */
+ public function unsubscribeUser($iUserId, $iTargetUserId)
+ {
+ $this->oMapper->unsubscribeUser($iUserId, $iTargetUserId);
+ }
+
+ /**
+ * Получает список записей на стене
+ *
+ * @param array $aIds Список ID записей на стене
+ * @return array
+ */
+ protected function loadRelatedWall($aIds)
+ {
+ return $this->Wall_GetWallAdditionalData($aIds);
+ }
+
+ /**
+ * Получает список топиков
+ *
+ * @param array $aIds Список ID топиков
+ * @return array
+ */
+ protected function loadRelatedTopic($aIds)
+ {
+ return $this->Topic_GetTopicsAdditionalData($aIds);
+ }
+
+ /**
+ * Получает список блогов
+ *
+ * @param array $aIds Список ID блогов
+ * @return array
+ */
+ protected function loadRelatedBlog($aIds)
+ {
+ return $this->Blog_GetBlogsAdditionalData($aIds);
+ }
+
+ /**
+ * Получает список комментариев
+ *
+ * @param array $aIds Список ID комментариев
+ * @return array
+ */
+ protected function loadRelatedComment($aIds)
+ {
+ return $this->Comment_GetCommentsAdditionalData($aIds);
+
+ }
+
+ /**
+ * Получает список пользователей
+ *
+ * @param array $aIds Список ID пользователей
+ * @return array
+ */
+ protected function loadRelatedUser($aIds)
+ {
+ return $this->User_GetUsersAdditionalData($aIds);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/stream/entity/Event.entity.class.php b/application/classes/modules/stream/entity/Event.entity.class.php
new file mode 100644
index 0000000..ebf543a
--- /dev/null
+++ b/application/classes/modules/stream/entity/Event.entity.class.php
@@ -0,0 +1,31 @@
+
+ *
+ */
+
+/**
+ * Объект сущности события в активности
+ *
+ * @package application.modules.stream
+ * @since 1.0
+ */
+class ModuleStream_EntityEvent extends Entity
+{
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/stream/mapper/Stream.mapper.class.php b/application/classes/modules/stream/mapper/Stream.mapper.class.php
new file mode 100644
index 0000000..bb09e48
--- /dev/null
+++ b/application/classes/modules/stream/mapper/Stream.mapper.class.php
@@ -0,0 +1,225 @@
+
+ *
+ */
+
+/**
+ * Объект маппера для работы с БД
+ *
+ * @package application.modules.stream
+ * @since 1.0
+ */
+class ModuleStream_MapperStream extends Mapper
+{
+ /**
+ * Добавление события в БД
+ *
+ * @param ModuleStream_EntityEvent $oObject
+ * @return int|bool
+ */
+ public function AddEvent($oObject)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.stream_event') . " SET ?a ";
+ if ($iId = $this->oDb->query($sql, $oObject->_getData())) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Получает событие по типу и его ID
+ *
+ * @param string $sEventType Тип
+ * @param int $iTargetId ID владельца события
+ * @param int|null $iUserId ID пользователя
+ * @return ModuleStream_EntityEvent
+ */
+ public function GetEventByTarget($sEventType, $iTargetId, $iUserId = null)
+ {
+ $sql = "SELECT * FROM
+ " . Config::Get('db.table.stream_event') . "
+ WHERE target_id = ?d AND event_type = ? { AND user_id = ?d } ";
+ if ($aRow = $this->oDb->selectRow($sql, $iTargetId, $sEventType, is_null($iUserId) ? DBSIMPLE_SKIP : $iUserId)
+ ) {
+ return Engine::GetEntity('ModuleStream_EntityEvent', $aRow);
+ }
+ return null;
+ }
+
+ /**
+ * Обновление события
+ *
+ * @param ModuleStream_EntityEvent $oObject Объект события
+ * @return int
+ */
+ public function UpdateEvent($oObject)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.stream_event') . " SET ?a WHERE id = ?d ";
+ $res = $this->oDb->query($sql, $oObject->_getData(array('publish')), $oObject->getId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получение типов событий, на которые подписан пользователь
+ *
+ * @param int $iUserId ID пользователя
+ * @return array
+ */
+ public function getTypesList($iUserId)
+ {
+ $sql = 'SELECT event_type FROM ' . Config::Get('db.table.stream_user_type') . ' WHERE user_id = ?d';
+ $aRet = $this->oDb->selectCol($sql, $iUserId);
+ return $aRet;
+ }
+
+ /**
+ * Получение списка пользователей, на которых подписан пользователь
+ *
+ * @param int $iUserId ID пользователя
+ * @return array
+ */
+ public function getUserSubscribes($iUserId)
+ {
+ $sql = 'SELECT target_user_id FROM ' . Config::Get('db.table.stream_subscribe') . ' WHERE user_id = ?d';
+ return $this->oDb->selectCol($sql, $iUserId);
+ }
+
+ /**
+ * Чтение событий
+ *
+ * @param array $aEventTypes Список типов событий
+ * @param array|null $aUsersList Список пользователей, чьи события читать
+ * @param int $iCount Количество
+ * @param int $iFromId ID события с которого начинать выборку
+ * @return array
+ */
+ public function Read($aEventTypes, $aUsersList, $iCount, $iFromId)
+ {
+ $sql = 'SELECT * FROM ' . Config::Get('db.table.stream_event') . '
+ WHERE
+ event_type IN (?a)
+ { AND user_id IN (?a) }
+ AND publish = 1
+ AND date_added <= ?
+ { AND id < ?d }
+ ORDER BY id DESC
+ { LIMIT 0,?d }';
+
+ $aReturn = array();
+ if ($aRows = $this->oDb->select($sql, $aEventTypes,
+ (!is_null($aUsersList) and count($aUsersList)) ? $aUsersList : DBSIMPLE_SKIP, date('Y-m-d H:i:s'),
+ !is_null($iFromId) ? $iFromId : DBSIMPLE_SKIP, !is_null($iCount) ? $iCount : DBSIMPLE_SKIP)
+ ) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = Engine::GetEntity('Stream_Event', $aRow);
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Количество событий для пользователя
+ *
+ * @param array $aEventTypes Список типов событий
+ * @param array|null $aUserId ID пользователя
+ * @return int
+ */
+ public function GetCount($aEventTypes, $aUserId)
+ {
+ if (!is_null($aUserId) and !is_array($aUserId)) {
+ $aUserId = array($aUserId);
+ }
+ $sql = 'SELECT count(*) as c FROM ' . Config::Get('db.table.stream_event') . '
+ WHERE
+ event_type IN (?a)
+ { AND user_id IN (?a) }
+ AND publish = 1 AND date_added <= ? ';
+ if ($aRow = $this->oDb->selectRow($sql, $aEventTypes,
+ (!is_null($aUserId) and count($aUserId)) ? $aUserId : DBSIMPLE_SKIP, date('Y-m-d H:i:s'))
+ ) {
+ return $aRow['c'];
+ }
+ return 0;
+ }
+
+ /**
+ * Редактирование списка событий, на которые подписан юзер
+ *
+ * @param int $iUserId ID пользователя
+ * @param string $sEventType Тип
+ * @return bool
+ */
+ public function switchUserEventType($iUserId, $sEventType)
+ {
+ $sql = 'SELECT * FROM ' . Config::Get('db.table.stream_user_type') . ' WHERE user_id = ?d AND event_type = ?';
+ if ($this->oDb->select($sql, $iUserId, $sEventType)) {
+ $sql = 'DELETE FROM ' . Config::Get('db.table.stream_user_type') . ' WHERE user_id = ?d AND event_type = ?';
+ } else {
+ $sql = 'INSERT INTO ' . Config::Get('db.table.stream_user_type') . ' SET user_id = ?d , event_type = ?';
+ }
+ $this->oDb->query($sql, $iUserId, $sEventType);
+ }
+
+ /**
+ * Подписать пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param int $iTargetUserId ID пользователя на которого подписываем
+ */
+ public function subscribeUser($iUserId, $iTargetUserId)
+ {
+ $sql = 'SELECT * FROM ' . Config::Get('db.table.stream_subscribe') . ' WHERE
+ user_id = ?d AND target_user_id = ?d';
+ if (!$this->oDb->select($sql, $iUserId, $iTargetUserId)) {
+ $sql = 'INSERT INTO ' . Config::Get('db.table.stream_subscribe') . ' SET
+ user_id = ?d, target_user_id = ?d';
+ $this->oDb->query($sql, $iUserId, $iTargetUserId);
+ }
+ }
+
+ /**
+ * Отписать пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param int $iTargetUserId ID пользователя на которого подписываем
+ */
+ public function unsubscribeUser($iUserId, $iTargetUserId)
+ {
+ $sql = 'DELETE FROM ' . Config::Get('db.table.stream_subscribe') . ' WHERE
+ user_id = ?d AND target_user_id = ?d';
+ $this->oDb->query($sql, $iUserId, $iTargetUserId);
+ }
+
+ /**
+ * Проверяет подписан ли пользователь на конкретного пользователя
+ *
+ * @param $iUserId ID пользователя
+ * @param $iTargetUserId ID пользователя на которого подписан
+ * @return bool
+ */
+ public function IsSubscribe($iUserId, $iTargetUserId)
+ {
+ $sql = 'SELECT * FROM ' . Config::Get('db.table.stream_subscribe') . ' WHERE
+ user_id = ?d AND target_user_id = ?d LIMIT 0,1';
+ if ($this->oDb->selectRow($sql, $iUserId, $iTargetUserId)) {
+ return true;
+ }
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/subscribe/Subscribe.class.php b/application/classes/modules/subscribe/Subscribe.class.php
new file mode 100644
index 0000000..4a44c4d
--- /dev/null
+++ b/application/classes/modules/subscribe/Subscribe.class.php
@@ -0,0 +1,360 @@
+
+ *
+ */
+
+/**
+ * Модуль Subscribe - подписки пользователей
+ *
+ * @package application.modules.subscribe
+ * @since 1.0
+ */
+class ModuleSubscribe extends Module
+{
+ /**
+ * Объект маппера
+ *
+ * @var ModuleSubscribe_MapperSubscribe
+ */
+ protected $oMapper;
+ /**
+ * Объект текущего пользователя
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent;
+ /**
+ * Список доступных объектов подписок с параметрами
+ * На данный момент допустим параметр allow_for_guest=>1 - указывает на возможность создавать подписку для гостя
+ *
+ * @var array
+ */
+ protected $aTargetTypes = array(
+ 'topic_new_comment' => array(),
+ );
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Возвращает список типов объектов
+ *
+ * @return array
+ */
+ public function GetTargetTypes()
+ {
+ return $this->aTargetTypes;
+ }
+
+ /**
+ * Добавляет в разрешенные новый тип
+ *
+ * @param string $sTargetType Тип
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function AddTargetType($sTargetType, $aParams = array())
+ {
+ if (!array_key_exists($sTargetType, $this->aTargetTypes)) {
+ $this->aTargetTypes[$sTargetType] = $aParams;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет разрешен ли данный тип в подписке
+ *
+ * @param string $sTargetType Тип
+ * @return bool
+ */
+ public function IsAllowTargetType($sTargetType)
+ {
+ return in_array($sTargetType, array_keys($this->aTargetTypes));
+ }
+
+ /**
+ * Проверка объекта подписки
+ *
+ * @param string $sTargetType Тип
+ * @param int $iTargetId ID владельца
+ * @param int $iStatus Статус подписки
+ * @return bool
+ */
+ public function CheckTarget($sTargetType, $iTargetId, $iStatus = null)
+ {
+ $sMethod = 'CheckTarget' . func_camelize($sTargetType);
+ if (method_exists($this, $sMethod)) {
+ return $this->$sMethod($iTargetId, $iStatus);
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает URL страницы с объектом подписки
+ * Актуально при переходе по ссылки с отпиской от рассылки и последующим редиректом
+ *
+ * @param string $sTargetType Тип
+ * @param int $iTargetId ID владельца
+ * @return bool
+ */
+ public function GetUrlTarget($sTargetType, $iTargetId)
+ {
+ $sMethod = 'GetUrlTarget' . func_camelize($sTargetType);
+ if (method_exists($this, $sMethod)) {
+ return $this->$sMethod($iTargetId);
+ }
+ return false;
+ }
+
+ /**
+ * Проверка на подписку для гостей
+ *
+ * @param string $sTargetType Тип
+ * @return bool
+ */
+ public function IsAllowTargetForGuest($sTargetType)
+ {
+ if ($this->IsAllowTargetType($sTargetType)) {
+ if (isset($this->aTargetTypes[$sTargetType]['allow_for_guest']) and $this->aTargetTypes[$sTargetType]['allow_for_guest']) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Добавляет подписку в БД
+ *
+ * @param ModuleSubscribe_EntitySubscribe $oSubscribe Объект подписки
+ * @return ModuleSubscribe_EntitySubscribe|bool
+ */
+ public function AddSubscribe($oSubscribe)
+ {
+ if ($sId = $this->oMapper->AddSubscribe($oSubscribe)) {
+ $oSubscribe->setId($sId);
+ return $oSubscribe;
+ }
+ return false;
+ }
+
+ /**
+ * Создает подписку, если уже есть, то возвращает существующую
+ *
+ * @param string $sTargetType Тип
+ * @param string $sTargetId ID владельца
+ * @param string $sMail Емайл
+ * @return ModuleSubscribe_EntitySubscribe|bool
+ */
+ public function AddSubscribeSimple($sTargetType, $sTargetId, $sMail, $sUserId = null)
+ {
+ if (!$sMail) {
+ return false;
+ }
+ if (!($oSubscribe = $this->Subscribe_GetSubscribeByTargetAndMail($sTargetType, $sTargetId, $sMail))) {
+ $oSubscribe = Engine::GetEntity('Subscribe');
+ $oSubscribe->setTargetType($sTargetType);
+ $oSubscribe->setTargetId($sTargetId);
+ $oSubscribe->setMail($sMail);
+ $oSubscribe->setDateAdd(date("Y-m-d H:i:s"));
+ $oSubscribe->setKey(func_generator(32));
+ $oSubscribe->setIp(func_getIp());
+ $oSubscribe->setStatus(1);
+ /**
+ * Если только для авторизованных, то добавляем user_id
+ */
+ if ($sUserId and !$this->IsAllowTargetForGuest($sTargetType)) {
+ $oSubscribe->setUserId($sUserId);
+ }
+ $this->Subscribe_AddSubscribe($oSubscribe);
+ }
+ return $oSubscribe;
+ }
+
+ /**
+ * Обновление подписки
+ *
+ * @param ModuleSubscribe_EntitySubscribe $oSubscribe Объект подписки
+ * @return int
+ */
+ public function UpdateSubscribe($oSubscribe)
+ {
+ return $this->oMapper->UpdateSubscribe($oSubscribe);
+ }
+
+ /**
+ * Смена емайла в подписках
+ *
+ * @param string $sMailOld Старый емайл
+ * @param string $sMailNew Новый емайл
+ * @param int|null $iUserId Id пользователя
+ *
+ * @return int
+ */
+ public function ChangeSubscribeMail($sMailOld, $sMailNew, $iUserId = null)
+ {
+ return $this->oMapper->ChangeSubscribeMail($sMailOld, $sMailNew, $iUserId);
+ }
+
+ /**
+ * Возвращает список подписок по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetSubscribes($aFilter, $aOrder, $iCurrPage, $iPerPage)
+ {
+ return array(
+ 'collection' => $this->oMapper->GetSubscribes($aFilter, $aOrder, $iCount, $iCurrPage, $iPerPage),
+ 'count' => $iCount
+ );
+ }
+
+ /**
+ * Возвращает подписку по объекту подписки и емайлу
+ *
+ * @param string $sTargetType Тип
+ * @param int $iTargetId ID владельца
+ * @param string $sMail Емайл
+ * @return ModuleSubscribe_EntitySubscribe|null
+ */
+ public function GetSubscribeByTargetAndMail($sTargetType, $iTargetId, $sMail)
+ {
+ $aRes = $this->GetSubscribes(array('target_type' => $sTargetType, 'target_id' => $iTargetId, 'mail' => $sMail),
+ array(), 1, 1);
+ if (isset($aRes['collection'][0])) {
+ return $aRes['collection'][0];
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает подписку по ключу
+ *
+ * @param string $sKey Ключ
+ * @return ModuleSubscribe_EntitySubscribe|null
+ */
+ public function GetSubscribeByKey($sKey)
+ {
+ $aRes = $this->GetSubscribes(array('key' => $sKey), array(), 1, 1);
+ if (isset($aRes['collection'][0])) {
+ return $aRes['collection'][0];
+ }
+ return null;
+ }
+
+ /**
+ * Производит отправку писем по подписчикам подписки
+ *
+ * @param int $sTargetType Тип объекта подписки
+ * @param int $iTargetId ID объекта подписки
+ * @param string $sTemplate Имя шаблона письма, например, notify.mail.tpl
+ * @param string $sTitle Заголовок письма
+ * @param array $aParams Параметра для передачи в шаблон письма
+ * @param array $aExcludeMail Список емайлов на которые НЕ нужно отправлять
+ * @param string $sPluginName Название или класс плагина для корректной отправки
+ */
+ public function Send(
+ $sTargetType,
+ $iTargetId,
+ $sTemplate,
+ $sTitle,
+ $aParams = array(),
+ $aExcludeMail = array(),
+ $sPluginName = null
+ ) {
+ $iPage = 1;
+ $aSubscribes = $this->Subscribe_GetSubscribes(array(
+ 'target_type' => $sTargetType,
+ 'target_id' => $iTargetId,
+ 'status' => 1,
+ 'exclude_mail' => $aExcludeMail
+ ), array(), $iPage, 20);
+ while ($aSubscribes['collection']) {
+ $iPage++;
+ foreach ($aSubscribes['collection'] as $oSubscribe) {
+ $aParams['sSubscribeKey'] = $oSubscribe->getKey();
+ $this->Notify_Send(
+ $oSubscribe->getMail(),
+ $sTemplate,
+ $sTitle,
+ $aParams,
+ $sPluginName
+ );
+ }
+ $aSubscribes = $this->Subscribe_GetSubscribes(array(
+ 'target_type' => $sTargetType,
+ 'target_id' => $iTargetId,
+ 'status' => 1
+ ), array(), $iPage, 20);
+ }
+ }
+
+ /**
+ * Проверка объекта подписки с типом "topic_new_comment"
+ * Название метода формируется автоматически
+ *
+ * @param int $iTargetId ID владельца
+ * @param int $iStatus Статус
+ * @return bool
+ */
+ public function CheckTargetTopicNewComment($iTargetId, $iStatus)
+ {
+ if ($oTopic = $this->Topic_GetTopicById($iTargetId)) {
+ /**
+ * Топик может быть в закрытом блоге, поэтому необходимо разрешить подписку только если пользователь в нем состоит, или является автором блога
+ * Отписываться разрешаем с любого топика
+ */
+ if ($iStatus == 1 and $oTopic->getBlog()->getType() == 'close') {
+ if (!$this->oUserCurrent or !($oTopic->getBlog()->getOwnerId() == $this->oUserCurrent->getId() or $this->Blog_GetBlogUserByBlogIdAndUserId($oTopic->getBlogId(),
+ $this->oUserCurrent->getId()))
+ ) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает URL на страницы объекта подписки с типом "topic_new_comment"
+ * Название метода формируется автоматически
+ *
+ * @param int $iTargetId ID топика
+ * @return string|bool
+ */
+ public function GetUrlTargetTopicNewComment($iTargetId)
+ {
+ if ($oTopic = $this->Topic_GetTopicById($iTargetId) and $oTopic->getPublish()) {
+ return $oTopic->getUrl();
+ }
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/subscribe/entity/Subscribe.entity.class.php b/application/classes/modules/subscribe/entity/Subscribe.entity.class.php
new file mode 100644
index 0000000..f608c9a
--- /dev/null
+++ b/application/classes/modules/subscribe/entity/Subscribe.entity.class.php
@@ -0,0 +1,31 @@
+
+ *
+ */
+
+/**
+ * Объект сущности подписки
+ *
+ * @package application.modules.subscribe
+ * @since 1.0
+ */
+class ModuleSubscribe_EntitySubscribe extends Entity
+{
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/subscribe/mapper/Subscribe.mapper.class.php b/application/classes/modules/subscribe/mapper/Subscribe.mapper.class.php
new file mode 100644
index 0000000..3411e7e
--- /dev/null
+++ b/application/classes/modules/subscribe/mapper/Subscribe.mapper.class.php
@@ -0,0 +1,163 @@
+
+ *
+ */
+
+/**
+ * Объект маппера для работы с БД
+ *
+ * @package application.modules.subscribe
+ * @since 1.0
+ */
+class ModuleSubscribe_MapperSubscribe extends Mapper
+{
+ /**
+ * Добавляет подписку в БД
+ *
+ * @param ModuleSubscribe_EntitySubscribe $oSubscribe Объект подписки
+ * @return int|bool
+ */
+ public function AddSubscribe($oSubscribe)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.subscribe') . " SET ?a ";
+ if ($iId = $this->oDb->query($sql, $oSubscribe->_getData())) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Получение подписки по типы и емайлу
+ *
+ * @param string $sType Тип
+ * @param string $sMail Емайл
+ * @return ModuleSubscribe_EntitySubscribe|null
+ */
+ public function GetSubscribeByTypeAndMail($sType, $sMail)
+ {
+ $sql = "SELECT * FROM " . Config::Get('db.table.subscribe') . " WHERE target_type = ? and mail = ?";
+ if ($aRow = $this->oDb->selectRow($sql, $sType, $sMail)) {
+ return Engine::GetEntity('Subscribe', $aRow);
+ }
+ return null;
+ }
+
+ /**
+ * Обновление подписки
+ *
+ * @param ModuleSubscribe_EntitySubscribe $oSubscribe Объект подписки
+ * @return int
+ */
+ public function UpdateSubscribe($oSubscribe)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.subscribe') . "
+ SET
+ status = ?,
+ date_remove = ?
+ WHERE id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oSubscribe->getStatus(),
+ $oSubscribe->getDateRemove(),
+ $oSubscribe->getId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Смена емайла в подписках
+ *
+ * @param string $sMailOld Старый емайл
+ * @param string $sMailNew Новый емайл
+ * @param int|null $iUserId Id пользователя
+ *
+ * @return int
+ */
+ public function ChangeSubscribeMail($sMailOld, $sMailNew, $iUserId = null)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.subscribe') . "
+ SET
+ mail = ?
+ WHERE mail = ? { and user_id = ?d }
+ ";
+ $res = $this->oDb->query($sql, $sMailNew, $sMailOld, $iUserId ? $iUserId : DBSIMPLE_SKIP);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Возвращает список подписок по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetSubscribes($aFilter, $aOrder, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $aOrderAllow = array('id', 'date_add', 'status');
+ $sOrder = '';
+ foreach ($aOrder as $key => $value) {
+ if (!in_array($key, $aOrderAllow)) {
+ unset($aOrder[$key]);
+ } elseif (in_array($value, array('asc', 'desc'))) {
+ $sOrder .= " {$key} {$value},";
+ }
+ }
+ $sOrder = trim($sOrder, ',');
+ if ($sOrder == '') {
+ $sOrder = ' id desc ';
+ }
+
+ if (isset($aFilter['exclude_mail']) and !is_array($aFilter['exclude_mail'])) {
+ $aFilter['exclude_mail'] = array($aFilter['exclude_mail']);
+ }
+
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.subscribe') . "
+ WHERE
+ 1 = 1
+ { AND target_type = ? }
+ { AND target_id = ?d }
+ { AND mail = ? }
+ { AND mail not IN (?a) }
+ { AND `key` = ? }
+ { AND status = ?d }
+ ORDER by {$sOrder}
+ LIMIT ?d, ?d ;
+ ";
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql,
+ isset($aFilter['target_type']) ? $aFilter['target_type'] : DBSIMPLE_SKIP,
+ isset($aFilter['target_id']) ? $aFilter['target_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['mail']) ? $aFilter['mail'] : DBSIMPLE_SKIP,
+ (isset($aFilter['exclude_mail']) and count($aFilter['exclude_mail'])) ? $aFilter['exclude_mail'] : DBSIMPLE_SKIP,
+ isset($aFilter['key']) ? $aFilter['key'] : DBSIMPLE_SKIP,
+ isset($aFilter['status']) ? $aFilter['status'] : DBSIMPLE_SKIP,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('Subscribe', $aRow);
+ }
+ }
+ return $aResult;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/talk/Talk.class.php b/application/classes/modules/talk/Talk.class.php
new file mode 100644
index 0000000..c5e0ee4
--- /dev/null
+++ b/application/classes/modules/talk/Talk.class.php
@@ -0,0 +1,951 @@
+
+ *
+ */
+
+/**
+ * Модуль разговоров(почта)
+ *
+ * @package application.modules.talk
+ * @since 1.0
+ */
+class ModuleTalk extends Module
+{
+ /**
+ * Статус TalkUser в базе данных
+ * Пользователь активен в разговоре
+ */
+ const TALK_USER_ACTIVE = 1;
+ /**
+ * Пользователь удалил разговор
+ */
+ const TALK_USER_DELETE_BY_SELF = 2;
+ /**
+ * Пользователя удалил из разговора автор письма
+ */
+ const TALK_USER_DELETE_BY_AUTHOR = 4;
+
+ /**
+ * Объект маппера
+ *
+ * @var ModuleTalk_MapperTalk
+ */
+ protected $oMapper;
+ /**
+ * Объект текущего пользователя
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Формирует и отправляет личное сообщение
+ *
+ * @param string $sTitle Заголовок сообщения
+ * @param string $sText Текст сообщения
+ * @param int|ModuleUser_EntityUser $oUserFrom Пользователь от которого отправляем
+ * @param array|int|ModuleUser_EntityUser $aUserTo Пользователь которому отправляем
+ * @param bool $bSendNotify Отправлять или нет уведомление на емайл
+ * @param bool $bUseBlacklist Исклюать или нет пользователей из блэклиста
+ * @return ModuleTalk_EntityTalk|bool
+ */
+ public function SendTalk($sTitle, $sText, $oUserFrom, $aUserTo, $bSendNotify = true, $bUseBlacklist = true)
+ {
+ $iUserIdFrom = $oUserFrom instanceof ModuleUser_EntityUser ? $oUserFrom->getId() : (int)$oUserFrom;
+ if (!is_array($aUserTo)) {
+ $aUserTo = array($aUserTo);
+ }
+ $aUserIdTo = array($iUserIdFrom);
+ if ($bUseBlacklist) {
+ $aUserInBlacklist = $this->GetBlacklistByTargetId($iUserIdFrom);
+ }
+
+ foreach ($aUserTo as $oUserTo) {
+ $sUserIdTo = $oUserTo instanceof ModuleUser_EntityUser ? $oUserTo->getId() : (int)$oUserTo;
+ if (!$bUseBlacklist || !in_array($sUserIdTo, $aUserInBlacklist)) {
+ $aUserIdTo[] = $sUserIdTo;
+ }
+ }
+ $aUserIdTo = array_unique($aUserIdTo);
+ if (!empty($aUserIdTo)) {
+ $oTalk = Engine::GetEntity('Talk');
+ $oTalk->setUserId($iUserIdFrom);
+ $oTalk->setTitle($sTitle);
+ $oTalk->setText($sText);
+ $oTalk->setDate(date("Y-m-d H:i:s"));
+ $oTalk->setDateLast(date("Y-m-d H:i:s"));
+ $oTalk->setUserIdLast($oTalk->getUserId());
+ $oTalk->setUserIp(func_getIp());
+ if ($oTalk = $this->AddTalk($oTalk)) {
+ foreach ($aUserIdTo as $iUserId) {
+ $oTalkUser = Engine::GetEntity('Talk_TalkUser');
+ $oTalkUser->setTalkId($oTalk->getId());
+ $oTalkUser->setUserId($iUserId);
+ if ($iUserId == $iUserIdFrom) {
+ $oTalkUser->setDateLast(date("Y-m-d H:i:s"));
+ } else {
+ $oTalkUser->setDateLast(null);
+ }
+ $this->AddTalkUser($oTalkUser);
+
+ if ($bSendNotify) {
+ if ($iUserId != $iUserIdFrom) {
+ $oUserFrom = $this->User_GetUserById($iUserIdFrom);
+ $oUserToMail = $this->User_GetUserById($iUserId);
+ $this->SendNotifyTalkNew($oUserToMail, $oUserFrom, $oTalk);
+ }
+ }
+ }
+ return $oTalk;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Добавляет новую тему разговора
+ *
+ * @param ModuleTalk_EntityTalk $oTalk Объект сообщения
+ * @return ModuleTalk_EntityTalk|bool
+ */
+ public function AddTalk(ModuleTalk_EntityTalk $oTalk)
+ {
+ if ($sId = $this->oMapper->AddTalk($oTalk)) {
+ $oTalk->setId($sId);
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array('talk_new', "talk_new_user_{$oTalk->getUserId()}"));
+ return $oTalk;
+ }
+ return false;
+ }
+
+ /**
+ * Обновление разговора
+ *
+ * @param ModuleTalk_EntityTalk $oTalk Объект сообщения
+ * @return int
+ */
+ public function UpdateTalk(ModuleTalk_EntityTalk $oTalk)
+ {
+ $this->Cache_Delete("talk_{$oTalk->getId()}");
+ return $this->oMapper->UpdateTalk($oTalk);
+ }
+
+ /**
+ * Получает дополнительные данные(объекты) для разговоров по их ID
+ *
+ * @param array $aTalkId Список ID сообщений
+ * @param array|null $aAllowData Список дополнительных типов подгружаемых в объект
+ * @return array
+ */
+ public function GetTalksAdditionalData($aTalkId, $aAllowData = null)
+ {
+ if (is_null($aAllowData)) {
+ $aAllowData = array('user', 'talk_user', 'favourite', 'comment_last');
+ }
+ func_array_simpleflip($aAllowData);
+ if (!is_array($aTalkId)) {
+ $aTalkId = array($aTalkId);
+ }
+ /**
+ * Получаем "голые" разговоры
+ */
+ $aTalks = $this->GetTalksByArrayId($aTalkId);
+ /**
+ * Формируем ID дополнительных данных, которые нужно получить
+ */
+ if (isset($aAllowData['favourite']) and $this->oUserCurrent) {
+ $aFavouriteTalks = $this->Favourite_GetFavouritesByArray($aTalkId, 'talk', $this->oUserCurrent->getId());
+ }
+
+ $aUserId = array();
+ $aCommentLastId = array();
+ foreach ($aTalks as $oTalk) {
+ if (isset($aAllowData['user'])) {
+ $aUserId[] = $oTalk->getUserId();
+ }
+ if (isset($aAllowData['comment_last']) and $oTalk->getCommentIdLast()) {
+ $aCommentLastId[] = $oTalk->getCommentIdLast();
+ }
+ }
+ /**
+ * Получаем дополнительные данные
+ */
+
+ $aTalkUsers = array();
+ $aCommentLast = array();
+ $aUsers = isset($aAllowData['user']) && is_array($aAllowData['user']) ? $this->User_GetUsersAdditionalData($aUserId,
+ $aAllowData['user']) : $this->User_GetUsersAdditionalData($aUserId);
+
+ if (isset($aAllowData['talk_user']) and $this->oUserCurrent) {
+ $aTalkUsers = $this->GetTalkUsersByArray($aTalkId, $this->oUserCurrent->getId());
+ }
+ if (isset($aAllowData['comment_last'])) {
+ $aCommentLast = $this->Comment_GetCommentsAdditionalData($aCommentLastId, array());
+ }
+
+ /**
+ * Добавляем данные к результату - списку разговоров
+ */
+ foreach ($aTalks as $oTalk) {
+ if (isset($aUsers[$oTalk->getUserId()])) {
+ $oTalk->setUser($aUsers[$oTalk->getUserId()]);
+ } else {
+ $oTalk->setUser(null); // или $oTalk->setUser(new ModuleUser_EntityUser());
+ }
+
+ if (isset($aTalkUsers[$oTalk->getId()])) {
+ $oTalk->setTalkUser($aTalkUsers[$oTalk->getId()]);
+ } else {
+ $oTalk->setTalkUser(null);
+ }
+
+ if (isset($aFavouriteTalks[$oTalk->getId()])) {
+ $oTalk->setIsFavourite(true);
+ } else {
+ $oTalk->setIsFavourite(false);
+ }
+
+ if ($oTalk->getCommentIdLast() and isset($aCommentLast[$oTalk->getCommentIdLast()])) {
+ $oTalk->setCommentLast($aCommentLast[$oTalk->getCommentIdLast()]);
+ } else {
+ $oTalk->setCommentLast(null);
+ }
+ }
+ return $aTalks;
+ }
+
+ /**
+ * Получить список разговоров по списку айдишников
+ *
+ * @param array $aTalkId Список ID сообщений
+ * @return array
+ */
+ public function GetTalksByArrayId($aTalkId)
+ {
+ if (Config::Get('sys.cache.solid')) {
+ return $this->GetTalksByArrayIdSolid($aTalkId);
+ }
+ if (!is_array($aTalkId)) {
+ $aTalkId = array($aTalkId);
+ }
+ $aTalkId = array_unique($aTalkId);
+ $aTalks = array();
+ $aTalkIdNotNeedQuery = array();
+ /**
+ * Делаем мульти-запрос к кешу
+ */
+ $aCacheKeys = func_build_cache_keys($aTalkId, 'talk_');
+ if (false !== ($data = $this->Cache_Get($aCacheKeys))) {
+ /**
+ * проверяем что досталось из кеша
+ */
+ foreach ($aCacheKeys as $sValue => $sKey) {
+ if (array_key_exists($sKey, $data)) {
+ if ($data[$sKey]) {
+ $aTalks[$data[$sKey]->getId()] = $data[$sKey];
+ } else {
+ $aTalkIdNotNeedQuery[] = $sValue;
+ }
+ }
+ }
+ }
+ /**
+ * Смотрим каких разговоров не было в кеше и делаем запрос в БД
+ */
+ $aTalkIdNeedQuery = array_diff($aTalkId, array_keys($aTalks));
+ $aTalkIdNeedQuery = array_diff($aTalkIdNeedQuery, $aTalkIdNotNeedQuery);
+ $aTalkIdNeedStore = $aTalkIdNeedQuery;
+ if ($data = $this->oMapper->GetTalksByArrayId($aTalkIdNeedQuery)) {
+ foreach ($data as $oTalk) {
+ /**
+ * Добавляем к результату и сохраняем в кеш
+ */
+ $aTalks[$oTalk->getId()] = $oTalk;
+ $this->Cache_Set($oTalk, "talk_{$oTalk->getId()}", array(), 60 * 60 * 24 * 4);
+ $aTalkIdNeedStore = array_diff($aTalkIdNeedStore, array($oTalk->getId()));
+ }
+ }
+ /**
+ * Сохраняем в кеш запросы не вернувшие результата
+ */
+ foreach ($aTalkIdNeedStore as $sId) {
+ $this->Cache_Set(null, "talk_{$sId}", array(), 60 * 60 * 24 * 4);
+ }
+ /**
+ * Сортируем результат согласно входящему массиву
+ */
+ $aTalks = func_array_sort_by_keys($aTalks, $aTalkId);
+ return $aTalks;
+ }
+
+ /**
+ * Получить список разговоров по списку айдишников, используя общий кеш
+ *
+ * @param array $aTalkId Список ID сообщений
+ * @return array
+ */
+ public function GetTalksByArrayIdSolid($aTalkId)
+ {
+ if (!is_array($aTalkId)) {
+ $aTalkId = array($aTalkId);
+ }
+ $aTalkId = array_unique($aTalkId);
+ $aTalks = array();
+ $s = join(',', $aTalkId);
+ if (false === ($data = $this->Cache_Get("talk_id_{$s}"))) {
+ $data = $this->oMapper->GetTalksByArrayId($aTalkId);
+ foreach ($data as $oTalk) {
+ $aTalks[$oTalk->getId()] = $oTalk;
+ }
+ $this->Cache_Set($aTalks, "talk_id_{$s}", array("update_talk_user", "talk_new"), 60 * 60 * 24 * 1);
+ return $aTalks;
+ }
+ return $data;
+ }
+
+ /**
+ * Получить список отношений разговор-юзер по списку айдишников
+ *
+ * @param array $aTalkId Список ID сообщений
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetTalkUsersByArray($aTalkId, $sUserId)
+ {
+ if (!is_array($aTalkId)) {
+ $aTalkId = array($aTalkId);
+ }
+ $aTalkId = array_unique($aTalkId);
+ $aTalkUsers = array();
+ $aTalkIdNotNeedQuery = array();
+ /**
+ * Делаем мульти-запрос к кешу
+ */
+ $aCacheKeys = func_build_cache_keys($aTalkId, 'talk_user_', '_' . $sUserId);
+ if (false !== ($data = $this->Cache_Get($aCacheKeys))) {
+ /**
+ * проверяем что досталось из кеша
+ */
+ foreach ($aCacheKeys as $sValue => $sKey) {
+ if (array_key_exists($sKey, $data)) {
+ if ($data[$sKey]) {
+ $aTalkUsers[$data[$sKey]->getTalkId()] = $data[$sKey];
+ } else {
+ $aTalkIdNotNeedQuery[] = $sValue;
+ }
+ }
+ }
+ }
+ /**
+ * Смотрим чего не было в кеше и делаем запрос в БД
+ */
+ $aTalkIdNeedQuery = array_diff($aTalkId, array_keys($aTalkUsers));
+ $aTalkIdNeedQuery = array_diff($aTalkIdNeedQuery, $aTalkIdNotNeedQuery);
+ $aTalkIdNeedStore = $aTalkIdNeedQuery;
+ if ($data = $this->oMapper->GetTalkUserByArray($aTalkIdNeedQuery, $sUserId)) {
+ foreach ($data as $oTalkUser) {
+ /**
+ * Добавляем к результату и сохраняем в кеш
+ */
+ $aTalkUsers[$oTalkUser->getTalkId()] = $oTalkUser;
+ $this->Cache_Set($oTalkUser, "talk_user_{$oTalkUser->getTalkId()}_{$oTalkUser->getUserId()}",
+ array("update_talk_user_{$oTalkUser->getTalkId()}"), 60 * 60 * 24 * 4);
+ $aTalkIdNeedStore = array_diff($aTalkIdNeedStore, array($oTalkUser->getTalkId()));
+ }
+ }
+ /**
+ * Сохраняем в кеш запросы не вернувшие результата
+ */
+ foreach ($aTalkIdNeedStore as $sId) {
+ $this->Cache_Set(null, "talk_user_{$sId}_{$sUserId}", array("update_talk_user_{$sId}"), 60 * 60 * 24 * 4);
+ }
+ /**
+ * Сортируем результат согласно входящему массиву
+ */
+ $aTalkUsers = func_array_sort_by_keys($aTalkUsers, $aTalkId);
+ return $aTalkUsers;
+ }
+
+ /**
+ * Получает тему разговора по айдишнику
+ *
+ * @param int $sId ID сообщения
+ * @return ModuleTalk_EntityTalk|null
+ */
+ public function GetTalkById($sId)
+ {
+ if (!is_numeric($sId)) {
+ return null;
+ }
+ $aTalks = $this->GetTalksAdditionalData($sId);
+ if (isset($aTalks[$sId])) {
+ $aResult = $this->GetTalkUsersByTalkId($sId);
+ foreach ((array)$aResult as $oTalkUser) {
+ $aTalkUsers[$oTalkUser->getUserId()] = $oTalkUser;
+ }
+ $aTalks[$sId]->setTalkUsers($aTalkUsers);
+ return $aTalks[$sId];
+ }
+ return null;
+ }
+
+ /**
+ * Добавляет юзера к разговору(теме)
+ *
+ * @param ModuleTalk_EntityTalkUser $oTalkUser Объект связи пользователя и сообщения(разговора)
+ * @return bool
+ */
+ public function AddTalkUser(ModuleTalk_EntityTalkUser $oTalkUser)
+ {
+ $this->Cache_Delete("talk_{$oTalkUser->getTalkId()}");
+ $this->Cache_Clean(
+ Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array(
+ "update_talk_user_{$oTalkUser->getTalkId()}"
+ )
+ );
+ return $this->oMapper->AddTalkUser($oTalkUser);
+ }
+
+ /**
+ * Помечает разговоры как прочитанные
+ *
+ * @param array $aTalkId Список ID сообщений
+ * @param int $iUserId ID пользователя
+ */
+ public function MarkReadTalkUserByArray($aTalkId, $iUserId)
+ {
+ if (!is_array($aTalkId)) {
+ $aTalkId = array($aTalkId);
+ }
+ foreach ($aTalkId as $sTalkId) {
+ if ($oTalk = $this->Talk_GetTalkById((string)$sTalkId)) {
+ if ($oTalkUser = $this->Talk_GetTalkUser($oTalk->getId(), $iUserId)) {
+ $oTalkUser->setDateLast(date("Y-m-d H:i:s"));
+ if ($oTalk->getCommentIdLast()) {
+ $oTalkUser->setCommentIdLast($oTalk->getCommentIdLast());
+ }
+ $oTalkUser->setCommentCountNew(0);
+ $this->Talk_UpdateTalkUser($oTalkUser);
+ }
+ }
+ }
+ }
+
+ /**
+ * Удаляет юзера из разговора
+ *
+ * @param array $aTalkId Список ID сообщений
+ * @param int $sUserId ID пользователя
+ * @param int $iActive Статус связи
+ * @return bool
+ */
+ public function DeleteTalkUserByArray($aTalkId, $sUserId, $iActive = self::TALK_USER_DELETE_BY_SELF)
+ {
+ if (!is_array($aTalkId)) {
+ $aTalkId = array($aTalkId);
+ }
+ // Удаляем для каждого отметку избранного
+ foreach ($aTalkId as $sTalkId) {
+ $this->DeleteFavouriteTalk(
+ Engine::GetEntity('Favourite',
+ array(
+ 'target_id' => (string)$sTalkId,
+ 'target_type' => 'talk',
+ 'user_id' => $sUserId
+ )
+ )
+ );
+ }
+ // Нужно почистить зависимые кеши
+ foreach ($aTalkId as $sTalkId) {
+ $sTalkId = (string)$sTalkId;
+ $this->Cache_Clean(
+ Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("update_talk_user_{$sTalkId}")
+ );
+ }
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("update_talk_user"));
+ $ret = $this->oMapper->DeleteTalkUserByArray($aTalkId, $sUserId, $iActive);
+
+ // Удаляем пустые беседы, если в них нет пользователей
+ foreach ($aTalkId as $sTalkId) {
+ $sTalkId = (string)$sTalkId;
+ if (!count($this->GetUsersTalk($sTalkId, array(self::TALK_USER_ACTIVE)))) {
+ $this->DeleteTalk($sTalkId);
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * Есть ли юзер в этом разговоре
+ *
+ * @param int $sTalkId ID разговора
+ * @param int $sUserId ID пользователя
+ * @return ModuleTalk_EntityTalkUser|null
+ */
+ public function GetTalkUser($sTalkId, $sUserId)
+ {
+ $aTalkUser = $this->GetTalkUsersByArray($sTalkId, $sUserId);
+ if (isset($aTalkUser[$sTalkId])) {
+ return $aTalkUser[$sTalkId];
+ }
+ return null;
+ }
+
+ /**
+ * Получить все темы разговора где есть юзер
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetTalksByUserId($sUserId, $iPage, $iPerPage)
+ {
+ $data = array(
+ 'collection' => $this->oMapper->GetTalksByUserId($sUserId, $iCount, $iPage, $iPerPage),
+ 'count' => $iCount
+ );
+ $aTalks = $this->GetTalksAdditionalData($data['collection']);
+ /**
+ * Добавляем данные об участниках разговора
+ */
+ foreach ($aTalks as $oTalk) {
+ $aResult = $this->GetTalkUsersByTalkId($oTalk->getId());
+ foreach ((array)$aResult as $oTalkUser) {
+ $aTalkUsers[$oTalkUser->getUserId()] = $oTalkUser;
+ }
+ $oTalk->setTalkUsers($aTalkUsers);
+ }
+ $data['collection'] = $aTalks;
+ return $data;
+ }
+
+ /**
+ * Получить все темы разговора по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetTalksByFilter($aFilter, $iPage, $iPerPage)
+ {
+ $data = array(
+ 'collection' => $this->oMapper->GetTalksByFilter($aFilter, $iCount, $iPage, $iPerPage),
+ 'count' => $iCount
+ );
+ $aTalks = $this->GetTalksAdditionalData($data['collection']);
+ /**
+ * Добавляем данные об участниках разговора
+ */
+ foreach ($aTalks as $oTalk) {
+ $aResult = $this->GetTalkUsersByTalkId($oTalk->getId());
+ $aTalkUsers = array();
+ foreach ((array)$aResult as $oTalkUser) {
+ $aTalkUsers[$oTalkUser->getUserId()] = $oTalkUser;
+ }
+ $oTalk->setTalkUsers($aTalkUsers);
+ }
+ $data['collection'] = $aTalks;
+ return $data;
+ }
+
+ /**
+ * Обновляет связку разговор-юзер
+ *
+ * @param ModuleTalk_EntityTalkUser $oTalkUser Объект связи пользователя с разговором
+ * @return bool
+ */
+ public function UpdateTalkUser(ModuleTalk_EntityTalkUser $oTalkUser)
+ {
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("talk_read_user_{$oTalkUser->getUserId()}"));
+ $this->Cache_Delete("talk_user_{$oTalkUser->getTalkId()}_{$oTalkUser->getUserId()}");
+ return $this->oMapper->UpdateTalkUser($oTalkUser);
+ }
+
+ /**
+ * Получает число новых тем и комментов где есть юзер
+ *
+ * @param int $sUserId ID пользователя
+ * @return int
+ */
+ public function GetCountTalkNew($sUserId)
+ {
+ if (false === ($data = $this->Cache_Get("talk_count_all_new_user_{$sUserId}"))) {
+ $data = $this->oMapper->GetCountCommentNew($sUserId) + $this->oMapper->GetCountTalkNew($sUserId);
+ $this->Cache_Set($data, "talk_count_all_new_user_{$sUserId}",
+ array("talk_new", "update_talk_user", "talk_read_user_{$sUserId}"), 60 * 60 * 24);
+ }
+ return $data;
+ }
+
+ /**
+ * Получает список юзеров в теме разговора
+ *
+ * @param int $sTalkId ID разговора
+ * @param array $aActive Список статусов
+ * @return array
+ */
+ public function GetUsersTalk($sTalkId, $aActive = array())
+ {
+ if (!is_array($aActive)) {
+ $aActive = array($aActive);
+ }
+
+ $data = $this->oMapper->GetUsersTalk($sTalkId, $aActive);
+ return $this->User_GetUsersAdditionalData($data);
+ }
+
+ /**
+ * Возвращает массив пользователей, участвующих в разговоре
+ *
+ * @param int $sTalkId ID разговора
+ * @return array
+ */
+ public function GetTalkUsersByTalkId($sTalkId, $aAllowData = null)
+ {
+ if (is_null($aAllowData)) {
+ $aAllowData = array('user' => array());
+ }
+ if (false === ($aTalkUsers = $this->Cache_Get("talk_relation_user_by_talk_id_{$sTalkId}"))) {
+ $aTalkUsers = $this->oMapper->GetTalkUsers($sTalkId);
+ $this->Cache_Set($aTalkUsers, "talk_relation_user_by_talk_id_{$sTalkId}",
+ array("update_talk_user_{$sTalkId}"), 60 * 60 * 24 * 1);
+ }
+
+ if ($aTalkUsers) {
+ $aUserId = array();
+ foreach ($aTalkUsers as $oTalkUser) {
+ $aUserId[] = $oTalkUser->getUserId();
+ }
+ $aUsers = $this->User_GetUsersAdditionalData($aUserId,
+ isset($aAllowData['user']) && is_array($aAllowData['user']) ? $aAllowData['user'] : null);
+
+ foreach ($aTalkUsers as $oTalkUser) {
+ if (isset($aUsers[$oTalkUser->getUserId()])) {
+ $oTalkUser->setUser($aUsers[$oTalkUser->getUserId()]);
+ } else {
+ $oTalkUser->setUser(null);
+ }
+ }
+ }
+ return $aTalkUsers;
+ }
+
+ /**
+ * Увеличивает число новых комментов у юзеров
+ *
+ * @param int $sTalkId ID разговора
+ * @param array $aExcludeId Список ID пользователей для исключения
+ * @return int
+ */
+ public function increaseCountCommentNew($sTalkId, $aExcludeId = null)
+ {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("update_talk_user_{$sTalkId}"));
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("update_talk_user"));
+ return $this->oMapper->increaseCountCommentNew($sTalkId, $aExcludeId);
+ }
+
+ /**
+ * Получает привязку письма к ибранному(добавлено ли письмо в избранное у юзера)
+ *
+ * @param int $sTalkId ID разговора
+ * @param int $sUserId ID пользователя
+ * @return ModuleFavourite_EntityFavourite|null
+ */
+ public function GetFavouriteTalk($sTalkId, $sUserId)
+ {
+ return $this->Favourite_GetFavourite($sTalkId, 'talk', $sUserId);
+ }
+
+ /**
+ * Получить список избранного по списку айдишников
+ *
+ * @param array $aTalkId Список ID разговоров
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetFavouriteTalkByArray($aTalkId, $sUserId)
+ {
+ return $this->Favourite_GetFavouritesByArray($aTalkId, 'talk', $sUserId);
+ }
+
+ /**
+ * Получить список избранного по списку айдишников, но используя единый кеш
+ *
+ * @param array $aTalkId Список ID разговоров
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetFavouriteTalksByArraySolid($aTalkId, $sUserId)
+ {
+ return $this->Favourite_GetFavouritesByArraySolid($aTalkId, 'talk', $sUserId);
+ }
+
+ /**
+ * Получает список писем из избранного пользователя
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iCurrPage Номер текущей страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetTalksFavouriteByUserId($sUserId, $iCurrPage, $iPerPage)
+ {
+ // Получаем список идентификаторов избранных комментов
+ $data = $this->Favourite_GetFavouritesByUserId($sUserId, 'talk', $iCurrPage, $iPerPage);
+ // Получаем комменты по переданому массиву айдишников
+ $aTalks = $this->GetTalksAdditionalData($data['collection']);
+
+ /**
+ * Добавляем данные об участниках разговора
+ */
+ foreach ($aTalks as $oTalk) {
+ $aResult = $this->GetTalkUsersByTalkId($oTalk->getId());
+ $aTalkUsers = array();
+ foreach ((array)$aResult as $oTalkUser) {
+ $aTalkUsers[$oTalkUser->getUserId()] = $oTalkUser;
+ }
+ $oTalk->setTalkUsers($aTalkUsers);
+ }
+ $data['collection'] = $aTalks;
+ return $data;
+ }
+
+ /**
+ * Возвращает число писем в избранном
+ *
+ * @param int $sUserId ID пользователя
+ * @return int
+ */
+ public function GetCountTalksFavouriteByUserId($sUserId)
+ {
+ return $this->Favourite_GetCountFavouritesByUserId($sUserId, 'talk');
+ }
+
+ /**
+ * Добавляет письмо в избранное
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavourite Объект избранного
+ * @return bool
+ */
+ public function AddFavouriteTalk(ModuleFavourite_EntityFavourite $oFavourite)
+ {
+ return ($oFavourite->getTargetType() == 'talk')
+ ? $this->Favourite_AddFavourite($oFavourite)
+ : false;
+ }
+
+ /**
+ * Удаляет письмо из избранного
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavourite Объект избранного
+ * @return bool
+ */
+ public function DeleteFavouriteTalk(ModuleFavourite_EntityFavourite $oFavourite)
+ {
+ return ($oFavourite->getTargetType() == 'talk')
+ ? $this->Favourite_DeleteFavourite($oFavourite)
+ : false;
+ }
+
+ /**
+ * Получает информацию о пользователях, занесенных в блеклист
+ *
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetBlacklistByUserId($sUserId)
+ {
+ $data = $this->oMapper->GetBlacklistByUserId($sUserId);
+ return $this->User_GetUsersAdditionalData($data);
+ }
+
+ /**
+ * Возвращает пользователей, у которых данный занесен в Blacklist
+ *
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetBlacklistByTargetId($sUserId)
+ {
+ return $this->oMapper->GetBlacklistByTargetId($sUserId);
+ }
+
+ /**
+ * Добавление пользователя в блеклист по переданному идентификатору
+ *
+ * @param int $sTargetId ID пользователя, которого добавляем в блэклист
+ * @param int $sUserId ID пользователя
+ * @return bool
+ */
+ public function AddUserToBlacklist($sTargetId, $sUserId)
+ {
+ return $this->oMapper->AddUserToBlacklist($sTargetId, $sUserId);
+ }
+
+ /**
+ * Добавление пользователя в блеклист по списку идентификаторов
+ *
+ * @param array $aTargetId Список ID пользователей, которых добавляем в блэклист
+ * @param int $sUserId ID пользователя
+ * @return bool
+ */
+ public function AddUserArrayToBlacklist($aTargetId, $sUserId)
+ {
+ foreach ((array)$aTargetId as $oUser) {
+ $aUsersId[] = $oUser instanceof ModuleUser_EntityUser ? $oUser->getId() : (int)$oUser;
+ }
+ return $this->oMapper->AddUserArrayToBlacklist($aUsersId, $sUserId);
+ }
+
+ /**
+ * Удаляем пользователя из блеклиста
+ *
+ * @param int $sTargetId ID пользователя, которого удаляем из блэклиста
+ * @param int $sUserId ID пользователя
+ * @return bool
+ */
+ public function DeleteUserFromBlacklist($sTargetId, $sUserId)
+ {
+ return $this->oMapper->DeleteUserFromBlacklist($sTargetId, $sUserId);
+ }
+
+ /**
+ * Возвращает список последних инбоксов пользователя,
+ * отправленных не более чем $iTimeLimit секунд назад
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iTimeLimit Количество секунд
+ * @param int $iCountLimit Количество
+ * @return array
+ */
+ public function GetLastTalksByUserId($sUserId, $iTimeLimit, $iCountLimit = 1)
+ {
+ $aFilter = array(
+ 'sender_id' => $sUserId,
+ 'date_min' => date("Y-m-d H:i:s", time() - $iTimeLimit),
+ );
+ $aTalks = $this->GetTalksByFilter($aFilter, 1, $iCountLimit);
+
+ return $aTalks;
+ }
+
+ /**
+ * Удаление письма из БД
+ *
+ * @param int $iTalkId ID разговора
+ */
+ public function DeleteTalk($iTalkId)
+ {
+ $this->oMapper->deleteTalk($iTalkId);
+ /**
+ * Удаляем комментарии к письму.
+ * При удалении комментариев они удаляются из избранного,прямого эфира и голоса за них
+ */
+ $this->Comment_DeleteCommentByTargetId($iTalkId, 'talk');
+ /**
+ * Удаляем медиа данные
+ */
+ $this->Media_RemoveTarget('talk', $iTalkId, true);
+ }
+
+ /**
+ * Отправляет уведомление при новом личном сообщении
+ *
+ * @param ModuleUser_EntityUser $oUserTo Объект пользователя, которому отправляем сообщение
+ * @param ModuleUser_EntityUser $oUserFrom Объект пользователя, который отправляет сообщение
+ * @param ModuleTalk_EntityTalk $oTalk Объект сообщения
+ * @return bool
+ */
+ public function SendNotifyTalkNew(
+ ModuleUser_EntityUser $oUserTo,
+ ModuleUser_EntityUser $oUserFrom,
+ ModuleTalk_EntityTalk $oTalk
+ ) {
+ /**
+ * Проверяем можно ли юзеру рассылать уведомление
+ */
+ if (!$oUserTo->getSettingsNoticeNewTalk()) {
+ return false;
+ }
+ $this->Notify_Send(
+ $oUserTo,
+ 'talk_new.tpl',
+ $this->Lang_Get('emails.talk_new.subject'),
+ array(
+ 'oUserTo' => $oUserTo,
+ 'oUserFrom' => $oUserFrom,
+ 'oTalk' => $oTalk,
+ )
+ );
+ return true;
+ }
+
+ /**
+ * Отправляет уведомление о новом сообщение в личке
+ *
+ * @param ModuleUser_EntityUser $oUserTo Объект пользователя, которому отправляем уведомление
+ * @param ModuleUser_EntityUser $oUserFrom Объект пользователя, которыф написал комментарий
+ * @param ModuleTalk_EntityTalk $oTalk Объект сообщения
+ * @param ModuleComment_EntityComment $oTalkComment Объект комментария
+ * @return bool
+ */
+ public function SendNotifyTalkCommentNew(
+ ModuleUser_EntityUser $oUserTo,
+ ModuleUser_EntityUser $oUserFrom,
+ ModuleTalk_EntityTalk $oTalk,
+ ModuleComment_EntityComment $oTalkComment
+ ) {
+ /**
+ * Проверяем можно ли юзеру рассылать уведомление
+ */
+ if (!$oUserTo->getSettingsNoticeNewTalk()) {
+ return false;
+ }
+ $this->Notify_Send(
+ $oUserTo,
+ 'talk_comment_new.tpl',
+ $this->Lang_Get('emails.talk_comment_new.subject'),
+ array(
+ 'oUserTo' => $oUserTo,
+ 'oUserFrom' => $oUserFrom,
+ 'oTalk' => $oTalk,
+ 'oTalkComment' => $oTalkComment,
+ )
+ );
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/talk/entity/Talk.entity.class.php b/application/classes/modules/talk/entity/Talk.entity.class.php
new file mode 100644
index 0000000..4aea14e
--- /dev/null
+++ b/application/classes/modules/talk/entity/Talk.entity.class.php
@@ -0,0 +1,355 @@
+
+ *
+ */
+
+/**
+ * Объект сущности сообщения
+ *
+ * @package application.modules.talk
+ * @since 1.0
+ */
+class ModuleTalk_EntityTalk extends Entity
+{
+ /**
+ * Возвращает ID сообщения
+ *
+ * @return int|null
+ */
+ public function getId()
+ {
+ return $this->_getDataOne('talk_id');
+ }
+
+ /**
+ * Возвращает ID пользователя
+ *
+ * @return int|null
+ */
+ public function getUserId()
+ {
+ return $this->_getDataOne('user_id');
+ }
+
+ /**
+ * Вовзращает заголовок сообщения
+ *
+ * @return string|null
+ */
+ public function getTitle()
+ {
+ return $this->_getDataOne('talk_title');
+ }
+
+ /**
+ * Возвращает текст сообщения
+ *
+ * @return string|null
+ */
+ public function getText()
+ {
+ return $this->_getDataOne('talk_text');
+ }
+
+ /**
+ * Возвращает дату сообщения
+ *
+ * @return string|null
+ */
+ public function getDate()
+ {
+ return $this->_getDataOne('talk_date');
+ }
+
+ /**
+ * Возвращает дату последнего сообщения
+ *
+ * @return string|null
+ */
+ public function getDateLast()
+ {
+ return $this->_getDataOne('talk_date_last');
+ }
+
+ /**
+ * Возвращает ID последнего пользователя
+ *
+ * @return int|null
+ */
+ public function getUserIdLast()
+ {
+ return $this->_getDataOne('talk_user_id_last');
+ }
+
+ /**
+ * Вовзращает IP пользователя
+ *
+ * @return string|null
+ */
+ public function getUserIp()
+ {
+ return $this->_getDataOne('talk_user_ip');
+ }
+
+ /**
+ * Возвращает ID последнего комментария
+ *
+ * @return int|null
+ */
+ public function getCommentIdLast()
+ {
+ return $this->_getDataOne('talk_comment_id_last');
+ }
+
+ /**
+ * Возвращает количество комментариев
+ *
+ * @return int|null
+ */
+ public function getCountComment()
+ {
+ return $this->_getDataOne('talk_count_comment');
+ }
+
+
+ /**
+ * Возвращает последний текст(коммент) из письма, если комментов нет, то текст исходного сообщения
+ *
+ * @return string
+ */
+ public function getTextLast()
+ {
+ if ($oComment = $this->getCommentLast()) {
+ return $oComment->getText();
+ }
+ return $this->getText();
+ }
+
+ /**
+ * Возвращает список пользователей
+ *
+ * @return array|null
+ */
+ public function getUsers()
+ {
+ return $this->_getDataOne('users');
+ }
+
+ /**
+ * Возвращает объект пользователя
+ *
+ * @return ModuleUser_EntityUser|null
+ */
+ public function getUser()
+ {
+ return $this->_getDataOne('user');
+ }
+
+ /**
+ * Возвращает объект связи пользователя с сообщением
+ *
+ * @return ModuleTalk_EntityTalkUser|null
+ */
+ public function getTalkUser()
+ {
+ return $this->_getDataOne('talk_user');
+ }
+
+ /**
+ * Возращает true, если разговор занесен в избранное
+ *
+ * @return bool
+ */
+ public function getIsFavourite()
+ {
+ return $this->_getDataOne('talk_is_favourite');
+ }
+
+ /**
+ * Возращает пользователей разговора
+ *
+ * @return array
+ */
+ public function getTalkUsers()
+ {
+ return $this->_getDataOne('talk_users');
+ }
+
+
+ /**
+ * Возвращает полный URL для удаления сообщения
+ *
+ * @return string
+ */
+ public function getUrlDelete()
+ {
+ return Router::GetPath('talk') . 'delete/' . $this->getId() . '/';
+ }
+
+
+ /**
+ * Устанавливает ID сообщения
+ *
+ * @param int $data
+ */
+ public function setId($data)
+ {
+ $this->_aData['talk_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID пользователя
+ *
+ * @param int $data
+ */
+ public function setUserId($data)
+ {
+ $this->_aData['user_id'] = $data;
+ }
+
+ /**
+ * Устанавливает заголовок сообщения
+ *
+ * @param string $data
+ */
+ public function setTitle($data)
+ {
+ $this->_aData['talk_title'] = $data;
+ }
+
+ /**
+ * Устанавливает текст сообщения
+ *
+ * @param string $data
+ */
+ public function setText($data)
+ {
+ $this->_aData['talk_text'] = $data;
+ }
+
+ /**
+ * Устанавливает дату разговора
+ *
+ * @param string $data
+ */
+ public function setDate($data)
+ {
+ $this->_aData['talk_date'] = $data;
+ }
+
+ /**
+ * Устанавливает дату последнего сообщения в разговоре
+ *
+ * @param string $data
+ */
+ public function setDateLast($data)
+ {
+ $this->_aData['talk_date_last'] = $data;
+ }
+
+ /**
+ * Устанавливает ID последнего пользователя
+ *
+ * @param int $data
+ */
+ public function setUserIdLast($data)
+ {
+ $this->_aData['talk_user_id_last'] = $data;
+ }
+
+ /**
+ * Устанавливает IP пользователя
+ *
+ * @param string $data
+ */
+ public function setUserIp($data)
+ {
+ $this->_aData['talk_user_ip'] = $data;
+ }
+
+ /**
+ * Устанавливает ID последнего комментария
+ *
+ * @param string $data
+ */
+ public function setCommentIdLast($data)
+ {
+ $this->_aData['talk_comment_id_last'] = $data;
+ }
+
+ /**
+ * Устанавливает количество комментариев
+ *
+ * @param int $data
+ */
+ public function setCountComment($data)
+ {
+ $this->_aData['talk_count_comment'] = $data;
+ }
+
+ /**
+ * Устанавливает список пользователей
+ *
+ * @param array $data
+ */
+ public function setUsers($data)
+ {
+ $this->_aData['users'] = $data;
+ }
+
+ /**
+ * Устанавливает объект пользователя
+ *
+ * @param ModuleUser_EntityUser $data
+ */
+ public function setUser($data)
+ {
+ $this->_aData['user'] = $data;
+ }
+
+ /**
+ * Устанавливает объект связи
+ *
+ * @param ModuleTalk_EntityTalkUser $data
+ */
+ public function setTalkUser($data)
+ {
+ $this->_aData['talk_user'] = $data;
+ }
+
+ /**
+ * Устанавливает факт налиция разговора в избранном текущего пользователя
+ *
+ * @param bool $data
+ */
+ public function setIsFavourite($data)
+ {
+ $this->_aData['talk_is_favourite'] = $data;
+ }
+
+ /**
+ * Устанавливает список связей
+ *
+ * @param array $data
+ */
+ public function setTalkUsers($data)
+ {
+ $this->_aData['talk_users'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/talk/entity/TalkUser.entity.class.php b/application/classes/modules/talk/entity/TalkUser.entity.class.php
new file mode 100644
index 0000000..2ca3b9a
--- /dev/null
+++ b/application/classes/modules/talk/entity/TalkUser.entity.class.php
@@ -0,0 +1,170 @@
+
+ *
+ */
+
+/**
+ * Объект связи пользователя с разовором
+ *
+ * @package application.modules.talk
+ * @since 1.0
+ */
+class ModuleTalk_EntityTalkUser extends Entity
+{
+ /**
+ * Возвращает ID разговора
+ *
+ * @return int|null
+ */
+ public function getTalkId()
+ {
+ return $this->_getDataOne('talk_id');
+ }
+
+ /**
+ * Возвращает ID пользователя
+ *
+ * @return int|null
+ */
+ public function getUserId()
+ {
+ return $this->_getDataOne('user_id');
+ }
+
+ /**
+ * Возвращает дату последнего сообщения
+ *
+ * @return string|null
+ */
+ public function getDateLast()
+ {
+ return $this->_getDataOne('date_last');
+ }
+
+ /**
+ * Возвращает ID последнего комментария
+ *
+ * @return int|null
+ */
+ public function getCommentIdLast()
+ {
+ return $this->_getDataOne('comment_id_last');
+ }
+
+ /**
+ * Возвращает количество новых сообщений
+ *
+ * @return int|null
+ */
+ public function getCommentCountNew()
+ {
+ return $this->_getDataOne('comment_count_new');
+ }
+
+ /**
+ * Возвращает статус активности пользователя
+ *
+ * @return int
+ */
+ public function getUserActive()
+ {
+ return $this->_getDataOne('talk_user_active') ? $this->_getDataOne('talk_user_active') : ModuleTalk::TALK_USER_ACTIVE;
+ }
+
+ /**
+ * Возвращает соответствующий пользователю объект
+ *
+ * @return ModuleUser_EntityUser | null
+ */
+ public function getUser()
+ {
+ return $this->_getDataOne('user');
+ }
+
+
+ /**
+ * Устанавливает ID разговора
+ *
+ * @param int $data
+ */
+ public function setTalkId($data)
+ {
+ $this->_aData['talk_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID пользователя
+ *
+ * @param int $data
+ */
+ public function setUserId($data)
+ {
+ $this->_aData['user_id'] = $data;
+ }
+
+ /**
+ * Устанавливает последнюю дату
+ *
+ * @param string $data
+ */
+ public function setDateLast($data)
+ {
+ $this->_aData['date_last'] = $data;
+ }
+
+ /**
+ * Устанавливает ID последнее комментария
+ *
+ * @param int $data
+ */
+ public function setCommentIdLast($data)
+ {
+ $this->_aData['comment_id_last'] = $data;
+ }
+
+ /**
+ * Устанавливает количество новых комментариев
+ *
+ * @param int $data
+ */
+ public function setCommentCountNew($data)
+ {
+ $this->_aData['comment_count_new'] = $data;
+ }
+
+ /**
+ * Устанавливает статус связи
+ *
+ * @param int $data
+ */
+ public function setUserActive($data)
+ {
+ $this->_aData['talk_user_active'] = $data;
+ }
+
+ /**
+ * Устанавливает объект пользователя
+ *
+ * @param ModuleUser_EntityUser $data
+ */
+ public function setUser($data)
+ {
+ $this->_aData['user'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/talk/mapper/Talk.mapper.class.php b/application/classes/modules/talk/mapper/Talk.mapper.class.php
new file mode 100644
index 0000000..9ac3246
--- /dev/null
+++ b/application/classes/modules/talk/mapper/Talk.mapper.class.php
@@ -0,0 +1,618 @@
+
+ *
+ */
+
+/**
+ * Объект маппера для работы с БД
+ *
+ * @package application.modules.talk
+ * @since 1.0
+ */
+class ModuleTalk_MapperTalk extends Mapper
+{
+ /**
+ * Добавляет новую тему разговора
+ *
+ * @param ModuleTalk_EntityTalk $oTalk Объект сообщения
+ * @return int|bool
+ */
+ public function AddTalk(ModuleTalk_EntityTalk $oTalk)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.talk') . "
+ (user_id,
+ talk_title,
+ talk_text,
+ talk_date,
+ talk_date_last,
+ talk_user_id_last,
+ talk_user_ip
+ )
+ VALUES(?d, ?, ?, ?, ?, ?, ?)
+ ";
+ if ($iId = $this->oDb->query($sql, $oTalk->getUserId(), $oTalk->getTitle(), $oTalk->getText(),
+ $oTalk->getDate(), $oTalk->getDateLast(), $oTalk->getUserIdLast(), $oTalk->getUserIp())
+ ) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Удаление письма из БД
+ *
+ * @param int $iTalkId ID разговора
+ */
+ public function DeleteTalk($iTalkId)
+ {
+ // Удаление беседы
+ $sql = 'DELETE FROM ' . Config::Get('db.table.talk') . ' WHERE talk_id = ?d';
+ $this->oDb->query($sql, $iTalkId);
+ // Физическое удаление пользователей беседы (не флагом)
+ $sql = 'DELETE FROM ' . Config::Get('db.table.talk_user') . ' WHERE talk_id = ?d';
+ $this->oDb->query($sql, $iTalkId);
+ }
+
+ /**
+ * Обновление разговора
+ *
+ * @param ModuleTalk_EntityTalk $oTalk Объект сообщения
+ * @return int
+ */
+ public function UpdateTalk(ModuleTalk_EntityTalk $oTalk)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.talk') . " SET
+ talk_date_last = ? ,
+ talk_user_id_last = ? ,
+ talk_comment_id_last = ? ,
+ talk_count_comment = ?
+ WHERE
+ talk_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oTalk->getDateLast(), $oTalk->getUserIdLast(), $oTalk->getCommentIdLast(),
+ $oTalk->getCountComment(), $oTalk->getId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получить список разговоров по списку айдишников
+ *
+ * @param array $aArrayId Список ID сообщений
+ * @return array
+ */
+ public function GetTalksByArrayId($aArrayId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+
+ $sql = "SELECT
+ t.*
+ FROM
+ " . Config::Get('db.table.talk') . " as t
+ WHERE
+ t.talk_id IN(?a)
+ ORDER BY FIELD(t.talk_id,?a) ";
+ $aTalks = array();
+ if ($aRows = $this->oDb->select($sql, $aArrayId, $aArrayId)) {
+ foreach ($aRows as $aRow) {
+ $aTalks[] = Engine::GetEntity('Talk', $aRow);
+ }
+ }
+ return $aTalks;
+ }
+
+ /**
+ * Получить список отношений разговор-юзер по списку айдишников
+ *
+ * @param array $aArrayId Список ID сообщений
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetTalkUserByArray($aArrayId, $sUserId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+
+ $sql = "SELECT
+ t.*
+ FROM
+ " . Config::Get('db.table.talk_user') . " as t
+ WHERE
+ t.talk_id IN(?a)
+ AND
+ t.user_id = ?d
+ ";
+ $aTalkUsers = array();
+ if ($aRows = $this->oDb->select($sql, $aArrayId, $sUserId)) {
+ foreach ($aRows as $aRow) {
+ $aTalkUsers[] = Engine::GetEntity('Talk_TalkUser', $aRow);
+ }
+ }
+ return $aTalkUsers;
+ }
+
+ /**
+ * Получает тему разговора по айдишнику
+ *
+ * @param int $sId ID сообщения
+ * @return ModuleTalk_EntityTalk|null
+ */
+ public function GetTalkById($sId)
+ {
+
+ $sql = "SELECT
+ t.*,
+ u.user_login as user_login
+ FROM
+ " . Config::Get('db.table.talk') . " as t,
+ " . Config::Get('db.table.user') . " as u
+ WHERE
+ t.talk_id = ?d
+ AND
+ t.user_id=u.user_id
+ ";
+
+ if ($aRow = $this->oDb->selectRow($sql, $sId)) {
+ return Engine::GetEntity('Talk', $aRow);
+ }
+ return null;
+ }
+
+ /**
+ * Добавляет юзера к разговору(теме)
+ *
+ * @param ModuleTalk_EntityTalkUser $oTalkUser Объект связи пользователя и сообщения(разговора)
+ * @return bool
+ */
+ public function AddTalkUser(ModuleTalk_EntityTalkUser $oTalkUser)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.talk_user') . "
+ (talk_id,
+ user_id,
+ date_last,
+ talk_user_active
+ )
+ VALUES(?d, ?d, ?, ?d)
+ ON DUPLICATE KEY
+ UPDATE talk_user_active = ?d
+ ";
+ if ($this->oDb->query($sql,
+ $oTalkUser->getTalkId(),
+ $oTalkUser->getUserId(),
+ $oTalkUser->getDateLast(),
+ $oTalkUser->getUserActive(),
+ $oTalkUser->getUserActive()
+ ) === 0
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет связку разговор-юзер
+ *
+ * @param ModuleTalk_EntityTalkUser $oTalkUser Объект связи пользователя с разговором
+ * @return bool
+ */
+ public function UpdateTalkUser(ModuleTalk_EntityTalkUser $oTalkUser)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.talk_user') . "
+ SET
+ date_last = ?,
+ comment_id_last = ?d,
+ comment_count_new = ?d,
+ talk_user_active = ?d
+ WHERE
+ talk_id = ?d
+ AND
+ user_id = ?d
+ ";
+
+ $res = $this->oDb->query(
+ $sql,
+ $oTalkUser->getDateLast(),
+ $oTalkUser->getCommentIdLast(),
+ $oTalkUser->getCommentCountNew(),
+ $oTalkUser->getUserActive(),
+ $oTalkUser->getTalkId(),
+ $oTalkUser->getUserId()
+ );
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Удаляет юзера из разговора
+ *
+ * @param array $aTalkId Список ID сообщений
+ * @param int $sUserId ID пользователя
+ * @param int $iActive Статус связи
+ * @return bool
+ */
+ public function DeleteTalkUserByArray($aTalkId, $sUserId, $iActive)
+ {
+ if (!is_array($aTalkId)) {
+ $aTalkId = array($aTalkId);
+ }
+ $sql = "
+ UPDATE " . Config::Get('db.table.talk_user') . "
+ SET
+ talk_user_active = ?d
+ WHERE
+ talk_id IN (?a)
+ AND
+ user_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $iActive, $aTalkId, $sUserId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Возвращает количество новых комментариев
+ *
+ * @param $sUserId
+ * @return bool
+ */
+ public function GetCountCommentNew($sUserId)
+ {
+ $sql = "
+ SELECT
+ SUM(tu.comment_count_new) as count_new
+ FROM
+ " . Config::Get('db.table.talk_user') . " as tu
+ WHERE
+ tu.user_id = ?d
+ AND
+ tu.talk_user_active=?d
+ ";
+ if ($aRow = $this->oDb->selectRow($sql, $sUserId, ModuleTalk::TALK_USER_ACTIVE)) {
+ return $aRow['count_new'];
+ }
+ return false;
+ }
+
+ /**
+ * Получает число новых тем и комментов где есть юзер
+ *
+ * @param int $sUserId ID пользователя
+ * @return int
+ */
+ public function GetCountTalkNew($sUserId)
+ {
+ $sql = "
+ SELECT
+ COUNT(tu.talk_id) as count_new
+ FROM
+ " . Config::Get('db.table.talk_user') . " as tu
+ WHERE
+ tu.user_id = ?d
+ AND
+ tu.date_last IS NULL
+ AND
+ tu.talk_user_active=?d
+ ";
+ if ($aRow = $this->oDb->selectRow($sql, $sUserId, ModuleTalk::TALK_USER_ACTIVE)) {
+ return $aRow['count_new'];
+ }
+ return false;
+ }
+
+ /**
+ * Получить все темы разговора где есть юзер
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetTalksByUserId($sUserId, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $sql = "SELECT
+ tu.talk_id
+ FROM
+ " . Config::Get('db.table.talk_user') . " as tu,
+ " . Config::Get('db.table.talk') . " as t
+ WHERE
+ tu.user_id = ?d
+ AND
+ tu.talk_id=t.talk_id
+ AND
+ tu.talk_user_active = ?d
+ ORDER BY t.talk_date_last desc, t.talk_date desc
+ LIMIT ?d, ?d
+ ";
+
+ $aTalks = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql, $sUserId, ModuleTalk::TALK_USER_ACTIVE,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage)
+ ) {
+ foreach ($aRows as $aRow) {
+ $aTalks[] = $aRow['talk_id'];
+ }
+ }
+ return $aTalks;
+ }
+
+ /**
+ * Получает список юзеров в теме разговора
+ *
+ * @param int $sTalkId ID разговора
+ * @param array $aUserActive Список статусов
+ * @return array
+ */
+ public function GetUsersTalk($sTalkId, $aUserActive = array())
+ {
+ $sql = "
+ SELECT
+ user_id
+ FROM
+ " . Config::Get('db.table.talk_user') . "
+ WHERE
+ talk_id = ?
+ { AND talk_user_active IN(?a) }
+ ";
+ $aReturn = array();
+ if ($aRows = $this->oDb->select($sql, $sTalkId,
+ (count($aUserActive) ? $aUserActive : DBSIMPLE_SKIP)
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = $aRow['user_id'];
+ }
+ }
+
+ return $aReturn;
+ }
+
+ /**
+ * Увеличивает число новых комментов у юзеров
+ *
+ * @param int $sTalkId ID разговора
+ * @param array $aExcludeId Список ID пользователей для исключения
+ * @return int
+ */
+ public function increaseCountCommentNew($sTalkId, $aExcludeId)
+ {
+ if (!is_null($aExcludeId) and !is_array($aExcludeId)) {
+ $aExcludeId = array($aExcludeId);
+ }
+
+ $sql = "UPDATE
+ " . Config::Get('db.table.talk_user') . "
+ SET comment_count_new=comment_count_new+1
+ WHERE
+ talk_id = ?
+ { AND user_id NOT IN (?a) }";
+ $res = $this->oDb->query($sql, $sTalkId, !is_null($aExcludeId) ? $aExcludeId : DBSIMPLE_SKIP);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Возвращает массив пользователей, участвующих в разговоре
+ *
+ * @param int $sTalkId ID разговора
+ * @return array
+ */
+ public function GetTalkUsers($sTalkId)
+ {
+ $sql = "
+ SELECT
+ t.*
+ FROM
+ " . Config::Get('db.table.talk_user') . " as t
+ WHERE
+ talk_id = ?
+
+ ";
+ $aReturn = array();
+ if ($aRows = $this->oDb->select($sql, $sTalkId)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = Engine::GetEntity('Talk_TalkUser', $aRow);
+ }
+ }
+
+ return $aReturn;
+ }
+
+ /**
+ * Получить все темы разговора по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetTalksByFilter($aFilter, &$iCount, $iCurrPage, $iPerPage)
+ {
+ if (isset($aFilter['id']) and !is_array($aFilter['id'])) {
+ $aFilter['id'] = array($aFilter['id']);
+ }
+ $sql = "SELECT
+ tu.talk_id
+ FROM
+ " . Config::Get('db.table.talk_user') . " as tu,
+ " . Config::Get('db.table.talk') . " as t,
+ " . Config::Get('db.table.user') . " as u
+ WHERE
+ tu.talk_id=t.talk_id
+ AND tu.talk_user_active = ?d
+ AND u.user_id=t.user_id
+ { AND tu.user_id = ?d }
+ { AND tu.talk_id IN (?a) }
+ { AND ( tu.comment_count_new > ?d OR tu.date_last IS NULL ) }
+ { AND t.talk_date <= ? }
+ { AND t.talk_date >= ? }
+ { AND t.talk_title LIKE ? }
+ { AND t.talk_text LIKE ? }
+ { AND u.user_login = ? }
+ { AND t.user_id = ? }
+ { AND tu.talk_id IN (
+ SELECT stu.talk_id
+ FROM
+ " . Config::Get('db.table.talk_user') . " as stu,
+ " . Config::Get('db.table.talk') . " as st
+ WHERE
+ stu.user_id = ?d
+ AND stu.talk_id = st.talk_id
+ AND st.user_id != stu.user_id
+ ) }
+ ORDER BY t.talk_date_last desc, t.talk_date desc
+ LIMIT ?d, ?d
+ ";
+
+ $aTalks = array();
+ if (
+ $aRows = $this->oDb->selectPage(
+ $iCount,
+ $sql,
+ ModuleTalk::TALK_USER_ACTIVE,
+ (!empty($aFilter['user_id']) ? $aFilter['user_id'] : DBSIMPLE_SKIP),
+ ((isset($aFilter['id']) and count($aFilter['id'])) ? $aFilter['id'] : DBSIMPLE_SKIP),
+ (!empty($aFilter['only_new']) ? 0 : DBSIMPLE_SKIP),
+ (!empty($aFilter['date_max']) ? $aFilter['date_max'] : DBSIMPLE_SKIP),
+ (!empty($aFilter['date_min']) ? $aFilter['date_min'] : DBSIMPLE_SKIP),
+ (!empty($aFilter['keyword']) ? $aFilter['keyword'] : DBSIMPLE_SKIP),
+ (!empty($aFilter['text_like']) ? $aFilter['text_like'] : DBSIMPLE_SKIP),
+ (!empty($aFilter['user_login']) ? $aFilter['user_login'] : DBSIMPLE_SKIP),
+ (!empty($aFilter['sender_id']) ? $aFilter['sender_id'] : DBSIMPLE_SKIP),
+ (!empty($aFilter['receiver_user_id']) ? $aFilter['receiver_user_id'] : DBSIMPLE_SKIP),
+ ($iCurrPage - 1) * $iPerPage,
+ $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aTalks[] = $aRow['talk_id'];
+ }
+ }
+ return $aTalks;
+ }
+
+ /**
+ * Получает информацию о пользователях, занесенных в блеклист
+ *
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetBlacklistByUserId($sUserId)
+ {
+ $sql = "SELECT
+ tb.user_target_id
+ FROM
+ " . Config::Get('db.table.talk_blacklist') . " as tb
+ WHERE
+ tb.user_id = ?d";
+ $aTargetId = array();
+ if ($aRows = $this->oDb->select($sql, $sUserId)) {
+ foreach ($aRows as $aRow) {
+ $aTargetId[] = $aRow['user_target_id'];
+ }
+ }
+ return $aTargetId;
+ }
+
+ /**
+ * Возвращает пользователей, у которых данный занесен в Blacklist
+ *
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetBlacklistByTargetId($sUserId)
+ {
+ $sql = "SELECT
+ tb.user_id
+ FROM
+ " . Config::Get('db.table.talk_blacklist') . " as tb
+ WHERE
+ tb.user_target_id = ?d";
+ $aUserId = array();
+ if ($aRows = $this->oDb->select($sql, $sUserId)) {
+ foreach ($aRows as $aRow) {
+ $aUserId[] = $aRow['user_id'];
+ }
+ }
+ return $aUserId;
+ }
+
+ /**
+ * Добавление пользователя в блеклист по переданному идентификатору
+ *
+ * @param int $sTargetId ID пользователя, которого добавляем в блэклист
+ * @param int $sUserId ID пользователя
+ * @return bool
+ */
+ public function AddUserToBlacklist($sTargetId, $sUserId)
+ {
+ $sql = "
+ INSERT INTO " . Config::Get('db.table.talk_blacklist') . "
+ ( user_id, user_target_id )
+ VALUES
+ (?d, ?d)
+ ";
+ if ($this->oDb->query($sql, $sUserId, $sTargetId) === 0) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Удаляем пользователя из блеклиста
+ *
+ * @param int $sTargetId ID пользователя, которого удаляем из блэклиста
+ * @param int $sUserId ID пользователя
+ * @return bool
+ */
+ public function DeleteUserFromBlacklist($sTargetId, $sUserId)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.talk_blacklist') . "
+ WHERE
+ user_id = ?d
+ AND
+ user_target_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $sUserId, $sTargetId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Добавление пользователя в блеклист по списку идентификаторов
+ *
+ * @param array $aTargetId Список ID пользователей, которых добавляем в блэклист
+ * @param int $sUserId ID пользователя
+ * @return bool
+ */
+ public function AddUserArrayToBlacklist($aTargetId, $sUserId)
+ {
+ $sql = "
+ INSERT INTO " . Config::Get('db.table.talk_blacklist') . "
+ ( user_id, user_target_id )
+ VALUES
+ (?d, ?d)
+ ";
+ $bOk = true;
+ foreach ($aTargetId as $sTarget) {
+ $bOk = $bOk && $this->oDb->query($sql, $sUserId, $sTarget);
+ }
+ return $bOk;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/tools/Tools.class.php b/application/classes/modules/tools/Tools.class.php
new file mode 100644
index 0000000..aa4549a
--- /dev/null
+++ b/application/classes/modules/tools/Tools.class.php
@@ -0,0 +1,196 @@
+
+ *
+ */
+
+/**
+ * Модуль Tools - различные вспомогательные методы
+ *
+ * @package application.modules.tools
+ * @since 1.0
+ */
+class ModuleTools extends Module
+{
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+
+ }
+
+ /**
+ * Строит логарифмическое облако - расчитывает значение size в зависимости от count
+ * У объектов в коллекции обязательно должны быть методы getCount() и setSize()
+ *
+ * @param aray $aCollection Список тегов
+ * @param int $iMinSize Минимальный размер
+ * @param int $iMaxSize Максимальный размер
+ * @return array
+ */
+ public function MakeCloud($aCollection, $iMinSize = 1, $iMaxSize = 10)
+ {
+ if (count($aCollection)) {
+ $iSizeRange = $iMaxSize - $iMinSize;
+
+ $iMin = 10000;
+ $iMax = 0;
+ foreach ($aCollection as $oObject) {
+ if ($iMax < $oObject->getCount()) {
+ $iMax = $oObject->getCount();
+ }
+ if ($iMin > $oObject->getCount()) {
+ $iMin = $oObject->getCount();
+ }
+ }
+ $iMinCount = log($iMin + 1);
+ $iMaxCount = log($iMax + 1);
+ $iCountRange = $iMaxCount - $iMinCount;
+ if ($iCountRange == 0) {
+ $iCountRange = 1;
+ }
+ foreach ($aCollection as $oObject) {
+ $iTagSize = $iMinSize + (log($oObject->getCount() + 1) - $iMinCount) * ($iSizeRange / $iCountRange);
+ $oObject->setSize(round($iTagSize));
+ }
+ }
+ return $aCollection;
+ }
+
+ /**
+ * Возвращает дерево объектов
+ *
+ * @param array $aEntities Массив данных сущностей с заполнеными полями 'childNodes'
+ * @param bool $bBegin
+ *
+ * @return array
+ */
+ public function BuildEntityRecursive($aEntities, $bBegin = true)
+ {
+ static $aResultEntities;
+ static $iLevel;
+ static $iMaxIdEntity;
+ if ($bBegin) {
+ $aResultEntities = array();
+ $iLevel = 0;
+ $iMaxIdEntity = 0;
+ }
+ foreach ($aEntities as $aEntity) {
+ $aTemp = $aEntity;
+ if ($aEntity['id'] > $iMaxIdEntity) {
+ $iMaxIdEntity = $aEntity['id'];
+ }
+ $aTemp['level'] = $iLevel;
+ unset($aTemp['childNodes']);
+ $aResultEntities[$aTemp['id']] = $aTemp['level'];
+ if (isset($aEntity['childNodes']) and count($aEntity['childNodes']) > 0) {
+ $iLevel++;
+ $this->BuildEntityRecursive($aEntity['childNodes'], false);
+ }
+ }
+ $iLevel--;
+ return array('collection' => $aResultEntities, 'iMaxId' => $iMaxIdEntity);
+ }
+
+ /**
+ * Преобразует спец символы в html последовательнось, поведение аналогично htmlspecialchars, кроме преобразования амперсанта "&"
+ *
+ * @param string $sText
+ *
+ * @return string
+ */
+ public function Urlspecialchars($sText)
+ {
+ return func_urlspecialchars($sText);
+ }
+
+ /**
+ * Обработка тега ls в тексте
+ *
+ *
+ *
+ *
+ * @param string $sTag Тег на ктором сработал колбэк
+ * @param array $aParams Список параметров тега
+ * @return string
+ */
+ public function CallbackParserTagLs($sTag, $aParams)
+ {
+ $sText = '';
+ if (isset($aParams['user'])) {
+ if ($oUser = $this->User_GetUserByLogin($aParams['user'])) {
+ $sText .= "getUserWebPath()}\" class=\"ls-user\">{$oUser->getLogin()} ";
+ }
+ }
+ return $sText;
+ }
+
+ /**
+ * Отдает файл на загрузку в браузер пользователя
+ *
+ * @param $sFilePath
+ * @param $sFileName
+ * @param null $iFileSize
+ *
+ * @return bool
+ */
+ public function DownloadFile($sFilePath, $sFileName, $iFileSize = null)
+ {
+ if (file_exists($sFilePath) and $file = fopen($sFilePath, "r")) {
+ header("Content-Type: application/octet-stream");
+ header("Content-Disposition: attachment; filename=" . urlencode($sFileName) . ";");
+ header("Content-Transfer-Encoding: binary");
+ if ($iFileSize) {
+ header("Content-Length: " . $iFileSize);
+ }
+ while (!feof($file)) {
+ $sContent = fread($file, 1024 * 100);
+ echo $sContent;
+ }
+ Engine::getInstance()->Shutdown();
+ exit(0);
+ }
+ return false;
+ }
+
+ /**
+ * Запускает задачу рассылки емайлов (отложенная отправка)
+ */
+ public function SystemTaskNotify()
+ {
+ $aNotifyTasks = $this->Notify_GetTasksDelayed(Config::Get('module.notify.per_process'));
+ if (!$aNotifyTasks) {
+ return 'empty';
+ }
+ /**
+ * Последовательно загружаем задания
+ */
+ $aArrayId = array();
+ foreach ($aNotifyTasks as $oTask) {
+ $this->Notify_SendTask($oTask);
+ $aArrayId[] = $oTask->getTaskId();
+ }
+ /**
+ * Удаляем отработанные задания
+ */
+ $this->Notify_DeleteTaskByArrayId($aArrayId);
+ return "Send notify: " . count($aArrayId);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/topic/Topic.class.php b/application/classes/modules/topic/Topic.class.php
new file mode 100644
index 0000000..fa598f3
--- /dev/null
+++ b/application/classes/modules/topic/Topic.class.php
@@ -0,0 +1,2095 @@
+
+ *
+ */
+
+/**
+ * Модуль для работы с топиками
+ *
+ * @package application.modules.topic
+ * @since 1.0
+ */
+class ModuleTopic extends Module
+{
+
+ const TOPIC_TYPE_STATE_ACTIVE = 1;
+ const TOPIC_TYPE_STATE_NOT_ACTIVE = 0;
+
+ /**
+ * Объект маппера
+ *
+ * @var ModuleTopic_MapperTopic
+ */
+ protected $oMapperTopic;
+ /**
+ * Объект текущего пользователя
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+ /**
+ * Список типов топика
+ *
+ * @var array
+ */
+ protected $aTopicTypes = array();
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ $this->oMapperTopic = Engine::GetMapper(__CLASS__);
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+
+ $aTopicTypeItems = $this->GetTopicTypeItems(array('state' => self::TOPIC_TYPE_STATE_ACTIVE));
+ foreach ($aTopicTypeItems as $oTypeItem) {
+ $this->aTopicTypes[$oTypeItem->getCode()] = $oTypeItem;
+ }
+ }
+
+ /**
+ * Возвращает список типов топика, возвращаются только активные типы
+ *
+ * @param bool $bOnlyCode Вернуть только коды типов
+ *
+ * @return array
+ */
+ public function GetTopicTypes($bOnlyCode = false)
+ {
+ return $bOnlyCode ? array_keys($this->aTopicTypes) : $this->aTopicTypes;
+ }
+
+ /**
+ * Возвращает объект типа топика, поиск только среди активных типов
+ *
+ * @param string $sType
+ *
+ * @return ModuleTopic_EntityTopicType|null
+ */
+ public function GetTopicType($sType)
+ {
+ return isset($this->aTopicTypes[$sType]) ? $this->aTopicTypes[$sType] : null;
+ }
+
+ /**
+ * Возвращает первый доступные тип топика
+ *
+ * @return ModuleTopic_EntityTopicType|null
+ */
+ public function GetTopicTypeFirst()
+ {
+ $oType = reset($this->aTopicTypes);
+ return $oType ?: null;
+ }
+
+ /**
+ * Проверяет разрешен ли данный тип топика
+ *
+ * @param string $sType Тип
+ * @return bool
+ */
+ public function IsAllowTopicType($sType)
+ {
+ return array_key_exists($sType, $this->aTopicTypes);
+ }
+
+ /**
+ * Получает дополнительные данные(объекты) для топиков по их ID
+ *
+ * @param array $aTopicId Список ID топиков
+ * @param array|null $aAllowData Список типов дополнительных данных, которые нужно подключать к топикам
+ * @return array
+ */
+ public function GetTopicsAdditionalData($aTopicId, $aAllowData = null)
+ {
+ if (is_null($aAllowData)) {
+ $aAllowData = array(
+ 'user' => array(),
+ 'blog' => array('owner' => array(), 'relation_user'),
+ 'vote',
+ 'favourite',
+ 'comment_new',
+ 'properties'
+ );
+ }
+ func_array_simpleflip($aAllowData);
+ if (!is_array($aTopicId)) {
+ $aTopicId = array($aTopicId);
+ }
+ /**
+ * Получаем "голые" топики
+ */
+ $aTopics = $this->GetTopicsByArrayId($aTopicId);
+ /**
+ * Формируем ID дополнительных данных, которые нужно получить
+ */
+ $aUserId = array();
+ $aBlogId = array();
+ foreach ($aTopics as $oTopic) {
+ if (isset($aAllowData['user'])) {
+ $aUserId[] = $oTopic->getUserId();
+ }
+ if (isset($aAllowData['blog'])) {
+ $aBlogId = array_merge($aBlogId, $oTopic->getBlogIds());
+ }
+ }
+ /**
+ * Получаем дополнительные данные
+ */
+ $aTopicsVote = array();
+ $aFavouriteTopics = array();
+ $aTopicsRead = array();
+ $aUsers = isset($aAllowData['user']) && is_array($aAllowData['user']) ? $this->User_GetUsersAdditionalData($aUserId,
+ $aAllowData['user']) : $this->User_GetUsersAdditionalData($aUserId);
+ $aBlogs = isset($aAllowData['blog']) && is_array($aAllowData['blog']) ? $this->Blog_GetBlogsAdditionalData($aBlogId,
+ $aAllowData['blog']) : $this->Blog_GetBlogsAdditionalData($aBlogId);
+ if (isset($aAllowData['vote']) and $this->oUserCurrent) {
+ $aTopicsVote = $this->Vote_GetVoteByArray($aTopicId, 'topic', $this->oUserCurrent->getId());
+ }
+ if (isset($aAllowData['favourite']) and $this->oUserCurrent) {
+ $aFavouriteTopics = $this->GetFavouriteTopicsByArray($aTopicId, $this->oUserCurrent->getId());
+ }
+ if (isset($aAllowData['comment_new']) and $this->oUserCurrent) {
+ $aTopicsRead = $this->GetTopicsReadByArray($aTopicId, $this->oUserCurrent->getId());
+ }
+ /**
+ * Добавляем данные к результату - списку топиков
+ */
+ foreach ($aTopics as $oTopic) {
+ if (isset($aUsers[$oTopic->getUserId()])) {
+ $oTopic->setUser($aUsers[$oTopic->getUserId()]);
+ } else {
+ $oTopic->setUser(null); // или $oTopic->setUser(new ModuleUser_EntityUser());
+ }
+ $aBlogsTopic = array();
+ foreach ($oTopic->getBlogIds() as $iBlogId) {
+ if (isset($aBlogs[$iBlogId])) {
+ $aBlogsTopic[] = $aBlogs[$iBlogId];
+ }
+ }
+ $oTopic->setBlogs($aBlogsTopic);
+ if (isset($aTopicsVote[$oTopic->getId()])) {
+ $oTopic->setVote($aTopicsVote[$oTopic->getId()]);
+ } else {
+ $oTopic->setVote(null);
+ }
+ if (isset($aFavouriteTopics[$oTopic->getId()])) {
+ $oTopic->setFavourite($aFavouriteTopics[$oTopic->getId()]);
+ } else {
+ $oTopic->setFavourite(null);
+ }
+ if (isset($aTopicsRead[$oTopic->getId()])) {
+ $oTopic->setCountCommentNew($oTopic->getCountComment() - $aTopicsRead[$oTopic->getId()]->getCommentCountLast());
+ $oTopic->setDateRead($aTopicsRead[$oTopic->getId()]->getDateRead());
+ } else {
+ $oTopic->setCountCommentNew(0);
+ $oTopic->setDateRead(date("Y-m-d H:i:s"));
+ }
+ }
+ /**
+ * Цепляем дополнительные поля
+ */
+ if (isset($aAllowData['properties'])) {
+ $this->Property_RewriteGetItemsByFilter($aTopics, array('#properties' => true));
+ }
+ return $aTopics;
+ }
+
+ /**
+ * Добавляет топик
+ *
+ * @param ModuleTopic_EntityTopic $oTopic Объект топика
+ * @return ModuleTopic_EntityTopic|bool
+ */
+ public function AddTopic(ModuleTopic_EntityTopic $oTopic)
+ {
+ if (!$oTopic->getDatePublish()) {
+ $oTopic->setDatePublish($oTopic->getDateAdd());
+ }
+ if ($sId = $this->oMapperTopic->AddTopic($oTopic)) {
+ $oTopic->setId($sId);
+ if ($oTopic->getPublish() and $oTopic->getTags()) {
+ $aTags = explode(',', $oTopic->getTags());
+ foreach ($aTags as $sTag) {
+ $oTag = Engine::GetEntity('Topic_TopicTag');
+ $oTag->setTopicId($oTopic->getId());
+ $oTag->setUserId($oTopic->getUserId());
+ $oTag->setBlogId($oTopic->getBlogId());
+ $oTag->setText($sTag);
+ $this->AddTopicTag($oTag);
+ }
+ }
+ /**
+ * Обновляем дополнительные поля
+ * Здесь важный момент - перед сохранением топика всегда нужно вызывать валидацию полей $this->Property_ValidateEntityPropertiesCheck($oTopic);
+ * т.к. она подготавливает данные полей для сохранений
+ * Валидация вызывается автоматически при вызове $oTopic->_Validate();
+ */
+ $this->Property_UpdatePropertiesValue($oTopic->getPropertiesObject(), $oTopic);
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array(
+ 'topic_new',
+ "topic_update_user_{$oTopic->getUserId()}",
+ "topic_new_blog_{$oTopic->getBlogId()}"
+ ));
+ return $oTopic;
+ }
+ return false;
+ }
+
+ /**
+ * Добавление тега к топику
+ *
+ * @param ModuleTopic_EntityTopicTag $oTopicTag Объект тега топика
+ * @return int
+ */
+ public function AddTopicTag(ModuleTopic_EntityTopicTag $oTopicTag)
+ {
+ return $this->oMapperTopic->AddTopicTag($oTopicTag);
+ }
+
+ /**
+ * Удаляет теги у топика
+ *
+ * @param int $sTopicId ID топика
+ * @return bool
+ */
+ public function DeleteTopicTagsByTopicId($sTopicId)
+ {
+ return $this->oMapperTopic->DeleteTopicTagsByTopicId($sTopicId);
+ }
+
+ /**
+ * Удаляет топик.
+ * Если тип таблиц в БД InnoDB, то удалятся всё связи по топику(комменты,голосования,избранное)
+ *
+ * @param ModuleTopic_EntityTopic|int $oTopic Объект топика или ID
+ * @return bool
+ */
+ public function DeleteTopic($oTopic)
+ {
+ if ($oTopic instanceof ModuleTopic_EntityTopic) {
+ $sTopicId = $oTopic->getId();
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("topic_update_user_{$oTopic->getUserId()}"));
+ } else {
+ $sTopicId = $oTopic;
+ $oTopic = $this->GetTopicById($sTopicId);
+ }
+ /**
+ * Удаляем дополнительные поля
+ */
+ $this->Property_RemovePropertiesValue($oTopic);
+ /**
+ * Чистим зависимые кеши
+ */
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('topic_update'));
+ $this->Cache_Delete("topic_{$sTopicId}");
+ /**
+ * Если топик успешно удален, удаляем связанные данные
+ */
+ if ($this->oMapperTopic->DeleteTopic($sTopicId)) {
+ /**
+ * Обновляем счетчики топиков в блогах
+ */
+ $this->Blog_RecalculateCountTopicByBlogId($oTopic->getBlogsId());
+
+ return $this->DeleteTopicAdditionalData($sTopicId);
+ }
+
+ return false;
+ }
+
+ /**
+ * Удаляет свзяанные с топика данные
+ *
+ * @param int $iTopicId ID топика
+ * @return bool
+ */
+ public function DeleteTopicAdditionalData($iTopicId)
+ {
+ /**
+ * Чистим зависимые кеши
+ */
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('topic_update'));
+ $this->Cache_Delete("topic_{$iTopicId}");
+ /**
+ * Удаляем контент топика
+ */
+ $this->DeleteTopicContentByTopicId($iTopicId);
+ /**
+ * Удаляем медиа данные топика
+ */
+ $this->Media_RemoveTarget('topic', $iTopicId, true);
+ /**
+ * Удаляем комментарии к топику.
+ * При удалении комментариев они удаляются из избранного,прямого эфира и голоса за них
+ */
+ $this->Comment_DeleteCommentByTargetId($iTopicId, 'topic');
+ /**
+ * Удаляем топик из избранного
+ */
+ $this->DeleteFavouriteTopicByArrayId($iTopicId);
+ /**
+ * Удаляем топик из прочитанного
+ */
+ $this->DeleteTopicReadByArrayId($iTopicId);
+ /**
+ * Удаляем голосование к топику
+ */
+ $this->Vote_DeleteVoteByTarget($iTopicId, 'topic');
+ /**
+ * Удаляем теги
+ */
+ $this->DeleteTopicTagsByTopicId($iTopicId);
+ return true;
+ }
+
+ /**
+ * Обновляет топик
+ *
+ * @param ModuleTopic_EntityTopic $oTopic Объект топика
+ * @return bool
+ */
+ public function UpdateTopic(ModuleTopic_EntityTopic $oTopic)
+ {
+ /**
+ * Получаем топик ДО изменения
+ */
+ $oTopicOld = $this->GetTopicById($oTopic->getId());
+ $oTopic->setDateEdit(date("Y-m-d H:i:s"));
+ if ($this->oMapperTopic->UpdateTopic($oTopic)) {
+ /**
+ * Если топик изменил видимость(publish) или локацию (BlogId) или список тегов
+ */
+ if (($oTopic->getPublish() != $oTopicOld->getPublish()) || ($oTopic->getBlogId() != $oTopicOld->getBlogId()) || ($oTopic->getTags() != $oTopicOld->getTags())) {
+ /**
+ * Обновляем теги
+ */
+ $this->DeleteTopicTagsByTopicId($oTopic->getId());
+ if ($oTopic->getPublish() and $oTopic->getTags()) {
+ $aTags = explode(',', $oTopic->getTags());
+ foreach ($aTags as $sTag) {
+ $oTag = Engine::GetEntity('Topic_TopicTag');
+ $oTag->setTopicId($oTopic->getId());
+ $oTag->setUserId($oTopic->getUserId());
+ $oTag->setBlogId($oTopic->getBlogId());
+ $oTag->setText($sTag);
+ $this->AddTopicTag($oTag);
+ }
+ }
+ }
+ if ($oTopic->getPublish() != $oTopicOld->getPublish()) {
+ /**
+ * Обновляем избранное
+ */
+ $this->SetFavouriteTopicPublish($oTopic->getId(), $oTopic->getPublish());
+ /**
+ * Удаляем комментарий топика из прямого эфира
+ */
+ if ($oTopic->getPublish() == 0) {
+ $this->Comment_DeleteCommentOnlineByTargetId($oTopic->getId(), 'topic');
+ }
+ /**
+ * Изменяем видимость комментов
+ */
+ $this->Comment_SetCommentsPublish($oTopic->getId(), 'topic', $oTopic->getPublish());
+ }
+ /**
+ * Смена главного блога
+ */
+ if ($oTopic->getBlogId() != $oTopicOld->getBlogId()) {
+ // меняем target parent у комментов
+ $this->Comment_MoveTargetParent($oTopicOld->getBlogId(), 'topic', $oTopic->getBlogId());
+ // меняем target parent у комментов в прямом эфире
+ $this->Comment_MoveTargetParentOnline($oTopicOld->getBlogId(), 'topic', $oTopic->getBlogId());
+ }
+ /**
+ * Обновляем дополнительные поля
+ * Здесь важный момент - перед сохранением топика всегда нужно вызывать валидацию полей $this->Property_ValidateEntityPropertiesCheck($oTopic);
+ * т.к. она подготавливает данные полей для сохранений
+ * Валидация вызывается автоматически при вызове $oTopic->_Validate();
+ */
+ $this->Property_UpdatePropertiesValue($oTopic->getPropertiesObject(), $oTopic);
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array('topic_update', "topic_update_user_{$oTopic->getUserId()}"));
+ $this->Cache_Delete("topic_{$oTopic->getId()}");
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет контент топика в БД (таблица topic_content)
+ *
+ * @param ModuleTopic_EntityTopic $oTopic
+ *
+ * @return bool
+ */
+ public function UpdateTopicContent($oTopic)
+ {
+ $bRes = $this->oMapperTopic->UpdateTopicContent($oTopic);
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array('topic_update', "topic_update_user_{$oTopic->getUserId()}"));
+ $this->Cache_Delete("topic_{$oTopic->getId()}");
+ return $bRes;
+ }
+
+ /**
+ * Удаление контента топика по его номеру
+ *
+ * @param int $iTopicId ID топика
+ * @return bool
+ */
+ public function DeleteTopicContentByTopicId($iTopicId)
+ {
+ return $this->oMapperTopic->DeleteTopicContentByTopicId($iTopicId);
+ }
+
+ /**
+ * Получить топик по айдишнику
+ *
+ * @param int $sId ID топика
+ * @return ModuleTopic_EntityTopic|null
+ */
+ public function GetTopicById($sId)
+ {
+ if (!is_numeric($sId)) {
+ return null;
+ }
+ $aTopics = $this->GetTopicsAdditionalData($sId);
+ if (isset($aTopics[$sId])) {
+ return $aTopics[$sId];
+ }
+ return null;
+ }
+
+ /**
+ * Получить топик по url/slug
+ *
+ * @param string $sSlug url/slug топика
+ * @return ModuleTopic_EntityTopic|null
+ */
+ public function GetTopicBySlug($sSlug)
+ {
+ if (!is_scalar($sSlug)) {
+ return null;
+ }
+ $aTopics = $this->GetTopicsByFilter(array('topic_slug' => $sSlug), 1, 1);
+ if ($aTopics['collection']) {
+ return reset($aTopics['collection']);
+ }
+ return null;
+ }
+
+ /**
+ * Получить список топиков по списку айдишников
+ *
+ * @param array $aTopicId Список ID топиков
+ * @return array
+ */
+ public function GetTopicsByArrayId($aTopicId)
+ {
+ if (!$aTopicId) {
+ return array();
+ }
+ if (Config::Get('sys.cache.solid')) {
+ return $this->GetTopicsByArrayIdSolid($aTopicId);
+ }
+
+ if (!is_array($aTopicId)) {
+ $aTopicId = array($aTopicId);
+ }
+ $aTopicId = array_unique($aTopicId);
+ $aTopics = array();
+ $aTopicIdNotNeedQuery = array();
+ /**
+ * Делаем мульти-запрос к кешу
+ */
+ $aCacheKeys = func_build_cache_keys($aTopicId, 'topic_');
+ if (false !== ($data = $this->Cache_Get($aCacheKeys))) {
+ /**
+ * проверяем что досталось из кеша
+ */
+ foreach ($aCacheKeys as $sValue => $sKey) {
+ if (array_key_exists($sKey, $data)) {
+ if ($data[$sKey]) {
+ $aTopics[$data[$sKey]->getId()] = $data[$sKey];
+ } else {
+ $aTopicIdNotNeedQuery[] = $sValue;
+ }
+ }
+ }
+ }
+ /**
+ * Смотрим каких топиков не было в кеше и делаем запрос в БД
+ */
+ $aTopicIdNeedQuery = array_diff($aTopicId, array_keys($aTopics));
+ $aTopicIdNeedQuery = array_diff($aTopicIdNeedQuery, $aTopicIdNotNeedQuery);
+ $aTopicIdNeedStore = $aTopicIdNeedQuery;
+ if ($data = $this->oMapperTopic->GetTopicsByArrayId($aTopicIdNeedQuery)) {
+ foreach ($data as $oTopic) {
+ /**
+ * Добавляем к результату и сохраняем в кеш
+ */
+ $aTopics[$oTopic->getId()] = $oTopic;
+ $this->Cache_Set($oTopic, "topic_{$oTopic->getId()}", array(), 60 * 60 * 24 * 4);
+ $aTopicIdNeedStore = array_diff($aTopicIdNeedStore, array($oTopic->getId()));
+ }
+ }
+ /**
+ * Сохраняем в кеш запросы не вернувшие результата
+ */
+ foreach ($aTopicIdNeedStore as $sId) {
+ $this->Cache_Set(null, "topic_{$sId}", array(), 60 * 60 * 24 * 4);
+ }
+ /**
+ * Сортируем результат согласно входящему массиву
+ */
+ $aTopics = func_array_sort_by_keys($aTopics, $aTopicId);
+ return $aTopics;
+ }
+
+ /**
+ * Получить список топиков по списку айдишников, но используя единый кеш
+ *
+ * @param array $aTopicId Список ID топиков
+ * @return array
+ */
+ public function GetTopicsByArrayIdSolid($aTopicId)
+ {
+ if (!is_array($aTopicId)) {
+ $aTopicId = array($aTopicId);
+ }
+ $aTopicId = array_unique($aTopicId);
+ $aTopics = array();
+ $s = join(',', $aTopicId);
+ if (false === ($data = $this->Cache_Get("topic_id_{$s}"))) {
+ $data = $this->oMapperTopic->GetTopicsByArrayId($aTopicId);
+ foreach ($data as $oTopic) {
+ $aTopics[$oTopic->getId()] = $oTopic;
+ }
+ $this->Cache_Set($aTopics, "topic_id_{$s}", array("topic_update"), 60 * 60 * 24 * 1);
+ return $aTopics;
+ }
+ return $data;
+ }
+
+ /**
+ * Получает список топиков из избранного
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iCurrPage Номер текущей страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetTopicsFavouriteByUserId($sUserId, $iCurrPage, $iPerPage)
+ {
+ $aCloseTopics = array();
+ /**
+ * Получаем список идентификаторов избранных записей
+ */
+ $data = ($this->oUserCurrent && $sUserId == $this->oUserCurrent->getId())
+ ? $this->Favourite_GetFavouritesByUserId($sUserId, 'topic', $iCurrPage, $iPerPage, $aCloseTopics)
+ : $this->Favourite_GetFavouriteOpenTopicsByUserId($sUserId, $iCurrPage, $iPerPage);
+ /**
+ * Получаем записи по переданому массиву айдишников
+ */
+ $data['collection'] = $this->GetTopicsAdditionalData($data['collection']);
+ return $data;
+ }
+
+ /**
+ * Возвращает число топиков в избранном
+ *
+ * @param int $sUserId ID пользователя
+ * @return int
+ */
+ public function GetCountTopicsFavouriteByUserId($sUserId)
+ {
+ $aCloseTopics = array();
+ return ($this->oUserCurrent && $sUserId == $this->oUserCurrent->getId())
+ ? $this->Favourite_GetCountFavouritesByUserId($sUserId, 'topic', $aCloseTopics)
+ : $this->Favourite_GetCountFavouriteOpenTopicsByUserId($sUserId);
+ }
+
+ public function GetTimelifeCacheForTopics()
+ {
+ if ($sDate = $this->oMapperTopic->GetNextTopicDatePublish()) {
+ return abs(strtotime($sDate) - time());
+ }
+ return 60 * 60 * 24 * 1;
+ }
+
+ /**
+ * Список топиков по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param array|null $aAllowData Список типов данных для подгрузки в топики
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetTopicsByFilter($aFilter, $iPage = 1, $iPerPage = 10, $aAllowData = null)
+ {
+ if (!is_numeric($iPage) or $iPage <= 0) {
+ $iPage = 1;
+ }
+ $s = serialize($aFilter);
+ if (false === ($data = $this->Cache_Get("topic_filter_{$s}_{$iPage}_{$iPerPage}"))) {
+ $data = array(
+ 'collection' => $this->oMapperTopic->GetTopics($aFilter, $iCount, $iPage, $iPerPage),
+ 'count' => $iCount
+ );
+ $this->Cache_Set($data, "topic_filter_{$s}_{$iPage}_{$iPerPage}", array('topic_update', 'topic_new'),
+ $this->GetTimelifeCacheForTopics());
+ }
+ $data['collection'] = $this->GetTopicsAdditionalData($data['collection'], $aAllowData);
+ return $data;
+ }
+
+ /**
+ * Количество топиков по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @return int
+ */
+ public function GetCountTopicsByFilter($aFilter)
+ {
+ $s = serialize($aFilter);
+ if (false === ($data = $this->Cache_Get("topic_count_{$s}"))) {
+ $data = $this->oMapperTopic->GetCountTopics($aFilter);
+ $this->Cache_Set($data, "topic_count_{$s}", array('topic_update', 'topic_new'), $this->GetTimelifeCacheForTopics());
+ }
+ return $data;
+ }
+
+ /**
+ * Количество черновиков у пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @return int
+ */
+ public function GetCountDraftTopicsByUserId($iUserId)
+ {
+ return $this->GetCountTopicsByFilter(array(
+ 'user_id' => $iUserId,
+ 'topic_publish' => 0
+ ));
+ }
+
+ /**
+ * Количество отложенных у пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @return int
+ */
+ public function GetCountDeferredTopicsByUserId($iUserId)
+ {
+ return $this->GetCountTopicsByFilter(array(
+ 'user_id' => $iUserId,
+ 'topic_publish_only' => 1,
+ 'topic_new' => date('Y-m-d H:i:s', time() + 1)
+ ));
+ }
+
+ /**
+ * Получает список хороших топиков для вывода на главную страницу(из всех блогов, как коллективных так и персональных)
+ *
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param bool $bAddAccessible Указывает на необходимость добавить в выдачу топики,
+ * из блогов доступных пользователю. При указании false,
+ * в выдачу будут переданы только топики из общедоступных блогов.
+ * @return array
+ */
+ public function GetTopicsGood($iPage, $iPerPage, $bAddAccessible = true)
+ {
+ $aFilter = array(
+ 'blog_type' => array(
+ 'personal',
+ 'open'
+ ),
+ 'topic_publish' => 1,
+ 'topic_rating' => array(
+ 'value' => Config::Get('module.blog.index_good'),
+ 'type' => 'top',
+ 'publish_index' => 1,
+ )
+ );
+ /**
+ * Если пользователь авторизирован, то добавляем в выдачу
+ * закрытые блоги в которых он состоит
+ */
+ if ($this->oUserCurrent && $bAddAccessible) {
+ $aOpenBlogs = $this->Blog_GetAccessibleBlogsByUser($this->oUserCurrent);
+ if (count($aOpenBlogs)) {
+ $aFilter['blog_type']['close'] = $aOpenBlogs;
+ }
+ }
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array('aFilter' => &$aFilter, 'iPage' => $iPage, 'iPerPage' => $iPerPage, 'sMethod' => __FUNCTION__));
+ return $this->GetTopicsByFilter($aFilter, $iPage, $iPerPage);
+ }
+
+ /**
+ * Получает список новых топиков, ограничение новизны по дате из конфига
+ *
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param bool $bAddAccessible Указывает на необходимость добавить в выдачу топики,
+ * из блогов доступных пользователю. При указании false,
+ * в выдачу будут переданы только топики из общедоступных блогов.
+ * @return array
+ */
+ public function GetTopicsNew($iPage, $iPerPage, $bAddAccessible = true)
+ {
+ $sDate = date("Y-m-d H:00:00", time() - Config::Get('module.topic.new_time'));
+ $aFilter = array(
+ 'blog_type' => array(
+ 'personal',
+ 'open',
+ ),
+ 'topic_publish' => 1,
+ 'topic_new' => $sDate,
+ );
+ /**
+ * Если пользователь авторизирован, то добавляем в выдачу
+ * закрытые блоги в которых он состоит
+ */
+ if ($this->oUserCurrent && $bAddAccessible) {
+ $aOpenBlogs = $this->Blog_GetAccessibleBlogsByUser($this->oUserCurrent);
+ if (count($aOpenBlogs)) {
+ $aFilter['blog_type']['close'] = $aOpenBlogs;
+ }
+ }
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array('aFilter' => &$aFilter, 'iPage' => $iPage, 'iPerPage' => $iPerPage, 'sMethod' => __FUNCTION__));
+ return $this->GetTopicsByFilter($aFilter, $iPage, $iPerPage);
+ }
+
+ /**
+ * Получает список ВСЕХ новых топиков
+ *
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param bool $bAddAccessible Указывает на необходимость добавить в выдачу топики,
+ * из блогов доступных пользователю. При указании false,
+ * в выдачу будут переданы только топики из общедоступных блогов.
+ * @return array
+ */
+ public function GetTopicsNewAll($iPage, $iPerPage, $bAddAccessible = true)
+ {
+ $aFilter = array(
+ 'blog_type' => array(
+ 'personal',
+ 'open',
+ ),
+ 'topic_publish' => 1,
+ );
+ /**
+ * Если пользователь авторизирован, то добавляем в выдачу
+ * закрытые блоги в которых он состоит
+ */
+ if ($this->oUserCurrent && $bAddAccessible) {
+ $aOpenBlogs = $this->Blog_GetAccessibleBlogsByUser($this->oUserCurrent);
+ if (count($aOpenBlogs)) {
+ $aFilter['blog_type']['close'] = $aOpenBlogs;
+ }
+ }
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array('aFilter' => &$aFilter, 'iPage' => $iPage, 'iPerPage' => $iPerPage, 'sMethod' => __FUNCTION__));
+ return $this->GetTopicsByFilter($aFilter, $iPage, $iPerPage);
+ }
+
+ /**
+ * Получает список ВСЕХ обсуждаемых топиков
+ *
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param int|string $sPeriod Период в виде секунд или конкретной даты
+ * @param bool $bAddAccessible Указывает на необходимость добавить в выдачу топики,
+ * из блогов доступных пользователю. При указании false,
+ * в выдачу будут переданы только топики из общедоступных блогов.
+ * @return array
+ */
+ public function GetTopicsDiscussed($iPage, $iPerPage, $sPeriod = null, $bAddAccessible = true)
+ {
+ if (is_numeric($sPeriod)) {
+ // количество последних секунд
+ $sPeriod = date("Y-m-d H:00:00", time() - $sPeriod);
+ }
+
+ $aFilter = array(
+ 'blog_type' => array(
+ 'personal',
+ 'open',
+ ),
+ 'topic_publish' => 1
+ );
+ if ($sPeriod) {
+ $aFilter['topic_date_more'] = $sPeriod;
+ }
+ $aFilter['order'] = ' t.topic_count_comment desc, t.topic_id desc ';
+ /**
+ * Если пользователь авторизирован, то добавляем в выдачу
+ * закрытые блоги в которых он состоит
+ */
+ if ($this->oUserCurrent && $bAddAccessible) {
+ $aOpenBlogs = $this->Blog_GetAccessibleBlogsByUser($this->oUserCurrent);
+ if (count($aOpenBlogs)) {
+ $aFilter['blog_type']['close'] = $aOpenBlogs;
+ }
+ }
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array('aFilter' => &$aFilter, 'iPage' => $iPage, 'iPerPage' => $iPerPage, 'sMethod' => __FUNCTION__));
+ return $this->GetTopicsByFilter($aFilter, $iPage, $iPerPage);
+ }
+
+ /**
+ * Получает список ВСЕХ рейтинговых топиков
+ *
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param int|string $sPeriod Период в виде секунд или конкретной даты
+ * @param bool $bAddAccessible Указывает на необходимость добавить в выдачу топики,
+ * из блогов доступных пользователю. При указании false,
+ * в выдачу будут переданы только топики из общедоступных блогов.
+ * @return array
+ */
+ public function GetTopicsTop($iPage, $iPerPage, $sPeriod = null, $bAddAccessible = true)
+ {
+ if (is_numeric($sPeriod)) {
+ // количество последних секунд
+ $sPeriod = date("Y-m-d H:00:00", time() - $sPeriod);
+ }
+
+ $aFilter = array(
+ 'blog_type' => array(
+ 'personal',
+ 'open',
+ ),
+ 'topic_publish' => 1
+ );
+ if ($sPeriod) {
+ $aFilter['topic_date_more'] = $sPeriod;
+ }
+ $aFilter['order'] = array('t.topic_rating desc', 't.topic_id desc');
+ /**
+ * Если пользователь авторизирован, то добавляем в выдачу
+ * закрытые блоги в которых он состоит
+ */
+ if ($this->oUserCurrent && $bAddAccessible) {
+ $aOpenBlogs = $this->Blog_GetAccessibleBlogsByUser($this->oUserCurrent);
+ if (count($aOpenBlogs)) {
+ $aFilter['blog_type']['close'] = $aOpenBlogs;
+ }
+ }
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array('aFilter' => &$aFilter, 'iPage' => $iPage, 'iPerPage' => $iPerPage, 'sMethod' => __FUNCTION__));
+ return $this->GetTopicsByFilter($aFilter, $iPage, $iPerPage);
+ }
+
+ /**
+ * Получает заданое число последних топиков
+ *
+ * @param int $iCount Количество
+ * @return array
+ */
+ public function GetTopicsLast($iCount)
+ {
+ $aFilter = array(
+ 'blog_type' => array(
+ 'personal',
+ 'open',
+ ),
+ 'topic_publish' => 1,
+ );
+ /**
+ * Если пользователь авторизирован, то добавляем в выдачу
+ * закрытые блоги в которых он состоит
+ */
+ if ($this->oUserCurrent) {
+ $aOpenBlogs = $this->Blog_GetAccessibleBlogsByUser($this->oUserCurrent);
+ if (count($aOpenBlogs)) {
+ $aFilter['blog_type']['close'] = $aOpenBlogs;
+ }
+ }
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array('aFilter' => &$aFilter, 'iPage' => 1, 'iPerPage' => $iCount, 'sMethod' => __FUNCTION__));
+ $aReturn = $this->GetTopicsByFilter($aFilter, 1, $iCount);
+ if (isset($aReturn['collection'])) {
+ return $aReturn['collection'];
+ }
+ return false;
+ }
+
+ /**
+ * список топиков из персональных блогов
+ *
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param string $sShowType Тип выборки топиков
+ * @param string|int $sPeriod Период в виде секунд или конкретной даты
+ * @return array
+ */
+ public function GetTopicsPersonal($iPage, $iPerPage, $sShowType = 'good', $sPeriod = null)
+ {
+ if (is_numeric($sPeriod)) {
+ // количество последних секунд
+ $sPeriod = date("Y-m-d H:00:00", time() - $sPeriod);
+ }
+ $aFilter = array(
+ 'blog_type' => array(
+ 'personal',
+ ),
+ 'topic_publish' => 1,
+ );
+ if ($sPeriod) {
+ $aFilter['topic_date_more'] = $sPeriod;
+ }
+ switch ($sShowType) {
+ case 'good':
+ $aFilter['topic_rating'] = array(
+ 'value' => Config::Get('module.blog.personal_good'),
+ 'type' => 'top',
+ );
+ break;
+ case 'bad':
+ $aFilter['topic_rating'] = array(
+ 'value' => Config::Get('module.blog.personal_good'),
+ 'type' => 'down',
+ );
+ break;
+ case 'new':
+ $aFilter['topic_new'] = date("Y-m-d H:00:00", time() - Config::Get('module.topic.new_time'));
+ break;
+ case 'newall':
+ // нет доп фильтра
+ break;
+ case 'discussed':
+ $aFilter['order'] = array('t.topic_count_comment desc', 't.topic_id desc');
+ break;
+ case 'top':
+ $aFilter['order'] = array('t.topic_rating desc', 't.topic_id desc');
+ break;
+ default:
+ break;
+ }
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array(
+ 'aFilter' => &$aFilter,
+ 'iPage' => $iPage,
+ 'iPerPage' => $iPerPage,
+ 'sShowType' => $sShowType,
+ 'sMethod' => __FUNCTION__
+ ));
+ return $this->GetTopicsByFilter($aFilter, $iPage, $iPerPage);
+ }
+
+ /**
+ * Получает число новых топиков в персональных блогах
+ *
+ * @return int
+ */
+ public function GetCountTopicsPersonalNew()
+ {
+ $sDate = date("Y-m-d H:00:00", time() - Config::Get('module.topic.new_time'));
+ $aFilter = array(
+ 'blog_type' => array(
+ 'personal',
+ ),
+ 'topic_publish' => 1,
+ 'topic_new' => $sDate,
+ );
+ return $this->GetCountTopicsByFilter($aFilter);
+ }
+
+ /**
+ * Получает список топиков по юзеру
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iPublish Флаг публикации топика
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetTopicsPersonalByUser($sUserId, $iPublish, $iPage, $iPerPage)
+ {
+ $aFilter = array(
+ 'topic_publish' => $iPublish,
+ 'user_id' => $sUserId,
+ 'blog_type' => array('open', 'personal'),
+ );
+ /**
+ * Если пользователь смотрит свой профиль, то добавляем в выдачу
+ * закрытые блоги в которых он состоит
+ */
+ if ($this->oUserCurrent && $this->oUserCurrent->getId() == $sUserId) {
+ $aFilter['blog_type'][] = 'close';
+ }
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array('aFilter' => &$aFilter, 'iPage' => $iPage, 'iPerPage' => $iPerPage, 'sMethod' => __FUNCTION__));
+ return $this->GetTopicsByFilter($aFilter, $iPage, $iPerPage);
+ }
+
+ /**
+ * Получает список отложенных топиков по юзеру
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetTopicsPersonalDeferredByUser($sUserId, $iPage, $iPerPage)
+ {
+ $aFilter = array(
+ 'topic_publish_only' => 1,
+ 'topic_new' => date('Y-m-d H:i:s', time() + 1),
+ 'user_id' => $sUserId,
+ 'blog_type' => array('open', 'personal'),
+ );
+ /**
+ * Если пользователь смотрит свой профиль, то добавляем в выдачу
+ * закрытые блоги в которых он состоит
+ */
+ if ($this->oUserCurrent && $this->oUserCurrent->getId() == $sUserId) {
+ $aFilter['blog_type'][] = 'close';
+ }
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array('aFilter' => &$aFilter, 'iPage' => $iPage, 'iPerPage' => $iPerPage, 'sMethod' => __FUNCTION__));
+ return $this->GetTopicsByFilter($aFilter, $iPage, $iPerPage);
+ }
+
+ /**
+ * Возвращает количество топиков которые создал юзер
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iPublish Флаг публикации топика
+ * @return array
+ */
+ public function GetCountTopicsPersonalByUser($sUserId, $iPublish)
+ {
+ $aFilter = array(
+ 'topic_publish' => $iPublish,
+ 'user_id' => $sUserId,
+ 'blog_type' => array('open', 'personal'),
+ );
+ /**
+ * Если пользователь смотрит свой профиль, то добавляем в выдачу
+ * закрытые блоги в которых он состоит
+ */
+ if ($this->oUserCurrent && $this->oUserCurrent->getId() == $sUserId) {
+ $aFilter['blog_type'][] = 'close';
+ }
+ $s = serialize($aFilter);
+ if (false === ($data = $this->Cache_Get("topic_count_user_{$s}"))) {
+ $data = $this->oMapperTopic->GetCountTopics($aFilter);
+ $this->Cache_Set($data, "topic_count_user_{$s}", array("topic_update_user_{$sUserId}"), 60 * 60 * 24);
+ }
+ return $data;
+ }
+
+ /**
+ * Получает список топиков из указанного блога
+ *
+ * @param int $iBlogId ID блога
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param array $aAllowData Список типов данных для подгрузки в топики
+ * @param bool $bIdsOnly Возвращать только ID или список объектов
+ * @return array
+ */
+ public function GetTopicsByBlogId($iBlogId, $iPage = 1, $iPerPage = 20, $aAllowData = array(), $bIdsOnly = true)
+ {
+ $aFilter = array('blog_id' => $iBlogId);
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array('aFilter' => &$aFilter, 'iPage' => $iPage, 'iPerPage' => $iPerPage, 'sMethod' => __FUNCTION__));
+ if (!$aTopics = $this->GetTopicsByFilter($aFilter, $iPage, $iPerPage, $aAllowData)) {
+ return array();
+ }
+
+ return ($bIdsOnly)
+ ? array_keys($aTopics['collection'])
+ : $aTopics;
+ }
+
+ /**
+ * Список топиков из коллективных блогов
+ *
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param string $sShowType Тип выборки топиков
+ * @param string $sPeriod Период в виде секунд или конкретной даты
+ * @return array
+ */
+ public function GetTopicsCollective($iPage, $iPerPage, $sShowType = 'good', $sPeriod = null)
+ {
+ if (is_numeric($sPeriod)) {
+ // количество последних секунд
+ $sPeriod = date("Y-m-d H:00:00", time() - $sPeriod);
+ }
+ $aFilter = array(
+ 'blog_type' => array(
+ 'open',
+ ),
+ 'topic_publish' => 1,
+ );
+ if ($sPeriod) {
+ $aFilter['topic_date_more'] = $sPeriod;
+ }
+ switch ($sShowType) {
+ case 'good':
+ $aFilter['topic_rating'] = array(
+ 'value' => Config::Get('module.blog.collective_good'),
+ 'type' => 'top',
+ );
+ break;
+ case 'bad':
+ $aFilter['topic_rating'] = array(
+ 'value' => Config::Get('module.blog.collective_good'),
+ 'type' => 'down',
+ );
+ break;
+ case 'new':
+ $aFilter['topic_new'] = date("Y-m-d H:00:00", time() - Config::Get('module.topic.new_time'));
+ break;
+ case 'newall':
+ // нет доп фильтра
+ break;
+ case 'discussed':
+ $aFilter['order'] = array('t.topic_count_comment desc', 't.topic_id desc');
+ break;
+ case 'top':
+ $aFilter['order'] = array('t.topic_rating desc', 't.topic_id desc');
+ break;
+ default:
+ break;
+ }
+ /**
+ * Если пользователь авторизирован, то добавляем в выдачу
+ * закрытые блоги в которых он состоит
+ */
+ if ($this->oUserCurrent) {
+ $aOpenBlogs = $this->Blog_GetAccessibleBlogsByUser($this->oUserCurrent);
+ if (count($aOpenBlogs)) {
+ $aFilter['blog_type']['close'] = $aOpenBlogs;
+ }
+ }
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array(
+ 'aFilter' => &$aFilter,
+ 'iPage' => $iPage,
+ 'iPerPage' => $iPerPage,
+ 'sShowType' => $sShowType,
+ 'sMethod' => __FUNCTION__
+ ));
+ return $this->GetTopicsByFilter($aFilter, $iPage, $iPerPage);
+ }
+
+ /**
+ * Получает число новых топиков в коллективных блогах
+ *
+ * @return int
+ */
+ public function GetCountTopicsCollectiveNew()
+ {
+ $sDate = date("Y-m-d H:00:00", time() - Config::Get('module.topic.new_time'));
+ $aFilter = array(
+ 'blog_type' => array(
+ 'open',
+ ),
+ 'topic_publish' => 1,
+ 'topic_new' => $sDate,
+ );
+ /**
+ * Если пользователь авторизирован, то добавляем в выдачу
+ * закрытые блоги в которых он состоит
+ */
+ if ($this->oUserCurrent) {
+ $aOpenBlogs = $this->Blog_GetAccessibleBlogsByUser($this->oUserCurrent);
+ if (count($aOpenBlogs)) {
+ $aFilter['blog_type']['close'] = $aOpenBlogs;
+ }
+ }
+ return $this->GetCountTopicsByFilter($aFilter);
+ }
+
+ /**
+ * Получает топики по рейтингу и дате
+ *
+ * @param string $sDate Дата
+ * @param int $iLimit Количество
+ * @return array
+ */
+ public function GetTopicsRatingByDate($sDate, $iLimit = 20)
+ {
+ /**
+ * Получаем список блогов, топики которых нужно исключить из выдачи
+ */
+ $aCloseBlogs = ($this->oUserCurrent)
+ ? $this->Blog_GetInaccessibleBlogsByUser($this->oUserCurrent)
+ : $this->Blog_GetInaccessibleBlogsByUser();
+
+ $s = serialize($aCloseBlogs);
+
+ if (false === ($data = $this->Cache_Get("topic_rating_{$sDate}_{$iLimit}_{$s}"))) {
+ $data = $this->oMapperTopic->GetTopicsRatingByDate($sDate, $iLimit, $aCloseBlogs);
+ $this->Cache_Set($data, "topic_rating_{$sDate}_{$iLimit}_{$s}", array('topic_update'), 60 * 60 * 24 * 2);
+ }
+ $data = $this->GetTopicsAdditionalData($data);
+ return $data;
+ }
+
+ /**
+ * Список топиков из блога
+ *
+ * @param ModuleBlog_EntityBlog $oBlog Объект блога
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param string $sShowType Тип выборки топиков
+ * @param string $sPeriod Период в виде секунд или конкретной даты
+ * @return array
+ */
+ public function GetTopicsByBlog($oBlog, $iPage, $iPerPage, $sShowType = 'good', $sPeriod = null)
+ {
+ if (is_numeric($sPeriod)) {
+ // количество последних секунд
+ $sPeriod = date("Y-m-d H:00:00", time() - $sPeriod);
+ }
+ $aFilter = array(
+ 'topic_publish' => 1,
+ 'blog_id' => $oBlog->getId(),
+ );
+ if ($sPeriod) {
+ $aFilter['topic_date_more'] = $sPeriod;
+ }
+ switch ($sShowType) {
+ case 'good':
+ $aFilter['topic_rating'] = array(
+ 'value' => Config::Get('module.blog.collective_good'),
+ 'type' => 'top',
+ );
+ break;
+ case 'bad':
+ $aFilter['topic_rating'] = array(
+ 'value' => Config::Get('module.blog.collective_good'),
+ 'type' => 'down',
+ );
+ break;
+ case 'new':
+ $aFilter['topic_new'] = date("Y-m-d H:00:00", time() - Config::Get('module.topic.new_time'));
+ break;
+ case 'newall':
+ // нет доп фильтра
+ break;
+ case 'discussed':
+ $aFilter['order'] = array('t.topic_count_comment desc', 't.topic_id desc');
+ break;
+ case 'top':
+ $aFilter['order'] = array('t.topic_rating desc', 't.topic_id desc');
+ break;
+ default:
+ break;
+ }
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array(
+ 'aFilter' => &$aFilter,
+ 'iPage' => $iPage,
+ 'iPerPage' => $iPerPage,
+ 'sShowType' => $sShowType,
+ 'sMethod' => __FUNCTION__
+ ));
+ return $this->GetTopicsByFilter($aFilter, $iPage, $iPerPage);
+ }
+
+ /**
+ * Получает число новых топиков из блога
+ *
+ * @param ModuleBlog_EntityBlog $oBlog Объект блога
+ * @return int
+ */
+ public function GetCountTopicsByBlogNew($oBlog)
+ {
+ $sDate = date("Y-m-d H:00:00", time() - Config::Get('module.topic.new_time'));
+ $aFilter = array(
+ 'topic_publish' => 1,
+ 'blog_id' => $oBlog->getId(),
+ 'topic_new' => $sDate,
+
+ );
+ return $this->GetCountTopicsByFilter($aFilter);
+ }
+
+ /**
+ * Получает список топиков по тегу
+ *
+ * @param string $sTag Тег
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param bool $bAddAccessible Указывает на необходимость добавить в выдачу топики,
+ * из блогов доступных пользователю. При указании false,
+ * в выдачу будут переданы только топики из общедоступных блогов.
+ * @return array
+ */
+ public function GetTopicsByTag($sTag, $iPage, $iPerPage, $bAddAccessible = true)
+ {
+ $aCloseBlogs = ($this->oUserCurrent && $bAddAccessible)
+ ? $this->Blog_GetInaccessibleBlogsByUser($this->oUserCurrent)
+ : $this->Blog_GetInaccessibleBlogsByUser();
+
+ $s = serialize($aCloseBlogs);
+ if (false === ($data = $this->Cache_Get("topic_tag_{$sTag}_{$iPage}_{$iPerPage}_{$s}"))) {
+ $data = array(
+ 'collection' => $this->oMapperTopic->GetTopicsByTag($sTag, $aCloseBlogs, $iCount, $iPage, $iPerPage),
+ 'count' => $iCount
+ );
+ $this->Cache_Set($data, "topic_tag_{$sTag}_{$iPage}_{$iPerPage}_{$s}", array('topic_update', 'topic_new'),
+ 60 * 60 * 24 * 2);
+ }
+ $data['collection'] = $this->GetTopicsAdditionalData($data['collection']);
+ return $data;
+ }
+
+ /**
+ * Получает список тегов топиков
+ *
+ * @param int $iLimit Количество
+ * @param array $aExcludeTopic Список ID топиков для исключения
+ * @return array
+ */
+ public function GetTopicTags($iLimit, $aExcludeTopic = array())
+ {
+ $s = serialize($aExcludeTopic);
+ if (false === ($data = $this->Cache_Get("tag_{$iLimit}_{$s}"))) {
+ $data = $this->oMapperTopic->GetTopicTags($iLimit, $aExcludeTopic);
+ $this->Cache_Set($data, "tag_{$iLimit}_{$s}", array('topic_update', 'topic_new'), 60 * 60 * 24 * 3);
+ }
+ return $data;
+ }
+
+ /**
+ * Получает список тегов из топиков открытых блогов (open,personal)
+ *
+ * @param int $iLimit Количество
+ * @param int|null $iUserId ID пользователя, чью теги получаем
+ * @return array
+ */
+ public function GetOpenTopicTags($iLimit, $iUserId = null)
+ {
+ if (false === ($data = $this->Cache_Get("tag_{$iLimit}_{$iUserId}_open"))) {
+ $data = $this->oMapperTopic->GetOpenTopicTags($iLimit, $iUserId);
+ $this->Cache_Set($data, "tag_{$iLimit}_{$iUserId}_open", array('topic_update', 'topic_new'),
+ 60 * 60 * 24 * 3);
+ }
+ return $data;
+ }
+
+ /**
+ * Увеличивает у топика число комментов
+ *
+ * @param int $sTopicId ID топика
+ * @return bool
+ */
+ public function increaseTopicCountComment($sTopicId)
+ {
+ $this->Cache_Delete("topic_{$sTopicId}");
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("topic_update"));
+ return $this->oMapperTopic->increaseTopicCountComment($sTopicId);
+ }
+
+ /**
+ * Получает привязку топика к ибранному(добавлен ли топик в избранное у юзера)
+ *
+ * @param int $sTopicId ID топика
+ * @param int $sUserId ID пользователя
+ * @return ModuleFavourite_EntityFavourite
+ */
+ public function GetFavouriteTopic($sTopicId, $sUserId)
+ {
+ return $this->Favourite_GetFavourite($sTopicId, 'topic', $sUserId);
+ }
+
+ /**
+ * Получить список избранного по списку айдишников
+ *
+ * @param array $aTopicId Список ID топиков
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetFavouriteTopicsByArray($aTopicId, $sUserId)
+ {
+ return $this->Favourite_GetFavouritesByArray($aTopicId, 'topic', $sUserId);
+ }
+
+ /**
+ * Получить список избранного по списку айдишников, но используя единый кеш
+ *
+ * @param array $aTopicId Список ID топиков
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetFavouriteTopicsByArraySolid($aTopicId, $sUserId)
+ {
+ return $this->Favourite_GetFavouritesByArraySolid($aTopicId, 'topic', $sUserId);
+ }
+
+ /**
+ * Добавляет топик в избранное
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavouriteTopic Объект избранного
+ * @return bool
+ */
+ public function AddFavouriteTopic(ModuleFavourite_EntityFavourite $oFavouriteTopic)
+ {
+ return $this->Favourite_AddFavourite($oFavouriteTopic);
+ }
+
+ /**
+ * Удаляет топик из избранного
+ *
+ * @param ModuleFavourite_EntityFavourite $oFavouriteTopic Объект избранного
+ * @return bool
+ */
+ public function DeleteFavouriteTopic(ModuleFavourite_EntityFavourite $oFavouriteTopic)
+ {
+ return $this->Favourite_DeleteFavourite($oFavouriteTopic);
+ }
+
+ /**
+ * Устанавливает переданный параметр публикации таргета (топика)
+ *
+ * @param int $sTopicId ID топика
+ * @param int $iPublish Флаг публикации топика
+ * @return bool
+ */
+ public function SetFavouriteTopicPublish($sTopicId, $iPublish)
+ {
+ return $this->Favourite_SetFavouriteTargetPublish($sTopicId, 'topic', $iPublish);
+ }
+
+ /**
+ * Удаляет топики из избранного по списку
+ *
+ * @param array $aTopicId Список ID топиков
+ * @return bool
+ */
+ public function DeleteFavouriteTopicByArrayId($aTopicId)
+ {
+ return $this->Favourite_DeleteFavouriteByTargetId($aTopicId, 'topic');
+ }
+
+ /**
+ * Получает список тегов по первым буквам тега
+ *
+ * @param string $sTag Тэг
+ * @param int $iLimit Количество
+ * @return bool
+ */
+ public function GetTopicTagsByLike($sTag, $iLimit)
+ {
+ if (false === ($data = $this->Cache_Get("tag_like_{$sTag}_{$iLimit}"))) {
+ $data = $this->oMapperTopic->GetTopicTagsByLike($sTag, $iLimit);
+ $this->Cache_Set($data, "tag_like_{$sTag}_{$iLimit}", array("topic_update", "topic_new"), 60 * 60 * 24 * 3);
+ }
+ return $data;
+ }
+
+ /**
+ * Обновляем/устанавливаем дату прочтения топика, если читаем его первый раз то добавляем
+ *
+ * @param ModuleTopic_EntityTopicRead $oTopicRead Объект факта чтения топика
+ * @return bool
+ */
+ public function SetTopicRead(ModuleTopic_EntityTopicRead $oTopicRead)
+ {
+ if ($this->GetTopicRead($oTopicRead->getTopicId(), $oTopicRead->getUserId())) {
+ $this->Cache_Delete("topic_read_{$oTopicRead->getTopicId()}_{$oTopicRead->getUserId()}");
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("topic_read_user_{$oTopicRead->getUserId()}"));
+ $this->oMapperTopic->UpdateTopicRead($oTopicRead);
+ } else {
+ $this->Cache_Delete("topic_read_{$oTopicRead->getTopicId()}_{$oTopicRead->getUserId()}");
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("topic_read_user_{$oTopicRead->getUserId()}"));
+ $this->oMapperTopic->AddTopicRead($oTopicRead);
+ }
+ return true;
+ }
+
+ /**
+ * Получаем дату прочтения топика юзером
+ *
+ * @param int $sTopicId ID топика
+ * @param int $sUserId ID пользователя
+ * @return ModuleTopic_EntityTopicRead|null
+ */
+ public function GetTopicRead($sTopicId, $sUserId)
+ {
+ $data = $this->GetTopicsReadByArray($sTopicId, $sUserId);
+ if (isset($data[$sTopicId])) {
+ return $data[$sTopicId];
+ }
+ return null;
+ }
+
+ /**
+ * Удаляет записи о чтении записей по списку идентификаторов
+ *
+ * @param array|int $aTopicId Список ID топиков
+ * @return bool
+ */
+ public function DeleteTopicReadByArrayId($aTopicId)
+ {
+ if (!is_array($aTopicId)) {
+ $aTopicId = array($aTopicId);
+ }
+ return $this->oMapperTopic->DeleteTopicReadByArrayId($aTopicId);
+ }
+
+ /**
+ * Получить список просмотром/чтения топиков по списку айдишников
+ *
+ * @param array $aTopicId Список ID топиков
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetTopicsReadByArray($aTopicId, $sUserId)
+ {
+ if (!$aTopicId) {
+ return array();
+ }
+ if (Config::Get('sys.cache.solid')) {
+ return $this->GetTopicsReadByArraySolid($aTopicId, $sUserId);
+ }
+ if (!is_array($aTopicId)) {
+ $aTopicId = array($aTopicId);
+ }
+ $aTopicId = array_unique($aTopicId);
+ $aTopicsRead = array();
+ $aTopicIdNotNeedQuery = array();
+ /**
+ * Делаем мульти-запрос к кешу
+ */
+ $aCacheKeys = func_build_cache_keys($aTopicId, 'topic_read_', '_' . $sUserId);
+ if (false !== ($data = $this->Cache_Get($aCacheKeys))) {
+ /**
+ * проверяем что досталось из кеша
+ */
+ foreach ($aCacheKeys as $sValue => $sKey) {
+ if (array_key_exists($sKey, $data)) {
+ if ($data[$sKey]) {
+ $aTopicsRead[$data[$sKey]->getTopicId()] = $data[$sKey];
+ } else {
+ $aTopicIdNotNeedQuery[] = $sValue;
+ }
+ }
+ }
+ }
+ /**
+ * Смотрим каких топиков не было в кеше и делаем запрос в БД
+ */
+ $aTopicIdNeedQuery = array_diff($aTopicId, array_keys($aTopicsRead));
+ $aTopicIdNeedQuery = array_diff($aTopicIdNeedQuery, $aTopicIdNotNeedQuery);
+ $aTopicIdNeedStore = $aTopicIdNeedQuery;
+ if ($data = $this->oMapperTopic->GetTopicsReadByArray($aTopicIdNeedQuery, $sUserId)) {
+ foreach ($data as $oTopicRead) {
+ /**
+ * Добавляем к результату и сохраняем в кеш
+ */
+ $aTopicsRead[$oTopicRead->getTopicId()] = $oTopicRead;
+ $this->Cache_Set($oTopicRead, "topic_read_{$oTopicRead->getTopicId()}_{$oTopicRead->getUserId()}",
+ array(), 60 * 60 * 24 * 4);
+ $aTopicIdNeedStore = array_diff($aTopicIdNeedStore, array($oTopicRead->getTopicId()));
+ }
+ }
+ /**
+ * Сохраняем в кеш запросы не вернувшие результата
+ */
+ foreach ($aTopicIdNeedStore as $sId) {
+ $this->Cache_Set(null, "topic_read_{$sId}_{$sUserId}", array(), 60 * 60 * 24 * 4);
+ }
+ /**
+ * Сортируем результат согласно входящему массиву
+ */
+ $aTopicsRead = func_array_sort_by_keys($aTopicsRead, $aTopicId);
+ return $aTopicsRead;
+ }
+
+ /**
+ * Получить список просмотром/чтения топиков по списку айдишников, но используя единый кеш
+ *
+ * @param array $aTopicId Список ID топиков
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetTopicsReadByArraySolid($aTopicId, $sUserId)
+ {
+ if (!is_array($aTopicId)) {
+ $aTopicId = array($aTopicId);
+ }
+ $aTopicId = array_unique($aTopicId);
+ $aTopicsRead = array();
+ $s = join(',', $aTopicId);
+ if (false === ($data = $this->Cache_Get("topic_read_{$sUserId}_id_{$s}"))) {
+ $data = $this->oMapperTopic->GetTopicsReadByArray($aTopicId, $sUserId);
+ foreach ($data as $oTopicRead) {
+ $aTopicsRead[$oTopicRead->getTopicId()] = $oTopicRead;
+ }
+ $this->Cache_Set($aTopicsRead, "topic_read_{$sUserId}_id_{$s}", array("topic_read_user_{$sUserId}"),
+ 60 * 60 * 24 * 1);
+ return $aTopicsRead;
+ }
+ return $data;
+ }
+
+ /**
+ * Получает топик по уникальному хешу(текст топика)
+ *
+ * @param int $sUserId
+ * @param string $sHash
+ * @return ModuleTopic_EntityTopic|null
+ */
+ public function GetTopicUnique($sUserId, $sHash)
+ {
+ $sId = $this->oMapperTopic->GetTopicUnique($sUserId, $sHash);
+ return $this->GetTopicById($sId);
+ }
+
+ /**
+ * Рассылает уведомления о новом топике подписчикам блогов
+ *
+ * @param ModuleTopic_EntityTopic $oTopic Объект топика
+ * @param ModuleUser_EntityUser $oUserTopic Объект пользователя
+ */
+ public function SendNotifyTopicNew($oTopic, $oUserTopic)
+ {
+ /**
+ * Сначала отправляем подписчикам блогов
+ */
+ $iPage = 1;
+ $aBlogs = $oTopic->getBlogsId();
+ $aUserIdSend = array($oUserTopic->getId());
+ while ($aBlogUsersResult = $this->Blog_GetBlogUsersByBlogId($aBlogs, null, $iPage,
+ 50) and $aBlogUsersResult['collection']) {
+ $aBlogUsers = $aBlogUsersResult['collection'];
+ foreach ($aBlogUsers as $oBlogUser) {
+ if (in_array($oBlogUser->getUserId(), $aUserIdSend)) {
+ continue;
+ }
+ $ouser = $oBlogUser->getUser();
+ if (empty($ouser)) {
+ continue;
+ }
+ $this->SendNotifyTopicNewToSubscribeBlog($ouser, $oTopic, $oBlogUser->getBlog(),
+ $oUserTopic);
+ $aUserIdSend[] = $oBlogUser->getUserId();
+ }
+
+ $iPage++;
+ }
+ /**
+ * Теперь отправляем авторам блогов
+ */
+ $aBlogs = $this->Blog_GetBlogsAdditionalData($aBlogs);
+ foreach ($aBlogs as $oBlog) {
+ if ($oBlog->getOwnerId() != $oUserTopic->getId() and !in_array($oBlog->getOwnerId(), $aUserIdSend)) {
+ $this->SendNotifyTopicNewToSubscribeBlog($oBlog->getOwner(), $oTopic, $oBlog, $oUserTopic);
+ $aUserIdSend[] = $oBlog->getOwnerId();
+ }
+ }
+ }
+
+ /**
+ * Возвращает список последних топиков пользователя, опубликованных не более чем $iTimeLimit секунд назад
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iTimeLimit Число секунд
+ * @param int $iCountLimit Количество
+ * @param array $aAllowData Список типов данных для подгрузки в топики
+ * @return array
+ */
+ public function GetLastTopicsByUserId($sUserId, $iTimeLimit, $iCountLimit = 1, $aAllowData = array())
+ {
+ $aFilter = array(
+ 'topic_publish' => 1,
+ 'user_id' => $sUserId,
+ 'topic_new' => date("Y-m-d H:i:s", time() - $iTimeLimit),
+ );
+ $this->Hook_Run('get_topics_by_custom_filter',
+ array('aFilter' => &$aFilter, 'iPage' => 1, 'iPerPage' => $iCountLimit, 'sMethod' => __FUNCTION__));
+ $aTopics = $this->GetTopicsByFilter($aFilter, 1, $iCountLimit, $aAllowData);
+
+ return $aTopics;
+ }
+
+ /**
+ * Перемещает топики в другой блог
+ *
+ * @param int $sBlogId ID старого блога
+ * @param int $sBlogIdNew ID нового блога
+ * @return bool
+ */
+ public function MoveTopics($sBlogId, $sBlogIdNew)
+ {
+ if ($res = $this->oMapperTopic->MoveTopics($sBlogId, $sBlogIdNew)) {
+ // перемещаем теги
+ $this->oMapperTopic->MoveTopicsTags($sBlogId, $sBlogIdNew);
+ // меняем target parent у комментов
+ $this->Comment_MoveTargetParent($sBlogId, 'topic', $sBlogIdNew);
+ // меняем target parent у комментов в прямом эфире
+ $this->Comment_MoveTargetParentOnline($sBlogId, 'topic', $sBlogIdNew);
+ /**
+ * Обновляем количество топиков в блоге
+ */
+ $this->Blog_RecalculateCountTopicByBlogId($sBlogIdNew);
+ }
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("topic_update", "topic_new_blog_{$sBlogId}", "topic_new_blog_{$sBlogIdNew}"));
+ return $res;
+ }
+
+ /**
+ * Пересчитывает счетчик избранных топиков
+ *
+ * @return bool
+ */
+ public function RecalculateFavourite()
+ {
+ return $this->oMapperTopic->RecalculateFavourite();
+ }
+
+ /**
+ * Пересчитывает счетчики голосований
+ *
+ * @return bool
+ */
+ public function RecalculateVote()
+ {
+ return $this->oMapperTopic->RecalculateVote();
+ }
+
+ /**
+ * Алиас для корректной работы ORM
+ *
+ * @param array $aFilter Фильтр, который содержит список id топиков в параметре "id in"
+ * @return array
+ */
+ public function GetTopicItemsByFilter($aFilter)
+ {
+ if (isset($aFilter['id in'])) {
+ return $this->GetTopicsByArrayId($aFilter['id in']);
+ }
+ return array();
+ }
+
+ /**
+ * Парсинг текста с учетом конкретного топика
+ *
+ * @param string $sText
+ * @param ModuleTopic_EntityTopic $oTopic
+ *
+ * @return string
+ */
+ public function Parser($sText, $oTopic)
+ {
+ $this->Text_AddParams(array('oTopic' => $oTopic));
+ $sResult = $this->Text_Parser($sText);
+ $this->Text_RemoveParams(array('oTopic'));
+ return $sResult;
+ }
+
+ /**
+ * Возвращает объект типа топика по его коду
+ *
+ * @param string $sCode
+ *
+ * @return ModuleTopic_EntityTopicType|null
+ */
+ public function GetTopicTypeByCode($sCode)
+ {
+ return $this->oMapperTopic->GetTopicTypeByCode($sCode);
+ }
+
+ /**
+ * Возвращает объект типа топика по его ID
+ *
+ * @param int $iId
+ *
+ * @return ModuleTopic_EntityTopicType|null
+ */
+ public function GetTopicTypeById($iId)
+ {
+ return $this->oMapperTopic->GetTopicTypeById($iId);
+ }
+
+ /**
+ * Добавляет новый тип топика в БД
+ *
+ * @param ModuleTopic_EntityTopicType $oType
+ *
+ * @return ModuleTopic_EntityTopicType|bool
+ */
+ public function AddTopicType($oType)
+ {
+ if ($sId = $this->oMapperTopic->AddTopicType($oType)) {
+ $oType->setId($sId);
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('topic_type_new'));
+ /**
+ * Регистрируем новый тип в дополнительных полях
+ * todo: fix lang text
+ */
+ $this->Property_CreateTargetType('topic_' . $oType->getCode(),
+ array('entity' => 'ModuleTopic_EntityTopic', 'name' => 'Топик - ' . $oType->getName()), true);
+ return $oType;
+ }
+ return false;
+ }
+
+ /**
+ * @param array $aFilter
+ *
+ * @return mixed
+ */
+ public function GetTopicTypeItems($aFilter = array())
+ {
+ return $this->oMapperTopic->GetTopicTypeItems($aFilter);
+ }
+
+ /**
+ * Обновляет тип топика в БД
+ *
+ * @param ModuleTopic_EntityTopicType $oType
+ *
+ * @return bool
+ */
+ public function UpdateTopicType($oType)
+ {
+ return $this->oMapperTopic->UpdateTopicType($oType);
+ }
+
+ /**
+ * Удаляет тип топика из БД
+ *
+ * @param $sTypeId
+ *
+ * @return bool
+ */
+ public function DeleteTopicType($sTypeId)
+ {
+ return $this->oMapperTopic->DeleteTopicType($sTypeId);
+ }
+
+ public function UpdateTopicByType($sType, $sTypeNew)
+ {
+ $res = $this->oMapperTopic->UpdateTopicByType($sType, $sTypeNew);
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("topic_update"));
+ return $res;
+ }
+
+ /**
+ * Формирует и возвращает полный ЧПУ URL для топика
+ *
+ * @param ModuleTopic_EntityTopic $oTopic
+ * @param bool $bAbsolute При false вернет относительный УРЛ
+ * @return string
+ */
+ public function BuildUrlForTopic($oTopic, $bAbsolute = true)
+ {
+ $sUrlMask = Config::Get('module.topic.url');
+ $iDateCreate = strtotime($oTopic->getDatePublish());
+ $aReplace = array(
+ '%year%' => date("Y", $iDateCreate),
+ '%month%' => date("m", $iDateCreate),
+ '%day%' => date("d", $iDateCreate),
+ '%hour%' => date("H", $iDateCreate),
+ '%minute%' => date("i", $iDateCreate),
+ '%second%' => date("s", $iDateCreate),
+ '%login%' => '',
+ '%blog%' => '',
+ '%id%' => $oTopic->getId(),
+ '%title%' => $oTopic->getSlug(),
+ '%type%' => $oTopic->getType(),
+ );
+
+ /**
+ * Получаем связанные данные только если в этом есть необходимость
+ */
+ if (strpos($sUrlMask, '%blog%') !== false) {
+ if (!($oBlog = $oTopic->GetBlog())) {
+ $oBlog = $this->Blog_GetBlogById($oTopic->getBlogId());
+ }
+ if ($oBlog) {
+ if ($oBlog->getType() == 'personal') {
+ $sUrlMask = str_replace('%blog%', '%login%', $sUrlMask);
+ } else {
+ $aReplace['%blog%'] = $oBlog->getUrl();
+ }
+ }
+ }
+
+ if (strpos($sUrlMask, '%login%') !== false) {
+ if (!($oUser = $oTopic->GetUser())) {
+ $oUser = $this->User_GetUserById($oTopic->getUserId());
+ }
+ if ($oUser) {
+ $aReplace['%login%'] = $oUser->getLogin();
+ }
+ }
+ $sUrl = strtr($sUrlMask, $aReplace);
+ return $bAbsolute ? Router::GetPathRootWeb() . '/' . $sUrl : $sUrl;
+ }
+
+ /**
+ * Формирует из строки url
+ *
+ * @param string $sText
+ * @return string
+ */
+ public function MakeSlug($sText)
+ {
+ return $this->Text_Transliteration($sText);
+ }
+
+ /**
+ * Возвращает URL с учетом уникалькости по всем топикам
+ *
+ * @param string $sSlug
+ * @param int|null $iSkipTopicId
+ * @return string
+ */
+ public function GetUniqueSlug($sSlug, $iSkipTopicId = null)
+ {
+ $iPostfix = 0;
+ do {
+ $sUrl = $sSlug . ($iPostfix ? '-' . $iPostfix : '');
+ $iPostfix++;
+ } while ($oTopic = $this->GetTopicBySlug($sUrl) and (is_null($iSkipTopicId) or $iSkipTopicId != $oTopic->getId()));
+
+ return $sUrl;
+ }
+
+ /**
+ * Отправляет юзеру уведомление об ответе на его комментарий
+ *
+ * @param ModuleUser_EntityUser $oUserTo Объект пользователя кому отправляем
+ * @param ModuleTopic_EntityTopic $oTopic Объект топика
+ * @param ModuleComment_EntityComment $oComment Объект комментария
+ * @param ModuleUser_EntityUser $oUserComment Объект пользователя, написавшего комментарий
+ * @return bool
+ */
+ public function SendNotifyCommentReplyToAuthorParentComment(
+ ModuleUser_EntityUser $oUserTo,
+ ModuleTopic_EntityTopic $oTopic,
+ ModuleComment_EntityComment $oComment,
+ ModuleUser_EntityUser $oUserComment
+ ) {
+ /**
+ * Проверяем можно ли юзеру рассылать уведомление
+ */
+ if (!$oUserTo->getSettingsNoticeReplyComment()) {
+ return false;
+ }
+ $this->Notify_Send(
+ $oUserTo,
+ 'comment_reply.tpl',
+ $this->Lang_Get('emails.comment_reply.subject'),
+ array(
+ 'oUserTo' => $oUserTo,
+ 'oTopic' => $oTopic,
+ 'oComment' => $oComment,
+ 'oUserComment' => $oUserComment,
+ )
+ );
+ return true;
+ }
+
+ /**
+ * Отправляет юзеру уведомление о новом топике в блоге, в котором он состоит
+ *
+ * @param ModuleUser_EntityUser $oUserTo Объект пользователя кому отправляем
+ * @param ModuleTopic_EntityTopic $oTopic Объект топика
+ * @param ModuleBlog_EntityBlog $oBlog Объект блога
+ * @param ModuleUser_EntityUser $oUserTopic Объект пользователя, написавшего топик
+ * @return bool
+ */
+ public function SendNotifyTopicNewToSubscribeBlog(
+ ModuleUser_EntityUser $oUserTo,
+ ModuleTopic_EntityTopic $oTopic,
+ ModuleBlog_EntityBlog $oBlog,
+ ModuleUser_EntityUser $oUserTopic
+ ) {
+ /**
+ * Проверяем можно ли юзеру рассылать уведомление
+ */
+ if (!$oUserTo->getSettingsNoticeNewTopic()) {
+ return false;
+ }
+ $this->Notify_Send(
+ $oUserTo,
+ 'topic_new.tpl',
+ $this->Lang_Get('emails.topic_new.subject') . ' «' . htmlspecialchars($oBlog->getTitle()) . '»',
+ array(
+ 'oUserTo' => $oUserTo,
+ 'oTopic' => $oTopic,
+ 'oBlog' => $oBlog,
+ 'oUserTopic' => $oUserTopic,
+ )
+ );
+ return true;
+ }
+
+ /**
+ * Регистрация сайтмапа для топиков
+ */
+ public function RegisterSitemap()
+ {
+ $aFilter = array(
+ 'blog_type' => array(
+ 'open',
+ 'personal',
+ ),
+ 'topic_publish' => 1,
+ 'order' => 't.topic_id asc'
+ );
+ $this->Sitemap_AddTargetType('topics', array(
+ 'callback_data' => function ($iPage) use ($aFilter) {
+ $aTopics = $this->GetTopicsByFilter($aFilter, $iPage, 500, array('blog' => array()));
+ $aData = array();
+ foreach ($aTopics['collection'] as $oTopic) {
+ $aData[] = $this->Sitemap_GetDataForSitemapRow(
+ $oTopic->getUrl(),
+ is_null($oTopic->getDateEdit()) ? $oTopic->getDatePublish() : $oTopic->getDateEdit(),
+ Config::Get('module.sitemap.topic.priority'),
+ Config::Get('module.sitemap.topic.changefreq')
+ );
+ }
+ return $aData;
+ },
+ 'callback_counters' => function () use ($aFilter) {
+ $iCount = (int)$this->GetCountTopicsByFilter($aFilter);
+ return ceil($iCount / 500);
+ }
+ ));
+ }
+}
diff --git a/application/classes/modules/topic/entity/Topic.entity.class.php b/application/classes/modules/topic/entity/Topic.entity.class.php
new file mode 100644
index 0000000..01dc29e
--- /dev/null
+++ b/application/classes/modules/topic/entity/Topic.entity.class.php
@@ -0,0 +1,1425 @@
+
+ *
+ */
+
+/**
+ * Объект сущности топика
+ *
+ * @package application.modules.topic
+ * @since 1.0
+ */
+class ModuleTopic_EntityTopic extends Entity
+{
+ /**
+ * Массив объектов(не всегда) для дополнительных типов топиков(линки, опросы, подкасты и т.п.)
+ *
+ * @var array
+ */
+ protected $aExtra = null;
+ /**
+ * Список поведений
+ *
+ * @var array
+ */
+ protected $aBehaviors = array(
+ /**
+ * Дополнительные поля
+ */
+ 'property' => 'ModuleProperty_BehaviorEntity',
+ );
+
+ /**
+ * Определяем правила валидации
+ */
+ public function Init()
+ {
+ parent::Init();
+ $this->aValidateRules[] = array(
+ 'topic_title',
+ 'string',
+ 'max' => Config::Get('module.topic.title_max_length'),
+ 'min' => Config::Get('module.topic.title_min_length'),
+ 'allowEmpty' => Config::Get('module.topic.title_allow_empty'),
+ 'label' => $this->Lang_Get('topic.add.fields.title.label')
+ );
+ $this->aValidateRules[] = array(
+ 'topic_slug_raw',
+ 'regexp',
+ 'allowEmpty' => true,
+ 'pattern' => '#^[a-z0-9\-]{1,500}$#i'
+ );
+ $this->aValidateRules[] = array(
+ 'topic_text_source',
+ 'string',
+ 'max' => Config::Get('module.topic.max_length'),
+ 'min' => Config::Get('module.topic.min_length'),
+ 'allowEmpty' => Config::Get('module.topic.allow_empty'),
+ 'condition' => 'isNeedValidateText',
+ 'label' => $this->Lang_Get('topic.add.fields.text.label')
+ );
+ $this->aValidateRules[] = array(
+ 'topic_tags',
+ 'tags',
+ 'countMax' => Config::Get('module.topic.tags_count_max'),
+ 'countMin' => Config::Get('module.topic.tags_count_min'),
+ 'condition' => 'isNeedValidateTags',
+ 'label' => $this->Lang_Get('topic.add.fields.tags.label'),
+ 'allowEmpty' => Config::Get('module.topic.tags_allow_empty')
+ );
+
+ $this->aValidateRules[] = array('blogs_id_raw', 'blogs');
+ $this->aValidateRules[] = array('topic_text_source', 'topic_unique');
+ $this->aValidateRules[] = array('topic_slug_raw', 'slug_check');
+ $this->aValidateRules[] = array('publish_date_raw', 'publish_date_check');
+ }
+
+ /**
+ * Проверяет нужно проводить валидацию текста топика или нет
+ *
+ * @return bool
+ */
+ public function isNeedValidateText()
+ {
+ $oTopicType = $this->getTypeObject();
+ if (!$oTopicType or $oTopicType->getParam('allow_text')) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет нужно проводить валидацию тегов топика или нет
+ *
+ * @return bool
+ */
+ public function isNeedValidateTags()
+ {
+ $oTopicType = $this->getTypeObject();
+ if (!$oTopicType or $oTopicType->getParam('allow_tags')) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверка типа топика
+ *
+ * @param string $sValue Проверяемое значение
+ * @param array $aParams Параметры
+ * @return bool|string
+ */
+ public function ValidateTopicType($sValue, $aParams)
+ {
+ if ($this->Topic_IsAllowTopicType($sValue)) {
+ return true;
+ }
+ return $this->Lang_Get('topic.add.notices.error_type');
+ }
+
+ /**
+ * Проверка даты отложенной публикации
+ *
+ * @param array $aValue Проверяемое значение
+ * @param array $aParams Параметры
+ * @return bool|string
+ */
+ public function ValidatePublishDateCheck($aValue, $aParams)
+ {
+ $oTopicType = $this->getTypeObject();
+ $oUser = $this->getUserCreator();
+ if ($oTopicType and $oTopicType->isAllowCreateDeferredTopic($oUser)) {
+ if ((!$this->getId() or !$this->getPublishDraft() or ($this->getDatePublish() and strtotime($this->getDatePublish()) > time())) and isset($aValue['date']) and is_string($aValue['date']) and $aValue['date'] and isset($aValue['time']) and is_string($aValue['time'])) {
+ $sDateFull = $aValue['date'] . ' ' . $aValue['time'];
+ if ($this->Validate_Validate('date', $sDateFull, array('format' => 'dd.MM.yyyy HH:mm', 'allowEmpty' => true))) {
+ $sDateFull = strtotime($sDateFull); // для охвата всей минуты
+ /**
+ * Переводим дату к серверному часовому поясу
+ */
+ if ($oUser = $this->getUserCreator() and $sTz = $oUser->getSettingsTimezone()) {
+ $oNow = new DateTime(null, new DateTimeZone($sTz));
+ $iTz = $oNow->getOffset() / 3600;
+ $iDiff = (date('I') + $iTz - (strtotime(date("Y-m-d H:i:s")) - strtotime(gmdate("Y-m-d H:i:s"))) / 3600) * 3600;
+ $sDateFull = $sDateFull - $iDiff;
+ }
+ if ($sDateFull >= strtotime(date('Y-m-d H:i:00'))) {
+ $this->setPublishDateRaw($sDateFull);
+ return true;
+ } else {
+ return $this->Lang_Get('topic.add.notices.error_publish_date');
+ }
+ } else {
+ return $this->Lang_Get('topic.add.notices.error_publish_date');
+ }
+ }
+ }
+ $this->setPublishDateRaw(null);
+ return true;
+ }
+
+ /**
+ * Проверка URL топика
+ *
+ * @param string $sValue Проверяемое значение
+ * @param array $aParams Параметры
+ * @return bool|string
+ */
+ public function ValidateSlugCheck($sValue, $aParams)
+ {
+ if (!$this->User_GetIsAdmin()) {
+ /**
+ * Простому пользователю разрешаем менять url только в течении X времени после создания топика
+ * Причем не прямую смену url, а через транлитерацию заголовка топика
+ */
+ if ($this->getId()) {
+ if (strtotime($this->getDatePublish()) < time() - 60 * 60 * 1) {
+ /**
+ * Не меняем url
+ */
+ return true;
+ }
+ }
+ /**
+ * Для нового топика всегда формируем url
+ */
+ $this->setSlugRaw('');
+ }
+
+ if ($this->getSlugRaw()) {
+ $this->setSlug($this->Topic_GetUniqueSlug($this->getSlugRaw(), $this->getId()));
+ } elseif ($this->getTitle()) {
+ if ($sUrl = $this->Topic_MakeSlug($this->getTitle())) {
+ /**
+ * Получаем уникальный URL
+ */
+ $this->setSlug($this->Topic_GetUniqueSlug($sUrl, $this->getId()));
+ } else {
+ return $this->Lang_Get('topic.add.notices.error_slug');
+ }
+ } else {
+ /**
+ * Заголовок топика пустой, генерируем случайный url
+ */
+ $this->setSlug($this->Topic_GetUniqueSlug(func_generator(5), $this->getId()));
+ }
+ return true;
+ }
+
+ /**
+ * Проверка топика на уникальность
+ *
+ * @param string $sValue Проверяемое значение
+ * @param array $aParams Параметры
+ * @return bool|string
+ */
+ public function ValidateTopicUnique($sValue, $aParams)
+ {
+ $this->setTextHash(md5($this->getType() . $sValue . $this->getTitle()));
+ if ($this->isNeedValidateText()) {
+ if ($oTopicEquivalent = $this->Topic_GetTopicUnique($this->getUserId(), $this->getTextHash())) {
+ if ($iId = $this->getId() and $oTopicEquivalent->getId() == $iId) {
+ return true;
+ }
+ return $this->Lang_Get('topic.add.notices.error_text_unique');
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Валидация ID блогов
+ *
+ * @param string $sValue Проверяемое значение
+ * @param array $aParams Параметры
+ * @return bool|string
+ */
+ public function ValidateBlogs($sValue, $aParams)
+ {
+ if ($sValue and is_string($sValue)) {
+ $sValue = explode(',', $sValue);
+ }
+ if (!$sValue or !is_array($sValue)) {
+ if ($oBlog = $this->Blog_GetPersonalBlogByUserId($this->getUserId())) {
+ $this->setBlogs(array($oBlog));
+ $this->setBlogId($oBlog->getId());
+ $this->setBlogId2(null);
+ $this->setBlogId3(null);
+ $this->setBlogId4(null);
+ $this->setBlogId5(null);
+ return true; // персональный блог
+ } else {
+ return $this->Lang_Get('topic.add.notices.error_blog_not_found');
+ }
+ }
+ /**
+ * Проверяем список блогов
+ */
+ $aBlogs = array();
+ foreach ($sValue as $iKey => $iBlogId) {
+ if (is_numeric($iBlogId) and $oBlog = $this->Blog_GetBlogById($iBlogId)) {
+ /**
+ * Проверяем права на постинг в блог
+ */
+ if ($this->ACL_IsAllowBlog($oBlog, $this->getUserCreator())) {
+ $aBlogs[] = $oBlog;
+ } else {
+ return $this->Lang_Get('topic.add.notices.error_blog_not_allowed');
+ }
+ }
+ }
+ if (count($aBlogs) == 0) {
+ return $this->Lang_Get('topic.add.notices.error_blog_not_found');
+ }
+ if (count($sValue) > Config::Get('module.topic.max_blog_count')) {
+ return $this->Lang_Get('topic.add.notices.error_blog_max_count',
+ array('count' => Config::Get('module.topic.max_blog_count')));
+ }
+ /**
+ * Заполняем поля с ID
+ */
+ $this->setBlogId($aBlogs[0]->getId());
+ $this->setBlogId2(isset($aBlogs[1]) ? $aBlogs[1]->getId() : null);
+ $this->setBlogId3(isset($aBlogs[2]) ? $aBlogs[2]->getId() : null);
+ $this->setBlogId4(isset($aBlogs[3]) ? $aBlogs[3]->getId() : null);
+ $this->setBlogId5(isset($aBlogs[4]) ? $aBlogs[4]->getId() : null);
+ $this->setBlogs($aBlogs);
+ return true;
+ }
+
+ /**
+ * Возвращает ID топика
+ *
+ * @return int|null
+ */
+ public function getId()
+ {
+ return $this->_getDataOne('topic_id');
+ }
+
+ /**
+ * Возвращает ID блога
+ *
+ * @return int|null
+ */
+ public function getBlogId()
+ {
+ return $this->_getDataOne('blog_id');
+ }
+
+ /**
+ * Возвращает ID блога 2
+ *
+ * @return int|null
+ */
+ public function getBlogId2()
+ {
+ return $this->_getDataOne('blog_id2');
+ }
+
+ /**
+ * Возвращает ID блога 3
+ *
+ * @return int|null
+ */
+ public function getBlogId3()
+ {
+ return $this->_getDataOne('blog_id3');
+ }
+
+ /**
+ * Возвращает ID блога 4
+ *
+ * @return int|null
+ */
+ public function getBlogId4()
+ {
+ return $this->_getDataOne('blog_id4');
+ }
+
+ /**
+ * Возвращает ID блога 5
+ *
+ * @return int|null
+ */
+ public function getBlogId5()
+ {
+ return $this->_getDataOne('blog_id5');
+ }
+
+ /**
+ * Возвращает ID пользователя
+ *
+ * @return int|null
+ */
+ public function getUserId()
+ {
+ return $this->_getDataOne('user_id');
+ }
+
+ /**
+ * Возвращает тип топика
+ *
+ * @return string|null
+ */
+ public function getType()
+ {
+ return $this->_getDataOne('topic_type');
+ }
+
+ /**
+ * Возвращает заголовок топика
+ *
+ * @return string|null
+ */
+ public function getTitle()
+ {
+ return $this->_getDataOne('topic_title');
+ }
+
+ /**
+ * Возвращает url топика
+ *
+ * @return string|null
+ */
+ public function getSlug()
+ {
+ return $this->_getDataOne('topic_slug');
+ }
+
+ /**
+ * Возвращает текст топика
+ *
+ * @return string|null
+ */
+ public function getText()
+ {
+ return $this->_getDataOne('topic_text');
+ }
+
+ /**
+ * Возвращает короткий текст топика (до ката)
+ *
+ * @return string|null
+ */
+ public function getTextShort()
+ {
+ return $this->_getDataOne('topic_text_short');
+ }
+
+ /**
+ * Возвращает исходный текст топика, без примененя парсера тегов
+ *
+ * @return string|null
+ */
+ public function getTextSource()
+ {
+ return $this->_getDataOne('topic_text_source');
+ }
+
+ /**
+ * Возвращает сериализованные строку дополнительный данных топика
+ *
+ * @return string
+ */
+ public function getExtra()
+ {
+ return $this->_getDataOne('topic_extra') ? $this->_getDataOne('topic_extra') : serialize('');
+ }
+
+ /**
+ * Возвращает строку со списком тегов через запятую
+ *
+ * @return string|null
+ */
+ public function getTags()
+ {
+ return $this->_getDataOne('topic_tags');
+ }
+
+ /**
+ * Возвращает дату создания топика
+ *
+ * @return string|null
+ */
+ public function getDateAdd()
+ {
+ return $this->_getDataOne('topic_date_add');
+ }
+
+ /**
+ * Возвращает дату редактирования топика
+ *
+ * @return string|null
+ */
+ public function getDateEdit()
+ {
+ return $this->_getDataOne('topic_date_edit');
+ }
+
+ /**
+ * Возвращает дату редактирования контента топика
+ *
+ * @return string|null
+ */
+ public function getDateEditContent()
+ {
+ return $this->_getDataOne('topic_date_edit_content');
+ }
+
+ /**
+ * Возвращает дату публикации топика
+ *
+ * @return string|null
+ */
+ public function getDatePublish()
+ {
+ return $this->_getDataOne('topic_date_publish');
+ }
+
+ /**
+ * Возвращает IP пользователя
+ *
+ * @return string|null
+ */
+ public function getUserIp()
+ {
+ return $this->_getDataOne('topic_user_ip');
+ }
+
+ /**
+ * Возвращает статус опубликованности топика
+ *
+ * @return int|null
+ */
+ public function getPublish()
+ {
+ return $this->_getDataOne('topic_publish');
+ }
+
+ /**
+ * Возвращает статус опубликованности черновика
+ *
+ * @return int|null
+ */
+ public function getPublishDraft()
+ {
+ return $this->_getDataOne('topic_publish_draft');
+ }
+
+ /**
+ * Возвращает статус публикации топика на главной странице
+ *
+ * @return int|null
+ */
+ public function getPublishIndex()
+ {
+ return $this->_getDataOne('topic_publish_index');
+ }
+
+ /**
+ * Возвращает статус пропуска топика на главной странице
+ *
+ * @return int|null
+ */
+ public function getSkipIndex()
+ {
+ return $this->_getDataOne('topic_skip_index');
+ }
+
+ /**
+ * Возвращает рейтинг топика
+ *
+ * @return string
+ */
+ public function getRating()
+ {
+ return number_format(round($this->_getDataOne('topic_rating'), 2), 0, '.', '');
+ }
+
+ /**
+ * Возвращает число проголосовавших за топик
+ *
+ * @return int|null
+ */
+ public function getCountVote()
+ {
+ return $this->_getDataOne('topic_count_vote');
+ }
+
+ /**
+ * Возвращает число проголосовавших за топик положительно
+ *
+ * @return int|null
+ */
+ public function getCountVoteUp()
+ {
+ return $this->_getDataOne('topic_count_vote_up');
+ }
+
+ /**
+ * Возвращает число проголосовавших за топик отрицательно
+ *
+ * @return int|null
+ */
+ public function getCountVoteDown()
+ {
+ return $this->_getDataOne('topic_count_vote_down');
+ }
+
+ /**
+ * Возвращает число воздержавшихся при голосовании за топик
+ *
+ * @return int|null
+ */
+ public function getCountVoteAbstain()
+ {
+ return $this->_getDataOne('topic_count_vote_abstain');
+ }
+
+ /**
+ * Возвращает число прочтений топика
+ *
+ * @return int|null
+ */
+ public function getCountRead()
+ {
+ return $this->_getDataOne('topic_count_read');
+ }
+
+ /**
+ * Возвращает количество комментариев к топику
+ *
+ * @return int|null
+ */
+ public function getCountComment()
+ {
+ return $this->_getDataOne('topic_count_comment');
+ }
+
+ /**
+ * Возвращает текст ката
+ *
+ * @return string|null
+ */
+ public function getCutText()
+ {
+ return $this->_getDataOne('topic_cut_text');
+ }
+
+ /**
+ * Возвращает статус запрета комментировать топик
+ *
+ * @return int|null
+ */
+ public function getForbidComment()
+ {
+ return $this->_getDataOne('topic_forbid_comment');
+ }
+
+ /**
+ * Возвращает хеш топика для проверки топика на уникальность
+ *
+ * @return string|null
+ */
+ public function getTextHash()
+ {
+ return $this->_getDataOne('topic_text_hash');
+ }
+
+ /**
+ * Возвращает массив тегов
+ *
+ * @return array
+ */
+ public function getTagsArray()
+ {
+ if ($this->getTags()) {
+ return explode(',', $this->getTags());
+ }
+ return array();
+ }
+
+ /**
+ * Возвращает массив тегов в виде объектов
+ *
+ * @return array
+ */
+ public function getTagsObjects()
+ {
+ $aReturn = array();
+ if ($aTags = $this->getTagsArray()) {
+ foreach ($aTags as $sTag) {
+ if ($sTag) {
+ $aReturn[] = Engine::GetEntity('ModuleTopic_EntityTopicTag', array(
+ 'topic_id' => $this->getId(),
+ 'user_id' => $this->getUserId(),
+ 'blog_id' => $this->getBlogId(),
+ 'topic_tag_text' => $sTag,
+ ));
+ }
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Возвращает количество новых комментариев в топике для текущего пользователя
+ *
+ * @return int|null
+ */
+ public function getCountCommentNew()
+ {
+ return $this->_getDataOne('count_comment_new');
+ }
+
+ /**
+ * Возвращает дату прочтения топика для текущего пользователя
+ *
+ * @return string|null
+ */
+ public function getDateRead()
+ {
+ return $this->_getDataOne('date_read');
+ }
+
+ /**
+ * Возвращает объект пользователя, автора топик
+ *
+ * @return ModuleUser_EntityUser|null
+ */
+ public function getUser()
+ {
+ if (!$this->_getDataOne('user')) {
+ $this->_aData['user'] = $this->User_GetUserById($this->getUserId());
+ }
+ return $this->_getDataOne('user');
+ }
+
+ /**
+ * Возвращает объект блого, в котором находится топик
+ *
+ * @return ModuleBlog_EntityBlog|null
+ */
+ public function getBlog()
+ {
+ if ($aBlogs = $this->getBlogs() and is_array($aBlogs)) {
+ return reset($aBlogs);
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает список блогов
+ *
+ * @return mixed|null
+ */
+ public function getBlogs()
+ {
+ return $this->_getDataOne('blogs');
+ }
+
+ /**
+ * Возвращает список ID блогов
+ *
+ * @return array
+ */
+ public function getBlogsId()
+ {
+ $aResult = array();
+ if ($aBlogs = $this->getBlogs()) {
+ foreach ($aBlogs as $oBlog) {
+ $aResult[] = (int)$oBlog->getId();
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Возвращает полный URL до топика
+ *
+ * @param bool $bAbsolute При false вернет относительный УРЛ
+ * @return string
+ */
+ public function getUrl($bAbsolute = true)
+ {
+ return $this->Topic_BuildUrlForTopic($this, $bAbsolute);
+ }
+
+ /**
+ * Возвращает полный URL до страницы редактировани топика
+ *
+ * @return string
+ */
+ public function getUrlEdit()
+ {
+ return Router::GetPath('content') . 'edit/' . $this->getId() . '/';
+ }
+
+ /**
+ * Возвращает полный URL для удаления топика
+ *
+ * @return string
+ */
+ public function getUrlDelete()
+ {
+ return Router::GetPath('content') . 'delete/' . $this->getId() . '/';
+ }
+
+ /**
+ * Возвращает объект голосования за топик текущим пользователем
+ *
+ * @return ModuleVote_EntityVote|null
+ */
+ public function getVote()
+ {
+ return $this->_getDataOne('vote');
+ }
+
+ /**
+ * Проверяет находится ли данный топик в избранном у текущего пользователя
+ *
+ * @return bool
+ */
+ public function getIsFavourite()
+ {
+ if ($this->getFavourite()) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет разрешение на удаление топика у текущего пользователя
+ *
+ * @return bool
+ */
+ public function getIsAllowDelete()
+ {
+ if ($oUser = $this->User_GetUserCurrent()) {
+ return $this->ACL_IsAllowDeleteTopic($this, $oUser);
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет разрешение на редактирование топика у текущего пользователя
+ *
+ * @return bool
+ */
+ public function getIsAllowEdit()
+ {
+ if ($oUser = $this->User_GetUserCurrent()) {
+ return $this->ACL_IsAllowEditTopic($this, $oUser);
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет разрешение на какое-либо действие для топика у текущего пользователя
+ *
+ * @return bool
+ */
+ public function getIsAllowAction()
+ {
+ if ($this->User_GetUserCurrent()) {
+ return $this->getIsAllowEdit() || $this->getIsAllowDelete();
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает количество добавивших топик в избранное
+ *
+ * @return int|null
+ */
+ public function getCountFavourite()
+ {
+ return $this->_getDataOne('topic_count_favourite');
+ }
+
+ /**
+ * Возвращает объект подписки на новые комментарии к топику
+ *
+ * @return ModuleSubscribe_EntitySubscribe|null
+ */
+ public function getSubscribeNewComment()
+ {
+ if (!($oUserCurrent = $this->User_GetUserCurrent())) {
+ return null;
+ }
+ return $this->Subscribe_GetSubscribeByTargetAndMail('topic_new_comment', $this->getId(),
+ $oUserCurrent->getMail());
+ }
+
+ /**
+ * Возвращает тип объекта для дополнительных полей
+ * Метод необходим для интеграции с дополнительными полями (модуль Property)
+ * Данный метод автоматически добавляется поведением 'property' ( $this->property->getPropertyTargetType() ),
+ * который возвращает тип из параметра. Но т.к. у нас тип является вычисляемым (зависит от $this->getType() ), то необходимо явно объявить данный метод
+ *
+ * @return string
+ */
+ public function getPropertyTargetType()
+ {
+ return 'topic_' . $this->getType();
+ }
+
+ /**
+ * Возвращает объект типа топика
+ *
+ * @return ModuleTopic_EntityTopicType|null
+ */
+ public function getTypeObject()
+ {
+ if (!$this->_getDataOne('type_object')) {
+ /**
+ * Сначала смотрим среди загруженых активных типов, если нет, то делаем запрос к БД
+ */
+ if (!($this->_aData['type_object'] = $this->Topic_GetTopicType($this->getType()))) {
+ $this->_aData['type_object'] = $this->Topic_GetTopicTypeByCode($this->getType());
+ }
+ }
+ return $this->_getDataOne('type_object');
+ }
+
+ /**
+ * Возвращает список опросов, которые есть у топика
+ *
+ * @return array|null
+ */
+ public function getPolls()
+ {
+ if (is_null($this->_getDataOne('polls'))) {
+ $this->_aData['polls'] = $this->Poll_GetPollItemsByTarget('topic', $this->getId());
+ }
+ return $this->_getDataOne('polls');
+ }
+
+ /**
+ * Возвращает список ID всех блогов
+ *
+ * @return array
+ */
+ public function getBlogIds()
+ {
+ $aResult = array();
+ if ($this->getBlogId()) {
+ $aResult[] = $this->getBlogId();
+ }
+ if ($this->getBlogId2()) {
+ $aResult[] = $this->getBlogId2();
+ }
+ if ($this->getBlogId3()) {
+ $aResult[] = $this->getBlogId3();
+ }
+ if ($this->getBlogId4()) {
+ $aResult[] = $this->getBlogId4();
+ }
+ if ($this->getBlogId5()) {
+ $aResult[] = $this->getBlogId5();
+ }
+ return $aResult;
+ }
+
+ /***************************************************************************************************************************************************
+ * методы расширения типов топика
+ ***************************************************************************************************************************************************
+ */
+
+ /**
+ * Извлекает сериализованные данные топика
+ */
+ protected function extractExtra()
+ {
+ if (is_null($this->aExtra)) {
+ $this->aExtra = @unserialize($this->getExtra());
+ }
+ if (!is_array($this->aExtra)) {
+ $this->aExtra = array();
+ }
+ }
+
+ /**
+ * Устанавливает значение нужного параметра
+ *
+ * @param string $sName Название параметра/данных
+ * @param mixed $data Данные
+ */
+ protected function setExtraValue($sName, $data)
+ {
+ $this->extractExtra();
+ $this->aExtra[$sName] = $data;
+ $this->setExtra($this->aExtra);
+ }
+
+ /**
+ * Извлекает значение параметра
+ *
+ * @param string $sName Название параметра
+ * @return null|mixed
+ */
+ protected function getExtraValue($sName)
+ {
+ $this->extractExtra();
+ if (isset($this->aExtra[$sName])) {
+ return $this->aExtra[$sName];
+ }
+ return null;
+ }
+
+ /**
+ * Сохраняет путь до превью
+ *
+ * @param $data
+ */
+ public function setPreviewImage($data)
+ {
+ $this->setExtraValue('preview_image', $data);
+ }
+
+ /**
+ * Возвращает веб путь до превью нужного размера
+ *
+ * @param $sSize
+ *
+ * @return null
+ */
+ public function getPreviewImageWebPath($sSize)
+ {
+ if ($sPath = $this->getExtraValue('preview_image')) {
+ return $this->Media_GetImageWebPath($sPath, $sSize);
+ } else {
+ return null;
+ }
+ }
+
+
+
+ //*************************************************************************************************************************************************
+
+ /**
+ * Устанваливает ID топика
+ *
+ * @param int $data
+ */
+ public function setId($data)
+ {
+ $this->_aData['topic_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID блога
+ *
+ * @param int $data
+ */
+ public function setBlogId($data)
+ {
+ $this->_aData['blog_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID блога 2
+ *
+ * @param int $data
+ */
+ public function setBlogId2($data)
+ {
+ $this->_aData['blog_id2'] = $data;
+ }
+
+ /**
+ * Устанавливает ID блога 3
+ *
+ * @param int $data
+ */
+ public function setBlogId3($data)
+ {
+ $this->_aData['blog_id3'] = $data;
+ }
+
+ /**
+ * Устанавливает ID блога 4
+ *
+ * @param int $data
+ */
+ public function setBlogId4($data)
+ {
+ $this->_aData['blog_id4'] = $data;
+ }
+
+ /**
+ * Устанавливает ID блога 5
+ *
+ * @param int $data
+ */
+ public function setBlogId5($data)
+ {
+ $this->_aData['blog_id5'] = $data;
+ }
+
+ /**
+ * Устанавливает ID пользователя
+ *
+ * @param int $data
+ */
+ public function setUserId($data)
+ {
+ $this->_aData['user_id'] = $data;
+ }
+
+ /**
+ * Устанавливает тип топика
+ *
+ * @param string $data
+ */
+ public function setType($data)
+ {
+ $this->_aData['topic_type'] = $data;
+ }
+
+ /**
+ * Устанавливает заголовок топика
+ *
+ * @param string $data
+ */
+ public function setTitle($data)
+ {
+ $this->_aData['topic_title'] = $data;
+ }
+
+ /**
+ * Устанавливает url топика
+ *
+ * @param string $data
+ */
+ public function setSlug($data)
+ {
+ $this->_aData['topic_slug'] = $data;
+ }
+
+ /**
+ * Устанавливает текст топика
+ *
+ * @param string $data
+ */
+ public function setText($data)
+ {
+ $this->_aData['topic_text'] = $data;
+ }
+
+ /**
+ * Устанавливает сериализованную строчку дополнительных данных
+ *
+ * @param string $data
+ */
+ public function setExtra($data)
+ {
+ $this->_aData['topic_extra'] = serialize($data);
+ }
+
+ /**
+ * Устанавливает короткий текст топика до ката
+ *
+ * @param string $data
+ */
+ public function setTextShort($data)
+ {
+ $this->_aData['topic_text_short'] = $data;
+ }
+
+ /**
+ * Устаналивает исходный текст топика
+ *
+ * @param string $data
+ */
+ public function setTextSource($data)
+ {
+ $this->_aData['topic_text_source'] = $data;
+ }
+
+ /**
+ * Устанавливает список тегов в виде строки
+ *
+ * @param string $data
+ */
+ public function setTags($data)
+ {
+ $this->_aData['topic_tags'] = $data;
+ }
+
+ /**
+ * Устанавливает дату создания топика
+ *
+ * @param string $data
+ */
+ public function setDateAdd($data)
+ {
+ $this->_aData['topic_date_add'] = $data;
+ }
+
+ /**
+ * Устанавливает дату редактирования топика
+ *
+ * @param string $data
+ */
+ public function setDateEdit($data)
+ {
+ $this->_aData['topic_date_edit'] = $data;
+ }
+
+ /**
+ * Устанавливает дату редактирования контента топика
+ *
+ * @param string $data
+ */
+ public function setDateEditContent($data)
+ {
+ $this->_aData['topic_date_edit_content'] = $data;
+ }
+
+ /**
+ * Устанавливает дату публикации топика
+ *
+ * @param string $data
+ */
+ public function setDatePublish($data)
+ {
+ $this->_aData['topic_date_publish'] = $data;
+ }
+
+ /**
+ * Устанавливает IP пользователя
+ *
+ * @param string $data
+ */
+ public function setUserIp($data)
+ {
+ $this->_aData['topic_user_ip'] = $data;
+ }
+
+ /**
+ * Устанавливает флаг публикации топика
+ *
+ * @param string $data
+ */
+ public function setPublish($data)
+ {
+ $this->_aData['topic_publish'] = $data;
+ }
+
+ /**
+ * Устанавливает флаг публикации черновика
+ *
+ * @param string $data
+ */
+ public function setPublishDraft($data)
+ {
+ $this->_aData['topic_publish_draft'] = $data;
+ }
+
+ /**
+ * Устанавливает флаг публикации на главной странице
+ *
+ * @param string $data
+ */
+ public function setPublishIndex($data)
+ {
+ $this->_aData['topic_publish_index'] = $data;
+ }
+
+ /**
+ * Устанавливает флаг пропуска на главной странице
+ *
+ * @param string $data
+ */
+ public function setSkipIndex($data)
+ {
+ $this->_aData['topic_skip_index'] = $data;
+ }
+
+ /**
+ * Устанавливает рейтинг топика
+ *
+ * @param string $data
+ */
+ public function setRating($data)
+ {
+ $this->_aData['topic_rating'] = $data;
+ }
+
+ /**
+ * Устанавливает количество проголосовавших
+ *
+ * @param int $data
+ */
+ public function setCountVote($data)
+ {
+ $this->_aData['topic_count_vote'] = $data;
+ }
+
+ /**
+ * Устанавливает количество проголосовавших в плюс
+ *
+ * @param int $data
+ */
+ public function setCountVoteUp($data)
+ {
+ $this->_aData['topic_count_vote_up'] = $data;
+ }
+
+ /**
+ * Устанавливает количество проголосовавших в минус
+ *
+ * @param int $data
+ */
+ public function setCountVoteDown($data)
+ {
+ $this->_aData['topic_count_vote_down'] = $data;
+ }
+
+ /**
+ * Устанавливает число воздержавшихся
+ *
+ * @param int $data
+ */
+ public function setCountVoteAbstain($data)
+ {
+ $this->_aData['topic_count_vote_abstain'] = $data;
+ }
+
+ /**
+ * Устанавливает число прочтения топика
+ *
+ * @param int $data
+ */
+ public function setCountRead($data)
+ {
+ $this->_aData['topic_count_read'] = $data;
+ }
+
+ /**
+ * Устанавливает количество комментариев
+ *
+ * @param int $data
+ */
+ public function setCountComment($data)
+ {
+ $this->_aData['topic_count_comment'] = $data;
+ }
+
+ /**
+ * Устанавливает текст ката
+ *
+ * @param string $data
+ */
+ public function setCutText($data)
+ {
+ $this->_aData['topic_cut_text'] = $data;
+ }
+
+ /**
+ * Устанавливает флаг запрета коментирования топика
+ *
+ * @param int $data
+ */
+ public function setForbidComment($data)
+ {
+ $this->_aData['topic_forbid_comment'] = $data;
+ }
+
+ /**
+ * Устанавливает хеш топика
+ *
+ * @param string $data
+ */
+ public function setTextHash($data)
+ {
+ $this->_aData['topic_text_hash'] = $data;
+ }
+
+ /**
+ * Устанавливает объект пользователя
+ *
+ * @param ModuleUser_EntityUser $data
+ */
+ public function setUser($data)
+ {
+ $this->_aData['user'] = $data;
+ }
+
+ /**
+ * Устанавливает объект блога
+ *
+ * @param ModuleBlog_EntityBlog $data
+ */
+ public function setBlog($data)
+ {
+ $this->_aData['blog'] = $data;
+ }
+
+ /**
+ * Устанавливает факт голосования пользователя в топике-опросе
+ *
+ * @param int $data
+ */
+ public function setUserQuestionIsVote($data)
+ {
+ $this->_aData['user_question_is_vote'] = $data;
+ }
+
+ /**
+ * Устанавливает объект голосования за топик
+ *
+ * @param ModuleVote_EntityVote $data
+ */
+ public function setVote($data)
+ {
+ $this->_aData['vote'] = $data;
+ }
+
+ /**
+ * Устанавливает количество новых комментариев
+ *
+ * @param int $data
+ */
+ public function setCountCommentNew($data)
+ {
+ $this->_aData['count_comment_new'] = $data;
+ }
+
+ /**
+ * Устанавливает дату прочтения топика текущим пользователем
+ *
+ * @param string $data
+ */
+ public function setDateRead($data)
+ {
+ $this->_aData['date_read'] = $data;
+ }
+
+ /**
+ * Устанавливает количество пользователей, добавивших топик в избранное
+ *
+ * @param int $data
+ */
+ public function setCountFavourite($data)
+ {
+ $this->_aData['topic_count_favourite'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/topic/entity/TopicRead.entity.class.php b/application/classes/modules/topic/entity/TopicRead.entity.class.php
new file mode 100644
index 0000000..8a428a7
--- /dev/null
+++ b/application/classes/modules/topic/entity/TopicRead.entity.class.php
@@ -0,0 +1,130 @@
+
+ *
+ */
+
+/**
+ * Объект сущности факта прочтения топика
+ *
+ * @package application.modules.topic
+ * @since 1.0
+ */
+class ModuleTopic_EntityTopicRead extends Entity
+{
+ /**
+ * Возвращает ID топика
+ *
+ * @return int|null
+ */
+ public function getTopicId()
+ {
+ return $this->_getDataOne('topic_id');
+ }
+
+ /**
+ * Возвращает ID пользователя
+ *
+ * @return int|null
+ */
+ public function getUserId()
+ {
+ return $this->_getDataOne('user_id');
+ }
+
+ /**
+ * Возвращает дату прочтения
+ *
+ * @return string|null
+ */
+ public function getDateRead()
+ {
+ return $this->_getDataOne('date_read');
+ }
+
+ /**
+ * Возвращает число комментариев в последнем прочтении топика
+ *
+ * @return int|null
+ */
+ public function getCommentCountLast()
+ {
+ return $this->_getDataOne('comment_count_last');
+ }
+
+ /**
+ * Возвращает ID последнего комментария
+ *
+ * @return int|null
+ */
+ public function getCommentIdLast()
+ {
+ return $this->_getDataOne('comment_id_last');
+ }
+
+
+ /**
+ * Устанавливает ID топика
+ *
+ * @param int $data
+ */
+ public function setTopicId($data)
+ {
+ $this->_aData['topic_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID пользователя
+ *
+ * @param int $data
+ */
+ public function setUserId($data)
+ {
+ $this->_aData['user_id'] = $data;
+ }
+
+ /**
+ * Устанавливает дату прочтения
+ *
+ * @param string $data
+ */
+ public function setDateRead($data)
+ {
+ $this->_aData['date_read'] = $data;
+ }
+
+ /**
+ * Устанавливает число комментариев в последнем прочтении топика
+ *
+ * @param int $data
+ */
+ public function setCommentCountLast($data)
+ {
+ $this->_aData['comment_count_last'] = $data;
+ }
+
+ /**
+ * Устанавливает ID последнего комментария
+ *
+ * @param int $data
+ */
+ public function setCommentIdLast($data)
+ {
+ $this->_aData['comment_id_last'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/topic/entity/TopicTag.entity.class.php b/application/classes/modules/topic/entity/TopicTag.entity.class.php
new file mode 100644
index 0000000..b76d891
--- /dev/null
+++ b/application/classes/modules/topic/entity/TopicTag.entity.class.php
@@ -0,0 +1,169 @@
+
+ *
+ */
+
+/**
+ * Объект сущности тега топика
+ *
+ * @package application.modules.topic
+ * @since 1.0
+ */
+class ModuleTopic_EntityTopicTag extends Entity
+{
+ /**
+ * Возвращает ID тега
+ *
+ * @return int|null
+ */
+ public function getId()
+ {
+ return $this->_getDataOne('topic_tag_id');
+ }
+
+ /**
+ * Возвращает ID топика
+ *
+ * @return int|null
+ */
+ public function getTopicId()
+ {
+ return $this->_getDataOne('topic_id');
+ }
+
+ /**
+ * Возвращает ID пользователя
+ *
+ * @return int|null
+ */
+ public function getUserId()
+ {
+ return $this->_getDataOne('user_id');
+ }
+
+ /**
+ * Возвращает ID блога
+ *
+ * @return int|null
+ */
+ public function getBlogId()
+ {
+ return $this->_getDataOne('blog_id');
+ }
+
+ /**
+ * Возвращает текст тега
+ *
+ * @return string|null
+ */
+ public function getText()
+ {
+ return $this->_getDataOne('topic_tag_text');
+ }
+
+ /**
+ * Возвращает количество тегов
+ *
+ * @return int|null
+ */
+ public function getCount()
+ {
+ return $this->_getDataOne('count');
+ }
+
+ /**
+ * Возвращает просчитанный размер тега для облака тегов
+ *
+ * @return int|null
+ */
+ public function getSize()
+ {
+ return $this->_getDataOne('size');
+ }
+
+ /**
+ * Возвращает URL страницы тегов
+ *
+ * @return string
+ */
+ public function getUrl()
+ {
+ return Router::GetPath('tag') . urlencode($this->getText()) . '/';
+ }
+
+ /**
+ * Устанавливает ID тега
+ *
+ * @param int $data
+ */
+ public function setId($data)
+ {
+ $this->_aData['topic_tag_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID топика
+ *
+ * @param int $data
+ */
+ public function setTopicId($data)
+ {
+ $this->_aData['topic_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID пользователя
+ *
+ * @param int $data
+ */
+ public function setUserId($data)
+ {
+ $this->_aData['user_id'] = $data;
+ }
+
+ /**
+ * Устанавливает ID блога
+ *
+ * @param int $data
+ */
+ public function setBlogId($data)
+ {
+ $this->_aData['blog_id'] = $data;
+ }
+
+ /**
+ * Устанавливает текст тега
+ *
+ * @param string $data
+ */
+ public function setText($data)
+ {
+ $this->_aData['topic_tag_text'] = $data;
+ }
+
+ /**
+ * Устанавливает просчитанный размер тега для облака тегов
+ *
+ * @param int $data
+ */
+ public function setSize($data)
+ {
+ $this->_aData['size'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/topic/entity/TopicType.entity.class.php b/application/classes/modules/topic/entity/TopicType.entity.class.php
new file mode 100644
index 0000000..1b1bf1c
--- /dev/null
+++ b/application/classes/modules/topic/entity/TopicType.entity.class.php
@@ -0,0 +1,146 @@
+
+ *
+ */
+
+/**
+ * Объект типа топика
+ * TODO: при удалении типа топика необходимо удалять дополнительные поля
+ *
+ * @package application.modules.topic
+ * @since 2.0
+ */
+class ModuleTopic_EntityTopicType extends Entity
+{
+
+ protected $aValidateRules = array(
+ array('name, name_many', 'string', 'max' => 200, 'min' => 1, 'allowEmpty' => false),
+ array('code', 'regexp', 'pattern' => "#^[a-z0-9_]{1,30}$#", 'allowEmpty' => false),
+ array('code', 'code_unique'),
+ array('params', 'check_params'),
+ array('name', 'check_name'),
+ array('name_many', 'check_name_many'),
+ );
+
+ public function ValidateCheckParams()
+ {
+ $aParamsResult = array();
+ $aParams = $this->getParamsArray();
+
+ $aParamsResult['allow_poll'] = (isset($aParams['allow_poll']) and $aParams['allow_poll']) ? true : false;
+ $aParamsResult['allow_preview'] = (isset($aParams['allow_preview']) and $aParams['allow_preview']) ? true : false;
+ $aParamsResult['allow_text'] = (isset($aParams['allow_text']) and $aParams['allow_text']) ? true : false;
+ $aParamsResult['allow_tags'] = (isset($aParams['allow_tags']) and $aParams['allow_tags']) ? true : false;
+ $aParamsResult['allow_deferred_all'] = (isset($aParams['allow_deferred_all']) and $aParams['allow_deferred_all']) ? true : false;
+ $aParamsResult['allow_deferred_admin'] = (isset($aParams['allow_deferred_admin']) and $aParams['allow_deferred_admin']) ? true : false;
+ $aParamsResult['css_icon'] = (isset($aParams['css_icon']) and is_string($aParams['css_icon']) and $aParams['css_icon']) ? htmlspecialchars($aParams['css_icon']) : null;
+
+ $this->setParams($aParamsResult);
+ return true;
+ }
+
+ public function ValidateCheckName()
+ {
+ $this->setName(htmlspecialchars($this->getName()));
+ return true;
+ }
+
+ public function ValidateCheckNameMany()
+ {
+ $this->setNameMany(htmlspecialchars($this->getNameMany()));
+ return true;
+ }
+
+ public function ValidateCodeUnique()
+ {
+ if ($oType = $this->Topic_GetTopicTypeByCode($this->getCode())) {
+ if ($oType->getId() != $this->getId()) {
+ return $this->Lang_Get('topic.content_type.notices.error_code');
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Возвращает список дополнительных параметров типа
+ *
+ * @return array|mixed
+ */
+ public function getParamsArray()
+ {
+ $aData = @unserialize($this->_getDataOne('params'));
+ if (!$aData) {
+ $aData = array();
+ }
+ return $aData;
+ }
+
+ /**
+ * Устанавливает список дополнительных параметров типа
+ *
+ * @param array $aParams
+ */
+ public function setParams($aParams)
+ {
+ $this->_aData['params'] = @serialize($aParams);
+ }
+
+ /**
+ * Возвращает конкретный параметр типа
+ *
+ * @param string $sName
+ * @param mixed $mDefault
+ *
+ * @return null
+ */
+ public function getParam($sName, $mDefault = null)
+ {
+ $aParams = $this->getParamsArray();
+ return isset($aParams[$sName]) ? $aParams[$sName] : $mDefault;
+ }
+
+ public function getStateText()
+ {
+ if ($this->getState() == ModuleTopic::TOPIC_TYPE_STATE_ACTIVE) {
+ return $this->Lang_Get('topic.content_type.states.active');
+ }
+ if ($this->getState() == ModuleTopic::TOPIC_TYPE_STATE_NOT_ACTIVE) {
+ return $this->Lang_Get('topic.content_type.states.not_active');
+ }
+ return $this->Lang_Get('topic.content_type.states.wrong');
+ }
+
+ public function getUrlForAdd()
+ {
+ return Router::GetPath('content/add') . $this->getCode() . '/';
+ }
+
+ public function getPropertyTargetType()
+ {
+ return 'topic_' . $this->getCode();
+ }
+
+ public function isAllowCreateDeferredTopic($oUser)
+ {
+ if (!$oUser) {
+ return false;
+ }
+ return $this->getParam('allow_deferred_all') or ($this->getParam('allow_deferred_admin') and $oUser->isAdministrator());
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/topic/mapper/Topic.mapper.class.php b/application/classes/modules/topic/mapper/Topic.mapper.class.php
new file mode 100644
index 0000000..d679b9f
--- /dev/null
+++ b/application/classes/modules/topic/mapper/Topic.mapper.class.php
@@ -0,0 +1,1024 @@
+
+ *
+ */
+
+/**
+ * Объект маппера для работы с БД
+ *
+ * @package application.modules.topic
+ * @since 1.0
+ */
+class ModuleTopic_MapperTopic extends Mapper
+{
+ /**
+ * Добавляет топик
+ *
+ * @param ModuleTopic_EntityTopic $oTopic Объект топика
+ * @return int|bool
+ */
+ public function AddTopic(ModuleTopic_EntityTopic $oTopic)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.topic') . "
+ (blog_id,
+ blog_id2,
+ blog_id3,
+ blog_id4,
+ blog_id5,
+ user_id,
+ topic_type,
+ topic_title,
+ topic_slug,
+ topic_tags,
+ topic_date_add,
+ topic_date_publish,
+ topic_user_ip,
+ topic_publish,
+ topic_publish_draft,
+ topic_publish_index,
+ topic_skip_index,
+ topic_cut_text,
+ topic_forbid_comment,
+ topic_text_hash
+ )
+ VALUES(?d, ?d, ?d, ?d, ?d, ?d, ?, ?, ?, ?, ?, ?, ?, ?d, ?d, ?d, ?d, ?, ?, ?)
+ ";
+ if ($iId = $this->oDb->query($sql, $oTopic->getBlogId(), $oTopic->getBlogId2(), $oTopic->getBlogId3(),
+ $oTopic->getBlogId4(), $oTopic->getBlogId5(), $oTopic->getUserId(), $oTopic->getType(), $oTopic->getTitle(), $oTopic->getSlug(),
+ $oTopic->getTags(), $oTopic->getDateAdd(), $oTopic->getDatePublish(), $oTopic->getUserIp(), $oTopic->getPublish(),
+ $oTopic->getPublishDraft(), $oTopic->getPublishIndex(), $oTopic->getSkipIndex(), $oTopic->getCutText(),
+ $oTopic->getForbidComment(), $oTopic->getTextHash())
+ ) {
+ $oTopic->setId($iId);
+ $this->AddTopicContent($oTopic);
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Добавляет контент топика
+ *
+ * @param ModuleTopic_EntityTopic $oTopic Объект топика
+ * @return int|bool
+ */
+ public function AddTopicContent(ModuleTopic_EntityTopic $oTopic)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.topic_content') . "
+ (topic_id,
+ topic_text,
+ topic_text_short,
+ topic_text_source,
+ topic_extra
+ )
+ VALUES(?d, ?, ?, ?, ? )
+ ";
+ if ($iId = $this->oDb->query($sql, $oTopic->getId(), $oTopic->getText(),
+ $oTopic->getTextShort(), $oTopic->getTextSource(), $oTopic->getExtra())
+ ) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Добавление тега к топику
+ *
+ * @param ModuleTopic_EntityTopicTag $oTopicTag Объект тега топика
+ * @return int
+ */
+ public function AddTopicTag(ModuleTopic_EntityTopicTag $oTopicTag)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.topic_tag') . "
+ (topic_id,
+ user_id,
+ blog_id,
+ topic_tag_text
+ )
+ VALUES(?d, ?d, ?d, ?)
+ ";
+ if ($iId = $this->oDb->query($sql, $oTopicTag->getTopicId(), $oTopicTag->getUserId(), $oTopicTag->getBlogId(),
+ $oTopicTag->getText())
+ ) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Удаление контента топика по его номеру
+ *
+ * @param int $iTopicId ID топика
+ * @return bool
+ */
+ public function DeleteTopicContentByTopicId($iTopicId)
+ {
+ $sql = "DELETE FROM " . Config::Get('db.table.topic_content') . " WHERE topic_id = ?d ";
+ $res = $this->oDb->query($sql, $iTopicId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Удаляет теги у топика
+ *
+ * @param int $sTopicId ID топика
+ * @return bool
+ */
+ public function DeleteTopicTagsByTopicId($sTopicId)
+ {
+ $sql = "DELETE FROM " . Config::Get('db.table.topic_tag') . "
+ WHERE
+ topic_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $sTopicId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Удаляет топик.
+ * Если тип таблиц в БД InnoDB, то удалятся всё связи по топику(комменты,голосования,избранное)
+ *
+ * @param int $sTopicId Объект топика или ID
+ * @return bool
+ */
+ public function DeleteTopic($sTopicId)
+ {
+ $sql = "DELETE FROM " . Config::Get('db.table.topic') . "
+ WHERE
+ topic_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $sTopicId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получает топик по уникальному хешу(текст топика)
+ *
+ * @param int $sUserId
+ * @param string $sHash
+ * @return int|null
+ */
+ public function GetTopicUnique($sUserId, $sHash)
+ {
+ $sql = "SELECT topic_id FROM " . Config::Get('db.table.topic') . "
+ WHERE
+ topic_text_hash =?
+ AND
+ user_id = ?d
+ LIMIT 0,1
+ ";
+ if ($aRow = $this->oDb->selectRow($sql, $sHash, $sUserId)) {
+ return $aRow['topic_id'];
+ }
+ return null;
+ }
+
+ /**
+ * Получить список топиков по списку айдишников
+ *
+ * @param array $aArrayId Список ID топиков
+ * @return array
+ */
+ public function GetTopicsByArrayId($aArrayId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+
+ $sql = "SELECT
+ t.*,
+ tc.*
+ FROM
+ " . Config::Get('db.table.topic') . " as t
+ JOIN " . Config::Get('db.table.topic_content') . " AS tc ON t.topic_id=tc.topic_id
+ WHERE
+ t.topic_id IN(?a)
+ ORDER BY FIELD(t.topic_id,?a) ";
+ $aTopics = array();
+ if ($aRows = $this->oDb->select($sql, $aArrayId, $aArrayId)) {
+ foreach ($aRows as $aTopic) {
+ $aTopics[] = Engine::GetEntity('Topic', $aTopic);
+ }
+ }
+ return $aTopics;
+ }
+
+ /**
+ * Список топиков по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param int $iCount Возвращает общее число элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetTopics($aFilter, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $sWhere = $this->buildFilter($aFilter);
+
+ if (!isset($aFilter['order'])) {
+ $aFilter['order'] = 't.topic_date_publish desc';
+ }
+ if (!is_array($aFilter['order'])) {
+ $aFilter['order'] = array($aFilter['order']);
+ }
+
+ $sql = "SELECT
+ t.topic_id
+ FROM
+ " . Config::Get('db.table.topic') . " as t,
+ " . Config::Get('db.table.blog') . " as b
+ WHERE
+ 1=1
+ " . $sWhere . "
+ AND
+ t.blog_id=b.blog_id
+ ORDER BY " .
+ implode(', ', $aFilter['order'])
+ . "
+ LIMIT ?d, ?d";
+ $aTopics = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql, ($iCurrPage - 1) * $iPerPage, $iPerPage)) {
+ foreach ($aRows as $aTopic) {
+ $aTopics[] = $aTopic['topic_id'];
+ }
+ }
+ return $aTopics;
+ }
+
+ /**
+ * Количество топиков по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @return int
+ */
+ public function GetCountTopics($aFilter)
+ {
+ $sWhere = $this->buildFilter($aFilter);
+ $sql = "SELECT
+ count(t.topic_id) as count
+ FROM
+ " . Config::Get('db.table.topic') . " as t,
+ " . Config::Get('db.table.blog') . " as b
+ WHERE
+ 1=1
+
+ " . $sWhere . "
+
+ AND
+ t.blog_id=b.blog_id;";
+ if ($aRow = $this->oDb->selectRow($sql)) {
+ return $aRow['count'];
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает все топики по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @return array
+ */
+ public function GetAllTopics($aFilter)
+ {
+ $sWhere = $this->buildFilter($aFilter);
+
+ if (!isset($aFilter['order'])) {
+ $aFilter['order'] = 't.topic_id desc';
+ }
+ if (!is_array($aFilter['order'])) {
+ $aFilter['order'] = array($aFilter['order']);
+ }
+
+ $sql = "SELECT
+ t.topic_id
+ FROM
+ " . Config::Get('db.table.topic') . " as t,
+ " . Config::Get('db.table.blog') . " as b
+ WHERE
+ 1=1
+ " . $sWhere . "
+ AND
+ t.blog_id=b.blog_id
+ ORDER by " . implode(', ', $aFilter['order']) . " ";
+ $aTopics = array();
+ if ($aRows = $this->oDb->select($sql)) {
+ foreach ($aRows as $aTopic) {
+ $aTopics[] = $aTopic['topic_id'];
+ }
+ }
+
+ return $aTopics;
+ }
+
+ /**
+ * Получает список топиков по тегу
+ *
+ * @param string $sTag Тег
+ * @param array $aExcludeBlog Список ID блогов для исключения
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetTopicsByTag($sTag, $aExcludeBlog, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $sql = "
+ SELECT
+ tt.topic_id
+ FROM
+ " . Config::Get('db.table.topic_tag') . " as tt,
+ " . Config::Get('db.table.topic') . " as t
+ WHERE
+ tt.topic_tag_text = ?
+ AND tt.topic_id = t.topic_id AND t.topic_publish = 1 AND t.topic_date_publish <= ?
+ { AND tt.blog_id NOT IN (?a) }
+ ORDER BY tt.topic_id DESC
+ LIMIT ?d, ?d ";
+
+ $aTopics = array();
+ if ($aRows = $this->oDb->selectPage(
+ $iCount, $sql, $sTag, date('Y-m-d H:i:s'),
+ (is_array($aExcludeBlog) && count($aExcludeBlog)) ? $aExcludeBlog : DBSIMPLE_SKIP,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aTopic) {
+ $aTopics[] = $aTopic['topic_id'];
+ }
+ }
+ return $aTopics;
+ }
+
+ /**
+ * Получает топики по рейтингу и дате
+ *
+ * @param string $sDate Дата
+ * @param int $iLimit Количество
+ * @param array $aExcludeBlog Список ID блогов для исключения
+ * @return array
+ */
+ public function GetTopicsRatingByDate($sDate, $iLimit, $aExcludeBlog = array())
+ {
+ $sql = "SELECT
+ t.topic_id
+ FROM
+ " . Config::Get('db.table.topic') . " as t
+ WHERE
+ t.topic_publish = 1
+ AND
+ t.topic_date_publish >= ?
+ AND
+ t.topic_rating >= 0
+ { AND t.blog_id NOT IN(?a) }
+ ORDER by t.topic_rating desc, t.topic_id desc
+ LIMIT 0, ?d ";
+ $aTopics = array();
+ if ($aRows = $this->oDb->select(
+ $sql, $sDate,
+ (is_array($aExcludeBlog) && count($aExcludeBlog)) ? $aExcludeBlog : DBSIMPLE_SKIP,
+ $iLimit
+ )
+ ) {
+ foreach ($aRows as $aTopic) {
+ $aTopics[] = $aTopic['topic_id'];
+ }
+ }
+ return $aTopics;
+ }
+
+ /**
+ * Получает список тегов топиков
+ *
+ * @param int $iLimit Количество
+ * @param array $aExcludeTopic Список ID топиков для исключения
+ * @return array
+ */
+ public function GetTopicTags($iLimit, $aExcludeTopic = array())
+ {
+ $sql = "SELECT
+ tt.topic_tag_text,
+ count(tt.topic_tag_text) as count
+ FROM
+ " . Config::Get('db.table.topic_tag') . " as tt,
+ " . Config::Get('db.table.topic') . " as t
+ WHERE
+ tt.topic_id = t.topic_id AND t.topic_publish = 1 AND t.topic_date_publish <= ?
+ {AND tt.topic_id NOT IN(?a) }
+ GROUP BY
+ tt.topic_tag_text
+ ORDER BY
+ count desc
+ LIMIT 0, ?d
+ ";
+ $aReturn = array();
+ $aReturnSort = array();
+ if ($aRows = $this->oDb->select(
+ $sql, date('Y-m-d H:i:s'),
+ (is_array($aExcludeTopic) && count($aExcludeTopic)) ? $aExcludeTopic : DBSIMPLE_SKIP,
+ $iLimit
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aReturn[mb_strtolower($aRow['topic_tag_text'], 'UTF-8')] = $aRow;
+ }
+ ksort($aReturn);
+ foreach ($aReturn as $aRow) {
+ $aReturnSort[] = Engine::GetEntity('Topic_TopicTag', $aRow);
+ }
+ }
+ return $aReturnSort;
+ }
+
+ /**
+ * Получает список тегов из топиков открытых блогов (open,personal)
+ *
+ * @param int $iLimit Количество
+ * @param int|null $iUserId ID пользователя, чью теги получаем
+ * @return array
+ */
+ public function GetOpenTopicTags($iLimit, $iUserId = null)
+ {
+ $sql = "
+ SELECT
+ tt.topic_tag_text,
+ count(tt.topic_tag_text) as count
+ FROM
+ " . Config::Get('db.table.topic_tag') . " as tt,
+ " . Config::Get('db.table.blog') . " as b,
+ " . Config::Get('db.table.topic') . " as t
+ WHERE
+ 1 = 1
+ { AND tt.user_id = ?d }
+ AND
+ tt.blog_id = b.blog_id
+ AND
+ b.blog_type <> 'close'
+ AND
+ tt.topic_id = t.topic_id AND t.topic_publish = 1 AND t.topic_date_publish <= ?
+ GROUP BY
+ tt.topic_tag_text
+ ORDER BY
+ count desc
+ LIMIT 0, ?d
+ ";
+ $aReturn = array();
+ $aReturnSort = array();
+ if ($aRows = $this->oDb->select($sql, is_null($iUserId) ? DBSIMPLE_SKIP : $iUserId, date('Y-m-d H:i:s'), $iLimit)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[mb_strtolower($aRow['topic_tag_text'], 'UTF-8')] = $aRow;
+ }
+ ksort($aReturn);
+ foreach ($aReturn as $aRow) {
+ $aReturnSort[] = Engine::GetEntity('Topic_TopicTag', $aRow);
+ }
+ }
+ return $aReturnSort;
+ }
+
+ /**
+ * Увеличивает у топика число комментов
+ *
+ * @param int $sTopicId ID топика
+ * @return bool
+ */
+ public function increaseTopicCountComment($sTopicId)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.topic') . "
+ SET
+ topic_count_comment=topic_count_comment+1
+ WHERE
+ topic_id = ?
+ ";
+ $res = $this->oDb->query($sql, $sTopicId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Обновляет топик
+ *
+ * @param ModuleTopic_EntityTopic $oTopic Объект топика
+ * @return bool
+ */
+ public function UpdateTopic(ModuleTopic_EntityTopic $oTopic)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.topic') . "
+ SET
+ blog_id= ?d,
+ blog_id2= ?d,
+ blog_id3= ?d,
+ blog_id4= ?d,
+ blog_id5= ?d,
+ topic_title= ?,
+ topic_slug= ?,
+ topic_tags= ?,
+ topic_date_add = ?,
+ topic_date_edit = ?,
+ topic_date_edit_content = ?,
+ topic_date_publish = ?,
+ topic_user_ip= ?,
+ topic_publish= ?d ,
+ topic_publish_draft= ?d ,
+ topic_publish_index= ?d,
+ topic_skip_index= ?d,
+ topic_rating= ?f,
+ topic_count_vote= ?d,
+ topic_count_vote_up= ?d,
+ topic_count_vote_down= ?d,
+ topic_count_vote_abstain= ?d,
+ topic_count_read= ?d,
+ topic_count_comment= ?d,
+ topic_count_favourite= ?d,
+ topic_cut_text = ? ,
+ topic_forbid_comment = ? ,
+ topic_text_hash = ?
+ WHERE
+ topic_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oTopic->getBlogId(), $oTopic->getBlogId2(), $oTopic->getBlogId3(),
+ $oTopic->getBlogId4(), $oTopic->getBlogId5(), $oTopic->getTitle(), $oTopic->getSlug(), $oTopic->getTags(),
+ $oTopic->getDateAdd(), $oTopic->getDateEdit(), $oTopic->getDateEditContent(), $oTopic->getDatePublish(), $oTopic->getUserIp(),
+ $oTopic->getPublish(), $oTopic->getPublishDraft(), $oTopic->getPublishIndex(), $oTopic->getSkipIndex(),
+ $oTopic->getRating(), $oTopic->getCountVote(), $oTopic->getCountVoteUp(), $oTopic->getCountVoteDown(),
+ $oTopic->getCountVoteAbstain(), $oTopic->getCountRead(), $oTopic->getCountComment(),
+ $oTopic->getCountFavourite(), $oTopic->getCutText(), $oTopic->getForbidComment(), $oTopic->getTextHash(),
+ $oTopic->getId());
+ if ($res !== false and !is_null($res)) {
+ $this->UpdateTopicContent($oTopic);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет контент топика
+ *
+ * @param ModuleTopic_EntityTopic $oTopic Объект топика
+ * @return bool
+ */
+ public function UpdateTopicContent(ModuleTopic_EntityTopic $oTopic)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.topic_content') . "
+ SET
+ topic_text= ?,
+ topic_text_short= ?,
+ topic_text_source= ?,
+ topic_extra= ?
+ WHERE
+ topic_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oTopic->getText(), $oTopic->getTextShort(), $oTopic->getTextSource(),
+ $oTopic->getExtra(), $oTopic->getId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Строит строку условий для SQL запроса топиков
+ *
+ * @param array $aFilter Фильтр
+ * @return string
+ */
+ protected function buildFilter($aFilter)
+ {
+ $sDateNow = date('Y-m-d H:i:s');
+ $sWhere = '';
+ if (isset($aFilter['topic_date_more'])) {
+ $sWhere .= " AND t.topic_date_publish > " . $this->oDb->escape($aFilter['topic_date_more']);
+ }
+ if (isset($aFilter['topic_slug'])) {
+ $sWhere .= " AND t.topic_slug = " . $this->oDb->escape($aFilter['topic_slug']);
+ }
+ if (isset($aFilter['topic_publish'])) {
+ $sWhere .= " AND t.topic_publish = " . (int)$aFilter['topic_publish'] . " AND t.topic_date_publish <= '{$sDateNow}' ";
+ }
+ if (isset($aFilter['topic_publish_only'])) {
+ $sWhere .= " AND t.topic_publish = " . (int)$aFilter['topic_publish_only'] . " ";
+ }
+ if (isset($aFilter['topic_rating']) and is_array($aFilter['topic_rating'])) {
+ $sPublishIndex = '';
+ if (isset($aFilter['topic_rating']['publish_index']) and $aFilter['topic_rating']['publish_index'] == 1) {
+ $sPublishIndex = " or topic_publish_index = 1 ) and ( topic_skip_index = 0 and b.blog_skip_index = 0 ";
+ }
+ if ($aFilter['topic_rating']['type'] == 'top') {
+ $sWhere .= " AND ( t.topic_rating >= " . (float)$aFilter['topic_rating']['value'] . " {$sPublishIndex} ) ";
+ } else {
+ $sWhere .= " AND ( t.topic_rating < " . (float)$aFilter['topic_rating']['value'] . " ) ";
+ }
+ }
+ if (isset($aFilter['topic_new'])) {
+ $sWhere .= " AND t.topic_date_publish >= '" . $aFilter['topic_new'] . "'";
+ }
+ if (isset($aFilter['user_id'])) {
+ $sWhere .= is_array($aFilter['user_id'])
+ ? " AND t.user_id IN(" . implode(', ', $aFilter['user_id']) . ")"
+ : " AND t.user_id = " . (int)$aFilter['user_id'];
+ }
+ if (isset($aFilter['blog_id'])) {
+ if (!is_array($aFilter['blog_id'])) {
+ $aFilter['blog_id'] = array($aFilter['blog_id']);
+ }
+ $sBlogList = join("','", $aFilter['blog_id']);
+ $sWhere .= " AND ( t.blog_id IN ('{$sBlogList}') ";
+ $sWhere .= " OR t.blog_id2 IN ('{$sBlogList}') ";
+ $sWhere .= " OR t.blog_id3 IN ('{$sBlogList}') ";
+ $sWhere .= " OR t.blog_id4 IN ('{$sBlogList}') ";
+ $sWhere .= " OR t.blog_id5 IN ('{$sBlogList}') ) ";
+ }
+ if (isset($aFilter['blog_type']) and is_array($aFilter['blog_type'])) {
+ $aBlogTypes = array();
+ foreach ($aFilter['blog_type'] as $sType => $aBlogId) {
+ /**
+ * Позиция вида 'type'=>array('id1', 'id2')
+ */
+ if (!is_array($aBlogId) && is_string($sType)) {
+ $aBlogId = array($aBlogId);
+ }
+ /**
+ * Позиция вида 'type'
+ */
+ if (is_string($aBlogId) && is_int($sType)) {
+ $sType = $aBlogId;
+ $aBlogId = array();
+ }
+
+ $aBlogTypes[] = (count($aBlogId) == 0)
+ ? "(b.blog_type='" . $sType . "')"
+ : "(b.blog_type='" . $sType . "' AND t.blog_id IN ('" . join("','", $aBlogId) . "'))";
+ }
+ $sWhere .= " AND (" . join(" OR ", (array)$aBlogTypes) . ")";
+ }
+ if (isset($aFilter['topic_type'])) {
+ if (!is_array($aFilter['topic_type'])) {
+ $aFilter['topic_type'] = array($aFilter['topic_type']);
+ }
+ $sWhere .= " AND t.topic_type IN (" . join(",",
+ array_map(array($this->oDb, 'escape'), $aFilter['topic_type'])) . ")";
+ }
+ return $sWhere;
+ }
+
+ /**
+ * Получает список тегов по первым буквам тега
+ *
+ * @param string $sTag Тэг
+ * @param int $iLimit Количество
+ * @return bool
+ */
+ public function GetTopicTagsByLike($sTag, $iLimit)
+ {
+ $sTag = mb_strtolower($sTag, "UTF-8");
+ $sql = "SELECT
+ topic_tag_text
+ FROM
+ " . Config::Get('db.table.topic_tag') . "
+ WHERE
+ topic_tag_text LIKE ?
+ GROUP BY
+ topic_tag_text
+ LIMIT 0, ?d
+ ";
+ $aReturn = array();
+ if ($aRows = $this->oDb->select($sql, $sTag . '%', $iLimit)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = Engine::GetEntity('Topic_TopicTag', $aRow);
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Обновляем дату прочтения топика
+ *
+ * @param ModuleTopic_EntityTopicRead $oTopicRead Объект факта чтения топика
+ * @return int
+ */
+ public function UpdateTopicRead(ModuleTopic_EntityTopicRead $oTopicRead)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.topic_read') . "
+ SET
+ comment_count_last = ? ,
+ comment_id_last = ? ,
+ date_read = ?
+ WHERE
+ topic_id = ?
+ AND
+ user_id = ?
+ ";
+ $res = $this->oDb->query($sql, $oTopicRead->getCommentCountLast(), $oTopicRead->getCommentIdLast(),
+ $oTopicRead->getDateRead(), $oTopicRead->getTopicId(), $oTopicRead->getUserId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Устанавливаем дату прочтения топика
+ *
+ * @param ModuleTopic_EntityTopicRead $oTopicRead Объект факта чтения топика
+ * @return bool
+ */
+ public function AddTopicRead(ModuleTopic_EntityTopicRead $oTopicRead)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.topic_read') . "
+ SET
+ comment_count_last = ? ,
+ comment_id_last = ? ,
+ date_read = ? ,
+ topic_id = ? ,
+ user_id = ?
+ ";
+ return $this->oDb->query($sql, $oTopicRead->getCommentCountLast(), $oTopicRead->getCommentIdLast(),
+ $oTopicRead->getDateRead(), $oTopicRead->getTopicId(), $oTopicRead->getUserId());
+ }
+
+ /**
+ * Удаляет записи о чтении записей по списку идентификаторов
+ *
+ * @param array $aTopicId Список ID топиков
+ * @return bool
+ */
+ public function DeleteTopicReadByArrayId($aTopicId)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.topic_read') . "
+ WHERE
+ topic_id IN(?a)
+ ";
+ $res = $this->oDb->query($sql, $aTopicId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получить список просмотром/чтения топиков по списку айдишников
+ *
+ * @param array $aArrayId Список ID топиков
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetTopicsReadByArray($aArrayId, $sUserId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+
+ $sql = "SELECT
+ t.*
+ FROM
+ " . Config::Get('db.table.topic_read') . " as t
+ WHERE
+ t.topic_id IN(?a)
+ AND
+ t.user_id = ?d
+ ";
+ $aReads = array();
+ if ($aRows = $this->oDb->select($sql, $aArrayId, $sUserId)) {
+ foreach ($aRows as $aRow) {
+ $aReads[] = Engine::GetEntity('Topic_TopicRead', $aRow);
+ }
+ }
+ return $aReads;
+ }
+
+ /**
+ * Перемещает топики в другой блог
+ *
+ * @param int $sBlogId ID старого блога
+ * @param int $sBlogIdNew ID нового блога
+ * @return bool
+ */
+ public function MoveTopics($sBlogId, $sBlogIdNew)
+ {
+ $aFields = array('blog_id', 'blog_id2', 'blog_id3', 'blog_id4', 'blog_id5');
+ foreach ($aFields as $sField) {
+ $sql = "UPDATE " . Config::Get('db.table.topic') . "
+ SET
+ {$sField} = ?d
+ WHERE
+ {$sField} = ?d
+ ";
+ $this->oDb->query($sql, $sBlogIdNew, $sBlogId);
+ }
+
+ return true;
+ }
+
+ /**
+ * Перемещает теги топиков в другой блог
+ *
+ * @param int $sBlogId ID старого блога
+ * @param int $sBlogIdNew ID нового блога
+ * @return bool
+ */
+ public function MoveTopicsTags($sBlogId, $sBlogIdNew)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.topic_tag') . "
+ SET
+ blog_id= ?d
+ WHERE
+ blog_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $sBlogIdNew, $sBlogId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Пересчитывает счетчик избранных топиков
+ *
+ * @return bool
+ */
+ public function RecalculateFavourite()
+ {
+ $sql = "
+ UPDATE " . Config::Get('db.table.topic') . " t
+ SET t.topic_count_favourite = (
+ SELECT count(f.user_id)
+ FROM " . Config::Get('db.table.favourite') . " f
+ WHERE
+ f.target_id = t.topic_id
+ AND
+ f.target_publish = 1
+ AND
+ f.target_type = 'topic'
+ )
+ ";
+ $res = $this->oDb->query($sql);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Пересчитывает счетчики голосований
+ *
+ * @return bool
+ */
+ public function RecalculateVote()
+ {
+ $sql = "
+ UPDATE " . Config::Get('db.table.topic') . " t
+ SET t.topic_count_vote_up = (
+ SELECT count(*)
+ FROM " . Config::Get('db.table.vote') . " v
+ WHERE
+ v.target_id = t.topic_id
+ AND
+ v.vote_direction = 1
+ AND
+ v.target_type = 'topic'
+ ), t.topic_count_vote_down = (
+ SELECT count(*)
+ FROM " . Config::Get('db.table.vote') . " v
+ WHERE
+ v.target_id = t.topic_id
+ AND
+ v.vote_direction = -1
+ AND
+ v.target_type = 'topic'
+ ), t.topic_count_vote_abstain = (
+ SELECT count(*)
+ FROM " . Config::Get('db.table.vote') . " v
+ WHERE
+ v.target_id = t.topic_id
+ AND
+ v.vote_direction = 0
+ AND
+ v.target_type = 'topic'
+ )
+ ";
+ $res = $this->oDb->query($sql);
+ return $this->IsSuccessful($res);
+ }
+
+
+ public function GetTopicTypeByCode($sCode)
+ {
+ $sql = 'SELECT * FROM ' . Config::Get('db.table.topic_type') . ' WHERE code = ?';
+ if ($aRow = $this->oDb->selectRow($sql, $sCode)) {
+ return Engine::GetEntity('ModuleTopic_EntityTopicType', $aRow);
+ }
+ return null;
+ }
+
+ public function GetTopicTypeById($iId)
+ {
+ $sql = 'SELECT * FROM ' . Config::Get('db.table.topic_type') . ' WHERE id = ?d';
+ if ($aRow = $this->oDb->selectRow($sql, $iId)) {
+ return Engine::GetEntity('ModuleTopic_EntityTopicType', $aRow);
+ }
+ return null;
+ }
+
+ public function AddTopicType($oType)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.topic_type') . "
+ (name,
+ name_many,
+ code,
+ allow_remove,
+ date_create,
+ state,
+ params
+ )
+ VALUES(?, ?, ?, ?d, ?, ?d, ?)
+ ";
+ if ($iId = $this->oDb->query($sql, $oType->getName(), $oType->getNameMany(), $oType->getCode(),
+ $oType->getAllowRemove(),
+ $oType->getDateCreate(), $oType->getState(), $oType->getParams())
+ ) {
+ return $iId;
+ }
+ return false;
+ }
+
+ public function GetTopicTypeItems($aFilter = array())
+ {
+ if (isset($aFilter['code_not']) and !is_array($aFilter['code_not'])) {
+ $aFilter['code_not'] = array($aFilter['code_not']);
+ }
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.topic_type') . "
+ WHERE
+ 1 = 1
+ { and `state` = ?d }
+ { and `code` not IN (?a) }
+ ORDER BY sort desc
+ LIMIT 0, 500
+ ";
+ $aReturn = array();
+ if ($aRows = $this->oDb->select($sql,
+ isset($aFilter['state']) ? $aFilter['state'] : DBSIMPLE_SKIP,
+ (isset($aFilter['code_not']) and $aFilter['code_not']) ? $aFilter['code_not'] : DBSIMPLE_SKIP
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = Engine::GetEntity('ModuleTopic_EntityTopicType', $aRow);
+ }
+ }
+ return $aReturn;
+ }
+
+ public function UpdateTopicType($oType)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.topic_type') . "
+ SET
+ name= ?,
+ name_many= ?,
+ code= ?,
+ state= ?d,
+ sort= ?d,
+ params= ?
+ WHERE
+ id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oType->getName(), $oType->getNameMany(), $oType->getCode(), $oType->getState(),
+ $oType->getSort(), $oType->getParams(), $oType->getId());
+ return $this->IsSuccessful($res);
+ }
+
+ public function DeleteTopicType($sTypeId)
+ {
+ $sql = "DELETE FROM " . Config::Get('db.table.topic_type') . "
+ WHERE
+ id = ?d
+ ";
+ $res = $this->oDb->query($sql, $sTypeId);
+ return $this->IsSuccessful($res);
+ }
+
+ public function UpdateTopicByType($sType, $sTypeNew)
+ {
+ $sql = "UPDATE
+ " . Config::Get('db.table.topic') . "
+ SET topic_type = ?
+ WHERE
+ topic_type = ?
+ ";
+ if ($this->oDb->query($sql, $sTypeNew, $sType) !== false) {
+ return true;
+ }
+ return false;
+ }
+
+ public function GetNextTopicDatePublish()
+ {
+ $sql = 'SELECT min(topic_date_publish) FROM ' . Config::Get('db.table.topic') . ' WHERE topic_date_publish > ? and topic_publish = 1';
+ if ($sDate = $this->oDb->selectCell($sql, date('Y-m-d H:i:s'))) {
+ return $sDate;
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/user/User.class.php b/application/classes/modules/user/User.class.php
new file mode 100644
index 0000000..aed5b5c
--- /dev/null
+++ b/application/classes/modules/user/User.class.php
@@ -0,0 +1,2075 @@
+
+ *
+ */
+
+/**
+ * Модуль для работы с пользователями
+ *
+ * @package application.modules.user
+ * @since 1.0
+ */
+class ModuleUser extends Module
+{
+ /**
+ * Статусы дружбы между пользователями
+ */
+ const USER_FRIEND_OFFER = 1;
+ const USER_FRIEND_ACCEPT = 2;
+ const USER_FRIEND_DELETE = 4;
+ const USER_FRIEND_REJECT = 8;
+ const USER_FRIEND_NULL = 16;
+ /**
+ * Статусы жалобы на пользователя
+ */
+ const COMPLAINT_STATE_NEW = 1;
+ const COMPLAINT_STATE_READ = 2;
+ /**
+ * Объект маппера
+ *
+ * @var ModuleUser_MapperUser
+ */
+ protected $oMapper;
+ /**
+ * Объект текущего пользователя
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+ /**
+ * Объект сессии текущего пользователя
+ *
+ * @var ModuleUser_EntitySession|null
+ */
+ protected $oSession = null;
+ /**
+ * Список типов пользовательских полей
+ *
+ * @var array
+ */
+ protected $aUserFieldTypes = array(
+ 'social',
+ 'contact'
+ );
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ /**
+ * Проверяем есть ли у юзера сессия, т.е. залогинен или нет
+ */
+ $sUserId = $this->Session_Get('user_id');
+ $sSessionKey = $this->Session_Get('session_key');
+ if ($sUserId and $oUser = $this->GetUserById($sUserId) and $oUser->getActivate()) {
+ /**
+ * Проверяем сессию
+ */
+ if ($oSession = $oUser->getSession()) {
+ $bSessionValid = false;
+ /**
+ * Т.к. у пользователя может быть несколько сессий (разные браузеры), то нужно дополнительно сверить
+ */
+ if ($oSession->getKey() == $sSessionKey and $oSession->isActive()) {
+ $bSessionValid = true;
+ } else {
+ /**
+ * Пробуем скорректировать сессию
+ */
+ if ($oSession = $this->oMapper->GetSessionByKey($sSessionKey) and $oSession->getUserId() == $oUser->getId() and $oSession->isActive()) {
+ $bSessionValid = true;
+ $oUser->setSession($oSession);
+ }
+ }
+ if ($bSessionValid) {
+ /**
+ * Сюда можно вставить условие на проверку айпишника сессии
+ */
+ $this->oUserCurrent = $oUser;
+ $this->oSession = $oSession;
+ }
+ }
+ }
+ /**
+ * Запускаем автозалогинивание
+ * В куках стоит время на сколько запоминать юзера
+ */
+ $this->AutoLogin();
+ /**
+ * Обновляем сессию
+ */
+ if (isset($this->oSession)) {
+ $this->UpdateSession();
+ }
+ }
+
+ /**
+ * Возвращает список типов полей
+ *
+ * @return array
+ */
+ public function GetUserFieldTypes()
+ {
+ return $this->aUserFieldTypes;
+ }
+
+ /**
+ * Добавляет новый тип с пользовательские поля
+ *
+ * @param string $sType Тип
+ * @return bool
+ */
+ public function AddUserFieldTypes($sType)
+ {
+ if (!in_array($sType, $this->aUserFieldTypes)) {
+ $this->aUserFieldTypes[] = $sType;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Получает дополнительные данные(объекты) для юзеров по их ID
+ *
+ * @param array $aUserId Список ID пользователей
+ * @param array|null $aAllowData Список типод дополнительных данных для подгрузки у пользователей
+ * @return array
+ */
+ public function GetUsersAdditionalData($aUserId, $aAllowData = null)
+ {
+ if (is_null($aAllowData)) {
+ $aAllowData = array('vote', 'session', 'friend', 'geo_target', 'note');
+ }
+ func_array_simpleflip($aAllowData);
+ if (!is_array($aUserId)) {
+ $aUserId = array($aUserId);
+ }
+ /**
+ * Получаем юзеров
+ */
+ $aUsers = $this->GetUsersByArrayId($aUserId);
+ /**
+ * Получаем дополнительные данные
+ */
+ $aSessions = array();
+ $aFriends = array();
+ $aVote = array();
+ $aGeoTargets = array();
+ $aNotes = array();
+ if (isset($aAllowData['session'])) {
+ $aSessions = $this->GetSessionsByArrayId($aUserId);
+ }
+ if (isset($aAllowData['friend']) and $this->oUserCurrent) {
+ $aFriends = $this->GetFriendsByArray($aUserId, $this->oUserCurrent->getId());
+ }
+
+ if (isset($aAllowData['vote']) and $this->oUserCurrent) {
+ $aVote = $this->Vote_GetVoteByArray($aUserId, 'user', $this->oUserCurrent->getId());
+ }
+ if (isset($aAllowData['geo_target'])) {
+ $aGeoTargets = $this->Geo_GetTargetsByTargetArray('user', $aUserId);
+ }
+ if (isset($aAllowData['note']) and $this->oUserCurrent) {
+ $aNotes = $this->GetUserNotesByArray($aUserId, $this->oUserCurrent->getId());
+ }
+ /**
+ * Добавляем данные к результату
+ */
+ foreach ($aUsers as $oUser) {
+ if (isset($aSessions[$oUser->getId()])) {
+ $oUser->setSession($aSessions[$oUser->getId()]);
+ } else {
+ $oUser->setSession(null); // или $oUser->setSession(new ModuleUser_EntitySession());
+ }
+ if ($aFriends && isset($aFriends[$oUser->getId()])) {
+ $oUser->setUserFriend($aFriends[$oUser->getId()]);
+ } else {
+ $oUser->setUserFriend(null);
+ }
+
+ if (isset($aVote[$oUser->getId()])) {
+ $oUser->setVote($aVote[$oUser->getId()]);
+ } else {
+ $oUser->setVote(null);
+ }
+ if (isset($aGeoTargets[$oUser->getId()])) {
+ $aTargets = $aGeoTargets[$oUser->getId()];
+ $oUser->setGeoTarget(isset($aTargets[0]) ? $aTargets[0] : null);
+ } else {
+ $oUser->setGeoTarget(null);
+ }
+ if (isset($aAllowData['note'])) {
+ if (isset($aNotes[$oUser->getId()])) {
+ $oUser->setUserNote($aNotes[$oUser->getId()]);
+ } else {
+ $oUser->setUserNote(false);
+ }
+ }
+ }
+
+ return $aUsers;
+ }
+
+ /**
+ * Список юзеров по ID
+ *
+ * @param array $aUserId Список ID пользователей
+ * @return array
+ */
+ public function GetUsersByArrayId($aUserId)
+ {
+ if (!$aUserId) {
+ return array();
+ }
+ if (Config::Get('sys.cache.solid')) {
+ return $this->GetUsersByArrayIdSolid($aUserId);
+ }
+ if (!is_array($aUserId)) {
+ $aUserId = array($aUserId);
+ }
+ $aUserId = array_unique($aUserId);
+ $aUsers = array();
+ $aUserIdNotNeedQuery = array();
+ /**
+ * Делаем мульти-запрос к кешу
+ */
+ $aCacheKeys = func_build_cache_keys($aUserId, 'user_');
+ if (false !== ($data = $this->Cache_Get($aCacheKeys))) {
+ /**
+ * проверяем что досталось из кеша
+ */
+ foreach ($aCacheKeys as $sValue => $sKey) {
+ if (array_key_exists($sKey, $data)) {
+ if ($data[$sKey]) {
+ $aUsers[$data[$sKey]->getId()] = $data[$sKey];
+ } else {
+ $aUserIdNotNeedQuery[] = $sValue;
+ }
+ }
+ }
+ }
+ /**
+ * Смотрим каких юзеров не было в кеше и делаем запрос в БД
+ */
+ $aUserIdNeedQuery = array_diff($aUserId, array_keys($aUsers));
+ $aUserIdNeedQuery = array_diff($aUserIdNeedQuery, $aUserIdNotNeedQuery);
+ $aUserIdNeedStore = $aUserIdNeedQuery;
+ if ($data = $this->oMapper->GetUsersByArrayId($aUserIdNeedQuery)) {
+ foreach ($data as $oUser) {
+ /**
+ * Добавляем к результату и сохраняем в кеш
+ */
+ $aUsers[$oUser->getId()] = $oUser;
+ $this->Cache_Set($oUser, "user_{$oUser->getId()}", array(), 60 * 60 * 24 * 4);
+ $aUserIdNeedStore = array_diff($aUserIdNeedStore, array($oUser->getId()));
+ }
+ }
+ /**
+ * Сохраняем в кеш запросы не вернувшие результата
+ */
+ foreach ($aUserIdNeedStore as $sId) {
+ $this->Cache_Set(null, "user_{$sId}", array(), 60 * 60 * 24 * 4);
+ }
+ /**
+ * Сортируем результат согласно входящему массиву
+ */
+ $aUsers = func_array_sort_by_keys($aUsers, $aUserId);
+ return $aUsers;
+ }
+
+ /**
+ * Алиас для корректной работы ORM
+ *
+ * @param array $aFilter Фильтр, который содержит список id пользователей в параметре "id in"
+ * @return array
+ */
+ public function GetUserItemsByFilter($aFilter)
+ {
+ if (isset($aFilter['id in'])) {
+ return $this->GetUsersByArrayId($aFilter['id in']);
+ }
+ return array();
+ }
+
+ /**
+ * Получение пользователей по списку ID используя общий кеш
+ *
+ * @param array $aUserId Список ID пользователей
+ * @return array
+ */
+ public function GetUsersByArrayIdSolid($aUserId)
+ {
+ if (!is_array($aUserId)) {
+ $aUserId = array($aUserId);
+ }
+ $aUserId = array_unique($aUserId);
+ $aUsers = array();
+ $s = join(',', $aUserId);
+ if (false === ($data = $this->Cache_Get("user_id_{$s}"))) {
+ $data = $this->oMapper->GetUsersByArrayId($aUserId);
+ foreach ($data as $oUser) {
+ $aUsers[$oUser->getId()] = $oUser;
+ }
+ $this->Cache_Set($aUsers, "user_id_{$s}", array("user_update", "user_new"), 60 * 60 * 24 * 1);
+ return $aUsers;
+ }
+ return $data;
+ }
+
+ /**
+ * Список сессий юзеров по ID
+ *
+ * @param array $aUserId Список ID пользователей
+ * @return array
+ */
+ public function GetSessionsByArrayId($aUserId)
+ {
+ if (!$aUserId) {
+ return array();
+ }
+ if (Config::Get('sys.cache.solid')) {
+ return $this->GetSessionsByArrayIdSolid($aUserId);
+ }
+ if (!is_array($aUserId)) {
+ $aUserId = array($aUserId);
+ }
+ $aUserId = array_unique($aUserId);
+ $aSessions = array();
+ $aUserIdNotNeedQuery = array();
+ /**
+ * Делаем мульти-запрос к кешу
+ */
+ $aCacheKeys = func_build_cache_keys($aUserId, 'user_session_');
+ if (false !== ($data = $this->Cache_Get($aCacheKeys))) {
+ /**
+ * проверяем что досталось из кеша
+ */
+ foreach ($aCacheKeys as $sValue => $sKey) {
+ if (array_key_exists($sKey, $data)) {
+ if ($data[$sKey] and $data[$sKey]['session']) {
+ $aSessions[$data[$sKey]['session']->getUserId()] = $data[$sKey]['session'];
+ } else {
+ $aUserIdNotNeedQuery[] = $sValue;
+ }
+ }
+ }
+ }
+ /**
+ * Смотрим каких юзеров не было в кеше и делаем запрос в БД
+ */
+ $aUserIdNeedQuery = array_diff($aUserId, array_keys($aSessions));
+ $aUserIdNeedQuery = array_diff($aUserIdNeedQuery, $aUserIdNotNeedQuery);
+ $aUserIdNeedStore = $aUserIdNeedQuery;
+ if ($data = $this->oMapper->GetSessionsByArrayId($aUserIdNeedQuery)) {
+ foreach ($data as $oSession) {
+ /**
+ * Добавляем к результату и сохраняем в кеш
+ */
+ $aSessions[$oSession->getUserId()] = $oSession;
+ $this->Cache_Set(array('time' => time(), 'session' => $oSession),
+ "user_session_{$oSession->getUserId()}", array(), 60 * 60 * 24 * 4);
+ $aUserIdNeedStore = array_diff($aUserIdNeedStore, array($oSession->getUserId()));
+ }
+ }
+ /**
+ * Сохраняем в кеш запросы не вернувшие результата
+ */
+ foreach ($aUserIdNeedStore as $sId) {
+ $this->Cache_Set(array('time' => time(), 'session' => null), "user_session_{$sId}", array(),
+ 60 * 60 * 24 * 4);
+ }
+ /**
+ * Сортируем результат согласно входящему массиву
+ */
+ $aSessions = func_array_sort_by_keys($aSessions, $aUserId);
+ return $aSessions;
+ }
+
+ /**
+ * Получить список сессий по списку айдишников, но используя единый кеш
+ *
+ * @param array $aUserId Список ID пользователей
+ * @return array
+ */
+ public function GetSessionsByArrayIdSolid($aUserId)
+ {
+ if (!is_array($aUserId)) {
+ $aUserId = array($aUserId);
+ }
+ $aUserId = array_unique($aUserId);
+ $aSessions = array();
+ $s = join(',', $aUserId);
+ if (false === ($data = $this->Cache_Get("user_session_id_{$s}"))) {
+ $data = $this->oMapper->GetSessionsByArrayId($aUserId);
+ foreach ($data as $oSession) {
+ $aSessions[$oSession->getUserId()] = $oSession;
+ }
+ $this->Cache_Set($aSessions, "user_session_id_{$s}", array("user_session_update"), 60 * 60 * 24 * 1);
+ return $aSessions;
+ }
+ return $data;
+ }
+
+ /**
+ * Получает сессию юзера
+ *
+ * @param int $sUserId ID пользователя
+ * @return ModuleUser_EntitySession|null
+ */
+ public function GetSessionByUserId($sUserId)
+ {
+ $aSessions = $this->GetSessionsByArrayId($sUserId);
+ if (isset($aSessions[$sUserId])) {
+ return $aSessions[$sUserId];
+ }
+ return null;
+ }
+
+ public function AssetUserMenu() {
+ $oUserMenu = $this->Menu_Get('user');
+
+ if($oUserMenu){
+ $iCountTopic = $this->Topic_GetCountTopicsPersonalByUser($this->oUserCurrent->getId(), 1);
+ $iCountComment = $this->Comment_GetCountCommentsByUserId($this->oUserCurrent->getId(), 'topic');
+ $iCountNote = $this->User_GetCountUserNotesByUserId($this->oUserCurrent->getId());
+
+ $iCountTopicFavourite = $this->Topic_GetCountTopicsFavouriteByUserId($this->oUserCurrent->getId());
+ $iCountCommentFavourite = $this->Comment_GetCountCommentsFavouriteByUserId($this->oUserCurrent->getId());
+
+ $oUserMenu->prependChild(Engine::GetEntity('Menu_Item', [
+ 'title' => "user.profile.nav.info",
+ 'url' => 'profile/'. $this->oUserCurrent->getLogin(),
+ 'name' => 'whois',
+ 'priority' => 80
+ ]))->prependChild(Engine::GetEntity('Menu_Item', [
+ 'title' => "user.profile.nav.wall",
+ 'url' => 'profile/' . $this->oUserCurrent->getLogin(). "/wall",
+ 'name' => 'wall',
+ 'count' => $this->Wall_GetCountWall(array('wall_user_id' => $this->oUserCurrent->getId(), 'pid' => null)),
+ 'priority' => 75
+ ]))->prependChild(Engine::GetEntity('Menu_Item', [
+ 'title' => "user.profile.nav.messages",
+ 'url' => 'talk',
+ 'name' => 'talk',
+ 'count' => $this->Talk_GetCountTalkNew($this->oUserCurrent->getId()),
+ 'priority' => 70
+ ]))->prependChild(Engine::GetEntity('Menu_Item', [
+ 'title' => "user.profile.nav.publications",
+ 'url' => 'profile/' . $this->oUserCurrent->getLogin(). "/created/topics",
+ 'name' => 'created',
+ 'count' => ($iCountTopic + $iCountComment + $iCountNote),
+ 'priority' => 60
+ ]))->prependChild(Engine::GetEntity('Menu_Item', [
+ 'title' => "user.profile.nav.favourite",
+ 'url' => 'profile/' .$this->oUserCurrent->getLogin(). "/favourites/topics",
+ 'name' => "favourites",
+ 'count' => ($iCountTopicFavourite + $iCountCommentFavourite),
+ 'priority' => 50
+ ]))->prependChild(Engine::GetEntity('Menu_Item', [
+ 'title' => "user.profile.nav.friends",
+ 'url' => 'profile/' .$this->oUserCurrent->getLogin(). "/friends",
+ 'name' => 'friends',
+ 'count' => $this->User_GetCountUsersFriend($this->oUserCurrent->getId()),
+ 'priority' => 40
+ ]))->prependChild(Engine::GetEntity('Menu_Item', [
+ 'title' => "user.profile.nav.activity",
+ 'url' => 'profile/' .$this->oUserCurrent->getLogin(). "/stream",
+ 'name' => 'activity',
+ 'priority' => 30
+ ]));
+
+ if($this->oUserCurrent->isAdministrator()){
+ $oUserMenu->appendChild(Engine::GetEntity('Menu_Item', [
+ 'title' => "admin.title",
+ 'url' => 'admin',
+ 'name' => 'admin',
+ 'priority' => 10
+ ]));
+ }
+ }
+ }
+
+ /**
+ * При завершенни модуля загружаем в шалон объект текущего юзера
+ *
+ */
+ public function Shutdown()
+ {
+ if ($this->oUserCurrent) {
+ $this->AssetUserMenu();
+
+ $this->Viewer_Assign('iUserCurrentCountTalkNew', $this->Talk_GetCountTalkNew($this->oUserCurrent->getId()));
+ $this->Viewer_Assign('iUserCurrentCountTopicDraft', $this->Topic_GetCountDraftTopicsByUserId($this->oUserCurrent->getId()));
+ $this->Viewer_Assign('iUserCurrentCountTopicDeferred', $this->Topic_GetCountDeferredTopicsByUserId($this->oUserCurrent->getId()));
+
+ }
+ $this->Viewer_Assign('oUserCurrent', $this->oUserCurrent);
+ }
+
+ /**
+ * Добавляет юзера
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @return ModuleUser_EntityUser|bool
+ */
+ public function Add(ModuleUser_EntityUser $oUser)
+ {
+ if (is_null($oUser->getReferralCode())) {
+ $oUser->setReferralCode(md5((string)$oUser->getMail() . func_generator(32)));
+ }
+ if ($sId = $this->oMapper->Add($oUser)) {
+ $oUser->setId($sId);
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('user_new'));
+ /**
+ * Создаем персональный блог
+ */
+ $this->Blog_CreatePersonalBlog($oUser);
+ /**
+ * Добавляем пользователю дефолтную роль для управления правами
+ */
+ $this->Rbac_AddRoleToUser(Config::Get('module.user.rbac_role_default'), $oUser);
+ return $oUser;
+ }
+ return false;
+ }
+
+ /**
+ * Получить юзера по ключу активации
+ *
+ * @param string $sKey Ключ активации
+ * @return ModuleUser_EntityUser|null
+ */
+ public function GetUserByActivateKey($sKey)
+ {
+ $id = $this->oMapper->GetUserByActivateKey($sKey);
+ return $this->GetUserById($id);
+ }
+
+ /**
+ * Получить юзера по ключу сессии
+ *
+ * @param string $sKey Сессионный ключ
+ * @return ModuleUser_EntityUser|null
+ */
+ public function GetUserBySessionKey($sKey)
+ {
+ $id = $this->oMapper->GetUserBySessionKey($sKey);
+ return $this->GetUserById($id);
+ }
+
+ /**
+ * Получить юзера по мылу
+ *
+ * @param string $sMail Емайл
+ * @return ModuleUser_EntityUser|null
+ */
+ public function GetUserByMail($sMail)
+ {
+ $id = $this->oMapper->GetUserByMail($sMail);
+ return $this->GetUserById($id);
+ }
+
+ /**
+ * Получить юзера по реферальному коду
+ *
+ * @param string $sCode Реферальный код
+ * @return ModuleUser_EntityUser|null
+ */
+ public function GetUserByReferralCode($sCode)
+ {
+ $id = $this->oMapper->GetUserByReferralCode($sCode);
+ return $this->GetUserById($id);
+ }
+
+ /**
+ * Получить юзера по логину
+ *
+ * @param string $sLogin Логин пользователя
+ * @return ModuleUser_EntityUser|null
+ */
+ public function GetUserByLogin($sLogin)
+ {
+ $s = strtolower($sLogin);
+ if (false === ($id = $this->Cache_Get("user_login_{$s}"))) {
+ if ($id = $this->oMapper->GetUserByLogin($sLogin)) {
+ $this->Cache_Set($id, "user_login_{$s}", array(), 60 * 60 * 24 * 1);
+ }
+ }
+ return $this->GetUserById($id);
+ }
+
+ /**
+ * Получить юзера по айдишнику
+ *
+ * @param int $sId ID пользователя
+ * @return ModuleUser_EntityUser|null
+ */
+ public function GetUserById($sId)
+ {
+ if (!is_numeric($sId)) {
+ return null;
+ }
+ $aUsers = $this->GetUsersAdditionalData($sId);
+ if (isset($aUsers[$sId])) {
+ return $aUsers[$sId];
+ }
+ return null;
+ }
+
+ /**
+ * Обновляет юзера
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @return bool
+ */
+ public function Update(ModuleUser_EntityUser $oUser)
+ {
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('user_update'));
+ $this->Cache_Delete("user_{$oUser->getId()}");
+ return $this->oMapper->Update($oUser);
+ }
+
+ /**
+ * Авторизовывает юзера
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @param bool $bRemember Запоминать пользователя или нет
+ * @param string $sKey Уникальный ключ сессии
+ * @return bool
+ */
+ public function Authorization(ModuleUser_EntityUser $oUser, $bRemember = true, $sKey = null)
+ {
+ if (!$oUser->getId() or !$oUser->getActivate()) {
+ return false;
+ }
+ /**
+ * Создаём новую сессию
+ */
+ if (!$this->CreateSession($oUser, $sKey)) {
+ return false;
+ }
+ /**
+ * Запоминаем в сесси юзера
+ */
+ $this->Session_Set('user_id', $oUser->getId());
+ $this->Session_Set('session_key', $this->oSession->getKey());
+ $this->oUserCurrent = $oUser;
+ /**
+ * Ставим куку
+ */
+ if ($bRemember) {
+ $this->Session_SetCookie('key', $this->oSession->getKey(),
+ time() + Config::Get('module.user.time_login_remember'), false,
+ true);
+ }
+ return true;
+ }
+
+ /**
+ * Автоматическое заллогинивание по ключу из куков
+ *
+ */
+ protected function AutoLogin()
+ {
+ if ($this->oUserCurrent) {
+ return;
+ }
+ if ($sKey = $this->Session_GetCookie('key') and is_string($sKey)) {
+ if ($oUser = $this->GetUserBySessionKey($sKey) and $oSession = $this->oMapper->GetSessionByKey($sKey) and $oSession->isActive()) {
+ /**
+ * Перед запуском авторизации дополнительно можно проверить user-agent'а пользователя
+ */
+ $this->Authorization($oUser, true, $oSession->getKey());
+ } else {
+ $this->Logout();
+ }
+ }
+ }
+
+ /**
+ * Авторизован ли юзер
+ *
+ * @return bool
+ */
+ public function IsAuthorization()
+ {
+ if ($this->oUserCurrent) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Получить текущего юзера
+ *
+ * @return ModuleUser_EntityUser|null
+ */
+ public function GetUserCurrent()
+ {
+ return $this->oUserCurrent;
+ }
+
+ /**
+ * Устанавливает текущего пользователя
+ *
+ * @param ModuleUser_EntityUser $oUser
+ */
+ public function SetUserCurrent($oUser)
+ {
+ $this->oUserCurrent = $oUser;
+ }
+
+ /**
+ * Обновляет данные текущего пользователя
+ *
+ * @param bool $bSafe Обновлять только данные объекта ($bSafe=true) или полностью весь объект. При обновлении всего объекта происходит потеря связей старых ссылок на объект.
+ */
+ public function ReloadUserCurrent($bSafe = true)
+ {
+ if ($this->oUserCurrent and $oUser = $this->GetUserById($this->oUserCurrent->getId())) {
+ if ($bSafe) {
+ $this->oUserCurrent->_setData($oUser->_getData());
+ } else {
+ $this->oUserCurrent = $oUser;
+ }
+ }
+ }
+
+ /**
+ * Проверяет является ли текущий пользователь администратором
+ *
+ * @param bool $bReturnUser Возвращать или нет объект пользователя
+ *
+ * @return bool|ModuleUser_EntityUser
+ */
+ public function GetIsAdmin($bReturnUser = false)
+ {
+ if ($this->oUserCurrent and $this->oUserCurrent->isAdministrator()) {
+ return $bReturnUser ? $this->oUserCurrent : true;
+ }
+ return false;
+ }
+
+ /**
+ * Разлогинивание
+ *
+ */
+ public function Logout()
+ {
+ /**
+ * Закрываем текущую сессию
+ */
+ if ($this->oSession) {
+ $this->oSession->setDateLast(date("Y-m-d H:i:s"));
+ $this->oSession->setIpLast(func_getIp());
+ $this->oSession->setDateClose(date("Y-m-d H:i:s"));
+ $this->oMapper->UpdateSession($this->oSession);
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('user_session_update'));
+ }
+ $this->oUserCurrent = null;
+ $this->oSession = null;
+ /**
+ * Дропаем из сессии
+ */
+ $this->Session_Drop('user_id');
+ $this->Session_Drop('session_key');
+ /**
+ * Дропаем куку
+ */
+ $this->Session_DropCookie('key');
+ }
+
+ /**
+ * Обновление данных сессии
+ * Важный момент: сессию обновляем в кеше и раз в 10 минут скидываем в БД
+ */
+ protected function UpdateSession()
+ {
+ $this->oSession->setDateLast(date("Y-m-d H:i:s"));
+ $this->oSession->setIpLast(func_getIp());
+ if (false === ($data = $this->Cache_Get("user_session_{$this->oSession->getUserId()}"))) {
+ $data = array(
+ 'time' => time(),
+ 'session' => $this->oSession
+ );
+ } else {
+ $data['session'] = $this->oSession;
+ }
+ if (!Config::Get('sys.cache.use') or $data['time'] < time() - 60 * 10) {
+ $data['time'] = time();
+ $this->oMapper->UpdateSession($this->oSession);
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('user_session_update'));
+ }
+ $this->Cache_Set($data, "user_session_{$this->oSession->getUserId()}", array(), 60 * 60 * 24 * 4);
+ }
+
+ /**
+ * Создание пользовательской сессии
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @param string $sKey Сессионный ключ
+ * @return bool
+ */
+ protected function CreateSession(ModuleUser_EntityUser $oUser, $sKey = null)
+ {
+ /**
+ * Генерим новый ключ
+ */
+ if (is_null($sKey)) {
+ $sKey = md5(func_generator() . time() . $oUser->getId());
+ }
+
+ /**
+ * Проверяем ключ сессии
+ */
+ if ($oSession = $this->oMapper->GetSessionByKey($sKey)) {
+ /**
+ * Если сессия уже не активна, то удаляем её
+ */
+ if (!$oSession->isActive()) {
+ $this->oMapper->DeleteSession($oSession);
+ unset($oSession);
+ }
+ }
+
+ if (!isset($oSession)) {
+ /**
+ * Проверяем количество активных сессий у пользователя и завершаем сверх лимита
+ */
+ $iCountMaxSessions = Config::Get('module.user.count_auth_session');
+ $aSessions = $this->GetSessionsByUserId($oUser->getId());
+ $aSessions = array_slice($aSessions, ($iCountMaxSessions - 1 < 0) ? 0 : $iCountMaxSessions - 1);
+ foreach ($aSessions as $oSessionOld) {
+ $oSessionOld->setDateClose(date("Y-m-d H:i:s"));
+ $this->oMapper->UpdateSession($oSessionOld);
+ }
+ /**
+ * Проверяем количество всех сессий у пользователя и удаляем сверх лимита
+ */
+ $iCountMaxSessions = Config::Get('module.user.count_auth_session_history');
+ $aSessions = $this->GetSessionsByUserId($oUser->getId(), false);
+ $aSessions = array_slice($aSessions, ($iCountMaxSessions - 1 < 0) ? 0 : $iCountMaxSessions - 1);
+ foreach ($aSessions as $oSessionOld) {
+ $this->oMapper->DeleteSession($oSessionOld);
+ }
+ }
+
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('user_session_update'));
+ $this->Cache_Delete("user_session_{$oUser->getId()}");
+ /**
+ * Создаем новую или обновляем данные у старой
+ */
+ if (!isset($oSession)) {
+ $oSession = Engine::GetEntity('User_Session');
+ $oSession->setKey($sKey);
+ $oSession->setIpCreate(func_getIp());
+ $oSession->setDateCreate(date("Y-m-d H:i:s"));
+ $oSession->setExtraParam('user_agent',
+ isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '');
+ }
+ $oSession->setUserId($oUser->getId());
+ $oSession->setIpLast(func_getIp());
+ $oSession->setDateLast(date("Y-m-d H:i:s"));
+
+ if ($this->oMapper->CreateSession($oSession)) {
+ $this->oSession = $oSession;
+ return true;
+ }
+ return false;
+ }
+
+ public function GetSessionsByUserId($iUserId, $bOnlyNotClose = true)
+ {
+ return $this->oMapper->GetSessionsByUserId($iUserId, $bOnlyNotClose);
+ }
+
+ /**
+ * Возвращает список пользователей по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элментов на страницу
+ * @param array $aAllowData Список типо данных для подгрузки к пользователям
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetUsersByFilter($aFilter, $aOrder, $iCurrPage, $iPerPage, $aAllowData = null)
+ {
+ $sKey = "user_filter_" . serialize($aFilter) . serialize($aOrder) . "_{$iCurrPage}_{$iPerPage}";
+ if (false === ($data = $this->Cache_Get($sKey))) {
+ $data = array(
+ 'collection' => $this->oMapper->GetUsersByFilter($aFilter, $aOrder, $iCount, $iCurrPage, $iPerPage),
+ 'count' => $iCount
+ );
+ /**
+ * Если есть фильтр по "кто онлайн", то уменьшаем время кеширования до 10 минут
+ */
+ $iTimeCache = isset($aFilter['date_last_more']) ? 60 * 10 : 60 * 60 * 24 * 2;
+ $this->Cache_Set($data, $sKey, array("user_update", "user_new"), $iTimeCache);
+ }
+ $data['collection'] = $this->GetUsersAdditionalData($data['collection'], $aAllowData);
+ return $data;
+ }
+
+ /**
+ * Получить список юзеров по дате регистрации
+ *
+ * @param int $iLimit Количество
+ * @return array
+ */
+ public function GetUsersByDateRegister($iLimit = 20)
+ {
+ $aResult = $this->GetUsersByFilter(array('activate' => 1), array('id' => 'desc'), 1, $iLimit);
+ return $aResult['collection'];
+ }
+
+ /**
+ * Получить статистику по юзерам
+ *
+ * @return array
+ */
+ public function GetStatUsers()
+ {
+ if (false === ($aStat = $this->Cache_Get("user_stats"))) {
+ $aStat['count_all'] = $this->oMapper->GetCountUsers();
+ $sDate = date("Y-m-d H:i:s", time() - Config::Get('module.user.time_active'));
+ $aStat['count_active'] = $this->oMapper->GetCountUsersActive($sDate);
+ $aStat['count_inactive'] = $aStat['count_all'] - $aStat['count_active'];
+ $aSex = $this->oMapper->GetCountUsersSex();
+ $aStat['count_sex_man'] = (isset($aSex['man']) ? $aSex['man']['count'] : 0);
+ $aStat['count_sex_woman'] = (isset($aSex['woman']) ? $aSex['woman']['count'] : 0);
+ $aStat['count_sex_other'] = (isset($aSex['other']) ? $aSex['other']['count'] : 0);
+
+ $this->Cache_Set($aStat, "user_stats", array("user_update", "user_new"), 60 * 60 * 24 * 4);
+ }
+ return $aStat;
+ }
+
+ /**
+ * Получить список юзеров по первым буквам логина
+ *
+ * @param string $sUserLogin Логин
+ * @param int $iLimit Количество
+ * @return array
+ */
+ public function GetUsersByLoginLike($sUserLogin, $iLimit)
+ {
+ if (false === ($data = $this->Cache_Get("user_like_{$sUserLogin}_{$iLimit}"))) {
+ $data = $this->oMapper->GetUsersByLoginLike($sUserLogin, $iLimit);
+ $this->Cache_Set($data, "user_like_{$sUserLogin}_{$iLimit}", array("user_new"), 60 * 60 * 24 * 2);
+ }
+ $data = $this->GetUsersAdditionalData($data);
+ return $data;
+ }
+
+ /**
+ * Получить список отношений друзей
+ *
+ * @param array $aUserId Список ID пользователей проверяемых на дружбу
+ * @param int $sUserId ID пользователя у которого проверяем друзей
+ * @return array
+ */
+ public function GetFriendsByArray($aUserId, $sUserId)
+ {
+ if (!$aUserId) {
+ return array();
+ }
+ if (Config::Get('sys.cache.solid')) {
+ return $this->GetFriendsByArraySolid($aUserId, $sUserId);
+ }
+ if (!is_array($aUserId)) {
+ $aUserId = array($aUserId);
+ }
+ $aUserId = array_unique($aUserId);
+ $aFriends = array();
+ $aUserIdNotNeedQuery = array();
+ /**
+ * Делаем мульти-запрос к кешу
+ */
+ $aCacheKeys = func_build_cache_keys($aUserId, 'user_friend_', '_' . $sUserId);
+ if (false !== ($data = $this->Cache_Get($aCacheKeys))) {
+ /**
+ * проверяем что досталось из кеша
+ */
+ foreach ($aCacheKeys as $sValue => $sKey) {
+ if (array_key_exists($sKey, $data)) {
+ if ($data[$sKey]) {
+ $aFriends[$data[$sKey]->getFriendId()] = $data[$sKey];
+ } else {
+ $aUserIdNotNeedQuery[] = $sValue;
+ }
+ }
+ }
+ }
+ /**
+ * Смотрим каких френдов не было в кеше и делаем запрос в БД
+ */
+ $aUserIdNeedQuery = array_diff($aUserId, array_keys($aFriends));
+ $aUserIdNeedQuery = array_diff($aUserIdNeedQuery, $aUserIdNotNeedQuery);
+ $aUserIdNeedStore = $aUserIdNeedQuery;
+ if ($data = $this->oMapper->GetFriendsByArrayId($aUserIdNeedQuery, $sUserId)) {
+ foreach ($data as $oFriend) {
+ /**
+ * Добавляем к результату и сохраняем в кеш
+ */
+ $aFriends[$oFriend->getFriendId($sUserId)] = $oFriend;
+ /**
+ * Тут кеш нужно будет продумать как-то по другому.
+ * Пока не трогаю, ибо этот код все равно не выполняется.
+ * by Kachaev
+ */
+ $this->Cache_Set($oFriend, "user_friend_{$oFriend->getFriendId()}_{$oFriend->getUserId()}", array(),
+ 60 * 60 * 24 * 4);
+ $aUserIdNeedStore = array_diff($aUserIdNeedStore, array($oFriend->getFriendId()));
+ }
+ }
+ /**
+ * Сохраняем в кеш запросы не вернувшие результата
+ */
+ foreach ($aUserIdNeedStore as $sId) {
+ $this->Cache_Set(null, "user_friend_{$sId}_{$sUserId}", array(), 60 * 60 * 24 * 4);
+ }
+ /**
+ * Сортируем результат согласно входящему массиву
+ */
+ $aFriends = func_array_sort_by_keys($aFriends, $aUserId);
+ return $aFriends;
+ }
+
+ /**
+ * Получить список отношений друзей используя единый кеш
+ *
+ * @param array $aUserId Список ID пользователей проверяемых на дружбу
+ * @param int $sUserId ID пользователя у которого проверяем друзей
+ * @return array
+ */
+ public function GetFriendsByArraySolid($aUserId, $sUserId)
+ {
+ if (!is_array($aUserId)) {
+ $aUserId = array($aUserId);
+ }
+ $aUserId = array_unique($aUserId);
+ $aFriends = array();
+ $s = join(',', $aUserId);
+ if (false === ($data = $this->Cache_Get("user_friend_{$sUserId}_id_{$s}"))) {
+ $data = $this->oMapper->GetFriendsByArrayId($aUserId, $sUserId);
+ foreach ($data as $oFriend) {
+ $aFriends[$oFriend->getFriendId($sUserId)] = $oFriend;
+ }
+
+ $this->Cache_Set($aFriends, "user_friend_{$sUserId}_id_{$s}", array("friend_change_user_{$sUserId}"),
+ 60 * 60 * 24 * 1);
+ return $aFriends;
+ }
+ return $data;
+ }
+
+ /**
+ * Получаем привязку друга к юзеру(есть ли у юзера данный друг)
+ *
+ * @param int $sFriendId ID пользователя друга
+ * @param int $sUserId ID пользователя
+ * @return ModuleUser_EntityFriend|null
+ */
+ public function GetFriend($sFriendId, $sUserId)
+ {
+ $data = $this->GetFriendsByArray($sFriendId, $sUserId);
+ if (isset($data[$sFriendId])) {
+ return $data[$sFriendId];
+ }
+ return null;
+ }
+
+ /**
+ * Добавляет друга
+ *
+ * @param ModuleUser_EntityFriend $oFriend Объект дружбы(связи пользователей)
+ * @return bool
+ */
+ public function AddFriend(ModuleUser_EntityFriend $oFriend)
+ {
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("friend_change_user_{$oFriend->getUserFrom()}", "friend_change_user_{$oFriend->getUserTo()}"));
+ $this->Cache_Delete("user_friend_{$oFriend->getUserFrom()}_{$oFriend->getUserTo()}");
+ $this->Cache_Delete("user_friend_{$oFriend->getUserTo()}_{$oFriend->getUserFrom()}");
+
+ return $this->oMapper->AddFriend($oFriend);
+ }
+
+ /**
+ * Удаляет друга
+ *
+ * @param ModuleUser_EntityFriend $oFriend Объект дружбы(связи пользователей)
+ * @return bool
+ */
+ public function DeleteFriend(ModuleUser_EntityFriend $oFriend)
+ {
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("friend_change_user_{$oFriend->getUserFrom()}", "friend_change_user_{$oFriend->getUserTo()}"));
+ $this->Cache_Delete("user_friend_{$oFriend->getUserFrom()}_{$oFriend->getUserTo()}");
+ $this->Cache_Delete("user_friend_{$oFriend->getUserTo()}_{$oFriend->getUserFrom()}");
+
+ // устанавливаем статус дружбы "удалено"
+ $oFriend->setStatusByUserId(ModuleUser::USER_FRIEND_DELETE, $oFriend->getUserId());
+ return $this->oMapper->UpdateFriend($oFriend);
+ }
+
+ /**
+ * Удаляет информацию о дружбе из базы данных
+ *
+ * @param ModuleUser_EntityFriend $oFriend Объект дружбы(связи пользователей)
+ * @return bool
+ */
+ public function EraseFriend(ModuleUser_EntityFriend $oFriend)
+ {
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("friend_change_user_{$oFriend->getUserFrom()}", "friend_change_user_{$oFriend->getUserTo()}"));
+ $this->Cache_Delete("user_friend_{$oFriend->getUserFrom()}_{$oFriend->getUserTo()}");
+ $this->Cache_Delete("user_friend_{$oFriend->getUserTo()}_{$oFriend->getUserFrom()}");
+ return $this->oMapper->EraseFriend($oFriend);
+ }
+
+ /**
+ * Обновляет информацию о друге
+ *
+ * @param ModuleUser_EntityFriend $oFriend Объект дружбы(связи пользователей)
+ * @return bool
+ */
+ public function UpdateFriend(ModuleUser_EntityFriend $oFriend)
+ {
+ //чистим зависимые кеши
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("friend_change_user_{$oFriend->getUserFrom()}", "friend_change_user_{$oFriend->getUserTo()}"));
+ $this->Cache_Delete("user_friend_{$oFriend->getUserFrom()}_{$oFriend->getUserTo()}");
+ $this->Cache_Delete("user_friend_{$oFriend->getUserTo()}_{$oFriend->getUserFrom()}");
+ return $this->oMapper->UpdateFriend($oFriend);
+ }
+
+ /**
+ * Получает список друзей
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetUsersFriend($sUserId, $iPage = 1, $iPerPage = 10)
+ {
+ $sKey = "user_friend_{$sUserId}_{$iPage}_{$iPerPage}";
+ if (false === ($data = $this->Cache_Get($sKey))) {
+ $data = array(
+ 'collection' => $this->oMapper->GetUsersFriend($sUserId, $iCount, $iPage, $iPerPage),
+ 'count' => $iCount
+ );
+ $this->Cache_Set($data, $sKey, array("friend_change_user_{$sUserId}"), 60 * 60 * 24 * 2);
+ }
+ $data['collection'] = $this->GetUsersAdditionalData($data['collection']);
+ return $data;
+ }
+
+ /**
+ * Получает количество друзей
+ *
+ * @param int $sUserId ID пользователя
+ * @return int
+ */
+ public function GetCountUsersFriend($sUserId)
+ {
+ $sKey = "count_user_friend_{$sUserId}";
+ if (false === ($data = $this->Cache_Get($sKey))) {
+ $data = $this->oMapper->GetCountUsersFriend($sUserId);
+ $this->Cache_Set($data, $sKey, array("friend_change_user_{$sUserId}"), 60 * 60 * 24 * 2);
+ }
+ return $data;
+ }
+
+ /**
+ * Добавляем воспоминание(восстановление) пароля
+ *
+ * @param ModuleUser_EntityReminder $oReminder Объект восстановления пароля
+ * @return bool
+ */
+ public function AddReminder(ModuleUser_EntityReminder $oReminder)
+ {
+ return $this->oMapper->AddReminder($oReminder);
+ }
+
+ /**
+ * Сохраняем воспомнинание(восстановление) пароля
+ *
+ * @param ModuleUser_EntityReminder $oReminder Объект восстановления пароля
+ * @return bool
+ */
+ public function UpdateReminder(ModuleUser_EntityReminder $oReminder)
+ {
+ return $this->oMapper->UpdateReminder($oReminder);
+ }
+
+ /**
+ * Получаем запись восстановления пароля по коду
+ *
+ * @param string $sCode Код восстановления пароля
+ * @return ModuleUser_EntityReminder|null
+ */
+ public function GetReminderByCode($sCode)
+ {
+ return $this->oMapper->GetReminderByCode($sCode);
+ }
+
+ /**
+ * Создает аватар пользователя на основе области из изображения
+ *
+ * @param $sFileFrom
+ * @param $oUser
+ * @param $aSize
+ * @param null $iCanvasWidth
+ *
+ * @return bool
+ */
+ public function CreateProfileAvatar($sFileFrom, $oUser, $aSize = null, $iCanvasWidth = null)
+ {
+ $aParams = $this->Image_BuildParams('profile_avatar');
+ /**
+ * Если объект изображения не создан, возвращаем ошибку
+ */
+ if (!$oImage = $this->Image_OpenFrom($sFileFrom, $aParams)) {
+ return $this->Image_GetLastError();
+ }
+ /**
+ * Если нет области, то берем центральный квадрат
+ */
+ if (!$aSize) {
+ $oImage->cropSquare();
+ } else {
+ /**
+ * Вырезаем область из исходного файла
+ */
+ $oImage->cropFromSelected($aSize, $iCanvasWidth);
+ }
+ if ($sError = $this->Image_GetLastError()) {
+ return $sError;
+ }
+ /**
+ * Сохраняем во временный файл для дальнейшего ресайза
+ */
+ if (false === ($sFileTmp = $oImage->saveTmp())) {
+ return $this->Image_GetLastError();
+ }
+ $sPath = $this->Image_GetIdDir($oUser->getId(), 'users');
+ /**
+ * Удаляем старый аватар
+ */
+ $this->DeleteProfileAvatar($oUser);
+ /**
+ * Имя файла для сохранения
+ */
+ $sFileName = 'avatar-user-' . $oUser->getId();
+ /**
+ * Сохраняем оригинальный аватар
+ */
+ if (false === ($sFileResult = $oImage->saveSmart($sPath, $sFileName))) {
+ return $this->Image_GetLastError();
+ }
+ /**
+ * Генерируем варианты с необходимыми размерами
+ */
+ $this->Media_GenerateImageBySizes($sFileTmp, $sPath, $sFileName, Config::Get('module.user.avatar_size'),
+ $aParams);
+ /**
+ * Теперь можно удалить временный файл
+ */
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ $oUser->setProfileAvatar($sFileResult);
+ $this->User_Update($oUser);
+ return true;
+ }
+
+ /**
+ * Создает фото пользователя на основе области из изображения
+ *
+ * @param $sFileFrom
+ * @param $oUser
+ * @param $aSize
+ * @param null $iCanvasWidth
+ *
+ * @return bool
+ */
+ public function CreateProfilePhoto($sFileFrom, $oUser, $aSize = null, $iCanvasWidth = null)
+ {
+ $aParams = $this->Image_BuildParams('profile_photo');
+ /**
+ * Если объект изображения не создан, возвращаем ошибку
+ */
+ if (!$oImage = $this->Image_OpenFrom($sFileFrom, $aParams)) {
+ return $this->Image_GetLastError();
+ }
+ /**
+ * Вырезаем область из исходного файла
+ */
+ if ($aSize) {
+ $oImage->cropFromSelected($aSize, $iCanvasWidth);
+ }
+ if ($sError = $this->Image_GetLastError()) {
+ return $sError;
+ }
+ /**
+ * Сохраняем во временный файл для дальнейшего ресайза
+ */
+ if (false === ($sFileTmp = $oImage->saveTmp())) {
+ return $this->Image_GetLastError();
+ }
+ $sPath = $this->Image_GetIdDir($oUser->getId(), 'users');
+ /**
+ * Имя файла для сохранения
+ */
+ $sFileName = func_generator(8);
+ /**
+ * Сохраняем копию нужного размера
+ */
+ $aSize = $this->Media_ParsedImageSize(Config::Get('module.user.profile_photo_size'));
+ if ($aSize['crop']) {
+ $oImage->cropProportion($aSize['w'] / $aSize['h'], 'center');
+ }
+ if (!$sFileResult = $oImage->resize($aSize['w'], $aSize['h'], true)->saveSmart($sPath, $sFileName)) {
+ return $this->Image_GetLastError();
+ }
+ /**
+ * Теперь можно удалить временный файл
+ */
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ /**
+ * Если было старое фото, то удаляем
+ */
+ $this->DeleteProfilePhoto($oUser);
+ $oUser->setProfileFoto($sFileResult);
+ $this->User_Update($oUser);
+ return true;
+ }
+
+ /**
+ * Загрузка фото в профиль пользователя
+ *
+ * @param $aFile
+ * @param $oUser
+ *
+ * @return bool
+ */
+ public function UploadProfilePhoto($aFile, $oUser)
+ {
+ if (!is_array($aFile) || !isset($aFile['tmp_name'])) {
+ return false;
+ }
+
+ $sFileTmp = Config::Get('sys.cache.dir') . func_generator();
+ if (!move_uploaded_file($aFile['tmp_name'], $sFileTmp)) {
+ return false;
+ }
+
+ $aParams = $this->Image_BuildParams('profile_photo');
+ /**
+ * Если объект изображения не создан, возвращаем ошибку
+ */
+ if (!$oImage = $this->Image_Open($sFileTmp, $aParams)) {
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ return $this->Image_GetLastError();
+ }
+ $sPath = $this->Image_GetIdDir($oUser->getId(), 'users');
+ /**
+ * Имя файла для сохранения
+ */
+ $sFileName = func_generator(8);
+ /**
+ * Сохраняем копию нужного размера
+ */
+ $aSize = $this->Media_ParsedImageSize(Config::Get('module.user.profile_photo_size'));
+ if ($aSize['crop']) {
+ $oImage->cropProportion($aSize['w'] / $aSize['h'], 'center');
+ }
+ if (!$sFileResult = $oImage->resize($aSize['w'], $aSize['h'], true)->saveSmart($sPath, $sFileName)) {
+ return $this->Image_GetLastError();
+ }
+ /**
+ * Теперь можно удалить временный файл
+ */
+ $this->Fs_RemoveFileLocal($sFileTmp);
+ /**
+ * Если было старое фото, то удаляем
+ */
+ $this->DeleteProfilePhoto($oUser);
+ $oUser->setProfileFoto($sFileResult);
+ $this->User_Update($oUser);
+ return true;
+ }
+
+ /**
+ * Удаляет фото пользователя
+ *
+ * @param ModuleUser_EntityUser $oUser
+ */
+ public function DeleteProfilePhoto($oUser)
+ {
+ if ($oUser->getProfileFoto()) {
+ $this->Image_RemoveFile($oUser->getProfileFoto());
+ $oUser->setProfileFoto(null);
+ }
+ }
+
+ /**
+ * Удаляет аватар пользователя
+ *
+ * @param ModuleUser_EntityUser $oUser
+ */
+ public function DeleteProfileAvatar($oUser)
+ {
+ if ($oUser->getProfileAvatar()) {
+ $this->Media_RemoveImageBySizes($oUser->getProfileAvatar(), Config::Get('module.user.avatar_size'));
+ $oUser->setProfileAvatar(null);
+ }
+ }
+
+ /**
+ * Проверяет логин на корректность
+ *
+ * @param string $sLogin Логин пользователя
+ * @return bool
+ */
+ public function CheckLogin($sLogin)
+ {
+ $charset = Config::Get('module.user.login.charset');
+ $min = Config::Get('module.user.login.min_size');
+ $max = Config::Get('module.user.login.max_size');
+ if (preg_match('/^[' . $charset . ']{' . $min . ',' . $max . '}$/ui', $sLogin)) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Получить дополнительные поля профиля пользователя
+ *
+ * @param array|null $aType Типы полей, null - все типы
+ * @return array
+ */
+ public function getUserFields($aType = null)
+ {
+ return $this->oMapper->getUserFields($aType);
+ }
+
+ /**
+ * Получить значения дополнительных полей профиля пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param bool $bOnlyNoEmpty Загружать только непустые поля
+ * @param array $aType Типы полей, null - все типы
+ * @return array
+ */
+ public function getUserFieldsValues($iUserId, $bOnlyNoEmpty = true, $aType = array(''))
+ {
+ return $this->oMapper->getUserFieldsValues($iUserId, $bOnlyNoEmpty, $aType);
+ }
+
+ /**
+ * Получить по имени поля его значение дял определённого пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param string $sName Имя поля
+ * @return string
+ */
+ public function getUserFieldValueByName($iUserId, $sName)
+ {
+ return $this->oMapper->getUserFieldValueByName($iUserId, $sName);
+ }
+
+ /**
+ * Установить значения дополнительных полей профиля пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param array $aFields Ассоциативный массив полей id => value
+ * @param int $iCountMax Максимальное количество одинаковых полей
+ * @return bool
+ */
+ public function setUserFieldsValues($iUserId, $aFields, $iCountMax = 1)
+ {
+ return $this->oMapper->setUserFieldsValues($iUserId, $aFields, $iCountMax);
+ }
+
+ /**
+ * Добавить поле
+ *
+ * @param ModuleUser_EntityField $oField Объект пользовательского поля
+ * @return bool
+ */
+ public function addUserField($oField)
+ {
+ return $this->oMapper->addUserField($oField);
+ }
+
+ /**
+ * Изменить поле
+ *
+ * @param ModuleUser_EntityField $oField Объект пользовательского поля
+ * @return bool
+ */
+ public function updateUserField($oField)
+ {
+ return $this->oMapper->updateUserField($oField);
+ }
+
+ /**
+ * Удалить поле
+ *
+ * @param int $iId ID пользовательского поля
+ * @return bool
+ */
+ public function deleteUserField($iId)
+ {
+ return $this->oMapper->deleteUserField($iId);
+ }
+
+ /**
+ * Проверяет существует ли поле с таким именем
+ *
+ * @param string $sName Имя поля
+ * @param int|null $iId ID поля
+ * @return bool
+ */
+ public function userFieldExistsByName($sName, $iId = null)
+ {
+ return $this->oMapper->userFieldExistsByName($sName, $iId);
+ }
+
+ /**
+ * Проверяет существует ли поле с таким ID
+ *
+ * @param int $iId ID поля
+ * @return bool
+ */
+ public function userFieldExistsById($iId)
+ {
+ return $this->oMapper->userFieldExistsById($iId);
+ }
+
+ /**
+ * Удаляет у пользователя значения полей
+ *
+ * @param int $iUserId ID пользователя
+ * @param array|null $aType Список типов для удаления
+ * @return bool
+ */
+ public function DeleteUserFieldValues($iUserId, $aType = null)
+ {
+ return $this->oMapper->DeleteUserFieldValues($iUserId, $aType);
+ }
+
+ /**
+ * Возвращает список заметок пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetUserNotesByUserId($iUserId, $iCurrPage, $iPerPage)
+ {
+ $aResult = $this->oMapper->GetUserNotesByUserId($iUserId, $iCount, $iCurrPage, $iPerPage);
+ /**
+ * Цепляем пользователей
+ */
+ $aUserId = array();
+ foreach ($aResult as $oNote) {
+ $aUserId[] = $oNote->getTargetUserId();
+ }
+ $aUsers = $this->GetUsersAdditionalData($aUserId, array());
+ foreach ($aResult as $oNote) {
+ if (isset($aUsers[$oNote->getTargetUserId()])) {
+ $oNote->setTargetUser($aUsers[$oNote->getTargetUserId()]);
+ } else {
+ $oNote->setTargetUser(Engine::GetEntity('User')); // пустого пользователя во избеания ошибок, т.к. пользователь всегда должен быть
+ }
+ }
+ return array('collection' => $aResult, 'count' => $iCount);
+ }
+
+ /**
+ * Возвращает список пользователей к которым юзер оставлял заметку
+ *
+ * @param int $iUserId ID пользователя
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ *
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetUsersByNoteAndUserId($iUserId, $iCurrPage, $iPerPage)
+ {
+ $aUsersId = $this->oMapper->GetUsersByNoteAndUserId($iUserId, $iCount, $iCurrPage, $iPerPage);
+ $aResult = $this->GetUsersAdditionalData($aUsersId);
+ return array('collection' => $aResult, 'count' => $iCount);
+ }
+
+ /**
+ * Возвращает количество заметок у пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @return int
+ */
+ public function GetCountUserNotesByUserId($iUserId)
+ {
+ return $this->oMapper->GetCountUserNotesByUserId($iUserId);
+ }
+
+ /**
+ * Возвращет заметку по автору и пользователю
+ *
+ * @param int $iTargetUserId ID пользователя о ком заметка
+ * @param int $iUserId ID пользователя автора заметки
+ * @return ModuleUser_EntityNote
+ */
+ public function GetUserNote($iTargetUserId, $iUserId)
+ {
+ return $this->oMapper->GetUserNote($iTargetUserId, $iUserId);
+ }
+
+ /**
+ * Возвращает заметку по ID
+ *
+ * @param int $iId ID заметки
+ * @return ModuleUser_EntityNote
+ */
+ public function GetUserNoteById($iId)
+ {
+ return $this->oMapper->GetUserNoteById($iId);
+ }
+
+ /**
+ * Возвращает список заметок пользователя по ID целевых юзеров
+ *
+ * @param array $aUserId Список ID целевых пользователей
+ * @param int $sUserId ID пользователя, кто оставлял заметки
+ * @return array
+ */
+ public function GetUserNotesByArray($aUserId, $sUserId)
+ {
+ if (!$aUserId) {
+ return array();
+ }
+ if (!is_array($aUserId)) {
+ $aUserId = array($aUserId);
+ }
+ $aUserId = array_unique($aUserId);
+ $aNotes = array();
+ $s = join(',', $aUserId);
+ if (false === ($data = $this->Cache_Get("user_notes_{$sUserId}_id_{$s}"))) {
+ $data = $this->oMapper->GetUserNotesByArrayUserId($aUserId, $sUserId);
+ foreach ($data as $oNote) {
+ $aNotes[$oNote->getTargetUserId()] = $oNote;
+ }
+
+ $this->Cache_Set($aNotes, "user_notes_{$sUserId}_id_{$s}", array("user_note_change_by_user_{$sUserId}"),
+ 60 * 60 * 24 * 1);
+ return $aNotes;
+ }
+ return $data;
+ }
+
+ /**
+ * Удаляет заметку по ID
+ *
+ * @param int $iId ID заметки
+ * @return bool
+ */
+ public function DeleteUserNoteById($iId)
+ {
+ if ($oNote = $this->GetUserNoteById($iId)) {
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("user_note_change_by_user_{$oNote->getUserId()}"));
+ }
+ return $this->oMapper->DeleteUserNoteById($iId);
+ }
+
+ /**
+ * Сохраняет заметку в БД, если ее нет то создает новую
+ *
+ * @param ModuleUser_EntityNote $oNote Объект заметки
+ * @return bool|ModuleUser_EntityNote
+ */
+ public function SaveNote($oNote)
+ {
+ if (!$oNote->getDateAdd()) {
+ $oNote->setDateAdd(date("Y-m-d H:i:s"));
+ }
+
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("user_note_change_by_user_{$oNote->getUserId()}"));
+ if ($oNoteOld = $this->GetUserNote($oNote->getTargetUserId(), $oNote->getUserId())) {
+ $oNoteOld->setText($oNote->getText());
+ $this->oMapper->UpdateUserNote($oNoteOld);
+ return $oNoteOld;
+ } else {
+ if ($iId = $this->oMapper->AddUserNote($oNote)) {
+ $oNote->setId($iId);
+ return $oNote;
+ }
+ }
+ return false;
+ }
+
+ public function AddComplaint($oComplaint)
+ {
+ if (!$oComplaint->getDateAdd()) {
+ $oComplaint->setDateAdd(date("Y-m-d H:i:s"));
+ }
+
+ if ($iId = $this->oMapper->AddComplaint($oComplaint)) {
+ $oComplaint->setId($iId);
+ return $oComplaint;
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает список префиксов логинов пользователей (для алфавитного указателя)
+ *
+ * @param int $iPrefixLength Длина префикса
+ * @return array
+ */
+ public function GetGroupPrefixUser($iPrefixLength = 1)
+ {
+ if (false === ($data = $this->Cache_Get("group_prefix_user_{$iPrefixLength}"))) {
+ $data = $this->oMapper->GetGroupPrefixUser($iPrefixLength);
+ $this->Cache_Set($data, "group_prefix_user_{$iPrefixLength}", array("user_new"), 60 * 60 * 24 * 1);
+ }
+ return $data;
+ }
+
+ /**
+ * Добавляет запись о смене емайла
+ *
+ * @param ModuleUser_EntityChangemail $oChangemail Объект смены емайла
+ * @return bool|ModuleUser_EntityChangemail
+ */
+ public function AddUserChangemail($oChangemail)
+ {
+ if ($sId = $this->oMapper->AddUserChangemail($oChangemail)) {
+ $oChangemail->setId($sId);
+ return $oChangemail;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет запись о смене емайла
+ *
+ * @param ModuleUser_EntityChangemail $oChangemail Объект смены емайла
+ * @return int
+ */
+ public function UpdateUserChangemail($oChangemail)
+ {
+ return $this->oMapper->UpdateUserChangemail($oChangemail);
+ }
+
+ /**
+ * Возвращает объект смены емайла по коду подтверждения
+ *
+ * @param string $sCode Код подтверждения
+ * @return ModuleUser_EntityChangemail|null
+ */
+ public function GetUserChangemailByCodeFrom($sCode)
+ {
+ return $this->oMapper->GetUserChangemailByCodeFrom($sCode);
+ }
+
+ /**
+ * Возвращает объект смены емайла по коду подтверждения
+ *
+ * @param string $sCode Код подтверждения
+ * @return ModuleUser_EntityChangemail|null
+ */
+ public function GetUserChangemailByCodeTo($sCode)
+ {
+ return $this->oMapper->GetUserChangemailByCodeTo($sCode);
+ }
+
+ /**
+ * Формирование процесса смены емайла в профиле пользователя
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @param string $sMailNew Новый емайл
+ * @return bool|ModuleUser_EntityChangemail
+ */
+ public function MakeUserChangemail($oUser, $sMailNew)
+ {
+ $oChangemail = Engine::GetEntity('ModuleUser_EntityChangemail');
+ $oChangemail->setUserId($oUser->getId());
+ $oChangemail->setDateAdd(date("Y-m-d H:i:s"));
+ $oChangemail->setDateExpired(date("Y-m-d H:i:s", time() + 3 * 24 * 60 * 60)); // 3 дня для смены емайла
+ $oChangemail->setMailFrom($oUser->getMail() ? $oUser->getMail() : '');
+ $oChangemail->setMailTo($sMailNew);
+ $oChangemail->setCodeFrom(func_generator(32));
+ $oChangemail->setCodeTo(func_generator(32));
+ if ($this->AddUserChangemail($oChangemail)) {
+ /**
+ * Если у пользователя раньше не было емайла, то сразу шлем подтверждение на новый емайл
+ */
+ if (!$oChangemail->getMailFrom()) {
+ $oChangemail->setConfirmFrom(1);
+ $this->User_UpdateUserChangemail($oChangemail);
+ /**
+ * Отправляем уведомление на новый емайл
+ */
+ $this->Notify_Send($oChangemail->getMailTo(),
+ 'user_changemail_to.tpl',
+ $this->Lang_Get('emails.user_changemail.subject'),
+ array(
+ 'oUser' => $oUser,
+ 'oChangemail' => $oChangemail,
+ ));
+
+ } else {
+ /**
+ * Отправляем уведомление на старый емайл
+ */
+ $this->Notify_Send($oUser,
+ 'user_changemail_from.tpl',
+ $this->Lang_Get('emails.user_changemail.subject'),
+ array(
+ 'oUser' => $oUser,
+ 'oChangemail' => $oChangemail,
+ ));
+ }
+ return $oChangemail;
+ }
+ return false;
+ }
+
+ /**
+ * Отправляет уведомление с новым линком активации
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ */
+ public function SendNotifyReactivationCode(ModuleUser_EntityUser $oUser)
+ {
+ $this->Notify_Send(
+ $oUser,
+ 'reactivation.tpl',
+ $this->Lang_Get('emails.reactivation.subject'),
+ array(
+ 'oUser' => $oUser,
+ ), null, true
+ );
+ }
+
+ /**
+ * Отправляет уведомление при регистрации с активацией
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @param string $sPassword Пароль пользователя
+ */
+ public function SendNotifyRegistrationActivate(ModuleUser_EntityUser $oUser, $sPassword)
+ {
+ $this->Notify_Send(
+ $oUser,
+ 'registration_activate.tpl',
+ $this->Lang_Get('emails.registration_activate.subject'),
+ array(
+ 'oUser' => $oUser,
+ 'sPassword' => $sPassword,
+ ), null, true
+ );
+ }
+
+ /**
+ * Отправляет уведомление о регистрации
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @param string $sPassword Пароль пользователя
+ */
+ public function SendNotifyRegistration(ModuleUser_EntityUser $oUser, $sPassword)
+ {
+ $this->Notify_Send(
+ $oUser,
+ 'registration.tpl',
+ $this->Lang_Get('emails.registration.subject'),
+ array(
+ 'oUser' => $oUser,
+ 'sPassword' => $sPassword,
+ ), null, true
+ );
+ }
+
+ /**
+ * Отправляет пользователю сообщение о добавлении его в друзья
+ *
+ * @param ModuleUser_EntityUser $oUserTo Объект пользователя
+ * @param ModuleUser_EntityUser $oUserFrom Объект пользователя, которого добавляем в друзья
+ * @param string $sText Текст сообщения
+ * @param string $sPath URL для подтверждения дружбы
+ * @return bool
+ */
+ public function SendNotifyUserFriendNew(ModuleUser_EntityUser $oUserTo, ModuleUser_EntityUser $oUserFrom, $sText, $sPath)
+ {
+ /**
+ * Проверяем можно ли юзеру рассылать уведомление
+ */
+ if (!$oUserTo->getSettingsNoticeNewFriend()) {
+ return false;
+ }
+ $this->Notify_Send(
+ $oUserTo,
+ 'user_friend_new.tpl',
+ $this->Lang_Get('emails.user_friend_new.subject'),
+ array(
+ 'oUserTo' => $oUserTo,
+ 'oUserFrom' => $oUserFrom,
+ 'sText' => $sText,
+ 'sPath' => $sPath,
+ )
+ );
+ return true;
+ }
+
+ /**
+ * Уведомление при восстановлении пароля
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @param ModuleUser_EntityReminder $oReminder объект напоминания пароля
+ */
+ public function SendNotifyReminderCode(ModuleUser_EntityUser $oUser, ModuleUser_EntityReminder $oReminder)
+ {
+ $this->Notify_Send(
+ $oUser,
+ 'reminder_code.tpl',
+ $this->Lang_Get('emails.reminder_code.subject'),
+ array(
+ 'oUser' => $oUser,
+ 'oReminder' => $oReminder,
+ ), null, true
+ );
+ }
+
+ /**
+ * Уведомление с новым паролем после его восставновления
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @param string $sNewPassword Новый пароль
+ */
+ public function SendNotifyReminderPassword(ModuleUser_EntityUser $oUser, $sNewPassword)
+ {
+ $this->Notify_Send(
+ $oUser,
+ 'reminder_password.tpl',
+ $this->Lang_Get('emails.reminder_password.subject'),
+ array(
+ 'oUser' => $oUser,
+ 'sNewPassword' => $sNewPassword,
+ ), null, true
+ );
+ }
+
+ /**
+ * Уведомление администрации о новой жалобе
+ *
+ * @param $oComplaint
+ */
+ public function SendNotifyUserComplaint($oComplaint)
+ {
+ $this->Notify_Send(
+ Config::Get('general.admin_mail'),
+ 'user_complaint.tpl',
+ $this->Lang_Get('emails.user_complaint.subject'),
+ array(
+ 'oUserTarget' => $oComplaint->getTargetUser(),
+ 'oUserFrom' => $oComplaint->getUser(),
+ 'oComplaint' => $oComplaint,
+ )
+ );
+ }
+
+ /**
+ * Генерация хеша пароля
+ *
+ * @param $sPassword
+ * @return string
+ */
+ public function MakeHashPassword($sPassword)
+ {
+ return func_encrypt($sPassword);
+ }
+
+ /**
+ * Проверка пароля
+ *
+ * @param $sPassword
+ * @param $sHash
+ * @return string
+ */
+ public function VerifyPassword($sPassword, $sHash)
+ {
+ return $this->MakeHashPassword($sPassword) == $sHash;
+ }
+
+ /**
+ * Проверка доступа к авторизации
+ *
+ * @param $oUser
+ * @return bool
+ */
+ public function VerifyAccessAuth($oUser)
+ {
+ return true;
+ }
+
+ /**
+ * Регистрация сайтмапа для пользователей
+ */
+ public function RegisterSitemap()
+ {
+ $aFilter = array(
+ 'activate' => 1,
+ );
+ $this->Sitemap_AddTargetType('users', array(
+ 'callback_data' => function ($iPage) use ($aFilter) {
+ $aUsers = $this->GetUsersByFilter($aFilter, array('user_id' => 'asc'), $iPage, 500, array());
+ $aData = array();
+ foreach ($aUsers['collection'] as $oUser) {
+ $aData[] = $this->Sitemap_GetDataForSitemapRow(
+ $oUser->getUserWebPath(),
+ is_null($oUser->getProfileDate()) ? $oUser->getDateRegister() : $oUser->getProfileDate(),
+ Config::Get('module.sitemap.user.priority'),
+ Config::Get('module.sitemap.user.changefreq')
+ );
+ }
+ return $aData;
+ },
+ 'callback_counters' => function () use ($aFilter) {
+ $aUsers = $this->GetUsersByFilter($aFilter, array(), 1, 1, array());
+ $iCount = (int)$aUsers['count'];
+ return ceil($iCount / 500);
+ }
+ ));
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/user/entity/Changemail.entity.class.php b/application/classes/modules/user/entity/Changemail.entity.class.php
new file mode 100644
index 0000000..9ee896e
--- /dev/null
+++ b/application/classes/modules/user/entity/Changemail.entity.class.php
@@ -0,0 +1,31 @@
+
+ *
+ */
+
+/**
+ * Сущность смены емайла пользователем
+ *
+ * @package application.modules.user
+ * @since 1.0
+ */
+class ModuleUser_EntityChangemail extends Entity
+{
+
+}
\ No newline at end of file
diff --git a/application/classes/modules/user/entity/Complaint.entity.class.php b/application/classes/modules/user/entity/Complaint.entity.class.php
new file mode 100644
index 0000000..e919da1
--- /dev/null
+++ b/application/classes/modules/user/entity/Complaint.entity.class.php
@@ -0,0 +1,112 @@
+
+ *
+ */
+
+/**
+ * Сущность жалобы о пользователе
+ *
+ * @package application.modules.user
+ * @since 2.0
+ */
+class ModuleUser_EntityComplaint extends Entity
+{
+ /**
+ * Определяем правила валидации
+ *
+ * @var array
+ */
+ protected $aValidateRules = array(
+ array('target_user_id', 'target'),
+ array('type', 'type'),
+ );
+
+ /**
+ * Инициализация
+ */
+ public function Init()
+ {
+ parent::Init();
+ $this->aValidateRules[] = array(
+ 'text',
+ 'string',
+ 'max' => Config::Get('module.user.complaint_text_max'),
+ 'min' => 1,
+ 'allowEmpty' => !Config::Get('module.user.complaint_text_required'),
+ 'label' => $this->Lang_Get('user_complaint_text_title')
+ );
+ if (Config::Get('module.user.complaint_captcha')) {
+ $sCaptchaValidateType = func_camelize('captcha_' . Config::Get('sys.captcha.type'));
+ $this->aValidateRules[] = array('captcha', $sCaptchaValidateType, 'name' => 'complaint_user');
+ }
+ }
+
+ /**
+ * Валидация пользователя
+ *
+ * @param string $sValue Значение
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function ValidateTarget($sValue, $aParams)
+ {
+ if ($oUserTarget = $this->User_GetUserById($sValue) and $this->getUserId() != $oUserTarget->getId()) {
+ return true;
+ }
+ return $this->Lang_Get('report.notices.target_error');
+ }
+
+ /**
+ * Валидация типа жалобы
+ *
+ * @param string $sValue Значение
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function ValidateType($sValue, $aParams)
+ {
+ $aTypes = (array)Config::Get('module.user.complaint_type');
+ if (in_array($sValue, $aTypes)) {
+ return true;
+ }
+ return $this->Lang_Get('report.notices.type_error');
+ }
+
+
+ public function getUser()
+ {
+ if (!$this->_getDataOne('user')) {
+ $this->_aData['user'] = $this->User_GetUserById($this->getUserId());
+ }
+ return $this->_getDataOne('user');
+ }
+
+ public function getTargetUser()
+ {
+ if (!$this->_getDataOne('target_user')) {
+ $this->_aData['target_user'] = $this->User_GetUserById($this->getTargetUserId());
+ }
+ return $this->_getDataOne('target_user');
+ }
+
+ public function getTypeTitle()
+ {
+ return $this->Lang_Get('user.report.types.' . $this->getType());
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/user/entity/Field.entity.class.php b/application/classes/modules/user/entity/Field.entity.class.php
new file mode 100644
index 0000000..3507fe0
--- /dev/null
+++ b/application/classes/modules/user/entity/Field.entity.class.php
@@ -0,0 +1,175 @@
+
+ *
+ */
+
+/**
+ * Сущность пользовательского поля у пользователя
+ *
+ * @package application.modules.user
+ * @since 1.0
+ */
+class ModuleUser_EntityField extends Entity
+{
+ /**
+ * Возвращает ID поля
+ *
+ * @return int|null
+ */
+ public function getId()
+ {
+ return $this->_getDataOne('id');
+ }
+
+ /**
+ * Возвращает имя поля(уникальное)
+ *
+ * @return string|null
+ */
+ public function getName()
+ {
+ return $this->_getDataOne('name');
+ }
+
+ /**
+ * Возвращает тип поля
+ *
+ * @return string|null
+ */
+ public function getType()
+ {
+ return $this->_getDataOne('type');
+ }
+
+ /**
+ * Возвращает заголовок/описание поля
+ *
+ * @return string|null
+ */
+ public function getTitle()
+ {
+ return $this->_getDataOne('title');
+ }
+
+ /**
+ * Возвращает паттерн подстановки поля
+ *
+ * @return string|null
+ */
+ public function getPattern()
+ {
+ return $this->_getDataOne('pattern');
+ }
+
+ /**
+ * Возвращает значение поля у пользователя
+ *
+ * @param bool $bEscapeValue Экранировать значение
+ * @param bool $bTransformed Применять паттерн или нет
+ * @return string
+ */
+ public function getValue($bEscapeValue = false, $bTransformed = false)
+ {
+ if (!isset($this->_aData['value']) || !$this->_aData['value']) {
+ return '';
+ }
+ if ($bEscapeValue) {
+ $this->_aData['value'] = htmlspecialchars($this->_aData['value']);
+ }
+
+ if ($bTransformed) {
+ if (!$this->_aData['pattern']) {
+ return $this->_aData['value'];
+ }
+ $sReturn = str_replace('{*}', $this->_aData['value'], $this->_aData['pattern']);
+ /**
+ * Грязный хак сайта в профиле (
+ * @todo Сделать валидацию полей в профиле
+ */
+ if ($this->getName() == 'www') {
+ $sReturn = str_replace(array('http://http://', 'http://https://'), array('http://', 'https://'),
+ $sReturn);
+ }
+ return $sReturn;
+ } else {
+ return (isset($this->_aData['value'])) ? $this->_aData['value'] : '';
+ }
+ }
+
+
+ /**
+ * Устанавливает ID поля
+ *
+ * @param int $iId
+ */
+ public function setId($iId)
+ {
+ $this->_aData['id'] = $iId;
+ }
+
+ /**
+ * Устанавливает имя поля(уникальное)
+ *
+ * @param string $sName
+ */
+ public function setName($sName)
+ {
+ $this->_aData['name'] = $sName;
+ }
+
+ /**
+ * Устанавливает тип поля
+ *
+ * @param string $sName
+ */
+ public function setType($sName)
+ {
+ $this->_aData['type'] = $sName;
+ }
+
+ /**
+ * Устанавливает заголовок/описание поля
+ *
+ * @param string $sTitle
+ */
+ public function setTitle($sTitle)
+ {
+ $this->_aData['title'] = $sTitle;
+ }
+
+ /**
+ * Устанавливает паттерн подстановки поля
+ *
+ * @param string $sPattern
+ */
+ public function setPattern($sPattern)
+ {
+ $this->_aData['pattern'] = $sPattern;
+ }
+
+ /**
+ * Устанавливает значение поля у пользователя
+ *
+ * @param string $sValue
+ */
+ public function setValue($sValue)
+ {
+ $this->_aData['value'] = $sValue;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/user/entity/Friend.entity.class.php b/application/classes/modules/user/entity/Friend.entity.class.php
new file mode 100644
index 0000000..12acb4b
--- /dev/null
+++ b/application/classes/modules/user/entity/Friend.entity.class.php
@@ -0,0 +1,198 @@
+
+ *
+ */
+
+/**
+ * Сущность дружбу - связи пользователей друг с другом
+ *
+ * @package application.modules.user
+ * @since 1.0
+ */
+class ModuleUser_EntityFriend extends Entity
+{
+ /**
+ * При переданном параметре $sUserId возвращает тот идентификатор,
+ * который не равен переданному
+ *
+ * @param string|null $sUserId ID пользователя
+ * @return string
+ */
+ public function getFriendId($sUserId = null)
+ {
+ if (!$sUserId) {
+ $sUserId = $this->getUserId();
+ }
+ if ($this->_getDataOne('user_from') == $sUserId) {
+ return $this->_aData['user_to'];
+ }
+ if ($this->_getDataOne('user_to') == $sUserId) {
+ return $this->_aData['user_from'];
+ }
+ return false;
+ }
+
+ /**
+ * Получает идентификатор пользователя,
+ * относительно которого был сделан запрос
+ *
+ * @return int
+ */
+ public function getUserId()
+ {
+ return $this->_getDataOne('user');
+ }
+
+ /**
+ * Возвращает ID пользователя, который приглашает в друзья
+ *
+ * @return int|null
+ */
+ public function getUserFrom()
+ {
+ return $this->_getDataOne('user_from');
+ }
+
+ /**
+ * Возвращает ID пользователя, которого пришлашаем в друзья
+ *
+ * @return int|null
+ */
+ public function getUserTo()
+ {
+ return $this->_getDataOne('user_to');
+ }
+
+ /**
+ * Возвращает статус заявки на добавления в друзья у отправителя
+ *
+ * @return int|null
+ */
+ public function getStatusFrom()
+ {
+ return $this->_getDataOne('status_from');
+ }
+
+ /**
+ * Возвращает статус заявки на добавления в друзья у получателя
+ *
+ * @return int|null
+ */
+ public function getStatusTo()
+ {
+ return $this->_getDataOne('status_to') ? $this->_getDataOne('status_to') : ModuleUser::USER_FRIEND_NULL;
+ }
+
+ /**
+ * Возвращает статус дружбы
+ *
+ * @return int|null
+ */
+ public function getFriendStatus()
+ {
+ return $this->getStatusFrom() + $this->getStatusTo();
+ }
+
+ /**
+ * Возвращает статус дружбы для конкретного пользователя
+ *
+ * @param int $sUserId ID пользователя
+ * @return bool|int
+ */
+ public function getStatusByUserId($sUserId)
+ {
+ if ($sUserId == $this->getUserFrom()) {
+ return $this->getStatusFrom();
+ }
+ if ($sUserId == $this->getUserTo()) {
+ return $this->getStatusTo();
+ }
+ return false;
+ }
+
+ /**
+ * Устанавливает ID пользователя, который приглашает в друзья
+ *
+ * @param int $data
+ */
+ public function setUserFrom($data)
+ {
+ $this->_aData['user_from'] = $data;
+ }
+
+ /**
+ * Устанавливает ID пользователя, которого пришлашаем в друзья
+ *
+ * @param int $data
+ */
+ public function setUserTo($data)
+ {
+ $this->_aData['user_to'] = $data;
+ }
+
+ /**
+ * Устанавливает статус заявки на добавления в друзья у отправителя
+ *
+ * @param int $data
+ */
+ public function setStatusFrom($data)
+ {
+ $this->_aData['status_from'] = $data;
+ }
+
+ /**
+ * Возвращает статус заявки на добавления в друзья у получателя
+ *
+ * @param int $data
+ */
+ public function setStatusTo($data)
+ {
+ $this->_aData['status_to'] = $data;
+ }
+
+ /**
+ * Устанавливает ID пользователя
+ *
+ * @param int $data
+ */
+ public function setUserId($data)
+ {
+ $this->_aData['user'] = $data;
+ }
+
+ /**
+ * Возвращает статус дружбы для конкретного пользователя
+ *
+ * @param int $data Статус
+ * @param int $sUserId ID пользователя
+ * @return bool
+ */
+ public function setStatusByUserId($data, $sUserId)
+ {
+ if ($sUserId == $this->getUserFrom()) {
+ $this->setStatusFrom($data);
+ return true;
+ }
+ if ($sUserId == $this->getUserTo()) {
+ $this->setStatusTo($data);
+ return true;
+ }
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/user/entity/Note.entity.class.php b/application/classes/modules/user/entity/Note.entity.class.php
new file mode 100644
index 0000000..6ff7632
--- /dev/null
+++ b/application/classes/modules/user/entity/Note.entity.class.php
@@ -0,0 +1,68 @@
+
+ *
+ */
+
+/**
+ * Сущность заметки о пользователе
+ *
+ * @package application.modules.user
+ * @since 1.0
+ */
+class ModuleUser_EntityNote extends Entity
+{
+ /**
+ * Определяем правила валидации
+ *
+ * @var array
+ */
+ protected $aValidateRules = array(
+ array('target_user_id', 'target'),
+ );
+
+ /**
+ * Инициализация
+ */
+ public function Init()
+ {
+ parent::Init();
+ $this->aValidateRules[] = array(
+ 'text',
+ 'string',
+ 'max' => Config::Get('module.user.usernote_text_max'),
+ 'min' => 1,
+ 'allowEmpty' => false
+ );
+ }
+
+ /**
+ * Валидация пользователя
+ *
+ * @param string $sValue Значение
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function ValidateTarget($sValue, $aParams)
+ {
+ if ($oUserTarget = $this->User_GetUserById($sValue) and $this->getUserId() != $oUserTarget->getId()) {
+ return true;
+ }
+ return $this->Lang_Get('user_note.notices.target_error');
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/user/entity/Reminder.entity.class.php b/application/classes/modules/user/entity/Reminder.entity.class.php
new file mode 100644
index 0000000..160d619
--- /dev/null
+++ b/application/classes/modules/user/entity/Reminder.entity.class.php
@@ -0,0 +1,149 @@
+
+ *
+ */
+
+/**
+ * Сущность восстановления пароля
+ *
+ * @package application.modules.user
+ * @since 1.0
+ */
+class ModuleUser_EntityReminder extends Entity
+{
+ /**
+ * Возвращает код восстановления
+ *
+ * @return string|null
+ */
+ public function getCode()
+ {
+ return $this->_getDataOne('reminder_code');
+ }
+
+ /**
+ * Возвращает ID пользователя
+ *
+ * @return int|null
+ */
+ public function getUserId()
+ {
+ return $this->_getDataOne('user_id');
+ }
+
+ /**
+ * Возвращает дату создания
+ *
+ * @return string|null
+ */
+ public function getDateAdd()
+ {
+ return $this->_getDataOne('reminder_date_add');
+ }
+
+ /**
+ * Возвращает дату использования
+ *
+ * @return string|null
+ */
+ public function getDateUsed()
+ {
+ return $this->_getDataOne('reminder_date_used');
+ }
+
+ /**
+ * Возвращает дату завершения срока действия кода
+ *
+ * @return string|null
+ */
+ public function getDateExpire()
+ {
+ return $this->_getDataOne('reminder_date_expire');
+ }
+
+ /**
+ * Возвращает статус использованости кода
+ *
+ * @return int|null
+ */
+ public function getIsUsed()
+ {
+ return $this->_getDataOne('reminde_is_used');
+ }
+
+ /**
+ * Устанавливает код восстановления
+ *
+ * @param string $data
+ */
+ public function setCode($data)
+ {
+ $this->_aData['reminder_code'] = $data;
+ }
+
+ /**
+ * Устанавливает ID пользователя
+ *
+ * @param int $data
+ */
+ public function setUserId($data)
+ {
+ $this->_aData['user_id'] = $data;
+ }
+
+ /**
+ * Устанавливает дату создания
+ *
+ * @param string $data
+ */
+ public function setDateAdd($data)
+ {
+ $this->_aData['reminder_date_add'] = $data;
+ }
+
+ /**
+ * Устанавливает дату использования
+ *
+ * @param string $data
+ */
+ public function setDateUsed($data)
+ {
+ $this->_aData['reminder_date_used'] = $data;
+ }
+
+ /**
+ * Устанавливает дату завершения срока действия кода
+ *
+ * @param string $data
+ */
+ public function setDateExpire($data)
+ {
+ $this->_aData['reminder_date_expire'] = $data;
+ }
+
+ /**
+ * Устанавливает статус использованости кода
+ *
+ * @param int $data
+ */
+ public function setIsUsed($data)
+ {
+ $this->_aData['reminde_is_used'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/user/entity/Session.entity.class.php b/application/classes/modules/user/entity/Session.entity.class.php
new file mode 100644
index 0000000..da39b8f
--- /dev/null
+++ b/application/classes/modules/user/entity/Session.entity.class.php
@@ -0,0 +1,234 @@
+
+ *
+ */
+
+/**
+ * Сущность сессии
+ *
+ * @package application.modules.user
+ * @since 1.0
+ */
+class ModuleUser_EntitySession extends Entity
+{
+ /**
+ * Возвращает ключ сессии
+ *
+ * @return string|null
+ */
+ public function getKey()
+ {
+ return $this->_getDataOne('session_key');
+ }
+
+ /**
+ * Возвращает ID пользователя
+ *
+ * @return int|null
+ */
+ public function getUserId()
+ {
+ return $this->_getDataOne('user_id');
+ }
+
+ /**
+ * Возвращает IP создания сессии
+ *
+ * @return string|null
+ */
+ public function getIpCreate()
+ {
+ return $this->_getDataOne('session_ip_create');
+ }
+
+ /**
+ * Возвращает последний IP сессии
+ *
+ * @return string|null
+ */
+ public function getIpLast()
+ {
+ return $this->_getDataOne('session_ip_last');
+ }
+
+ /**
+ * Возвращает дату создания сессии
+ *
+ * @return string|null
+ */
+ public function getDateCreate()
+ {
+ return $this->_getDataOne('session_date_create');
+ }
+
+ /**
+ * Возвращает последную дату сессии
+ *
+ * @return string|null
+ */
+ public function getDateLast()
+ {
+ return $this->_getDataOne('session_date_last');
+ }
+
+ /**
+ * Возвращает дату закрытия сессии
+ *
+ * @return string|null
+ */
+ public function getDateClose()
+ {
+ return $this->_getDataOne('session_date_close');
+ }
+
+ /**
+ * Возвращает дополнительные данные
+ *
+ * @return string|null
+ */
+ public function getExtra()
+ {
+ return $this->_getDataOne('session_extra');
+ }
+
+ /**
+ * Проверяет факт активности сессии
+ *
+ * @return bool
+ */
+ public function isActive()
+ {
+ if ($this->getDateClose()) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Возвращает параметр по имени
+ *
+ * @param $sName
+ * @return null
+ */
+ public function getExtraParam($sName)
+ {
+ if ($sExtra = $this->getExtra() and $aData = @unserialize($sExtra)) {
+ if (isset($aData[$sName])) {
+ return $aData[$sName];
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Устанавливает параметр по имени
+ *
+ * @param $sName
+ * @param $mValue
+ */
+ public function setExtraParam($sName, $mValue)
+ {
+ if (!($sExtra = $this->getExtra() and $aData = @unserialize($sExtra))) {
+ $aData = array();
+ }
+ $aData[$sName] = $mValue;
+ $this->setExtra(serialize($aData));
+ }
+
+
+ /**
+ * Устанавливает ключ сессии
+ *
+ * @param string $data
+ */
+ public function setKey($data)
+ {
+ $this->_aData['session_key'] = $data;
+ }
+
+ /**
+ * Устанавливает ID пользователя
+ *
+ * @param int $data
+ */
+ public function setUserId($data)
+ {
+ $this->_aData['user_id'] = $data;
+ }
+
+ /**
+ * Устанавливает IP создания сессии
+ *
+ * @param string $data
+ */
+ public function setIpCreate($data)
+ {
+ $this->_aData['session_ip_create'] = $data;
+ }
+
+ /**
+ * Устанавливает последний IP сессии
+ *
+ * @param string $data
+ */
+ public function setIpLast($data)
+ {
+ $this->_aData['session_ip_last'] = $data;
+ }
+
+ /**
+ * Устанавливает дату создания сессии
+ *
+ * @param string $data
+ */
+ public function setDateCreate($data)
+ {
+ $this->_aData['session_date_create'] = $data;
+ }
+
+ /**
+ * Устанавливает последную дату сессии
+ *
+ * @param string $data
+ */
+ public function setDateLast($data)
+ {
+ $this->_aData['session_date_last'] = $data;
+ }
+
+ /**
+ * Устанавливает дату закрытия сессии
+ *
+ * @param string $data
+ */
+ public function setDateClose($data)
+ {
+ $this->_aData['session_date_close'] = $data;
+ }
+
+ /**
+ * Устанавливает дополнительные данные
+ *
+ * @param string $data
+ */
+ public function setExtra($data)
+ {
+ $this->_aData['session_extra'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/user/entity/User.entity.class.php b/application/classes/modules/user/entity/User.entity.class.php
new file mode 100644
index 0000000..9536597
--- /dev/null
+++ b/application/classes/modules/user/entity/User.entity.class.php
@@ -0,0 +1,990 @@
+
+ *
+ */
+
+/**
+ * Сущность пользователя
+ *
+ * @package application.modules.user
+ * @since 1.0
+ */
+class ModuleUser_EntityUser extends Entity
+{
+ /**
+ * Определяем правила валидации
+ *
+ * @var array
+ */
+ protected $aValidateRules = array(
+ array('login', 'login', 'on' => array('registration', '')), // '' - означает дефолтный сценарий
+ array('login', 'login_exists', 'on' => array('registration')),
+ array('mail', 'email', 'allowEmpty' => false, 'on' => array('registration', '')),
+ array('mail', 'mail_exists', 'on' => array('registration')),
+ );
+
+ /**
+ * Определяем дополнительные правила валидации
+ *
+ * @param array|bool $aParam
+ */
+ public function __construct($aParam = false)
+ {
+ $this->aValidateRules[] = array(
+ 'password',
+ 'string',
+ 'allowEmpty' => false,
+ 'min' => 5,
+ 'on' => array('registration'),
+ 'label' => $this->Lang_Get('auth.labels.password')
+ );
+ $this->aValidateRules[] = array(
+ 'password_confirm',
+ 'compare',
+ 'compareField' => 'password',
+ 'on' => array('registration'),
+ 'label' => $this->Lang_Get('auth.registration.form.fields.password_confirm.label')
+ );
+
+ $sCaptchaValidateType = func_camelize('captcha_' . Config::Get('sys.captcha.type'));
+ if (Config::Get('module.user.captcha_use_registration')) {
+ $this->aValidateRules[] = array(
+ 'captcha',
+ $sCaptchaValidateType,
+ 'name' => 'user_signup',
+ 'on' => array('registration'),
+ 'label' => $this->Lang_Get('auth.labels.captcha_field')
+ );
+ }
+
+ if (Config::Get('general.login.captcha')) {
+ $this->aValidateRules[] = array(
+ 'captcha',
+ $sCaptchaValidateType,
+ 'name' => 'user_auth',
+ 'on' => array('signIn'),
+ 'label' => $this->Lang_Get('auth.labels.captcha_field')
+ );
+ }
+
+ parent::__construct($aParam);
+ }
+
+ /**
+ * Валидация пользователя
+ *
+ * @param string $sValue Валидируемое значение
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function ValidateLogin($sValue, $aParams)
+ {
+ if ($this->User_CheckLogin($sValue)) {
+ return true;
+ }
+ return $this->Lang_Get('auth.registration.notices.error_login');
+ }
+
+ /**
+ * Проверка логина на существование
+ *
+ * @param string $sValue Валидируемое значение
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function ValidateLoginExists($sValue, $aParams)
+ {
+ if ($oUserOld = $this->User_GetUserByLogin($sValue) and $oUserOld->getId() != $this->getId()) {
+ return $this->Lang_Get('auth.registration.notices.error_login_used');
+ }
+ return true;
+ }
+
+ /**
+ * Проверка емайла на существование
+ *
+ * @param string $sValue Валидируемое значение
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function ValidateMailExists($sValue, $aParams)
+ {
+ if ($oUserOld = $this->User_GetUserByMail($sValue) and $oUserOld->getId() != $this->getId()) {
+ return $this->Lang_Get('auth.registration.notices.error_mail_used');
+ }
+ return true;
+ }
+
+ /**
+ * Возвращает ID пользователя
+ *
+ * @return int|null
+ */
+ public function getId()
+ {
+ return $this->_getDataOne('user_id');
+ }
+
+ /**
+ * Возвращает логин
+ *
+ * @return string|null
+ */
+ public function getLogin()
+ {
+ return $this->_getDataOne('user_login');
+ }
+
+ /**
+ * Возвращает пароль (ввиде хеша)
+ *
+ * @return string|null
+ */
+ public function getPassword()
+ {
+ return $this->_getDataOne('user_password');
+ }
+
+ /**
+ * Возвращает емайл
+ *
+ * @return string|null
+ */
+ public function getMail()
+ {
+ return $this->_getDataOne('user_mail');
+ }
+
+ /**
+ * Возвращает флаг админа
+ *
+ * @return int|null
+ */
+ public function getAdmin()
+ {
+ return $this->_getDataOne('user_admin');
+ }
+
+ /**
+ * Возвращает дату регистрации
+ *
+ * @return string|null
+ */
+ public function getDateRegister()
+ {
+ return $this->_getDataOne('user_date_register');
+ }
+
+ /**
+ * Возвращает дату активации
+ *
+ * @return string|null
+ */
+ public function getDateActivate()
+ {
+ return $this->_getDataOne('user_date_activate');
+ }
+
+ /**
+ * Возвращает дату последнего комментирования
+ *
+ * @return mixed|null
+ */
+ public function getDateCommentLast()
+ {
+ return $this->_getDataOne('user_date_comment_last');
+ }
+
+ /**
+ * Возвращает IP регистрации
+ *
+ * @return string|null
+ */
+ public function getIpRegister()
+ {
+ return $this->_getDataOne('user_ip_register');
+ }
+
+ /**
+ * Возвращает рейтинг
+ *
+ * @return string
+ */
+ public function getRating()
+ {
+ return number_format(round($this->_getDataOne('user_rating'), 2), 2, '.', '');
+ }
+
+ /**
+ * Вовзращает количество проголосовавших
+ *
+ * @return int|null
+ */
+ public function getCountVote()
+ {
+ return $this->_getDataOne('user_count_vote');
+ }
+
+ /**
+ * Возвращает статус активированности
+ *
+ * @return int|null
+ */
+ public function getActivate()
+ {
+ return $this->_getDataOne('user_activate');
+ }
+
+ /**
+ * Возвращает ключ активации
+ *
+ * @return string|null
+ */
+ public function getActivateKey()
+ {
+ return $this->_getDataOne('user_activate_key');
+ }
+
+ /**
+ * Возвращает реферальный код
+ *
+ * @return string|null
+ */
+ public function getReferralCode()
+ {
+ return $this->_getDataOne('user_referral_code');
+ }
+
+ /**
+ * Возвращает имя
+ *
+ * @return string|null
+ */
+ public function getProfileName()
+ {
+ return $this->_getDataOne('user_profile_name');
+ }
+
+ /**
+ * Возвращает пол
+ *
+ * @return string|null
+ */
+ public function getProfileSex()
+ {
+ return $this->_getDataOne('user_profile_sex');
+ }
+
+ /**
+ * Возвращает название страны
+ *
+ * @return string|null
+ */
+ public function getProfileCountry()
+ {
+ return $this->_getDataOne('user_profile_country');
+ }
+
+ /**
+ * Возвращает название региона
+ *
+ * @return string|null
+ */
+ public function getProfileRegion()
+ {
+ return $this->_getDataOne('user_profile_region');
+ }
+
+ /**
+ * Возвращает название города
+ *
+ * @return string|null
+ */
+ public function getProfileCity()
+ {
+ return $this->_getDataOne('user_profile_city');
+ }
+
+ /**
+ * Возвращает дату рождения
+ *
+ * @return string|null
+ */
+ public function getProfileBirthday()
+ {
+ return $this->_getDataOne('user_profile_birthday');
+ }
+
+ /**
+ * Возвращает информацию о себе
+ *
+ * @return string|null
+ */
+ public function getProfileAbout()
+ {
+ return $this->_getDataOne('user_profile_about');
+ }
+
+ /**
+ * Возвращает дату редактирования профиля
+ *
+ * @return string|null
+ */
+ public function getProfileDate()
+ {
+ return $this->_getDataOne('user_profile_date');
+ }
+
+ /**
+ * Возвращает полный веб путь до аватра
+ *
+ * @return string|null
+ */
+ public function getProfileAvatar()
+ {
+ return $this->_getDataOne('user_profile_avatar');
+ }
+
+ /**
+ * Возвращает расширение автара
+ *
+ * @return string|null
+ */
+ public function getProfileAvatarType()
+ {
+ return ($sPath = $this->getProfileAvatarPath()) ? pathinfo($sPath, PATHINFO_EXTENSION) : null;
+ }
+
+ /**
+ * Возвращает полный веб путь до фото
+ *
+ * @return string|null
+ */
+ public function getProfileFoto()
+ {
+ return $this->_getDataOne('user_profile_foto');
+ }
+
+ /**
+ * Возвращает статус уведомления о новых топиках
+ *
+ * @return int|null
+ */
+ public function getSettingsNoticeNewTopic()
+ {
+ return $this->_getDataOne('user_settings_notice_new_topic');
+ }
+
+ /**
+ * Возвращает статус уведомления о новых комментариях
+ *
+ * @return int|null
+ */
+ public function getSettingsNoticeNewComment()
+ {
+ return $this->_getDataOne('user_settings_notice_new_comment');
+ }
+
+ /**
+ * Возвращает статус уведомления о новых письмах
+ *
+ * @return int|null
+ */
+ public function getSettingsNoticeNewTalk()
+ {
+ return $this->_getDataOne('user_settings_notice_new_talk');
+ }
+
+ /**
+ * Возвращает статус уведомления о новых ответах в комментариях
+ *
+ * @return int|null
+ */
+ public function getSettingsNoticeReplyComment()
+ {
+ return $this->_getDataOne('user_settings_notice_reply_comment');
+ }
+
+ /**
+ * Возвращает статус уведомления о новых друзьях
+ *
+ * @return int|null
+ */
+ public function getSettingsNoticeNewFriend()
+ {
+ return $this->_getDataOne('user_settings_notice_new_friend');
+ }
+
+ /**
+ * Возвращает значения пользовательских полей
+ *
+ * @param bool $bOnlyNoEmpty Возвращать или нет только не пустые
+ * @param string $sType Тип полей
+ * @return array
+ */
+ public function getUserFieldValues($bOnlyNoEmpty = true, $sType = '')
+ {
+ return $this->User_getUserFieldsValues($this->getId(), $bOnlyNoEmpty, $sType);
+ }
+
+ /**
+ * Возвращает объект сессии
+ *
+ * @return ModuleUser_EntitySession|null
+ */
+ public function getSession()
+ {
+ if (!$this->_getDataOne('session')) {
+ $this->_aData['session'] = $this->User_GetSessionByUserId($this->getId());
+ }
+ return $this->_getDataOne('session');
+ }
+
+ /**
+ * Возвращает статус онлайн пользователь или нет
+ *
+ * @return bool
+ */
+ public function isOnline()
+ {
+ if ($oSession = $this->getSession()) {
+ if (time() - strtotime($oSession->getDateLast()) < Config::Get('module.user.time_onlive')) { // 10 минут
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает полный веб путь до аватара нужного размера
+ *
+ * @param int $iSize Размер
+ * @return string
+ */
+ public function getProfileAvatarPath($iSize = 100)
+ {
+ if (is_numeric($iSize)) {
+ $iSize .= 'crop';
+ }
+
+ if ($this->getProfileAvatar()) {
+ return $this->Media_GetImageWebPath($this->getProfileAvatar(), $iSize) . '?' . date('His', strtotime($this->getProfileDate()));
+ } else {
+ return $this->Media_GetImagePathBySize(Router::GetFixPathWeb(Config::Get('path.skin.assets.web')) . '/images/avatars/avatar_' . ($this->getProfileSex() == 'woman' ? 'female' : 'male') . '.png',
+ $iSize);
+ }
+ }
+
+ /**
+ * Формирует массив с путями до аватаров
+ *
+ * @param object $oUser Пользователь
+ * @return array Массив с путями до аватаров
+ */
+ public function getProfileAvatarsPath()
+ {
+ $aAvatars = array();
+
+ foreach (Config::Get('module.user.avatar_size') as $sSize) {
+ $aAvatars[$sSize] = $this->getProfileAvatarPath($sSize);
+ }
+
+ return $aAvatars;
+ }
+
+ /**
+ * Возвращает полный веб путь до фото
+ *
+ * @return null|string
+ */
+ public function getProfileFotoPath()
+ {
+ if ($this->getProfileFoto()) {
+ return $this->Media_GetImageWebPath($this->getProfileFoto());
+ }
+ return $this->getProfileFotoDefault();
+ }
+
+ /**
+ * Возвращает дефолтную фото
+ *
+ * @return string
+ */
+ public function getProfileFotoDefault()
+ {
+ return Router::GetFixPathWeb(Config::Get('path.skin.assets.web')) . '/images/avatars/user_photo_' . ($this->getProfileSex() == 'woman' ? 'female' : 'male') . '.png';
+ }
+
+ /**
+ * Возвращает объект голосования за пользователя текущего пользователя
+ *
+ * @return ModuleVote_EntityVote|null
+ */
+ public function getVote()
+ {
+ return $this->_getDataOne('vote');
+ }
+
+ /**
+ * Возвращает статус дружбы
+ *
+ * @return bool|null
+ */
+ public function getUserIsFriend()
+ {
+ return $this->_getDataOne('user_is_friend');
+ }
+
+ /**
+ * Возвращает статус администратора сайта
+ *
+ * @return bool
+ */
+ public function isAdministrator()
+ {
+ return (bool)$this->getAdmin();
+ }
+
+ /**
+ * Возвращает веб путь до профиля пользователя
+ *
+ * @return string
+ */
+ public function getUserWebPath()
+ {
+ return Router::GetPath('profile') . $this->getLogin() . '/';
+ }
+
+ /**
+ * Возвращает объект дружбы с текущим пользователем
+ *
+ * @return ModuleUser_EntityFriend|null
+ */
+ public function getUserFriend()
+ {
+ return $this->_getDataOne('user_friend');
+ }
+
+ /**
+ * Проверяет подписан ли текущий пользователь на этого
+ *
+ * @return bool
+ */
+ public function isFollow()
+ {
+ if ($oUserCurrent = $this->User_GetUserCurrent()) {
+ return $this->Stream_IsSubscribe($oUserCurrent->getId(), $this->getId());
+ }
+ }
+
+ /**
+ * Возвращает объект заметки о подльзователе, которую оставил текущий пользователй
+ *
+ * @return ModuleUser_EntityNote|null
+ */
+ public function getUserNote()
+ {
+ $oUserCurrent = $this->User_GetUserCurrent();
+ if ($this->_getDataOne('user_note') === null and $oUserCurrent) {
+ $this->_aData['user_note'] = $this->User_GetUserNote($this->getId(), $oUserCurrent->getId());
+ }
+ return $this->_getDataOne('user_note');
+ }
+
+ /**
+ * Возвращает имя пользователя для отображения на сайте
+ * В дефолте логин пользователя
+ *
+ * @return null|string
+ */
+ public function getDisplayName()
+ {
+ return $this->getLogin();
+ }
+
+ /**
+ * Проверяем возможность редактирования пользователя текущим юзером
+ *
+ * @return bool
+ */
+ public function isAllowEdit()
+ {
+ if ($oUser = $this->User_GetUserCurrent()) {
+ if ($oUser->getId() == $this->getId() or $oUser->isAdministrator()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Проверка на возможность создания блога юзером
+ *
+ * @return bool
+ */
+ public function isAllowCreateBlog()
+ {
+ if ($this->isAdministrator() or $this->getRating() >= Config::Get('acl.create.blog.rating')) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверка пароля
+ *
+ * @param $sPassword
+ * @return string
+ */
+ public function verifyPassword($sPassword)
+ {
+ return $this->User_VerifyPassword($sPassword, $this->getPassword());
+ }
+
+
+ /**
+ * Устанавливает ID пользователя
+ *
+ * @param int $data
+ */
+ public function setId($data)
+ {
+ $this->_aData['user_id'] = $data;
+ }
+
+ /**
+ * Устанавливает логин
+ *
+ * @param string $data
+ */
+ public function setLogin($data)
+ {
+ $this->_aData['user_login'] = $data;
+ }
+
+ /**
+ * Устанавливает пароль (ввиде хеша)
+ *
+ * @param string $data
+ */
+ public function setPassword($data)
+ {
+ $this->_aData['user_password'] = $data;
+ }
+
+ /**
+ * Устанавливает емайл
+ *
+ * @param string $data
+ */
+ public function setMail($data)
+ {
+ $this->_aData['user_mail'] = $data;
+ }
+
+ /**
+ * Устанавливает флаг админа
+ *
+ * @param string $data
+ */
+ public function setAdmin($data)
+ {
+ $this->_aData['user_admin'] = $data;
+ }
+
+ /**
+ * Устанавливает дату регистрации
+ *
+ * @param string $data
+ */
+ public function setDateRegister($data)
+ {
+ $this->_aData['user_date_register'] = $data;
+ }
+
+ /**
+ * Устанавливает дату активации
+ *
+ * @param string $data
+ */
+ public function setDateActivate($data)
+ {
+ $this->_aData['user_date_activate'] = $data;
+ }
+
+ /**
+ * Устанавливает дату последнего комментирования
+ *
+ * @param string $data
+ */
+ public function setDateCommentLast($data)
+ {
+ $this->_aData['user_date_comment_last'] = $data;
+ }
+
+ /**
+ * Устанавливает IP регистрации
+ *
+ * @param string $data
+ */
+ public function setIpRegister($data)
+ {
+ $this->_aData['user_ip_register'] = $data;
+ }
+
+ /**
+ * Устанавливает рейтинг
+ *
+ * @param float $data
+ */
+ public function setRating($data)
+ {
+ $this->_aData['user_rating'] = $data;
+ }
+
+ /**
+ * Устанавливает количество проголосовавших
+ *
+ * @param int $data
+ */
+ public function setCountVote($data)
+ {
+ $this->_aData['user_count_vote'] = $data;
+ }
+
+ /**
+ * Устанавливает статус активированности
+ *
+ * @param int $data
+ */
+ public function setActivate($data)
+ {
+ $this->_aData['user_activate'] = $data;
+ }
+
+ /**
+ * Устанавливает ключ активации
+ *
+ * @param string $data
+ */
+ public function setActivateKey($data)
+ {
+ $this->_aData['user_activate_key'] = $data;
+ }
+
+ /**
+ * Устанавливает реферальный код
+ *
+ * @param string $data
+ */
+ public function setReferralCode($data)
+ {
+ $this->_aData['user_referral_code'] = $data;
+ }
+
+ /**
+ * Устанавливает имя
+ *
+ * @param string $data
+ */
+ public function setProfileName($data)
+ {
+ $this->_aData['user_profile_name'] = $data;
+ }
+
+ /**
+ * Устанавливает пол
+ *
+ * @param string $data
+ */
+ public function setProfileSex($data)
+ {
+ $this->_aData['user_profile_sex'] = $data;
+ }
+
+ /**
+ * Устанавливает название страны
+ *
+ * @param string $data
+ */
+ public function setProfileCountry($data)
+ {
+ $this->_aData['user_profile_country'] = $data;
+ }
+
+ /**
+ * Устанавливает название региона
+ *
+ * @param string $data
+ */
+ public function setProfileRegion($data)
+ {
+ $this->_aData['user_profile_region'] = $data;
+ }
+
+ /**
+ * Устанавливает название города
+ *
+ * @param string $data
+ */
+ public function setProfileCity($data)
+ {
+ $this->_aData['user_profile_city'] = $data;
+ }
+
+ /**
+ * Устанавливает дату рождения
+ *
+ * @param string $data
+ */
+ public function setProfileBirthday($data)
+ {
+ $this->_aData['user_profile_birthday'] = $data;
+ }
+
+ /**
+ * Устанавливает информацию о себе
+ *
+ * @param string $data
+ */
+ public function setProfileAbout($data)
+ {
+ $this->_aData['user_profile_about'] = $data;
+ }
+
+ /**
+ * Устанавливает дату редактирования профиля
+ *
+ * @param string $data
+ */
+ public function setProfileDate($data)
+ {
+ $this->_aData['user_profile_date'] = $data;
+ }
+
+ /**
+ * Устанавливает полный веб путь до аватра
+ *
+ * @param string $data
+ */
+ public function setProfileAvatar($data)
+ {
+ $this->_aData['user_profile_avatar'] = $data;
+ }
+
+ /**
+ * Устанавливает полный веб путь до фото
+ *
+ * @param string $data
+ */
+ public function setProfileFoto($data)
+ {
+ $this->_aData['user_profile_foto'] = $data;
+ }
+
+ /**
+ * Устанавливает статус уведомления о новых топиках
+ *
+ * @param int $data
+ */
+ public function setSettingsNoticeNewTopic($data)
+ {
+ $this->_aData['user_settings_notice_new_topic'] = $data;
+ }
+
+ /**
+ * Устанавливает статус уведомления о новых комментариях
+ *
+ * @param int $data
+ */
+ public function setSettingsNoticeNewComment($data)
+ {
+ $this->_aData['user_settings_notice_new_comment'] = $data;
+ }
+
+ /**
+ * Устанавливает статус уведомления о новых письмах
+ *
+ * @param int $data
+ */
+ public function setSettingsNoticeNewTalk($data)
+ {
+ $this->_aData['user_settings_notice_new_talk'] = $data;
+ }
+
+ /**
+ * Устанавливает статус уведомления о новых ответах в комментариях
+ *
+ * @param int $data
+ */
+ public function setSettingsNoticeReplyComment($data)
+ {
+ $this->_aData['user_settings_notice_reply_comment'] = $data;
+ }
+
+ /**
+ * Устанавливает статус уведомления о новых друзьях
+ *
+ * @param int $data
+ */
+ public function setSettingsNoticeNewFriend($data)
+ {
+ $this->_aData['user_settings_notice_new_friend'] = $data;
+ }
+
+ /**
+ * Устанавливает объект сессии
+ *
+ * @param ModuleUser_EntitySession $data
+ */
+ public function setSession($data)
+ {
+ $this->_aData['session'] = $data;
+ }
+
+ /**
+ * Устанавливает статус дружбы
+ *
+ * @param int $data
+ */
+ public function setUserIsFriend($data)
+ {
+ $this->_aData['user_is_friend'] = $data;
+ }
+
+ /**
+ * Устанавливает объект голосования за пользователя текущего пользователя
+ *
+ * @param ModuleVote_EntityVote $data
+ */
+ public function setVote($data)
+ {
+ $this->_aData['vote'] = $data;
+ }
+
+ /**
+ * Устанавливаем статус дружбы с текущим пользователем
+ *
+ * @param int $data
+ */
+ public function setUserFriend($data)
+ {
+ $this->_aData['user_friend'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/user/mapper/User.mapper.class.php b/application/classes/modules/user/mapper/User.mapper.class.php
new file mode 100644
index 0000000..4a7371d
--- /dev/null
+++ b/application/classes/modules/user/mapper/User.mapper.class.php
@@ -0,0 +1,1361 @@
+
+ *
+ */
+
+/**
+ * Маппер для работы с БД
+ *
+ * @package application.modules.user
+ * @since 1.0
+ */
+class ModuleUser_MapperUser extends Mapper
+{
+ /**
+ * Добавляет юзера
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @return int|bool
+ */
+ public function Add(ModuleUser_EntityUser $oUser)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.user') . "
+ (user_login,
+ user_password,
+ user_mail,
+ user_date_register,
+ user_ip_register,
+ user_activate,
+ user_activate_key,
+ user_referral_code
+ )
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?)
+ ";
+ if ($iId = $this->oDb->query($sql, $oUser->getLogin(), $oUser->getPassword(), $oUser->getMail(),
+ $oUser->getDateRegister(), $oUser->getIpRegister(), $oUser->getActivate(), $oUser->getActivateKey(), $oUser->getReferralCode())
+ ) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет юзера
+ *
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ * @return bool
+ */
+ public function Update(ModuleUser_EntityUser $oUser)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.user') . "
+ SET
+ user_password = ? ,
+ user_mail = ? ,
+ user_admin = ? ,
+ user_date_activate = ? ,
+ user_date_comment_last = ? ,
+ user_rating = ? ,
+ user_count_vote = ? ,
+ user_activate = ? ,
+ user_activate_key = ? ,
+ user_referral_code = ? ,
+ user_profile_name = ? ,
+ user_profile_sex = ? ,
+ user_profile_country = ? ,
+ user_profile_region = ? ,
+ user_profile_city = ? ,
+ user_profile_birthday = ? ,
+ user_profile_about = ? ,
+ user_profile_date = ? ,
+ user_profile_avatar = ? ,
+ user_profile_foto = ? ,
+ user_settings_notice_new_topic = ? ,
+ user_settings_notice_new_comment = ? ,
+ user_settings_notice_new_talk = ? ,
+ user_settings_notice_reply_comment = ? ,
+ user_settings_notice_new_friend = ? ,
+ user_settings_timezone = ?
+ WHERE user_id = ?
+ ";
+ $res = $this->oDb->query($sql, $oUser->getPassword(),
+ $oUser->getMail(),
+ $oUser->getAdmin(),
+ $oUser->getDateActivate(),
+ $oUser->getDateCommentLast(),
+ $oUser->getRating(),
+ $oUser->getCountVote(),
+ $oUser->getActivate(),
+ $oUser->getActivateKey(),
+ $oUser->getReferralCode(),
+ $oUser->getProfileName(),
+ $oUser->getProfileSex(),
+ $oUser->getProfileCountry(),
+ $oUser->getProfileRegion(),
+ $oUser->getProfileCity(),
+ $oUser->getProfileBirthday(),
+ $oUser->getProfileAbout(),
+ $oUser->getProfileDate(),
+ $oUser->getProfileAvatar(),
+ $oUser->getProfileFoto(),
+ $oUser->getSettingsNoticeNewTopic(),
+ $oUser->getSettingsNoticeNewComment(),
+ $oUser->getSettingsNoticeNewTalk(),
+ $oUser->getSettingsNoticeReplyComment(),
+ $oUser->getSettingsNoticeNewFriend(),
+ $oUser->getSettingsTimezone(),
+ $oUser->getId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получить юзера по ключу сессии
+ *
+ * @param string $sKey Сессионный ключ
+ * @return int|null
+ */
+ public function GetUserBySessionKey($sKey)
+ {
+ $sql = "SELECT
+ s.user_id
+ FROM
+ " . Config::Get('db.table.session') . " as s
+ WHERE
+ s.session_key = ?
+ ";
+ if ($aRow = $this->oDb->selectRow($sql, $sKey)) {
+ return $aRow['user_id'];
+ }
+ return null;
+ }
+
+ public function GetSessionByKey($sKey)
+ {
+ $sql = "SELECT
+ s.*
+ FROM
+ " . Config::Get('db.table.session') . " as s
+ WHERE
+ s.session_key = ?
+ ";
+ if ($aRow = $this->oDb->selectRow($sql, $sKey)) {
+ return Engine::GetEntity('User_Session', $aRow);
+ }
+ return null;
+ }
+
+ public function GetSessionsByUserId($iUserId, $bOnlyNotClose = true)
+ {
+ $sql = "SELECT
+ s.*
+ FROM
+ " . Config::Get('db.table.session') . " as s
+ WHERE
+ s.user_id = ?d
+ { and 1=?d and s.session_date_close is null }
+ ORDER BY session_date_last desc
+ ";
+ $aRes = array();
+ if ($aRows = $this->oDb->select($sql, $iUserId, $bOnlyNotClose ? 1 : DBSIMPLE_SKIP)) {
+ foreach ($aRows as $aRow) {
+ $aRes[] = Engine::GetEntity('User_Session', $aRow);
+ }
+ }
+ return $aRes;
+ }
+
+ /**
+ * Создание пользовательской сессии
+ *
+ * @param ModuleUser_EntitySession $oSession
+ * @return bool
+ */
+ public function CreateSession(ModuleUser_EntitySession $oSession)
+ {
+ $sql = "REPLACE INTO " . Config::Get('db.table.session') . "
+ SET
+ session_key = ? ,
+ user_id = ? ,
+ session_ip_create = ? ,
+ session_ip_last = ? ,
+ session_date_create = ? ,
+ session_date_last = ? ,
+ session_extra = ?
+ ";
+ return $this->oDb->query($sql, $oSession->getKey(), $oSession->getUserId(), $oSession->getIpCreate(),
+ $oSession->getIpLast(), $oSession->getDateCreate(), $oSession->getDateLast(), $oSession->getExtra());
+ }
+
+ /**
+ * Обновление данных сессии
+ *
+ * @param ModuleUser_EntitySession $oSession
+ * @return int|bool
+ */
+ public function UpdateSession(ModuleUser_EntitySession $oSession)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.session') . "
+ SET
+ session_ip_last = ? ,
+ session_date_last = ?,
+ session_date_close = ?
+ WHERE session_key = ?
+ ";
+ $res = $this->oDb->query($sql, $oSession->getIpLast(), $oSession->getDateLast(), $oSession->getDateClose(),
+ $oSession->getKey());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Удаление сессии
+ *
+ * @param ModuleUser_EntitySession $oSession
+ * @return int|bool
+ */
+ public function DeleteSession(ModuleUser_EntitySession $oSession)
+ {
+ $sql = "DELETE FROM " . Config::Get('db.table.session') . "
+ WHERE session_key = ?
+ ";
+ $res = $this->oDb->query($sql, $oSession->getKey());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Список сессий юзеров по ID
+ *
+ * @param array $aArrayId Список ID пользователей
+ * @return array
+ */
+ public function GetSessionsByArrayId($aArrayId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+
+ $sql = "SELECT
+ s.*
+ FROM
+ (
+ SELECT
+ user_id, max(session_date_last) as max_date_last
+ FROM
+ " . Config::Get('db.table.session') . "
+ WHERE
+ user_id IN (?a)
+ GROUP BY user_id
+ ) as s2,
+ " . Config::Get('db.table.session') . " as s
+ WHERE
+ s2.user_id = s.user_id and s2.max_date_last = s.session_date_last
+ ";
+
+ $aRes = array();
+ if ($aRows = $this->oDb->select($sql, $aArrayId)) {
+ foreach ($aRows as $aRow) {
+ $aRes[] = Engine::GetEntity('User_Session', $aRow);
+ }
+ }
+ return $aRes;
+ }
+
+ /**
+ * Список юзеров по ID
+ *
+ * @param array $aArrayId Список ID пользователей
+ * @return array
+ */
+ public function GetUsersByArrayId($aArrayId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+
+ $sql = "SELECT
+ u.*
+ FROM
+ " . Config::Get('db.table.user') . " as u
+ WHERE
+ u.user_id IN(?a)
+ ORDER BY FIELD(u.user_id,?a) ";
+ $aUsers = array();
+ if ($aRows = $this->oDb->select($sql, $aArrayId, $aArrayId)) {
+ foreach ($aRows as $aUser) {
+ $aUsers[] = Engine::GetEntity('User', $aUser);
+ }
+ }
+ return $aUsers;
+ }
+
+ /**
+ * Получить юзера по ключу активации
+ *
+ * @param string $sKey Ключ активации
+ * @return int|null
+ */
+ public function GetUserByActivateKey($sKey)
+ {
+ $sql = "SELECT
+ u.user_id
+ FROM
+ " . Config::Get('db.table.user') . " as u
+ WHERE u.user_activate_key = ? ";
+ if ($aRow = $this->oDb->selectRow($sql, $sKey)) {
+ return $aRow['user_id'];
+ }
+ return null;
+ }
+
+ /**
+ * Получить юзера по мылу
+ *
+ * @param string $sMail Емайл
+ * @return int|null
+ */
+ public function GetUserByMail($sMail)
+ {
+ $sql = "SELECT
+ u.user_id
+ FROM
+ " . Config::Get('db.table.user') . " as u
+ WHERE u.user_mail = ? ";
+ if ($aRow = $this->oDb->selectRow($sql, $sMail)) {
+ return $aRow['user_id'];
+ }
+ return null;
+ }
+
+ /**
+ * Получить юзера по реферальному коду
+ *
+ * @param string $sCode Код
+ * @return int|null
+ */
+ public function GetUserByReferralCode($sCode)
+ {
+ $sql = "SELECT
+ u.user_id
+ FROM
+ " . Config::Get('db.table.user') . " as u
+ WHERE u.user_referral_code = ? ";
+ if ($aRow = $this->oDb->selectRow($sql, $sCode)) {
+ return $aRow['user_id'];
+ }
+ return null;
+ }
+
+ /**
+ * Получить юзера по логину
+ *
+ * @param string $sLogin Логин пользователя
+ * @return int|null
+ */
+ public function GetUserByLogin($sLogin)
+ {
+ $sql = "SELECT
+ u.user_id
+ FROM
+ " . Config::Get('db.table.user') . " as u
+ WHERE
+ u.user_login = ? ";
+ if ($aRow = $this->oDb->selectRow($sql, $sLogin)) {
+ return $aRow['user_id'];
+ }
+ return null;
+ }
+
+ /**
+ * Получить список юзеров по дате регистрации
+ *
+ * @param int $iLimit Количество
+ * @return array
+ */
+ public function GetUsersByDateRegister($iLimit)
+ {
+ $sql = "SELECT
+ user_id
+ FROM
+ " . Config::Get('db.table.user') . "
+ WHERE
+ user_activate = 1
+ ORDER BY
+ user_id DESC
+ LIMIT 0, ?d
+ ";
+ $aReturn = array();
+ if ($aRows = $this->oDb->select($sql, $iLimit)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = $aRow['user_id'];
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Возвращает количество пользователй
+ *
+ * @return int
+ */
+ public function GetCountUsers()
+ {
+ $sql = "SELECT count(*) as count FROM " . Config::Get('db.table.user') . " WHERE user_activate = 1";
+ $result = $this->oDb->selectRow($sql);
+ return $result['count'];
+ }
+
+ /**
+ * Возвращает количество активных пользователей
+ *
+ * @param string $sDateActive Дата
+ * @return mixed
+ */
+ public function GetCountUsersActive($sDateActive)
+ {
+ $sql = "SELECT DISTINCT user_id FROM " . Config::Get('db.table.session') . " WHERE session_date_last >= ? ";
+ $result = $this->oDb->select($sql, $sDateActive);
+ return $result ? count($result) : 0;
+ }
+
+ /**
+ * Возвращает количество пользователей в разрезе полов
+ *
+ * @return array
+ */
+ public function GetCountUsersSex()
+ {
+ $sql = "SELECT user_profile_sex AS ARRAY_KEY, count(*) as count FROM " . Config::Get('db.table.user') . " WHERE user_activate = 1 GROUP BY user_profile_sex ";
+ $result = $this->oDb->select($sql);
+ return $result;
+ }
+
+ /**
+ * Получить список юзеров по первым буквам логина
+ *
+ * @param string $sUserLogin Логин
+ * @param int $iLimit Количество
+ * @return array
+ */
+ public function GetUsersByLoginLike($sUserLogin, $iLimit)
+ {
+ $sql = "SELECT
+ user_id
+ FROM
+ " . Config::Get('db.table.user') . "
+ WHERE
+ user_activate = 1
+ and
+ user_login LIKE ?
+ LIMIT 0, ?d
+ ";
+ $aReturn = array();
+ if ($aRows = $this->oDb->select($sql, $sUserLogin . '%', $iLimit)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = $aRow['user_id'];
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Добавляет друга
+ *
+ * @param ModuleUser_EntityFriend $oFriend Объект дружбы(связи пользователей)
+ * @return bool
+ */
+ public function AddFriend(ModuleUser_EntityFriend $oFriend)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.friend') . "
+ (user_from,
+ user_to,
+ status_from,
+ status_to
+ )
+ VALUES(?d, ?d, ?d, ?d)
+ ";
+ if (
+ $this->oDb->query(
+ $sql,
+ $oFriend->getUserFrom(),
+ $oFriend->getUserTo(),
+ $oFriend->getStatusFrom(),
+ $oFriend->getStatusTo()
+ ) === 0
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Удаляет информацию о дружбе из базы данных
+ *
+ * @param ModuleUser_EntityFriend $oFriend Объект дружбы(связи пользователей)
+ * @return bool
+ */
+ public function EraseFriend(ModuleUser_EntityFriend $oFriend)
+ {
+ $sql = "DELETE FROM " . Config::Get('db.table.friend') . "
+ WHERE
+ user_from = ?d
+ AND
+ user_to = ?d
+ ";
+ $res = $this->oDb->query($sql, $oFriend->getUserFrom(), $oFriend->getUserTo());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Обновляет информацию о друге
+ *
+ * @param ModuleUser_EntityFriend $oFriend Объект дружбы(связи пользователей)
+ * @return bool
+ */
+ public function UpdateFriend(ModuleUser_EntityFriend $oFriend)
+ {
+ $sql = "
+ UPDATE " . Config::Get('db.table.friend') . "
+ SET
+ status_from = ?d,
+ status_to = ?d
+ WHERE
+ user_from = ?d
+ AND
+ user_to = ?d
+ ";
+ $res = $this->oDb->query(
+ $sql,
+ $oFriend->getStatusFrom(),
+ $oFriend->getStatusTo(),
+ $oFriend->getUserFrom(),
+ $oFriend->getUserTo()
+ );
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получить список отношений друзей
+ *
+ * @param array $aArrayId Список ID пользователей проверяемых на дружбу
+ * @param int $sUserId ID пользователя у которого проверяем друзей
+ * @return array
+ */
+ public function GetFriendsByArrayId($aArrayId, $sUserId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.friend') . "
+ WHERE
+ ( `user_from`=?d AND `user_to` IN(?a) )
+ OR
+ ( `user_from` IN(?a) AND `user_to`=?d )
+ ";
+ $aRows = $this->oDb->select(
+ $sql,
+ $sUserId, $aArrayId,
+ $aArrayId, $sUserId
+ );
+ $aRes = array();
+ if ($aRows) {
+ foreach ($aRows as $aRow) {
+ $aRow['user'] = $sUserId;
+ $aRes[] = Engine::GetEntity('User_Friend', $aRow);
+ }
+ }
+ return $aRes;
+ }
+
+ /**
+ * Получает список друзей
+ *
+ * @param int $sUserId ID пользователя
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetUsersFriend($sUserId, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $sql = "SELECT
+ uf.user_from,
+ uf.user_to
+ FROM
+ " . Config::Get('db.table.friend') . " as uf
+ WHERE
+ ( uf.user_from = ?d
+ OR
+ uf.user_to = ?d )
+ AND
+ ( uf.status_from + uf.status_to = ?d
+ OR
+ (uf.status_from = ?d AND uf.status_to = ?d )
+ )
+ LIMIT ?d, ?d ;";
+ $aUsers = array();
+ if ($aRows = $this->oDb->selectPage(
+ $iCount,
+ $sql,
+ $sUserId,
+ $sUserId,
+ ModuleUser::USER_FRIEND_ACCEPT + ModuleUser::USER_FRIEND_OFFER,
+ ModuleUser::USER_FRIEND_ACCEPT,
+ ModuleUser::USER_FRIEND_ACCEPT,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aUser) {
+ $aUsers[] = ($aUser['user_from'] == $sUserId)
+ ? $aUser['user_to']
+ : $aUser['user_from'];
+ }
+ }
+ rsort($aUsers, SORT_NUMERIC);
+ return array_unique($aUsers);
+ }
+
+ /**
+ * Получает количество друзей
+ *
+ * @param int $sUserId ID пользователя
+ * @return int
+ */
+ public function GetCountUsersFriend($sUserId)
+ {
+ $sql = "SELECT
+ count(*) as c
+ FROM
+ " . Config::Get('db.table.friend') . " as uf
+ WHERE
+ ( uf.user_from = ?d
+ OR
+ uf.user_to = ?d )
+ AND
+ ( uf.status_from + uf.status_to = ?d
+ OR
+ (uf.status_from = ?d AND uf.status_to = ?d )
+ )";
+ if ($aRow = $this->oDb->selectRow(
+ $sql,
+ $sUserId,
+ $sUserId,
+ ModuleUser::USER_FRIEND_ACCEPT + ModuleUser::USER_FRIEND_OFFER,
+ ModuleUser::USER_FRIEND_ACCEPT,
+ ModuleUser::USER_FRIEND_ACCEPT
+ )
+ ) {
+ return $aRow['c'];
+ }
+ return 0;
+ }
+
+ /**
+ * Получить список заявок на добавление в друзья от указанного пользователя
+ *
+ * @param string $sUserId
+ * @param int $iStatus Статус запроса со стороны добавляемого
+ * @return array
+ */
+ public function GetUsersFriendOffer($sUserId, $iStatus = ModuleUser::USER_FRIEND_NULL)
+ {
+ $sql = "SELECT
+ uf.user_to
+ FROM
+ " . Config::Get('db.table.friend') . " as uf
+ WHERE
+ uf.user_from = ?d
+ AND
+ uf.status_from = ?d
+ AND
+ uf.status_to = ?d
+ ;";
+ $aUsers = array();
+ if ($aRows = $this->oDb->select(
+ $sql,
+ $sUserId,
+ ModuleUser::USER_FRIEND_OFFER,
+ $iStatus
+ )
+ ) {
+ foreach ($aRows as $aUser) {
+ $aUsers[] = $aUser['user_to'];
+ }
+ }
+ return $aUsers;
+ }
+
+ /**
+ * Получить список заявок на добавление в друзья от указанного пользователя
+ *
+ * @param string $sUserId
+ * @param int $iStatus Статус запроса со стороны самого пользователя
+ * @return array
+ */
+ public function GetUserSelfFriendOffer($sUserId, $iStatus = ModuleUser::USER_FRIEND_NULL)
+ {
+ $sql = "SELECT
+ uf.user_from
+ FROM
+ " . Config::Get('db.table.friend') . " as uf
+ WHERE
+ uf.user_to = ?d
+ AND
+ uf.status_from = ?d
+ AND
+ uf.status_to = ?d
+ ;";
+ $aUsers = array();
+ if ($aRows = $this->oDb->select(
+ $sql,
+ $sUserId,
+ ModuleUser::USER_FRIEND_OFFER,
+ $iStatus
+ )
+ ) {
+ foreach ($aRows as $aUser) {
+ $aUsers[] = $aUser['user_from'];
+ }
+ }
+ return $aUsers;
+ }
+
+ /**
+ * Добавляем воспоминание(восстановление) пароля
+ *
+ * @param ModuleUser_EntityReminder $oReminder Объект восстановления пароля
+ * @return bool
+ */
+ public function AddReminder(ModuleUser_EntityReminder $oReminder)
+ {
+ $sql = "REPLACE " . Config::Get('db.table.reminder') . "
+ SET
+ reminder_code = ? ,
+ user_id = ? ,
+ reminder_date_add = ? ,
+ reminder_date_used = ? ,
+ reminder_date_expire = ? ,
+ reminde_is_used = ?
+ ";
+ return $this->oDb->query($sql, $oReminder->getCode(), $oReminder->getUserId(), $oReminder->getDateAdd(),
+ $oReminder->getDateUsed(), $oReminder->getDateExpire(), $oReminder->getIsUsed());
+ }
+
+ /**
+ * Сохраняем воспомнинание(восстановление) пароля
+ *
+ * @param ModuleUser_EntityReminder $oReminder Объект восстановления пароля
+ * @return bool
+ */
+ public function UpdateReminder(ModuleUser_EntityReminder $oReminder)
+ {
+ return $this->AddReminder($oReminder);
+ }
+
+ /**
+ * Получаем запись восстановления пароля по коду
+ *
+ * @param string $sCode Код восстановления пароля
+ * @return ModuleUser_EntityReminder|null
+ */
+ public function GetReminderByCode($sCode)
+ {
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.reminder') . "
+ WHERE
+ reminder_code = ?";
+ if ($aRow = $this->oDb->selectRow($sql, $sCode)) {
+ return Engine::GetEntity('User_Reminder', $aRow);
+ }
+ return null;
+ }
+
+ /**
+ * Получить дополнительные поля профиля пользователя
+ *
+ * @param array|null $aType Типы полей, null - все типы
+ * @return array
+ */
+ public function getUserFields($aType)
+ {
+ if (!is_null($aType) and !is_array($aType)) {
+ $aType = array($aType);
+ }
+ $sql = 'SELECT * FROM ' . Config::Get('db.table.user_field') . ' WHERE 1=1 { and type IN (?a) }';
+ $aFields = $this->oDb->select($sql, (is_null($aType) or !count($aType)) ? DBSIMPLE_SKIP : $aType);
+ if (!count($aFields)) {
+ return array();
+ }
+ $aResult = array();
+ foreach ($aFields as $aField) {
+ $aResult[$aField['id']] = Engine::GetEntity('User_Field', $aField);
+ }
+ return $aResult;
+ }
+
+ /**
+ * Получить по имени поля его значение дял определённого пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param string $sName Имя поля
+ * @return string
+ */
+ public function getUserFieldValueByName($iUserId, $sName)
+ {
+ $sql = 'SELECT value FROM ' . Config::Get('db.table.user_field_value') . ' WHERE
+ user_id = ?d
+ AND
+ field_id = (SELECT id FROM ' . Config::Get('db.table.user_field') . ' WHERE name =?)';
+ $ret = $this->oDb->selectCol($sql, $iUserId, $sName);
+ return $ret[0];
+ }
+
+ /**
+ * Получить значения дополнительных полей профиля пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param bool $bOnlyNoEmpty Загружать только непустые поля
+ * @param array $aType Типы полей, null - все типы
+ * @return array
+ */
+ public function getUserFieldsValues($iUserId, $bOnlyNoEmpty, $aType)
+ {
+ if (!is_null($aType) and !is_array($aType)) {
+ $aType = array($aType);
+ }
+
+ /**
+ * Если запрашиваем без типа, то необходимо вернуть ВСЕ возможные поля с этим типом в не звависимости указал ли их пользователь у себя в профили или нет
+ * Выглядит костыльно
+ */
+ if (is_array($aType) and count($aType) == 1 and $aType[0] == '') {
+ $sql = 'SELECT f.*, v.value FROM ' . Config::Get('db.table.user_field') . ' as f LEFT JOIN ' . Config::Get('db.table.user_field_value') . ' as v ON f.id = v.field_id WHERE v.user_id = ?d and f.type IN (?a)';
+
+ } else {
+ $sql = 'SELECT v.value, f.* FROM ' . Config::Get('db.table.user_field_value') . ' as v, ' . Config::Get('db.table.user_field') . ' as f
+ WHERE v.user_id = ?d AND v.field_id = f.id { and f.type IN (?a) }';
+ }
+ $aResult = array();
+ if ($aRows = $this->oDb->select($sql, $iUserId, (is_null($aType) or !count($aType)) ? DBSIMPLE_SKIP : $aType)) {
+ foreach ($aRows as $aRow) {
+ if ($bOnlyNoEmpty and !$aRow['value']) {
+ continue;
+ }
+ $aResult[] = Engine::GetEntity('User_Field', $aRow);
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Установить значения дополнительных полей профиля пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param array $aFields Ассоциативный массив полей id => value
+ * @param int $iCountMax Максимальное количество одинаковых полей
+ * @return bool
+ */
+ public function setUserFieldsValues($iUserId, $aFields, $iCountMax)
+ {
+ if (!count($aFields)) {
+ return;
+ }
+ foreach ($aFields as $iId => $sValue) {
+ $sql = 'SELECT count(*) as c FROM ' . Config::Get('db.table.user_field_value') . ' WHERE user_id = ?d AND field_id = ?';
+ $aRow = $this->oDb->selectRow($sql, $iUserId, $iId);
+ $iCount = isset($aRow['c']) ? $aRow['c'] : 0;
+ if ($iCount < $iCountMax) {
+ $sql = 'INSERT INTO ' . Config::Get('db.table.user_field_value') . ' SET value = ?, user_id = ?d, field_id = ?';
+ } elseif ($iCount == $iCountMax and $iCount == 1) {
+ $sql = 'UPDATE ' . Config::Get('db.table.user_field_value') . ' SET value = ? WHERE user_id = ?d AND field_id = ?';
+ } else {
+ continue;
+ }
+ $this->oDb->query($sql, $sValue, $iUserId, $iId);
+ }
+ }
+
+ /**
+ * Добавить поле
+ *
+ * @param ModuleUser_EntityField $oField Объект пользовательского поля
+ * @return bool
+ */
+ public function addUserField($oField)
+ {
+ $sql = 'INSERT INTO ' . Config::Get('db.table.user_field') . ' SET
+ name = ?, title = ?, pattern = ?, type = ?';
+ return $this->oDb->query($sql, $oField->getName(), $oField->getTitle(), $oField->getPattern(),
+ $oField->getType());
+ }
+
+ /**
+ * Удалить поле
+ *
+ * @param int $iId ID пользовательского поля
+ * @return bool
+ */
+ public function deleteUserField($iId)
+ {
+ $sql = 'DELETE FROM ' . Config::Get('db.table.user_field_value') . ' WHERE field_id = ?d';
+ $this->oDb->query($sql, $iId);
+ $sql = 'DELETE FROM ' . Config::Get('db.table.user_field') . ' WHERE
+ id = ?d';
+ $this->oDb->query($sql, $iId);
+ return true;
+ }
+
+ /**
+ * Изменить поле
+ *
+ * @param ModuleUser_EntityField $oField Объект пользовательского поля
+ * @return bool
+ */
+ public function updateUserField($oField)
+ {
+ $sql = 'UPDATE ' . Config::Get('db.table.user_field') . ' SET
+ name = ?, title = ?, pattern = ?, type = ?
+ WHERE id = ?d';
+ $res = $this->oDb->query($sql, $oField->getName(), $oField->getTitle(), $oField->getPattern(),
+ $oField->getType(), $oField->getId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Проверяет существует ли поле с таким именем
+ *
+ * @param string $sName Имя поля
+ * @param int|null $iId ID поля
+ * @return bool
+ */
+ public function userFieldExistsByName($sName, $iId)
+ {
+ $sql = 'SELECT id FROM ' . Config::Get('db.table.user_field') . ' WHERE name = ? {AND id != ?d}';
+ return $this->oDb->select($sql, $sName, $iId ? $iId : DBSIMPLE_SKIP);
+ }
+
+ /**
+ * Проверяет существует ли поле с таким ID
+ *
+ * @param int $iId ID поля
+ * @return bool
+ */
+ public function userFieldExistsById($iId)
+ {
+ $sql = 'SELECT id FROM ' . Config::Get('db.table.user_field') . ' WHERE id = ?d';
+ return $this->oDb->select($sql, $iId);
+ }
+
+ /**
+ * Удаляет у пользователя значения полей
+ *
+ * @param int $iUserId ID пользователя
+ * @param array|null $aType Список типов для удаления
+ * @return bool
+ */
+ public function DeleteUserFieldValues($iUserId, $aType)
+ {
+ if (!is_null($aType) and !is_array($aType)) {
+ $aType = array($aType);
+ }
+ $sql = 'DELETE FROM ' . Config::Get('db.table.user_field_value') . '
+ WHERE user_id = ?d AND field_id IN (
+ SELECT id FROM ' . Config::Get('db.table.user_field') . ' WHERE 1=1 { and type IN (?a) }
+ )';
+ $res = $this->oDb->query($sql, $iUserId, (is_null($aType) or !count($aType)) ? DBSIMPLE_SKIP : $aType);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Возвращает список заметок пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetUserNotesByUserId($iUserId, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $sql = "
+ SELECT *
+ FROM
+ " . Config::Get('db.table.user_note') . "
+ WHERE
+ user_id = ?d
+ ORDER BY id DESC
+ LIMIT ?d, ?d ";
+ $aReturn = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql, $iUserId, ($iCurrPage - 1) * $iPerPage, $iPerPage)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = Engine::GetEntity('ModuleUser_EntityNote', $aRow);
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Возвращает список ID пользователей к которым юзер оставлял заметки
+ *
+ * @param int $iUserId ID пользователя
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetUsersByNoteAndUserId($iUserId, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $sql = "
+ SELECT target_user_id
+ FROM
+ " . Config::Get('db.table.user_note') . "
+ WHERE
+ user_id = ?d
+ ORDER BY id DESC
+ LIMIT ?d, ?d ";
+ $aReturn = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql, $iUserId, ($iCurrPage - 1) * $iPerPage, $iPerPage)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = $aRow['target_user_id'];
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Возвращает количество заметок у пользователя
+ *
+ * @param int $iUserId ID пользователя
+ * @return int
+ */
+ public function GetCountUserNotesByUserId($iUserId)
+ {
+ $sql = "
+ SELECT count(*) as c
+ FROM
+ " . Config::Get('db.table.user_note') . "
+ WHERE
+ user_id = ?d
+ ";
+ if ($aRow = $this->oDb->selectRow($sql, $iUserId)) {
+ return $aRow['c'];
+ }
+ return 0;
+ }
+
+ /**
+ * Возвращет заметку по автору и пользователю
+ *
+ * @param int $iTargetUserId ID пользователя о ком заметка
+ * @param int $iUserId ID пользователя автора заметки
+ * @return ModuleUser_EntityNote|null
+ */
+ public function GetUserNote($iTargetUserId, $iUserId)
+ {
+ $sql = "SELECT * FROM " . Config::Get('db.table.user_note') . " WHERE target_user_id = ?d and user_id = ?d ";
+ if ($aRow = $this->oDb->selectRow($sql, $iTargetUserId, $iUserId)) {
+ return Engine::GetEntity('ModuleUser_EntityNote', $aRow);
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает заметку по ID
+ *
+ * @param int $iId ID заметки
+ * @return ModuleUser_EntityNote|null
+ */
+ public function GetUserNoteById($iId)
+ {
+ $sql = "SELECT * FROM " . Config::Get('db.table.user_note') . " WHERE id = ?d ";
+ if ($aRow = $this->oDb->selectRow($sql, $iId)) {
+ return Engine::GetEntity('ModuleUser_EntityNote', $aRow);
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает список заметок пользователя по ID целевых юзеров
+ *
+ * @param array $aArrayId Список ID целевых пользователей
+ * @param int $sUserId ID пользователя, кто оставлял заметки
+ * @return array
+ */
+ public function GetUserNotesByArrayUserId($aArrayId, $sUserId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.user_note') . "
+ WHERE target_user_id IN (?a) and user_id = ?d
+ ";
+ $aRows = $this->oDb->select($sql, $aArrayId, $sUserId);
+ $aRes = array();
+ if ($aRows) {
+ foreach ($aRows as $aRow) {
+ $aRes[] = Engine::GetEntity('ModuleUser_EntityNote', $aRow);
+ }
+ }
+ return $aRes;
+ }
+
+ /**
+ * Удаляет заметку по ID
+ *
+ * @param int $iId ID заметки
+ * @return bool
+ */
+ public function DeleteUserNoteById($iId)
+ {
+ $sql = "DELETE FROM " . Config::Get('db.table.user_note') . " WHERE id = ?d ";
+ $res = $this->oDb->query($sql, $iId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Добавляет заметку
+ *
+ * @param ModuleUser_EntityNote $oNote Объект заметки
+ * @return int|null
+ */
+ public function AddUserNote($oNote)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.user_note') . " SET ?a ";
+ if ($iId = $this->oDb->query($sql, $oNote->_getData())) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет заметку
+ *
+ * @param ModuleUser_EntityNote $oNote Объект заметки
+ * @return int
+ */
+ public function UpdateUserNote($oNote)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.user_note') . "
+ SET
+ text = ?
+ WHERE id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oNote->getText(),
+ $oNote->getId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Добавляет жалобу
+ *
+ * @param ModuleUser_EntityComplaint $oComplaint
+ *
+ * @return int|bool
+ */
+ public function AddComplaint($oComplaint)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.user_complaint') . " SET ?a ";
+ if ($iId = $this->oDb->query($sql,
+ $oComplaint->_getData(array('type', 'target_user_id', 'user_id', 'text', 'date_add', 'state')))
+ ) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Добавляет запись о смене емайла
+ *
+ * @param ModuleUser_EntityChangemail $oChangemail Объект смены емайла
+ * @return int|null
+ */
+ public function AddUserChangemail($oChangemail)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.user_changemail') . " SET ?a ";
+ if ($iId = $this->oDb->query($sql, $oChangemail->_getData())) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет запись о смене емайла
+ *
+ * @param ModuleUser_EntityChangemail $oChangemail Объект смены емайла
+ * @return int
+ */
+ public function UpdateUserChangemail($oChangemail)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.user_changemail') . "
+ SET
+ date_used = ?,
+ confirm_from = ?d,
+ confirm_to = ?d
+ WHERE id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oChangemail->getDateUsed(), $oChangemail->getConfirmFrom(),
+ $oChangemail->getConfirmTo(), $oChangemail->getId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Возвращает объект смены емайла по коду подтверждения
+ *
+ * @param string $sCode Код подтверждения
+ * @return ModuleUser_EntityChangemail|null
+ */
+ public function GetUserChangemailByCodeFrom($sCode)
+ {
+ $sql = "SELECT * FROM " . Config::Get('db.table.user_changemail') . " WHERE code_from = ? ";
+ if ($aRow = $this->oDb->selectRow($sql, $sCode)) {
+ return Engine::GetEntity('ModuleUser_EntityChangemail', $aRow);
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает объект смены емайла по коду подтверждения
+ *
+ * @param string $sCode Код подтверждения
+ * @return ModuleUser_EntityChangemail|null
+ */
+ public function GetUserChangemailByCodeTo($sCode)
+ {
+ $sql = "SELECT * FROM " . Config::Get('db.table.user_changemail') . " WHERE code_to = ? ";
+ if ($aRow = $this->oDb->selectRow($sql, $sCode)) {
+ return Engine::GetEntity('ModuleUser_EntityChangemail', $aRow);
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает список пользователей по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элментов на страницу
+ * @return array
+ */
+ public function GetUsersByFilter($aFilter, $aOrder, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $aOrderAllow = array(
+ 'user_id',
+ 'user_login',
+ 'user_date_register',
+ 'user_rating',
+ 'user_profile_name'
+ );
+ $sOrder = '';
+ foreach ($aOrder as $key => $value) {
+ if (!in_array($key, $aOrderAllow)) {
+ unset($aOrder[$key]);
+ } elseif (in_array($value, array('asc', 'desc'))) {
+ $sOrder .= " u.{$key} {$value},";
+ }
+ }
+ $sOrder = trim($sOrder, ',');
+ if ($sOrder == '') {
+ $sOrder = ' u.user_id desc ';
+ }
+
+ $sql = "SELECT
+ DISTINCT u.user_id
+ FROM
+ " . Config::Get('db.table.user') . " as u
+ { JOIN " . Config::Get('db.table.geo_target') . " as g ON ( u.user_id=g.target_id and g.country_id = ? ) }
+ { JOIN " . Config::Get('db.table.geo_target') . " as g ON ( u.user_id=g.target_id and g.region_id = ? ) }
+ { JOIN " . Config::Get('db.table.geo_target') . " as g ON ( u.user_id=g.target_id and g.city_id = ? ) }
+ LEFT JOIN " . Config::Get('db.table.session') . " as s ON u.user_id=s.user_id
+ WHERE
+ 1 = 1
+ { AND s.session_date_last >= ? }
+ { AND u.user_id = ?d }
+ { AND u.user_mail = ? }
+ { AND u.user_password = ? }
+ { AND u.user_ip_register = ? }
+ { AND u.user_activate = ?d }
+ { AND u.user_activate_key = ? }
+ { AND u.user_profile_sex = ? }
+ { AND u.user_login LIKE ? }
+ { AND u.user_profile_name LIKE ? }
+ { AND ( u.user_profile_name LIKE ? OR u.user_login LIKE ? ) }
+ ORDER by {$sOrder}
+ LIMIT ?d, ?d ;
+ ";
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql,
+ isset($aFilter['geo_country']) ? $aFilter['geo_country'] : DBSIMPLE_SKIP,
+ isset($aFilter['geo_region']) ? $aFilter['geo_region'] : DBSIMPLE_SKIP,
+ isset($aFilter['geo_city']) ? $aFilter['geo_city'] : DBSIMPLE_SKIP,
+ isset($aFilter['date_last_more']) ? $aFilter['date_last_more'] : DBSIMPLE_SKIP,
+ isset($aFilter['id']) ? $aFilter['id'] : DBSIMPLE_SKIP,
+ isset($aFilter['mail']) ? $aFilter['mail'] : DBSIMPLE_SKIP,
+ isset($aFilter['password']) ? $aFilter['password'] : DBSIMPLE_SKIP,
+ isset($aFilter['ip_register']) ? $aFilter['ip_register'] : DBSIMPLE_SKIP,
+ isset($aFilter['activate']) ? $aFilter['activate'] : DBSIMPLE_SKIP,
+ isset($aFilter['activate_key']) ? $aFilter['activate_key'] : DBSIMPLE_SKIP,
+ isset($aFilter['profile_sex']) ? $aFilter['profile_sex'] : DBSIMPLE_SKIP,
+ isset($aFilter['login']) ? $aFilter['login'] : DBSIMPLE_SKIP,
+ isset($aFilter['profile_name']) ? $aFilter['profile_name'] : DBSIMPLE_SKIP,
+ isset($aFilter['name']) ? $aFilter['name'] : DBSIMPLE_SKIP,
+ isset($aFilter['name']) ? $aFilter['name'] : DBSIMPLE_SKIP,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = $aRow['user_id'];
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Возвращает список префиксов логинов пользователей (для алфавитного указателя)
+ *
+ * @param int $iPrefixLength Длина префикса
+ * @return array
+ */
+ public function GetGroupPrefixUser($iPrefixLength = 1)
+ {
+ $sql = "
+ SELECT SUBSTRING(`user_login` FROM 1 FOR ?d ) as prefix
+ FROM
+ " . Config::Get('db.table.user') . "
+ WHERE
+ user_activate = 1
+ GROUP BY prefix
+ ORDER BY prefix ";
+ $aReturn = array();
+ if ($aRows = $this->oDb->select($sql, $iPrefixLength)) {
+ foreach ($aRows as $aRow) {
+ $aReturn[] = mb_strtoupper($aRow['prefix'], 'utf-8');
+ }
+ }
+ return $aReturn;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/userfeed/Userfeed.class.php b/application/classes/modules/userfeed/Userfeed.class.php
new file mode 100644
index 0000000..6f2d0ce
--- /dev/null
+++ b/application/classes/modules/userfeed/Userfeed.class.php
@@ -0,0 +1,143 @@
+
+ *
+ */
+
+/**
+ * Модуль пользовательских лент контента (топиков)
+ *
+ * @package application.modules.userfeed
+ * @since 1.0
+ */
+class ModuleUserfeed extends Module
+{
+ /**
+ * Подписки на топики по блогу
+ */
+ const SUBSCRIBE_TYPE_BLOG = 1;
+ /**
+ * Подписки на топики по юзеру
+ */
+ const SUBSCRIBE_TYPE_USER = 2;
+ /**
+ * Объект маппера
+ *
+ * @var ModuleUserfeed_MapperUserfeed|null
+ */
+ protected $oMapper = null;
+ /**
+ * Объект текущего пользователя
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent = null;
+
+ /**
+ * Инициализация модуля
+ */
+ public function Init()
+ {
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Подписать пользователя
+ *
+ * @param int $iUserId ID подписываемого пользователя
+ * @param int $iSubscribeType Тип подписки (см. константы класса)
+ * @param int $iTargetId ID цели подписки
+ * @return bool
+ */
+ public function subscribeUser($iUserId, $iSubscribeType, $iTargetId)
+ {
+ return $this->oMapper->subscribeUser($iUserId, $iSubscribeType, $iTargetId);
+ }
+
+ /**
+ * Отписать пользователя
+ *
+ * @param int $iUserId ID подписываемого пользователя
+ * @param int $iSubscribeType Тип подписки (см. константы класса)
+ * @param int $iTargetId ID цели подписки
+ * @return bool
+ */
+ public function unsubscribeUser($iUserId, $iSubscribeType, $iTargetId)
+ {
+ return $this->oMapper->unsubscribeUser($iUserId, $iSubscribeType, $iTargetId);
+ }
+
+ /**
+ * Получить ленту топиков по подписке
+ *
+ * @param $iUserId ID пользователя, для которого получаем ленту
+ * @param $iCurrPage
+ * @param null $iPerPage
+ * @return array
+ */
+ public function read($iUserId, $iCurrPage, $iPerPage = null)
+ {
+ if (!is_null($iPerPage)) {
+ $iPerPage = Config::Get('module.userfeed.count_default');
+ }
+ $aSubscribes = $this->oMapper->getUserSubscribes($iUserId);
+ /**
+ * Добавляем в выдачу закрытые блоги
+ */
+ $aOpenBlogs = array();
+ if ($this->oUserCurrent) {
+ if ($aOpenBlogs = $this->Blog_GetAccessibleBlogsByUser($this->oUserCurrent)) {
+ $aOpenBlogs = array_intersect($aOpenBlogs, $aSubscribes['blogs']);
+ }
+ }
+ $aTopicsIds = $this->oMapper->ReadFeed($aSubscribes['users'], $aSubscribes['blogs'], $aOpenBlogs, $iCount,
+ $iCurrPage,
+ $iPerPage);
+ return array(
+ 'collection' => $this->Topic_GetTopicsAdditionalData($aTopicsIds),
+ 'count' => $iCount
+ );
+ }
+
+ /**
+ * Получить список подписок пользователя
+ *
+ * @param int $iUserId ID пользователя, для которого загружаются подписки
+ * @return array
+ */
+ public function getUserSubscribes($iUserId)
+ {
+ $aUserSubscribes = $this->oMapper->getUserSubscribes($iUserId);
+ $aResult = array('blogs' => array(), 'users' => array());
+ if (count($aUserSubscribes['blogs'])) {
+ $aBlogs = $this->Blog_getBlogsByArrayId($aUserSubscribes['blogs']);
+ foreach ($aBlogs as $oBlog) {
+ $aResult['blogs'][$oBlog->getId()] = $oBlog;
+ }
+ }
+ if (count($aUserSubscribes['users'])) {
+ $aUsers = $this->User_getUsersByArrayId($aUserSubscribes['users']);
+ foreach ($aUsers as $oUser) {
+ $aResult['users'][$oUser->getId()] = $oUser;
+ }
+ }
+
+ return $aResult;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/userfeed/mapper/Userfeed.mapper.class.php b/application/classes/modules/userfeed/mapper/Userfeed.mapper.class.php
new file mode 100644
index 0000000..7d74581
--- /dev/null
+++ b/application/classes/modules/userfeed/mapper/Userfeed.mapper.class.php
@@ -0,0 +1,168 @@
+
+ *
+ */
+
+/**
+ * Маппер для работы с БД
+ *
+ * @package application.modules.userfeed
+ * @since 1.0
+ */
+class ModuleUserfeed_MapperUserfeed extends Mapper
+{
+ /**
+ * Подписать пользователя
+ *
+ * @param int $iUserId ID подписываемого пользователя
+ * @param int $iSubscribeType Тип подписки (см. константы класса)
+ * @param int $iTargetId ID цели подписки
+ * @return bool
+ */
+ public function subscribeUser($iUserId, $iSubscribeType, $iTargetId)
+ {
+ $sql = 'SELECT * FROM ' . Config::Get('db.table.userfeed_subscribe') . ' WHERE
+ user_id = ?d AND subscribe_type = ?d AND target_id = ?d';
+ if (!$this->oDb->select($sql, $iUserId, $iSubscribeType, $iTargetId)) {
+ $sql = 'INSERT INTO ' . Config::Get('db.table.userfeed_subscribe') . ' SET
+ user_id = ?d, subscribe_type = ?d, target_id = ?d';
+ $this->oDb->query($sql, $iUserId, $iSubscribeType, $iTargetId);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Отписать пользователя
+ *
+ * @param int $iUserId ID подписываемого пользователя
+ * @param int $iSubscribeType Тип подписки (см. константы класса)
+ * @param int $iTargetId ID цели подписки
+ * @return bool
+ */
+ public function unsubscribeUser($iUserId, $iSubscribeType, $iTargetId)
+ {
+ $sql = 'DELETE FROM ' . Config::Get('db.table.userfeed_subscribe') . ' WHERE
+ user_id = ?d AND subscribe_type = ?d AND target_id = ?d';
+ $res = $this->oDb->query($sql, $iUserId, $iSubscribeType, $iTargetId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получить список подписок пользователя
+ *
+ * @param int $iUserId ID пользователя, для которого загружаются подписки
+ * @return array
+ */
+ public function getUserSubscribes($iUserId)
+ {
+ $sql = 'SELECT subscribe_type, target_id FROM ' . Config::Get('db.table.userfeed_subscribe') . ' WHERE user_id = ?d';
+ $aSubscribes = $this->oDb->select($sql, $iUserId);
+ $aResult = array('blogs' => array(), 'users' => array());
+
+ if (!count($aSubscribes)) {
+ return $aResult;
+ }
+
+ foreach ($aSubscribes as $aSubscribe) {
+ if ($aSubscribe['subscribe_type'] == ModuleUserfeed::SUBSCRIBE_TYPE_BLOG) {
+ $aResult['blogs'][] = $aSubscribe['target_id'];
+ } elseif ($aSubscribe['subscribe_type'] == ModuleUserfeed::SUBSCRIBE_TYPE_USER) {
+ $aResult['users'][] = $aSubscribe['target_id'];
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Получить ленту топиков по подписке
+ *
+ * @param $aUserId array Список ID юзеров
+ * @param $aBlogId array Список ID блогов
+ * @param $aBlogIdClose array Список ID закрытых блогов пользователя блогов
+ * @param $iCount
+ * @param $iCurrPage
+ * @param $iPerPage
+ * @return array
+ */
+ public function ReadFeed($aUserId, $aBlogId, $aBlogIdClose, &$iCount, $iCurrPage, $iPerPage)
+ {
+ if (!is_array($aUserId)) {
+ $aUserId = array($aUserId);
+ }
+ if (!is_array($aBlogId)) {
+ $aBlogId = array($aBlogId);
+ }
+ if (!is_array($aBlogIdClose)) {
+ $aBlogIdClose = array($aBlogIdClose);
+ }
+ $sql = "
+ SELECT
+ t.topic_id
+ FROM
+ " . Config::Get('db.table.topic') . " as t,
+ " . Config::Get('db.table.blog') . " as b
+ WHERE
+ t.topic_publish = 1
+ AND t.blog_id=b.blog_id
+ AND (
+ b.blog_type!='close'
+ { OR t.blog_id IN (?a) }
+ { OR t.blog_id2 IN (?a) }
+ { OR t.blog_id3 IN (?a) }
+ { OR t.blog_id4 IN (?a) }
+ { OR t.blog_id5 IN (?a) }
+ )
+ AND (
+ 1=0
+ { OR t.blog_id IN (?a) }
+ { OR t.blog_id2 IN (?a) }
+ { OR t.blog_id3 IN (?a) }
+ { OR t.blog_id4 IN (?a) }
+ { OR t.blog_id5 IN (?a) }
+
+ { OR t.user_id IN (?a) }
+ )
+ ORDER BY t.topic_date_publish DESC
+ LIMIT ?d, ?d ";
+
+ $aTopics = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql,
+ count($aBlogIdClose) ? $aBlogIdClose : DBSIMPLE_SKIP,
+ count($aBlogIdClose) ? $aBlogIdClose : DBSIMPLE_SKIP,
+ count($aBlogIdClose) ? $aBlogIdClose : DBSIMPLE_SKIP,
+ count($aBlogIdClose) ? $aBlogIdClose : DBSIMPLE_SKIP,
+ count($aBlogIdClose) ? $aBlogIdClose : DBSIMPLE_SKIP,
+
+ count($aBlogId) ? $aBlogId : DBSIMPLE_SKIP,
+ count($aBlogId) ? $aBlogId : DBSIMPLE_SKIP,
+ count($aBlogId) ? $aBlogId : DBSIMPLE_SKIP,
+ count($aBlogId) ? $aBlogId : DBSIMPLE_SKIP,
+ count($aBlogId) ? $aBlogId : DBSIMPLE_SKIP,
+
+ count($aUserId) ? $aUserId : DBSIMPLE_SKIP,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage)
+ ) {
+ foreach ($aRows as $aTopic) {
+ $aTopics[] = $aTopic['topic_id'];
+ }
+ }
+ return $aTopics;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/vote/Vote.class.php b/application/classes/modules/vote/Vote.class.php
new file mode 100644
index 0000000..806cc70
--- /dev/null
+++ b/application/classes/modules/vote/Vote.class.php
@@ -0,0 +1,204 @@
+
+ *
+ */
+
+/**
+ * Модуль для работы с голосованиями
+ *
+ * @package application.modules.vote
+ * @since 1.0
+ */
+class ModuleVote extends Module
+{
+ /**
+ * Объект маппера
+ *
+ * @var ModuleVote_MapperVote
+ */
+ protected $oMapper;
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ }
+
+ /**
+ * Добавляет голосование
+ *
+ * @param ModuleVote_EntityVote $oVote Объект голосования
+ * @return bool
+ */
+ public function AddVote(ModuleVote_EntityVote $oVote)
+ {
+ if (!$oVote->getIp()) {
+ $oVote->setIp(func_getIp());
+ }
+ if ($this->oMapper->AddVote($oVote)) {
+ $this->Cache_Delete("vote_{$oVote->getTargetType()}_{$oVote->getTargetId()}_{$oVote->getVoterId()}");
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,
+ array("vote_update_{$oVote->getTargetType()}_{$oVote->getVoterId()}"));
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Получает голосование
+ *
+ * @param int $sTargetId ID владельца
+ * @param string $sTargetType Тип владельца
+ * @param int $sUserId ID пользователя
+ * @return ModuleVote_EntityVote|null
+ */
+ public function GetVote($sTargetId, $sTargetType, $sUserId)
+ {
+ $data = $this->GetVoteByArray($sTargetId, $sTargetType, $sUserId);
+ if (isset($data[$sTargetId])) {
+ return $data[$sTargetId];
+ }
+ return null;
+ }
+
+ /**
+ * Получить список голосований по списку айдишников
+ *
+ * @param array $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetVoteByArray($aTargetId, $sTargetType, $sUserId)
+ {
+ if (!$aTargetId) {
+ return array();
+ }
+ if (Config::Get('sys.cache.solid')) {
+ return $this->GetVoteByArraySolid($aTargetId, $sTargetType, $sUserId);
+ }
+ if (!is_array($aTargetId)) {
+ $aTargetId = array($aTargetId);
+ }
+ $aTargetId = array_unique($aTargetId);
+ $aVote = array();
+ $aIdNotNeedQuery = array();
+ /**
+ * Делаем мульти-запрос к кешу
+ */
+ $aCacheKeys = func_build_cache_keys($aTargetId, "vote_{$sTargetType}_", '_' . $sUserId);
+ if (false !== ($data = $this->Cache_Get($aCacheKeys))) {
+ /**
+ * проверяем что досталось из кеша
+ */
+ foreach ($aCacheKeys as $sValue => $sKey) {
+ if (array_key_exists($sKey, $data)) {
+ if ($data[$sKey]) {
+ $aVote[$data[$sKey]->getTargetId()] = $data[$sKey];
+ } else {
+ $aIdNotNeedQuery[] = $sValue;
+ }
+ }
+ }
+ }
+ /**
+ * Смотрим каких топиков не было в кеше и делаем запрос в БД
+ */
+ $aIdNeedQuery = array_diff($aTargetId, array_keys($aVote));
+ $aIdNeedQuery = array_diff($aIdNeedQuery, $aIdNotNeedQuery);
+ $aIdNeedStore = $aIdNeedQuery;
+ if ($data = $this->oMapper->GetVoteByArray($aIdNeedQuery, $sTargetType, $sUserId)) {
+ foreach ($data as $oVote) {
+ /**
+ * Добавляем к результату и сохраняем в кеш
+ */
+ $aVote[$oVote->getTargetId()] = $oVote;
+ $this->Cache_Set($oVote,
+ "vote_{$oVote->getTargetType()}_{$oVote->getTargetId()}_{$oVote->getVoterId()}", array(),
+ 60 * 60 * 24 * 7);
+ $aIdNeedStore = array_diff($aIdNeedStore, array($oVote->getTargetId()));
+ }
+ }
+ /**
+ * Сохраняем в кеш запросы не вернувшие результата
+ */
+ foreach ($aIdNeedStore as $sId) {
+ $this->Cache_Set(null, "vote_{$sTargetType}_{$sId}_{$sUserId}", array(), 60 * 60 * 24 * 7);
+ }
+ /**
+ * Сортируем результат согласно входящему массиву
+ */
+ $aVote = func_array_sort_by_keys($aVote, $aTargetId);
+ return $aVote;
+ }
+
+ /**
+ * Получить список голосований по списку айдишников, но используя единый кеш
+ *
+ * @param array $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetVoteByArraySolid($aTargetId, $sTargetType, $sUserId)
+ {
+ if (!is_array($aTargetId)) {
+ $aTargetId = array($aTargetId);
+ }
+ $aTargetId = array_unique($aTargetId);
+ $aVote = array();
+ $s = join(',', $aTargetId);
+ if (false === ($data = $this->Cache_Get("vote_{$sTargetType}_{$sUserId}_id_{$s}"))) {
+ $data = $this->oMapper->GetVoteByArray($aTargetId, $sTargetType, $sUserId);
+ foreach ($data as $oVote) {
+ $aVote[$oVote->getTargetId()] = $oVote;
+ }
+ $this->Cache_Set(
+ $aVote, "vote_{$sTargetType}_{$sUserId}_id_{$s}",
+ array("vote_update_{$sTargetType}_{$sUserId}", "vote_update_{$sTargetType}"),
+ 60 * 60 * 24 * 1
+ );
+ return $aVote;
+ }
+ return $data;
+ }
+
+ /**
+ * Удаляет голосование из базы по списку идентификаторов таргета
+ *
+ * @param array|int $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @return bool
+ */
+ public function DeleteVoteByTarget($aTargetId, $sTargetType)
+ {
+ if (!is_array($aTargetId)) {
+ $aTargetId = array($aTargetId);
+ }
+ $aTargetId = array_unique($aTargetId);
+ /**
+ * Чистим зависимые кеши
+ */
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("vote_update_{$sTargetType}"));
+ return $this->oMapper->DeleteVoteByTarget($aTargetId, $sTargetType);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/vote/entity/Vote.entity.class.php b/application/classes/modules/vote/entity/Vote.entity.class.php
new file mode 100644
index 0000000..0b13682
--- /dev/null
+++ b/application/classes/modules/vote/entity/Vote.entity.class.php
@@ -0,0 +1,170 @@
+
+ *
+ */
+
+/**
+ * Сущность голосования
+ *
+ * @package application.modules.vote
+ * @since 1.0
+ */
+class ModuleVote_EntityVote extends Entity
+{
+ /**
+ * Возвращает ID владельца
+ *
+ * @return int|null
+ */
+ public function getTargetId()
+ {
+ return $this->_getDataOne('target_id');
+ }
+
+ /**
+ * Возвращает тип владельца
+ *
+ * @return string|null
+ */
+ public function getTargetType()
+ {
+ return $this->_getDataOne('target_type');
+ }
+
+ /**
+ * Возвращает ID проголосовавшего пользователя
+ *
+ * @return int|null
+ */
+ public function getVoterId()
+ {
+ return $this->_getDataOne('user_voter_id');
+ }
+
+ /**
+ * Возвращает направление голоса: 0, 1, -1
+ *
+ * @return int|null
+ */
+ public function getDirection()
+ {
+ return $this->_getDataOne('vote_direction');
+ }
+
+ /**
+ * Возвращает значение при голосовании
+ *
+ * @return float|null
+ */
+ public function getValue()
+ {
+ return $this->_getDataOne('vote_value');
+ }
+
+ /**
+ * Возвращает дату голосования
+ *
+ * @return string|null
+ */
+ public function getDate()
+ {
+ return $this->_getDataOne('vote_date');
+ }
+
+ /**
+ * Возвращает IP голосовавшего
+ *
+ * @return string|null
+ */
+ public function getIp()
+ {
+ return $this->_getDataOne('vote_ip');
+ }
+
+
+ /**
+ * Устанавливает ID владельца
+ *
+ * @param int $data
+ */
+ public function setTargetId($data)
+ {
+ $this->_aData['target_id'] = $data;
+ }
+
+ /**
+ * Устанавливает тип владельца
+ *
+ * @param string $data
+ */
+ public function setTargetType($data)
+ {
+ $this->_aData['target_type'] = $data;
+ }
+
+ /**
+ * Устанавливает ID проголосовавшего пользователя
+ *
+ * @param int $data
+ */
+ public function setVoterId($data)
+ {
+ $this->_aData['user_voter_id'] = $data;
+ }
+
+ /**
+ * Устанавливает направление голоса: 0, 1, -1
+ *
+ * @param int $data
+ */
+ public function setDirection($data)
+ {
+ $this->_aData['vote_direction'] = $data;
+ }
+
+ /**
+ * Устанавливает значение при голосовании
+ *
+ * @param float $data
+ */
+ public function setValue($data)
+ {
+ $this->_aData['vote_value'] = $data;
+ }
+
+ /**
+ * Устанавливает дату голосования
+ *
+ * @param string $data
+ */
+ public function setDate($data)
+ {
+ $this->_aData['vote_date'] = $data;
+ }
+
+ /**
+ * Устанавливает IP голосовавшего
+ *
+ * @param string $data
+ */
+ public function setIp($data)
+ {
+ $this->_aData['vote_ip'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/vote/mapper/Vote.mapper.class.php b/application/classes/modules/vote/mapper/Vote.mapper.class.php
new file mode 100644
index 0000000..396d69b
--- /dev/null
+++ b/application/classes/modules/vote/mapper/Vote.mapper.class.php
@@ -0,0 +1,108 @@
+
+ *
+ */
+
+/**
+ * Маппер для работы с БД
+ *
+ * @package application.modules.vote
+ * @since 1.0
+ */
+class ModuleVote_MapperVote extends Mapper
+{
+ /**
+ * Добавляет голосование
+ *
+ * @param ModuleVote_EntityVote $oVote Объект голосования
+ * @return bool
+ */
+ public function AddVote(ModuleVote_EntityVote $oVote)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.vote') . "
+ (target_id,
+ target_type,
+ user_voter_id,
+ vote_direction,
+ vote_value,
+ vote_date,
+ vote_ip
+ )
+ VALUES(?d, ?, ?d, ?d, ?f, ?, ?)
+ ";
+ if ($this->oDb->query($sql, $oVote->getTargetId(), $oVote->getTargetType(), $oVote->getVoterId(),
+ $oVote->getDirection(), $oVote->getValue(), $oVote->getDate(), $oVote->getIp()) === 0
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Получить список голосований по списку айдишников
+ *
+ * @param array $aArrayId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @param int $sUserId ID пользователя
+ * @return array
+ */
+ public function GetVoteByArray($aArrayId, $sTargetType, $sUserId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.vote') . "
+ WHERE
+ target_id IN(?a)
+ AND
+ target_type = ?
+ AND
+ user_voter_id = ?d ";
+ $aVotes = array();
+ if ($aRows = $this->oDb->select($sql, $aArrayId, $sTargetType, $sUserId)) {
+ foreach ($aRows as $aRow) {
+ $aVotes[] = Engine::GetEntity('Vote', $aRow);
+ }
+ }
+ return $aVotes;
+ }
+
+ /**
+ * Удаляет голосование из базы по списку идентификаторов таргета
+ *
+ * @param array|int $aTargetId Список ID владельцев
+ * @param string $sTargetType Тип владельца
+ * @return bool
+ */
+ public function DeleteVoteByTarget($aTargetId, $sTargetType)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.vote') . "
+ WHERE
+ target_id IN(?a)
+ AND
+ target_type = ?
+ ";
+ $res = $this->oDb->query($sql, $aTargetId, $sTargetType);
+ return $this->IsSuccessful($res);
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/wall/Wall.class.php b/application/classes/modules/wall/Wall.class.php
new file mode 100644
index 0000000..ed3baea
--- /dev/null
+++ b/application/classes/modules/wall/Wall.class.php
@@ -0,0 +1,323 @@
+
+ *
+ */
+
+/**
+ * Модуль Wall - записи на стене профиля пользователя
+ *
+ * @package application.modules.wall
+ * @since 1.0
+ */
+class ModuleWall extends Module
+{
+ /**
+ * Объект маппера
+ *
+ * @var ModuleWall_MapperWall
+ */
+ protected $oMapper;
+ /**
+ * Объект текущего пользователя
+ *
+ * @var ModuleUser_EntityUser|null
+ */
+ protected $oUserCurrent;
+
+ /**
+ * Инициализация
+ *
+ */
+ public function Init()
+ {
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ $this->oUserCurrent = $this->User_GetUserCurrent();
+ }
+
+ /**
+ * Добавление записи на стену
+ *
+ * @param ModuleWall_EntityWall $oWall Объект записи на стене
+ * @return bool|ModuleWall_EntityWall
+ */
+ public function AddWall($oWall)
+ {
+ if (!$oWall->getDateAdd()) {
+ $oWall->setDateAdd(date("Y-m-d H:i:s"));
+ }
+ if (!$oWall->getIp()) {
+ $oWall->setIp(func_getIp());
+ }
+ if ($iId = $this->oMapper->AddWall($oWall)) {
+ $oWall->setId($iId);
+ /**
+ * Обновляем данные у родительской записи
+ */
+ if ($oPidWall = $oWall->GetPidWall()) {
+ $this->UpdatePidWall($oPidWall);
+ }
+ return $oWall;
+ }
+ return false;
+ }
+
+ /**
+ * Обновление записи
+ *
+ * @param ModuleWall_EntityWall $oWall Объект записи на стене
+ * @return bool
+ */
+ public function UpdateWall($oWall)
+ {
+ return $this->oMapper->UpdateWall($oWall);
+ }
+
+ /**
+ * Получение списка записей по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @param array $aAllowData Список типов дополнительных данных для подгрузки в сообщения стены
+ * @return array('collection'=>array,'count'=>int)
+ */
+ public function GetWall($aFilter, $aOrder, $iCurrPage = 1, $iPerPage = 10, $aAllowData = null)
+ {
+ $aResult = array(
+ 'collection' => $this->oMapper->GetWall($aFilter, $aOrder, $iCount, $iCurrPage, $iPerPage),
+ 'count' => $iCount
+ );
+ $aResult['collection'] = $this->GetWallAdditionalData($aResult['collection'], $aAllowData);
+ return $aResult;
+ }
+
+ /**
+ * Возвращает число сообщений на стене по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @return int
+ */
+ public function GetCountWall($aFilter)
+ {
+ return $this->oMapper->GetCountWall($aFilter);
+ }
+
+ /**
+ * Получение записей по ID, без дополнительных данных
+ *
+ * @param array $aWallId Список ID сообщений
+ * @return array
+ */
+ public function GetWallsByArrayId($aWallId)
+ {
+ if (!is_array($aWallId)) {
+ $aWallId = array($aWallId);
+ }
+ $aWallId = array_unique($aWallId);
+ $aWalls = array();
+ $aResult = $this->oMapper->GetWallsByArrayId($aWallId);
+ foreach ($aResult as $oWall) {
+ $aWalls[$oWall->getId()] = $oWall;
+ }
+ return $aWalls;
+ }
+
+ /**
+ * Получение записей по ID с дополнительные связаными данными
+ *
+ * @param array $aWallId Список ID сообщений
+ * @param array $aAllowData Список типов дополнительных данных для подгрузки в сообщения стены
+ * @return array
+ */
+ public function GetWallAdditionalData($aWallId, $aAllowData = null)
+ {
+ if (is_null($aAllowData)) {
+ $aAllowData = array('user' => array(), 'wall_user' => array(), 'reply');
+ }
+ func_array_simpleflip($aAllowData);
+ if (!is_array($aWallId)) {
+ $aWallId = array($aWallId);
+ }
+
+ $aWalls = $this->GetWallsByArrayId($aWallId);
+ /**
+ * Формируем ID дополнительных данных, которые нужно получить
+ */
+ $aUserId = array();
+ $aWallUserId = array();
+ $aWallReplyId = array();
+ foreach ($aWalls as $oWall) {
+ if (isset($aAllowData['user'])) {
+ $aUserId[] = $oWall->getUserId();
+ }
+ if (isset($aAllowData['wall_user'])) {
+ $aWallUserId[] = $oWall->getWallUserId();
+ }
+ /**
+ * Список последних записей хранится в строке через запятую
+ */
+ if (isset($aAllowData['reply']) and is_null($oWall->getPid()) and $oWall->getLastReply()) {
+ $aReply = explode(',', trim($oWall->getLastReply()));
+ $aWallReplyId = array_merge($aWallReplyId, $aReply);
+ }
+ }
+ /**
+ * Получаем дополнительные данные
+ */
+ $aUsers = isset($aAllowData['user']) && is_array($aAllowData['user']) ? $this->User_GetUsersAdditionalData($aUserId,
+ $aAllowData['user']) : $this->User_GetUsersAdditionalData($aUserId);
+ $aWallUsers = isset($aAllowData['wall_user']) && is_array($aAllowData['wall_user']) ? $this->User_GetUsersAdditionalData($aWallUserId,
+ $aAllowData['wall_user']) : $this->User_GetUsersAdditionalData($aWallUserId);
+ $aWallReply = array();
+ if (isset($aAllowData['reply']) and count($aWallReplyId)) {
+ $aWallReply = $this->GetWallAdditionalData($aWallReplyId, array('user' => array()));
+ }
+ /**
+ * Добавляем данные к результату
+ */
+ foreach ($aWalls as $oWall) {
+ if (isset($aUsers[$oWall->getUserId()])) {
+ $oWall->setUser($aUsers[$oWall->getUserId()]);
+ } else {
+ $oWall->setUser(null); // или $oWall->setUser(new ModuleUser_EntityUser());
+ }
+ if (isset($aWallUsers[$oWall->getWallUserId()])) {
+ $oWall->setWallUser($aWallUsers[$oWall->getWallUserId()]);
+ } else {
+ $oWall->setWallUser(null);
+ }
+ $aReply = array();
+ if ($oWall->getLastReply()) {
+ $aReplyId = explode(',', trim($oWall->getLastReply()));
+ foreach ($aReplyId as $iReplyId) {
+ if (isset($aWallReply[$iReplyId])) {
+ $aReply[] = $aWallReply[$iReplyId];
+ }
+ }
+ }
+ $oWall->setLastReplyWall($aReply);
+ }
+ return $aWalls;
+ }
+
+ /**
+ * Получение записи по ID
+ *
+ * @param int $iId ID сообщения/записи
+ * @return ModuleWall_EntityWall
+ */
+ public function GetWallById($iId)
+ {
+ if (!is_numeric($iId)) {
+ return null;
+ }
+ $aResult = $this->GetWallAdditionalData($iId);
+ if (isset($aResult[$iId])) {
+ return $aResult[$iId];
+ }
+ return null;
+ }
+
+ /**
+ * Обновляет родительские данные у записи - количество ответов и ID последних ответов
+ *
+ * @param ModuleWall_EntityWall $oWall Объект записи на стене
+ * @param null|int $iLimit
+ */
+ public function UpdatePidWall($oWall, $iLimit = null)
+ {
+ if (is_null($iLimit)) {
+ $iLimit = Config::Get('module.wall.count_last_reply');
+ }
+
+ $aResult = $this->GetWall(array('pid' => $oWall->getId()), array('id' => 'desc'), 1, $iLimit, array());
+ if ($aResult['count']) {
+ $oWall->setCountReply($aResult['count']);
+ $aKeys = array_keys($aResult['collection']);
+ sort($aKeys, SORT_NUMERIC);
+ $oWall->setLastReply(join(',', $aKeys));
+ } else {
+ $oWall->setCountReply(0);
+ $oWall->setLastReply('');
+ }
+ $this->UpdateWall($oWall);
+ }
+
+ /**
+ * Удаление сообщения
+ *
+ * @param ModuleWall_EntityWall $oWall Объект записи на стене
+ */
+ public function DeleteWall($oWall)
+ {
+ $this->oMapper->DeleteWallsByPid($oWall->getId());
+ $this->oMapper->DeleteWallById($oWall->getId());
+ if ($oWallParent = $oWall->GetPidWall()) {
+ $this->UpdatePidWall($oWallParent);
+ }
+ }
+
+ /**
+ * Уведомление при ответе на сообщение на стене
+ *
+ * @param ModuleWall_EntityWall $oWallParent Объект сообщения на стене, на которое отвечаем
+ * @param ModuleWall_EntityWall $oWall Объект нового сообщения на стене
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ */
+ public function SendNotifyWallReply(
+ ModuleWall_EntityWall $oWallParent,
+ ModuleWall_EntityWall $oWall,
+ ModuleUser_EntityUser $oUser
+ ) {
+ $this->Notify_Send(
+ $oWallParent->getUser(),
+ 'wall.reply.tpl',
+ $this->Lang_Get('emails.wall_reply.subject'),
+ array(
+ 'oWallParent' => $oWallParent,
+ 'oUserTo' => $oWallParent->getUser(),
+ 'oWall' => $oWall,
+ 'oUser' => $oUser,
+ 'oUserWall' => $oWall->getWallUser(), // кому принадлежит стена
+ )
+ );
+ }
+
+ /**
+ * Уведомление о новом сообщение на стене
+ *
+ * @param ModuleWall_EntityWall $oWall Объект нового сообщения на стене
+ * @param ModuleUser_EntityUser $oUser Объект пользователя
+ */
+ public function SendNotifyWallNew(ModuleWall_EntityWall $oWall, ModuleUser_EntityUser $oUser)
+ {
+ $this->Notify_Send(
+ $oWall->getWallUser(),
+ 'wall.new.tpl',
+ $this->Lang_Get('emails.wall_new.subject'),
+ array(
+ 'oUserTo' => $oWall->getWallUser(),
+ 'oWall' => $oWall,
+ 'oUser' => $oUser,
+ 'oUserWall' => $oWall->getWallUser(), // кому принадлежит стена
+ )
+ );
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/wall/entity/Wall.entity.class.php b/application/classes/modules/wall/entity/Wall.entity.class.php
new file mode 100644
index 0000000..75ff037
--- /dev/null
+++ b/application/classes/modules/wall/entity/Wall.entity.class.php
@@ -0,0 +1,166 @@
+
+ *
+ */
+
+/**
+ * Сущность записи на стене
+ *
+ * @package application.modules.wall
+ * @since 1.0
+ */
+class ModuleWall_EntityWall extends Entity
+{
+ /**
+ * Определяем правила валидации
+ *
+ * @var array
+ */
+ protected $aValidateRules = array(
+ array('pid', 'pid', 'on' => array('', 'add')),
+ array('user_id', 'time_limit', 'on' => array('add')),
+ );
+
+ /**
+ * Инициализация
+ */
+ public function Init()
+ {
+ parent::Init();
+ $this->aValidateRules[] = array(
+ 'text',
+ 'string',
+ 'max' => Config::Get('module.wall.text_max'),
+ 'min' => Config::Get('module.wall.text_min'),
+ 'allowEmpty' => false,
+ 'on' => array('', 'add')
+ );
+ }
+
+ /**
+ * Проверка на ограничение по времени
+ *
+ * @param string $sValue Проверяемое значение
+ * @param array $aParams Параметры
+ * @return bool|string
+ */
+ public function ValidateTimeLimit($sValue, $aParams)
+ {
+ if ($oUser = $this->User_GetUserById($this->getUserId())) {
+ if ($this->ACL_CanAddWallTime($oUser, $this)) {
+ return true;
+ }
+ }
+ return $this->Lang_Get('wall.notices.error_add_time_limit');
+ }
+
+ /**
+ * Валидация родительского сообщения
+ *
+ * @param string $sValue Проверяемое значение
+ * @param array $aParams Параметры
+ * @return bool|string
+ */
+ public function ValidatePid($sValue, $aParams)
+ {
+ if (!$sValue) {
+ $this->setPid(null);
+ return true;
+ } elseif ($oParentWall = $this->GetPidWall()) {
+ /**
+ * Если отвечаем на сообщение нужной стены и оно корневое, то все ОК
+ */
+ if ($oParentWall->getWallUserId() == $this->getWallUserId() and !$oParentWall->getPid()) {
+ return true;
+ }
+ }
+ return $this->Lang_Get('wall.notices.error_add_pid');
+ }
+
+ /**
+ * Возвращает родительскую запись
+ *
+ * @return ModuleWall_EntityWall|null
+ */
+ public function GetPidWall()
+ {
+ if ($this->getPid()) {
+ return $this->Wall_GetWallById($this->getPid());
+ }
+ return null;
+ }
+
+ /**
+ * Проверка на возможность удаления сообщения
+ *
+ * @return bool
+ */
+ public function isAllowDelete()
+ {
+ if ($oUserCurrent = $this->User_GetUserCurrent()) {
+ if ($oUserCurrent->getId() == $this->getWallUserId() or $oUserCurrent->isAdministrator()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Проверка на разрешение редактировать
+ *
+ * @return mixed
+ */
+ public function isAllowEdit()
+ {
+ return false;
+ }
+
+ /**
+ * Возвращает пользователя, которому принадлежит стена
+ *
+ * @return ModuleUser_EntityUser|null
+ */
+ public function getWallUser()
+ {
+ if (!$this->_getDataOne('wall_user')) {
+ $this->_aData['wall_user'] = $this->User_GetUserById($this->getWallUserId());
+ }
+ return $this->_getDataOne('wall_user');
+ }
+
+ /**
+ * Возвращает URL стены
+ *
+ * @return string
+ */
+ public function getUrlWall()
+ {
+ return $this->getWallUser()->getUserWebPath() . 'wall/';
+ }
+
+ /**
+ * Дата добавления
+ *
+ * @return string
+ */
+ public function getDate()
+ {
+ return $this->getDateAdd();
+ }
+}
\ No newline at end of file
diff --git a/application/classes/modules/wall/mapper/Wall.mapper.class.php b/application/classes/modules/wall/mapper/Wall.mapper.class.php
new file mode 100644
index 0000000..baf4589
--- /dev/null
+++ b/application/classes/modules/wall/mapper/Wall.mapper.class.php
@@ -0,0 +1,217 @@
+
+ *
+ */
+
+/**
+ * Маппер для работы с БД
+ *
+ * @package application.modules.wall
+ * @since 1.0
+ */
+class ModuleWall_MapperWall extends Mapper
+{
+ /**
+ * Добавление записи на стену
+ *
+ * @param ModuleWall_EntityWall $oWall Объект записи на стене
+ * @return bool|int
+ */
+ public function AddWall($oWall)
+ {
+ $sql = "INSERT INTO " . Config::Get('db.table.wall') . " SET ?a ";
+ if ($iId = $this->oDb->query($sql, $oWall->_getData())) {
+ return $iId;
+ }
+ return false;
+ }
+
+ /**
+ * Обновление записи
+ *
+ * @param ModuleWall_EntityWall $oWall Объект записи на стене
+ * @return bool
+ */
+ public function UpdateWall($oWall)
+ {
+ $sql = "UPDATE " . Config::Get('db.table.wall') . "
+ SET
+ count_reply = ?d,
+ last_reply = ?
+ WHERE id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oWall->getCountReply(),
+ $oWall->getLastReply(),
+ $oWall->getId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Удаление записи
+ *
+ * @param int $iId ID записи
+ * @return bool
+ */
+ public function DeleteWallById($iId)
+ {
+ $sql = "DELETE FROM " . Config::Get('db.table.wall') . " WHERE id = ?d ";
+ $res = $this->oDb->query($sql, $iId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * @param int $iPid ID родительской записи
+ * @return bool
+ */
+ public function DeleteWallsByPid($iPid)
+ {
+ $sql = "DELETE FROM " . Config::Get('db.table.wall') . " WHERE pid = ?d ";
+ $res = $this->oDb->query($sql, $iPid);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получение списка записей по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param array $aOrder Сортировка
+ * @param int $iCount Возвращает общее количество элементов
+ * @param int $iCurrPage Номер страницы
+ * @param int $iPerPage Количество элементов на страницу
+ * @return array
+ */
+ public function GetWall($aFilter, $aOrder, &$iCount, $iCurrPage, $iPerPage)
+ {
+ $aOrderAllow = array('id', 'date_add');
+ $sOrder = '';
+ foreach ($aOrder as $key => $value) {
+ if (!in_array($key, $aOrderAllow)) {
+ unset($aOrder[$key]);
+ } elseif (in_array($value, array('asc', 'desc'))) {
+ $sOrder .= " {$key} {$value},";
+ }
+ }
+ $sOrder = trim($sOrder, ',');
+ if ($sOrder == '') {
+ $sOrder = ' id desc ';
+ }
+
+
+ $sql = "SELECT
+ id
+ FROM
+ " . Config::Get('db.table.wall') . "
+ WHERE
+ 1 = 1
+ { AND pid = ?d }
+ { AND pid IS NULL AND 1 = ?d }
+ { AND wall_user_id = ?d }
+ { AND user_id = ?d }
+ { AND ip = ? }
+ { AND id = ?d }
+ { AND id < ?d }
+ { AND id > ?d }
+ ORDER by {$sOrder}
+ LIMIT ?d, ?d ;
+ ";
+ $aResult = array();
+ if ($aRows = $this->oDb->selectPage($iCount, $sql,
+ (isset($aFilter['pid']) and !is_null($aFilter['pid'])) ? $aFilter['pid'] : DBSIMPLE_SKIP,
+ (array_key_exists('pid', $aFilter) and is_null($aFilter['pid'])) ? 1 : DBSIMPLE_SKIP,
+ isset($aFilter['wall_user_id']) ? $aFilter['wall_user_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['user_id']) ? $aFilter['user_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['ip']) ? $aFilter['ip'] : DBSIMPLE_SKIP,
+ isset($aFilter['id']) ? $aFilter['id'] : DBSIMPLE_SKIP,
+ isset($aFilter['id_less']) ? $aFilter['id_less'] : DBSIMPLE_SKIP,
+ isset($aFilter['id_more']) ? $aFilter['id_more'] : DBSIMPLE_SKIP,
+ ($iCurrPage - 1) * $iPerPage, $iPerPage
+ )
+ ) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = $aRow['id'];
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Возвращает число сообщений на стене по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @return int
+ */
+ public function GetCountWall($aFilter)
+ {
+ $sql = "SELECT
+ count(*) as c
+ FROM
+ " . Config::Get('db.table.wall') . "
+ WHERE
+ 1 = 1
+ { AND pid = ?d }
+ { AND pid IS NULL AND 1 = ?d }
+ { AND wall_user_id = ?d }
+ { AND ip = ? }
+ { AND id = ?d }
+ { AND id < ?d }
+ { AND id > ?d };
+ ";
+ if ($aRow = $this->oDb->selectRow($sql,
+ (isset($aFilter['pid']) and !is_null($aFilter['pid'])) ? $aFilter['pid'] : DBSIMPLE_SKIP,
+ (array_key_exists('pid', $aFilter) and is_null($aFilter['pid'])) ? 1 : DBSIMPLE_SKIP,
+ isset($aFilter['wall_user_id']) ? $aFilter['wall_user_id'] : DBSIMPLE_SKIP,
+ isset($aFilter['ip']) ? $aFilter['ip'] : DBSIMPLE_SKIP,
+ isset($aFilter['id']) ? $aFilter['id'] : DBSIMPLE_SKIP,
+ isset($aFilter['id_less']) ? $aFilter['id_less'] : DBSIMPLE_SKIP,
+ isset($aFilter['id_more']) ? $aFilter['id_more'] : DBSIMPLE_SKIP
+ )
+ ) {
+ return $aRow['c'];
+ }
+ return 0;
+ }
+
+ /**
+ * Получение записей по ID, без дополнительных данных
+ *
+ * @param array $aArrayId Список ID сообщений
+ * @return array
+ */
+ public function GetWallsByArrayId($aArrayId)
+ {
+ if (!is_array($aArrayId) or count($aArrayId) == 0) {
+ return array();
+ }
+
+ $sql = "SELECT
+ *
+ FROM
+ " . Config::Get('db.table.wall') . "
+ WHERE
+ id IN(?a)
+ ORDER BY FIELD(id,?a) ";
+ $aResult = array();
+ if ($aRows = $this->oDb->select($sql, $aArrayId, $aArrayId)) {
+ foreach ($aRows as $aRow) {
+ $aResult[] = Engine::GetEntity('Wall', $aRow);
+ }
+ }
+ return $aResult;
+ }
+}
\ No newline at end of file
diff --git a/application/config/.htaccess b/application/config/.htaccess
new file mode 100644
index 0000000..c3c2d19
--- /dev/null
+++ b/application/config/.htaccess
@@ -0,0 +1,2 @@
+Order Deny,Allow
+Deny from all
diff --git a/application/config/config.local.php.dist b/application/config/config.local.php.dist
new file mode 100644
index 0000000..e968bbd
--- /dev/null
+++ b/application/config/config.local.php.dist
@@ -0,0 +1,33 @@
+ и добавляя rel="nofollow"
+$config['view']['img_resize_width'] = 570; // до какого размера в пикселях ужимать картинку по щирине при загрузки её в топики и комменты
+$config['view']['img_max_width'] = 5000; // максимальная ширина загружаемых изображений в пикселях
+$config['view']['img_max_height'] = 5000; // максимальная высота загружаемых изображений в пикселях
+$config['view']['img_max_size_url'] = 500; // максимальный размер картинки в kB для загрузки по URL
+
+/**
+ * Настройки СЕО для вывода топиков
+ */
+$config['seo']['description_words_count'] = 20; // количество слов из топика для вывода в метатег description
+$config['view']['seo']['topic_heading_list'] = 'article'; // тег для списка топиков
+$config['view']['seo']['topic_heading'] = 'article'; // тег для списка топиков
+
+/**
+ * Настройка основных блоков
+ */
+$config['block']['stream']['row'] = 20; // сколько записей выводить в блоке "Прямой эфир"
+$config['block']['stream']['show_tip'] = true; // выводить или нет всплывающие сообщения в блоке "Прямой эфир"
+$config['block']['blogs']['row'] = 10; // сколько записей выводить в блоке "Блоги"
+$config['block']['tags']['tags_count'] = 70; // сколько тегов выводить в блоке "теги"
+$config['block']['tags']['personal_tags_count'] = 70; // сколько тегов пользователя выводить в блоке "теги"
+
+/**
+ * Общие настройки
+ */
+$config['general']['close'] = false; // использовать закрытый режим работы сайта, сайт будет доступен только авторизованным пользователям
+$config['general']['close_exceptions'] = array(
+ 'auth',
+ 'ajax' => array('captcha'),
+); // список action/avent для исключения при закрытом режиме
+$config['general']['rss_editor_mail'] = '___sys.mail.from_email___'; // мыло редактора РСС
+$config['general']['reg']['invite'] = false; // использовать режим регистрации по приглашению или нет. Если использовать, то регистрация будет доступна ТОЛЬКО по приглашениям!
+$config['general']['reg']['activation'] = false; // использовать активацию при регистрации или нет
+$config['general']['login']['captcha'] = false; // использовать каптчу при входе или нет
+$config['general']['admin_mail'] = 'admin@admin.adm'; // email администратора
+/**
+ * Настройка каптчи
+ */
+$config['sys']['captcha']['type'] = 'kcaptcha'; // тип используемой каптчи: kcaptcha, recaptcha
+/**
+ * Настройки кеширования
+ */
+$config['sys']['cache']['use'] = false; // использовать кеширование или нет
+$config['sys']['cache']['type'] = 'file'; // тип кеширования: file, xcache и memory. memory использует мемкеш, xcache - использует XCache
+
+/**
+ * Настройки ACL(Access Control List — список контроля доступа)
+ */
+$config['acl']['create']['blog']['rating'] = 1; // порог рейтинга при котором юзер может создать коллективный блог
+$config['acl']['create']['comment']['rating'] = -10; // порог рейтинга при котором юзер может добавлять комментарии
+$config['acl']['create']['comment']['limit_time'] = 10; // время в секундах между постингом комментариев, если 0 то ограничение по времени не будет работать
+$config['acl']['create']['comment']['limit_time_rating'] = -1; // рейтинг, выше которого перестаёт действовать ограничение по времени на постинг комментов. Не имеет смысла при $config['acl']['create']['comment']['limit_time']=0
+$config['acl']['create']['topic']['limit_time'] = 240;// время в секундах между созданием записей, если 0 то ограничение по времени не будет работать
+$config['acl']['create']['topic']['limit_time_rating'] = 5; // рейтинг, выше которого перестаёт действовать ограничение по времени на создание записей
+$config['acl']['create']['topic']['limit_rating'] = -20;// порог рейтинга при котором юзер может создавать топики (учитываются любые блоги, включая персональные), как дополнительная защита от спама/троллинга
+$config['acl']['create']['talk']['limit_time'] = 300; // время в секундах между отправкой инбоксов, если 0 то ограничение по времени не будет работать
+$config['acl']['create']['talk']['limit_time_rating'] = 1; // рейтинг, выше которого перестаёт действовать ограничение по времени на отправку инбоксов
+$config['acl']['create']['talk_comment']['limit_time'] = 10; // время в секундах между отправкой инбоксов, если 0 то ограничение по времени не будет работать
+$config['acl']['create']['talk_comment']['limit_time_rating'] = 5; // рейтинг, выше которого перестаёт действовать ограничение по времени на отправку инбоксов
+$config['acl']['create']['wall']['limit_time'] = 20; // рейтинг, выше которого перестаёт действовать ограничение по времени на отправку сообщений на стену
+$config['acl']['create']['wall']['limit_time_rating'] = 0; // рейтинг, выше которого перестаёт действовать ограничение по времени на отправку сообщений на стену
+$config['acl']['update']['comment']['rating'] = -5; // порог рейтинга при котором юзер может редактировать комментарии
+$config['acl']['update']['comment']['limit_time'] = 60 * 3; // время в секундах после создания комментария, когда можно его отредактировать, если 0 то ограничение по времени не будет работать
+$config['acl']['vote']['comment']['rating'] = -3; // порог рейтинга при котором юзер может голосовать за комментарии
+$config['acl']['vote']['topic']['rating'] = -7; // порог рейтинга при котором юзер может голосовать за топик
+$config['acl']['vote']['topic']['limit_time'] = 60 * 60 * 24 * 20; // ограничение времени голосования за топик
+$config['acl']['vote']['comment']['limit_time'] = 60 * 60 * 24 * 5; // ограничение времени голосования за комментарий
+/**
+ * Настройки модулей
+ */
+// Модуль Rating
+$config['module']['rating']['comment_multiplier'] = 0.1; // Множитель рейтинга при голосовании за комментарий
+$config['module']['rating']['topic_multiplier'] = 1; // Множитель рейтинга при голосовании за топик
+// Модуль Blog
+$config['module']['blog']['per_page'] = 20; // Число блогов на страницу
+$config['module']['blog']['users_per_page'] = 20; // Число пользователей блога на страницу
+$config['module']['blog']['personal_good'] = -5; // Рейтинг топика в персональном блоге ниже которого он считается плохим
+$config['module']['blog']['collective_good'] = -3; // рейтинг топика в коллективных блогах ниже которого он считается плохим
+$config['module']['blog']['index_good'] = 8; // Рейтинг топика выше которого(включительно) он попадает на главную
+$config['module']['blog']['encrypt'] = 'livestreet'; // Ключ XXTEA шифрования идентификаторов в ссылках приглашения в блоги
+$config['module']['blog']['avatar_size'] = array(
+ '500crop',
+ '100crop',
+ '64crop',
+ '48crop',
+ '24crop'
+); // Список размеров аватаров у блога
+$config['module']['blog']['avatar_size_big'] = '500crop'; // Размер большой аватарки блога, которая будет использоваться на странице блога
+$config['module']['blog']['category_allow'] = true; // Разрешить использование категорий бля блогов
+$config['module']['blog']['category_only_admin'] = true; // Задавать и менять категории для блога может только админ
+$config['module']['blog']['category_only_without_children'] = true; // Для блога можно выбрать только конечную категорию, у которой нет других вложенных
+$config['module']['blog']['category_allow_empty'] = true; // Разрешить блоги без категории
+// Модуль Topic
+$config['module']['topic']['new_time'] = 60 * 60 * 24 * 1; // Время в секундах в течении которого топик считается новым
+$config['module']['topic']['per_page'] = 10; // Число топиков на одну страницу
+$config['module']['topic']['max_length'] = 15000; // Максимальное количество символов в одном топике
+$config['module']['topic']['min_length'] = 2; // Минимальное количество символов в одном топике
+$config['module']['topic']['allow_empty'] = false; // Разрешать или нет не заполнять текст топика
+$config['module']['topic']['title_max_length'] = 200; // Максимальное количество символов в заголовке топика
+$config['module']['topic']['title_min_length'] = 2; // Минимальное количество символов в заголовке топика
+$config['module']['topic']['title_allow_empty'] = false; // Разрешать или нет не заполнять заголовок топика
+$config['module']['topic']['tags_allow_empty'] = false; // Разрешать или нет не заполнять теги
+$config['module']['topic']['tags_count_min'] = 1; // Минимальное количество тегов
+$config['module']['topic']['tags_count_max'] = 15; // Максимальное количество тегов
+$config['module']['topic']['default_period_top'] = 1; // Дефолтный период (количество дней) для отображения ТОП топиков. Значения: 1,7,30,'all'
+$config['module']['topic']['default_period_discussed'] = 1; // Дефолтный период (количество дней) для отображения обсуждаемых топиков. Значения: 1,7,30,'all'
+$config['module']['topic']['max_blog_count'] = 3; // Количество блогов, которые можно задать топику. Максимальное значение 5.
+$config['module']['topic']['max_rss_count'] = 20; // Максимальное количество топиков в RSS потоке
+$config['module']['topic']['default_preview_size'] = '900x300crop'; // Дефолтный размер превью для топика (все размеры задаются в конфиге media), который будет использоваться, например, для Open Graph
+/**
+ * Настройка ЧПУ URL топиков
+ * Допустимы шаблоны:
+ * %year% - год топика (2010)
+ * %month% - месяц (08)
+ * %day% - день (24)
+ * %hour% - час (17)
+ * %minute% - минуты (06)
+ * %second% - секунды (54)
+ * %login% - логин автора топика (admin)
+ * %blog% - url основного блога (report), если топик без блога, то этот параметр заменится на логин автора топика
+ * %id% - id топика (325)
+ * %title% - заголовок топика в транслите (moy_topic)
+ * %type% - тип топика (news)
+ *
+ * В шаблоне обязательно должен быть %id% или %title%, это необходимо для точной идентификации топика
+ */
+$config['module']['topic']['url'] = '%year%/%month%/%day%/%title%.html';
+/**
+ * Список регулярных выражений для частей URL топика
+ * Без необходимых навыков лучше этот параметр не менять
+ */
+$config['module']['topic']['url_preg'] = array(
+ '%year%' => '\d{4}',
+ '%month%' => '\d{2}',
+ '%day%' => '\d{2}',
+ '%hour%' => '\d{2}',
+ '%minute%' => '\d{2}',
+ '%second%' => '\d{2}',
+ '%login%' => '[\w\-\_]+',
+ '%blog%' => '[\w\-\_]+',
+ '%id%' => '\d+',
+ '%title%' => '[\w\-\_]+',
+ '%type%' => '[\w\-\_]+',
+);
+
+
+// Модуль User
+$config['module']['user']['per_page'] = 15; // Число юзеров на страницу на странице статистики и в профиле пользователя
+$config['module']['user']['friend_on_profile'] = 15; // Ограничение на вывод числа друзей пользователя на странице его профиля
+$config['module']['user']['friend_notice']['delete'] = false; // Отправить talk-сообщение в случае удаления пользователя из друзей
+$config['module']['user']['friend_notice']['accept'] = false; // Отправить talk-сообщение в случае одобрения заявки на добавление в друзья
+$config['module']['user']['friend_notice']['reject'] = false; // Отправить talk-сообщение в случае отклонения заявки на добавление в друзья
+$config['module']['user']['avatar_size'] = array(
+ '100crop',
+ '64crop',
+ '48crop',
+ '24crop'
+); // Список размеров аватаров у пользователя
+$config['module']['user']['login']['min_size'] = 3; // Минимальное количество символов в логине
+$config['module']['user']['login']['max_size'] = 30; // Максимальное количество символов в логине
+$config['module']['user']['login']['charset'] = '0-9a-z_\-'; // Допустимые в имени пользователя символы
+$config['module']['user']['time_active'] = 60 * 60 * 24 * 7; // Число секунд с момента последнего посещения пользователем сайта, в течение которых он считается активным
+$config['module']['user']['time_onlive'] = 60 * 10; // Число секунд с момента последнего посещения пользователем сайта, в течение которых он считается "онлайн"
+$config['module']['user']['time_login_remember'] = 60 * 60 * 24 * 7; // время жизни куки когда пользователь остается залогиненым на сайте, 7 дней
+$config['module']['user']['usernote_text_max'] = 250; // Максимальный размер заметки о пользователе
+$config['module']['user']['usernote_per_page'] = 20; // Число заметок на одну страницу
+$config['module']['user']['userfield_max_identical'] = 2; // Максимальное число контактов одного типа
+$config['module']['user']['profile_photo_size'] = '370x'; // размер фото в профиле пользователя, формат вида: WxH[crop]
+$config['module']['user']['name_max'] = 30; // максимальная длинна имени в профиле пользователя
+$config['module']['user']['captcha_use_registration'] = true; // проверять поле капчи при регистрации пользователя
+$config['module']['user']['complaint_captcha'] = true; // Использовать или нет каптчу при написании жалобы
+$config['module']['user']['complaint_notify_by_mail'] = true; // Уведомлять администратора на емайл о поступлении новой жалобы
+$config['module']['user']['complaint_text_required'] = true; // Обязательно указывать текст при жалобе
+$config['module']['user']['complaint_text_max'] = 2000; // Максимальный размер текста жалобы
+$config['module']['user']['complaint_type'] = array( // Список типов жалоб на пользователя
+ 'spam',
+ 'obscene',
+ 'other'
+);
+$config['module']['user']['rbac_role_default'] = 'user'; // Роль, которая автоматически назначается пользователю при регистрации
+$config['module']['user']['count_auth_session'] = 4; // Количество разрешенных сессий пользователя (авторизаций в разных браузерах)
+$config['module']['user']['count_auth_session_history'] = 10; // Общее количество сессий для хранения (значение должно быть больше чем count_auth_session)
+
+// Модуль Comment
+$config['module']['comment']['per_page'] = 20; // Число комментариев на одну страницу(это касается только полного списка комментариев прямого эфира)
+$config['module']['comment']['bad'] = -5; // Рейтинг комментария, начиная с которого он будет скрыт
+$config['module']['comment']['max_tree'] = 7; // Максимальная вложенность комментов при отображении
+$config['module']['comment']['show_form'] = false; // Показать или скрыть форму комментов по умолчанию
+$config['module']['comment']['use_nested'] = false; // Использовать или нет nested set при выборке комментов, увеличивает производительность при большом числе комментариев + позволяет делать постраничное разбиение комментов
+$config['module']['comment']['nested_per_page'] = 0; // Число комментов на одну страницу в топике, актуально только при use_nested = true
+$config['module']['comment']['nested_page_reverse'] = true; // Определяет порядок вывода страниц. true - последние комментарии на первой странице, false - последние комментарии на последней странице
+$config['module']['comment']['favourite_target_allow'] = array('topic'); // Список типов комментов, которые разрешено добавлять в избранное
+$config['module']['comment']['edit_target_allow'] = array(
+ 'topic',
+ 'talk'
+); // Список типов комментов, которые разрешено редактировать
+$config['module']['comment']['vote_target_allow'] = array('topic'); // Список типов комментов, за которые разрешено голосовать
+$config['module']['comment']['max_rss_count'] = 20; // Максимальное количество комментов в RSS потоке
+// Модуль Talk
+$config['module']['talk']['per_page'] = 30; // Число приватных сообщений на одну страницу
+$config['module']['talk']['encrypt'] = 'livestreet'; // Ключ XXTEA шифрования идентификаторов в ссылках
+$config['module']['talk']['max_users'] = 15; // Максимальное число адресатов в одном личном сообщении
+// Модуль Lang
+$config['module']['lang']['delete_undefined'] = true; // Если установлена true, то модуль будет автоматически удалять из языковых конструкций переменные вида %%var%%, по которым не была произведена замена
+// Модуль Notify
+$config['module']['notify']['delayed'] = false; // Указывает на необходимость использовать режим отложенной рассылки сообщений на email
+$config['module']['notify']['insert_single'] = false; // Если опция установлена в true, систему будет собирать записи заданий удаленной публикации, для вставки их в базу единым INSERT
+$config['module']['notify']['per_process'] = 10; // Количество отложенных заданий, обрабатываемых одним крон-процессом
+$config['module']['notify']['dir'] = 'emails'; // Путь до папки с емэйлами относительно шаблона
+$config['module']['notify']['prefix'] = 'email'; // Префикс шаблонов емэйлов
+
+// Модуль Security
+$config['module']['security']['hash'] = "livestreet_security_key"; // "примесь" к строке, хешируемой в качестве security-кода
+
+$config['module']['userfeed']['count_default'] = 10; // Число топиков в ленте по умолчанию
+
+$config['module']['stream']['count_default'] = 20; // Число топиков в ленте по умолчанию
+$config['module']['stream']['disable_vote_events'] = false;
+// Модуль Ls
+$config['module']['ls']['send_general'] = true; // Отправка на сервер LS общей информации о сайте (домен, версия LS и плагинов)
+$config['module']['ls']['use_counter'] = true; // Использование счетчика GA
+// Модуль Wall - стена
+$config['module']['wall']['count_last_reply'] = 3; // Число последних ответов на сообщени на стене для отображения в ленте
+$config['module']['wall']['per_page'] = 10; // Число сообщений на стене на одну страницу
+$config['module']['wall']['text_max'] = 250; // Ограничение на максимальное количество символов в одном сообщении на стене
+$config['module']['wall']['text_min'] = 1; // Ограничение на минимальное количество символов в одном сообщении на стене
+// Модуль Sitemap
+$config['module']['sitemap']['index'] = array( // Главная страница
+ 'priority' => '1',
+ 'changefreq' => 'hourly' // Вероятная частота изменения этой страницы (https://www.sitemaps.org/ru/protocol.html#changefreqdef)
+);
+$config['module']['sitemap']['stream'] = array( // Вся активность
+ 'priority' => '0.7',
+ 'changefreq' => 'hourly'
+);
+$config['module']['sitemap']['topic'] = array( // Топики
+ 'priority' => '0.9',
+ 'changefreq' => 'weekly'
+);
+$config['module']['sitemap']['blog'] = array( // Блоги
+ 'priority' => '0.8',
+ 'changefreq' => 'weekly'
+);
+$config['module']['sitemap']['user'] = array( // Пользователи
+ 'priority' => '0.5',
+ 'changefreq' => 'weekly'
+);
+
+/**
+ * Модуль опросов (Poll)
+ */
+$config['module']['poll']['max_answers'] = 20; // Максимальное количество вариантов которое можно добавить в опрос
+$config['module']['poll']['time_limit_update'] = 60 * 60 * 30; // Время в секундах, в течении которого можно изменять опрос
+/**
+ * Модуль Image
+ */
+$config['module']['image']['params']['blog_avatar']['size_max_width'] = 1000;
+$config['module']['image']['params']['blog_avatar']['size_max_height'] = 1000;
+/**
+ * Модуль Media
+ */
+$config['module']['media']['max_size'] = 3*1024; // Максимальный размер файла в kB
+$config['module']['media']['max_count_files'] = 30; // Максимальное количество файлов медиа у одного объекта
+$config['module']['media']['image']['max_size'] = 5*1024; // Максимальный размер файла изображения в kB
+$config['module']['media']['image']['autoresize'] = true; // Разрешает автоматическое создание изображений нужного размера при их запросе
+$config['module']['media']['image']['original'] = '1500x'; // Размер для хранения оригинала. Если true, то будет сохраняться исходный оригинал без ресайза. Если false, то оригинал сохраняться не будет
+$config['module']['media']['image']['sizes'] = array( // список размеров, которые необходимо делать при загрузке изображения
+ array(
+ 'w' => 1000,
+ 'h' => null,
+ 'crop' => false,
+ ),
+ array(
+ 'w' => 500,
+ 'h' => null,
+ 'crop' => false,
+ ),
+ array(
+ 'w' => 100,
+ 'h' => 100,
+ 'crop' => true,
+ ),
+ array(
+ 'w' => 50,
+ 'h' => 50,
+ 'crop' => true,
+ )
+);
+$config['module']['media']['image']['preview']['sizes'] = array( // список размеров, которые необходимо делать при создании превью
+ array(
+ 'w' => 900,
+ 'h' => 300,
+ 'crop' => true,
+ ),
+ array(
+ 'w' => 250,
+ 'h' => 150,
+ 'crop' => true,
+ ),
+);
+/**
+ * Модуль Validate
+ */
+// Настройки Google рекаптчи - https://www.google.com/recaptcha/admin#createsite
+$config['module']['validate']['recaptcha']= array(
+ 'site_key' => '', // Ключ
+ 'secret_key' => '', // Секретный ключ
+ 'use_ip' => false, // Использовать при валидации IP адрес клиента
+);
+/**
+ * Модель Component
+ */
+$config['module']['component']['cache_tree'] = true; // кешировать или нет построение дерева компонентов
+$config['module']['component']['cache_data'] = true; // кешировать или нет данные компонентов
+
+
+// Какие модули должны быть загружены на старте
+$config['module']['autoLoad'] = array('Hook', 'Cache', 'Logger', 'Security', 'Session', 'Lang', 'Message', 'User');
+/**
+ * Настройка базы данных
+ */
+$config['db']['params']['host'] = 'localhost';
+$config['db']['params']['port'] = '3306';
+$config['db']['params']['user'] = 'root';
+$config['db']['params']['pass'] = '';
+$config['db']['params']['type'] = 'mysqli';
+$config['db']['params']['dbname'] = 'social';
+/**
+ * Настройка таблиц базы данных
+ */
+$config['db']['table']['prefix'] = 'prefix_';
+
+$config['db']['table']['user'] = '___db.table.prefix___user';
+$config['db']['table']['blog'] = '___db.table.prefix___blog';
+$config['db']['table']['topic'] = '___db.table.prefix___topic';
+$config['db']['table']['topic_tag'] = '___db.table.prefix___topic_tag';
+$config['db']['table']['topic_type'] = '___db.table.prefix___topic_type';
+$config['db']['table']['comment'] = '___db.table.prefix___comment';
+$config['db']['table']['vote'] = '___db.table.prefix___vote';
+$config['db']['table']['topic_read'] = '___db.table.prefix___topic_read';
+$config['db']['table']['blog_user'] = '___db.table.prefix___blog_user';
+$config['db']['table']['favourite'] = '___db.table.prefix___favourite';
+$config['db']['table']['favourite_tag'] = '___db.table.prefix___favourite_tag';
+$config['db']['table']['talk'] = '___db.table.prefix___talk';
+$config['db']['table']['talk_user'] = '___db.table.prefix___talk_user';
+$config['db']['table']['talk_blacklist'] = '___db.table.prefix___talk_blacklist';
+$config['db']['table']['friend'] = '___db.table.prefix___friend';
+$config['db']['table']['topic_content'] = '___db.table.prefix___topic_content';
+$config['db']['table']['comment_online'] = '___db.table.prefix___comment_online';
+$config['db']['table']['invite_code'] = '___db.table.prefix___invite_code';
+$config['db']['table']['invite_use'] = '___db.table.prefix___invite_use';
+$config['db']['table']['page'] = '___db.table.prefix___page';
+$config['db']['table']['reminder'] = '___db.table.prefix___reminder';
+$config['db']['table']['session'] = '___db.table.prefix___session';
+$config['db']['table']['notify_task'] = '___db.table.prefix___notify_task';
+$config['db']['table']['userfeed_subscribe'] = '___db.table.prefix___userfeed_subscribe';
+$config['db']['table']['stream_subscribe'] = '___db.table.prefix___stream_subscribe';
+$config['db']['table']['stream_event'] = '___db.table.prefix___stream_event';
+$config['db']['table']['stream_user_type'] = '___db.table.prefix___stream_user_type';
+$config['db']['table']['user_field'] = '___db.table.prefix___user_field';
+$config['db']['table']['user_field_value'] = '___db.table.prefix___user_field_value';
+$config['db']['table']['subscribe'] = '___db.table.prefix___subscribe';
+$config['db']['table']['wall'] = '___db.table.prefix___wall';
+$config['db']['table']['user_note'] = '___db.table.prefix___user_note';
+$config['db']['table']['user_complaint'] = '___db.table.prefix___user_complaint';
+$config['db']['table']['geo_country'] = '___db.table.prefix___geo_country';
+$config['db']['table']['geo_region'] = '___db.table.prefix___geo_region';
+$config['db']['table']['geo_city'] = '___db.table.prefix___geo_city';
+$config['db']['table']['geo_target'] = '___db.table.prefix___geo_target';
+$config['db']['table']['user_changemail'] = '___db.table.prefix___user_changemail';
+$config['db']['table']['property'] = '___db.table.prefix___property';
+$config['db']['table']['property_target'] = '___db.table.prefix___property_target';
+$config['db']['table']['property_select'] = '___db.table.prefix___property_select';
+$config['db']['table']['property_value'] = '___db.table.prefix___property_value';
+$config['db']['table']['property_value_tag'] = '___db.table.prefix___property_value_tag';
+$config['db']['table']['property_value_select'] = '___db.table.prefix___property_value_select';
+$config['db']['table']['media'] = '___db.table.prefix___media';
+$config['db']['table']['media_target'] = '___db.table.prefix___media_target';
+$config['db']['table']['rbac_role'] = '___db.table.prefix___rbac_role';
+$config['db']['table']['rbac_permission'] = '___db.table.prefix___rbac_permission';
+$config['db']['table']['rbac_role_permission'] = '___db.table.prefix___rbac_role_permission';
+$config['db']['table']['rbac_role_user'] = '___db.table.prefix___rbac_role_user';
+$config['db']['table']['storage'] = '___db.table.prefix___storage';
+$config['db']['table']['poll'] = '___db.table.prefix___poll';
+$config['db']['table']['poll_answer'] = '___db.table.prefix___poll_answer';
+$config['db']['table']['poll_vote'] = '___db.table.prefix___poll_vote';
+$config['db']['table']['category'] = '___db.table.prefix___category';
+$config['db']['table']['category_type'] = '___db.table.prefix___category_type';
+$config['db']['table']['category_target'] = '___db.table.prefix___category_target';
+
+$config['db']['tables']['engine'] = 'InnoDB'; // InnoDB или MyISAM
+
+/**
+ * Настройки роутинга
+ */
+$config['router']['rewrite'] = array();
+// Правила реврайта для REQUEST_URI
+$config['router']['uri'] = array(
+ // короткий вызов топиков из личных блогов
+ '~^(\d+)\.html~i' => "blog/\\1.html",
+ '~^sitemap\.xml~i' => "sitemap",
+ '~^sitemap_(\w+)_(\d+)\.xml~i' => "sitemap/\\1/\\2",
+);
+// Распределение action
+$config['router']['page']['error'] = 'ActionError';
+$config['router']['page']['auth'] = 'ActionAuth';
+$config['router']['page']['profile'] = 'ActionProfile';
+$config['router']['page']['blog'] = 'ActionBlog';
+$config['router']['page']['index'] = 'ActionIndex';
+$config['router']['page']['people'] = 'ActionPeople';
+$config['router']['page']['settings'] = 'ActionSettings';
+$config['router']['page']['tag'] = 'ActionTag';
+$config['router']['page']['talk'] = 'ActionTalk';
+$config['router']['page']['comments'] = 'ActionComments';
+$config['router']['page']['rss'] = 'ActionRss';
+$config['router']['page']['blogs'] = 'ActionBlogs';
+$config['router']['page']['search'] = 'ActionSearch';
+$config['router']['page']['admin'] = 'ActionAdmin';
+$config['router']['page']['ajax'] = 'ActionAjax';
+$config['router']['page']['feed'] = 'ActionUserfeed';
+$config['router']['page']['stream'] = 'ActionStream';
+$config['router']['page']['subscribe'] = 'ActionSubscribe';
+$config['router']['page']['content'] = 'ActionContent';
+$config['router']['page']['property'] = 'ActionProperty';
+$config['router']['page']['wall'] = 'ActionWall';
+$config['router']['page']['sitemap'] = function() {
+ return LS::Sitemap_ShowSitemap();
+};
+// Глобальные настройки роутинга
+$config['router']['config']['default']['action'] = 'index';
+$config['router']['config']['default']['event'] = null;
+$config['router']['config']['default']['params'] = null;
+$config['router']['config']['default']['request'] = null;
+$config['router']['config']['action_not_found'] = 'error';
+// Принудительное использование https для экшенов. Например, 'login' и 'registration'
+$config['router']['force_secure'] = array();
+
+/**
+ * Настройки вывода блоков
+ */
+$config['block']['rule_index_blog'] = array(
+ 'action' => array(
+ 'index',
+ 'blog' => array('{topics}', '{blog}')
+ ),
+ 'blocks' => array(
+ 'right' => array(
+ 'activityRecent' => array('priority' => 100),
+ 'topicsTags' => array('priority' => 50),
+ 'blogs' => array('params' => array(), 'priority' => 1)
+ )
+ ),
+ 'clear' => false,
+);
+$config['block']['rule_topic_type'] = array(
+ 'action' => array(
+ 'content' => array('add', 'edit'),
+ ),
+ 'blocks' => array('right' => array('component@blog.block.info-note')),
+);
+$config['block']['rule_tag'] = array(
+ 'action' => array('tag'),
+ 'blocks' => array('right' => array('topicsTags')),
+);
+$config['block']['rule_blogs'] = array(
+ 'action' => array('blogs'),
+ 'blocks' => array(
+ 'right' => array(
+ 'component@blog.block.add' => array('priority' => 100),
+ 'blogsSearch' => array('priority' => 50)
+ )
+ ),
+);
+
+$config['block']['userfeedBlogs'] = array(
+ 'action' => array('feed'),
+ 'blocks' => array(
+ 'right' => array(
+ 'userfeedBlogs' => array()
+ )
+ )
+);
+$config['block']['userfeedUsers'] = array(
+ 'action' => array('feed'),
+ 'blocks' => array(
+ 'right' => array(
+ 'userfeedUsers' => array()
+ )
+ )
+);
+$config['block']['rule_users'] = array(
+ 'action' => array('people'),
+ 'blocks' => array(
+ 'right' => array(
+ 'component@user.block.users-statistics',
+ 'component@user.block.users-search',
+ )
+ )
+);
+$config['block']['rule_profile'] = array(
+ 'action' => array('profile', 'talk', 'settings'),
+ 'blocks' => array(
+ 'right' => array(
+ 'component@user.block.photo' => array('priority' => 100),
+ 'component@user.block.actions' => array('priority' => 50),
+ 'component@user.block.note' => array('priority' => 25),
+ 'component@user.block.nav' => array('priority' => 1),
+ )
+ )
+);
+$config['block']['rule_blog'] = array(
+ 'action' => array('blog' => array('{blog}')),
+ 'blocks' => array(
+ 'right' => array(
+ 'component@blog.block.photo' => array('priority' => 300),
+ 'component@blog.block.actions' => array('priority' => 300),
+ 'component@blog.block.users' => array('priority' => 300),
+ 'component@blog.block.admins' => array('priority' => 300)
+ )
+ ),
+ 'clear' => true
+);
+
+/**
+ * Подключение компонентов
+ */
+$config['components'] = array(
+ // Базовые компоненты
+ 'css-reset', 'css-helpers', 'typography', 'forms', 'grid', 'ls-vendor', 'ls-core', 'ls-component', 'lightbox', 'avatar', 'slider', 'details', 'alert', 'dropdown', 'button', 'block',
+ 'nav', 'tooltip', 'tabs', 'modal', 'table', 'text', 'uploader', 'email', 'field', 'pagination', 'editor', 'more', 'crop',
+ 'performance', 'toolbar', 'actionbar', 'badge', 'autocomplete', 'icon', 'item', 'highlighter', 'jumbotron', 'notification', 'blankslate', 'confirm',
+
+ // Компоненты LS CMS
+ 'favourite', 'vote', 'auth', 'media', 'property', 'photo', 'note', 'user-list-add', 'subscribe', 'content', 'report', 'comment',
+ 'toolbar-scrollup', 'toolbar-scrollnav', 'tags-personal', 'search-ajax', 'search', 'sort', 'search-form', 'info-list',
+ 'tags', 'userbar', 'admin', 'user', 'wall', 'blog', 'topic', 'poll', 'activity', 'feed', 'talk'
+);
+
+$config['head']['default']['js'] = array(
+ //"___path.skin.web___/components/ls-vendor/html5shiv.js" => array('browser' => 'lt IE 9'),
+ //"___path.skin.web___/components/ls-vendor/jquery.placeholder.min.js" => array('browser' => 'lt IE 9'),
+
+ "//yastatic.net/share/share.js" => array('merge' => false),
+ "https://www.google.com/recaptcha/api.js?onload=__do_nothing__&render=explicit" => array('merge' => false),
+);
+
+$config['head']['default']['css'] = array();
+
+// Стили для RTL языков
+if ( $config['view']['rtl'] ) {
+ //$config['head']['default']['css'][] = "___path.skin.web___/components/vote/css/vote-rtl.css";
+ //$config['head']['default']['css'][] = "___path.skin.web___/components/alert/css/alert-rtl.css";
+}
+
+/**
+ * Установка локали
+ */
+setlocale(LC_ALL, "ru_RU.UTF-8");
+date_default_timezone_set('Europe/Moscow'); // See http://php.net/manual/en/timezones.php
+
+/**
+ * Настройки типографа текста Jevix
+ * Добавляем к настройках из /framework/config/jevix.php
+ */
+$config['jevix'] = array_merge_recursive((array)Config::Get('jevix'), require(dirname(__FILE__) . '/jevix.php'));
+
+
+return $config;
diff --git a/application/config/config.production.php.dist b/application/config/config.production.php.dist
new file mode 100644
index 0000000..3d63412
--- /dev/null
+++ b/application/config/config.production.php.dist
@@ -0,0 +1,17 @@
+ array(
+ // Разрешённые теги
+ 'cfgAllowTags' => array(
+ // вызов метода с параметрами
+ array(
+ array(
+ 'p',
+ 'ls',
+ 'cut',
+ 'a',
+ 'img',
+ 'i',
+ 'b',
+ 'u',
+ 's',
+ 'small',
+ 'video',
+ 'em',
+ 'strong',
+ 'nobr',
+ 'li',
+ 'ol',
+ 'ul',
+ 'sup',
+ 'abbr',
+ 'sub',
+ 'acronym',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'br',
+ 'hr',
+ 'pre',
+ 'code',
+ 'codeline',
+ 'object',
+ 'param',
+ 'embed',
+ 'blockquote',
+ 'iframe',
+ 'table',
+ 'tbody',
+ 'thead',
+ 'th',
+ 'tr',
+ 'td',
+ 'gallery',
+ 'spoiler',
+ 'abbr',
+ 'audio',
+ 'source',
+ 'aside',
+ 'incut',
+ ),
+ ),
+ ),
+ // Короктие теги типа
+ 'cfgSetTagShort' => array(
+ array(
+ array(
+ 'br',
+ 'img',
+ 'hr',
+ 'cut',
+ 'ls',
+ 'gallery',
+ 'source'
+ )
+ ),
+ ),
+ // Преформатированные теги
+ 'cfgSetTagPreformatted' => array(
+ array(
+ array('pre', 'code', 'codeline', 'video')
+ ),
+ ),
+ // Разрешённые параметры тегов
+ 'cfgAllowTagParams' => array(
+ // вызов метода
+ array(
+ 'img',
+ array(
+ 'src',
+ 'alt' => '#text',
+ 'title',
+ 'align' => array('right', 'left', 'center', 'middle'),
+ 'width' => '#int',
+ 'height' => '#int',
+ 'hspace' => '#int',
+ 'vspace' => '#int',
+ 'class' => array('image-center')
+ )
+ ),
+ [
+ 'iframe',
+ [
+ 'width' => '#int',
+ 'height' => '#int',
+ 'src' => [
+ '#domain' => [
+ 'vk.com',
+ 'youtube.com',
+ 'rutube.ru',
+ 'vimeo.com',
+ 'video.yandex.ru',
+ 'b.gamejolt.net',
+ 'philome.la',
+ 'oreolek.ru',
+ 'instead-hub.github.io',
+ 'instead-games.ru',
+ 'cdn.rawgit.com',
+ 'itch.io',
+ 'html-classic.itch.zone',
+ 'html.itch.zone',
+ 'itch.zone',
+ 'ifhub.club',
+ ]
+ ],
+ 'frameborder' => '#int',
+ 'msallowfullscreen' => ['true', 'false'],
+ 'mozallowfullscreen' => ['true', 'false'],
+ 'allowtransparency' => ['true', 'false'],
+ 'allowfullscreen' => ['true', 'false'],
+ 'webkitallowfullscreen' => ['true', 'false']
+ ]
+ ],
+ [
+ 'cut',
+ array('name')
+ ],
+ array(
+ 'audio',
+ array('controls' => '#text', 'src' => '#text')
+ ),
+ array(
+ 'source',
+ array('src' => '#text', 'type' => ['audio/ogg', 'audio/mpeg'])
+ ),
+ [
+ 'object',
+ array(
+ 'width' => '#int',
+ 'height' => '#int',
+ 'data' => array('#domain' => array('youtube.com', 'rutube.ru', 'vimeo.com')),
+ 'type' => '#text'
+ )
+ ],
+ array(
+ 'param',
+ array('name' => '#text', 'value' => '#text')
+ ),
+ array(
+ 'embed',
+ array(
+ 'src' => array('#domain' => array('youtube.com', 'rutube.ru', 'vimeo.com')),
+ 'type' => '#text',
+ 'allowscriptaccess' => '#text',
+ 'allowfullscreen' => '#text',
+ 'width' => '#int',
+ 'height' => '#int',
+ 'flashvars' => '#text',
+ 'wmode' => '#text'
+ )
+ ),
+ array(
+ 'acronym',
+ array('title')
+ ),
+ array(
+ 'abbr',
+ array('title')
+ ),
+ [
+ 'ol',
+ [
+ 'start' => '#int',
+ ],
+ ],
+ array(
+ 'ls',
+ array('user' => '#text')
+ ),
+ array(
+ 'gallery',
+ array('items' => '#text', 'nav' => array('thumbs'), 'caption' => array('0', '1'))
+ ),
+ array(
+ 'a',
+ array('title', 'href', 'rel' => '#text', 'class' => array('js-lbx'), 'name' => '#text', 'target' => array('_blank')),
+ ),
+ array(
+ 'spoiler',
+ array('title' => '#text')
+ ),
+ array(
+ 'th',
+ array(
+ 'colspan' => '#int',
+ 'rowspan' => '#int',
+ 'align' => array('right', 'left', 'center', 'justify'),
+ 'height' => '#int',
+ 'width' => '#int'
+ )
+ ),
+ array(
+ 'td',
+ array(
+ 'colspan' => '#int',
+ 'rowspan' => '#int',
+ 'align' => array('right', 'left', 'center', 'justify'),
+ 'height' => '#int',
+ 'width' => '#int'
+ )
+ ),
+ array(
+ 'table',
+ array(
+ 'border' => '#int',
+ 'cellpadding' => '#int',
+ 'cellspacing' => '#int',
+ 'align' => array('right', 'left', 'center'),
+ 'height' => '#int',
+ 'width' => '#int'
+ )
+ ),
+ ),
+ // Теги с обязательными параметрами
+ 'cfgSetTagParamDefault' => array(
+ array(
+ 'embed',
+ 'wmode',
+ 'opaque',
+ true,
+ 'a',
+ ),
+ ),
+ // допустимые комбинации значений у параметров
+ 'cfgSetTagParamCombination' => array(
+ array(
+ 'param',
+ 'name',
+ array(
+ 'allowScriptAccess' => array(
+ 'value' => array('sameDomain'),
+ ),
+ 'movie' => array(
+ 'value' => array('#domain' => array('youtube.com', 'rutube.ru', 'vimeo.com')),
+ ),
+ 'align' => array(
+ 'value' => array('bottom', 'middle', 'top', 'left', 'right'),
+ ),
+ 'base' => array(
+ 'value' => true,
+ ),
+ 'bgcolor' => array(
+ 'value' => true,
+ ),
+ 'border' => array(
+ 'value' => true,
+ ),
+ 'devicefont' => array(
+ 'value' => true,
+ ),
+ 'flashVars' => array(
+ 'value' => true,
+ ),
+ 'hspace' => array(
+ 'value' => true,
+ ),
+ 'quality' => array(
+ 'value' => array('low', 'medium', 'high', 'autolow', 'autohigh', 'best'),
+ ),
+ 'salign' => array(
+ 'value' => array('L', 'T', 'R', 'B', 'TL', 'TR', 'BL', 'BR'),
+ ),
+ 'scale' => array(
+ 'value' => array('scale', 'showall', 'noborder', 'exactfit'),
+ ),
+ 'tabindex' => array(
+ 'value' => true,
+ ),
+ 'title' => array(
+ 'value' => true,
+ ),
+ 'type' => array(
+ 'value' => true,
+ ),
+ 'vspace' => array(
+ 'value' => true,
+ ),
+ 'wmode' => array(
+ 'value' => array('window', 'opaque', 'transparent'),
+ ),
+ ),
+ true, // Удалять тег, если нет основного значения параметра в списке комбинаций
+ ),
+ ),
+ // Теги, после которых необходимо пропускать одну пробельную строку
+ 'cfgSetTagBlockType' => array(
+ array(
+ array('h4', 'h5', 'h6', 'ol', 'ul', 'blockquote', 'pre', 'table', 'iframe', 'code')
+ )
+ ),
+ 'cfgSetTagCallbackFull' => array(
+ array(
+ 'video',
+ array('_this_', 'Text_CallbackParserTag'),
+ ),
+ array(
+ 'ls',
+ array('_this_', 'Tools_CallbackParserTagLs'),
+ ),
+ array(
+ 'gallery',
+ array('_this_', 'Media_CallbackParserTagGallery'),
+ ),
+ array(
+ 'spoiler',
+ array('_this_', 'Ifhub_CallbackParserTagSpoiler'),
+ ),
+ array(
+ 'aside',
+ array('_this_', 'Ifhub_CallbackParserTagAside'),
+ ),
+ array(
+ 'incut',
+ array('_this_', 'Ifhub_CallbackParserTagIncut'),
+ ),
+ array(
+ 'code',
+ array('_this_', 'Text_CallbackParserTag'),
+ ),
+ array(
+ 'codeline',
+ array('_this_', 'Text_CallbackParserTag'),
+ ),
+ )
+ ),
+ // настройки для обработки текста в результатах поиска
+ 'search' => array(
+ // Разрешённые теги
+ 'cfgAllowTags' => array(
+ // вызов метода с параметрами
+ array(
+ array('span'),
+ ),
+ ),
+ // Разрешённые параметры тегов
+ 'cfgAllowTagParams' => array(
+ array(
+ 'span',
+ array('class' => '#text')
+ ),
+ ),
+ ),
+);
diff --git a/application/config/modules/.gitignore b/application/config/modules/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/application/config/plugins/.gitignore b/application/config/plugins/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/application/frontend/.htaccess b/application/frontend/.htaccess
new file mode 100644
index 0000000..db38c5f
--- /dev/null
+++ b/application/frontend/.htaccess
@@ -0,0 +1,5 @@
+Options -Indexes
+
+ Order allow,deny
+ Deny from all
+
\ No newline at end of file
diff --git a/application/frontend/components/activity/README.md b/application/frontend/components/activity/README.md
new file mode 100644
index 0000000..e4a8890
--- /dev/null
+++ b/application/frontend/components/activity/README.md
@@ -0,0 +1,3 @@
+# Компонент activity
+
+Активность
\ No newline at end of file
diff --git a/application/frontend/components/activity/activity.tpl b/application/frontend/components/activity/activity.tpl
new file mode 100644
index 0000000..7524a0a
--- /dev/null
+++ b/application/frontend/components/activity/activity.tpl
@@ -0,0 +1,41 @@
+{**
+ * Список событий активности
+ *
+ * @param array $events
+ * @param integer $targetId
+ * @param integer $count
+ *
+ * @param string $mods
+ * @param string $classes
+ * @param string $attributes
+ *}
+
+{$component = 'activity'}
+{$jsprefix = 'js-activity'}
+{component_define_params params=[ 'events', 'count', 'targetId', 'mods', 'classes', 'attributes' ]}
+
+{$moreCount = $count - count($events)}
+
+
+ {if $events}
+ {* Список *}
+
+ {component 'activity' template='event-list' events=$events}
+
+
+ {* Кнопка подгрузки *}
+ {if $count > Config::Get('module.stream.count_default')}
+ {$last = end($events)}
+
+ {component 'more'
+ count = $moreCount
+ classes = "{$jsprefix}-more"
+ ajaxParams = [
+ 'last_id' => $last->getId(),
+ 'target_id' => $targetId
+ ]}
+ {/if}
+ {else}
+ {component 'blankslate' text=$aLang.common.empty}
+ {/if}
+
\ No newline at end of file
diff --git a/application/frontend/components/activity/blocks/block.activity-recent.tpl b/application/frontend/components/activity/blocks/block.activity-recent.tpl
new file mode 100644
index 0000000..b7dfc09
--- /dev/null
+++ b/application/frontend/components/activity/blocks/block.activity-recent.tpl
@@ -0,0 +1,24 @@
+{**
+ * Последняя активность
+ *}
+
+{component_define_params params=[ 'content' ]}
+
+{* Подвал *}
+{capture 'block_footer'}
+ {lang 'activity.block_recent.feed'}
+{/capture}
+
+{component 'block'
+ mods = 'primary activity-recent'
+ classes = 'js-block-default js-activity-block-recent'
+ title = {lang 'activity.block_recent.title'}
+ titleUrl = {router 'stream'}
+ footer = $smarty.capture.block_footer
+ tabs = [
+ 'classes' => 'js-tabs-block js-activity-block-recent-tabs',
+ 'tabs' => [
+ [ 'text' => {lang 'activity.block_recent.comments'}, 'url' => "{router page='ajax'}stream/comment", 'list' => $content ],
+ [ 'text' => {lang 'activity.block_recent.topics'}, 'url' => "{router page='ajax'}stream/topic" ]
+ ]
+ ]}
\ No newline at end of file
diff --git a/application/frontend/components/activity/blocks/block.activity-settings.tpl b/application/frontend/components/activity/blocks/block.activity-settings.tpl
new file mode 100644
index 0000000..46847ff
--- /dev/null
+++ b/application/frontend/components/activity/blocks/block.activity-settings.tpl
@@ -0,0 +1,8 @@
+{**
+ * Блок настройки ленты активности
+ *}
+
+{component 'block'
+ mods = 'activity-settings'
+ title = {lang 'activity.settings.title'}
+ content = {component 'activity' template='settings' typesActive=$typesActive types=$types}}
\ No newline at end of file
diff --git a/application/frontend/components/activity/blocks/block.activity-users.tpl b/application/frontend/components/activity/blocks/block.activity-users.tpl
new file mode 100644
index 0000000..61c869d
--- /dev/null
+++ b/application/frontend/components/activity/blocks/block.activity-users.tpl
@@ -0,0 +1,8 @@
+{**
+ * Выбор пользователей для чтения в ленте активности
+ *}
+
+{component 'block'
+ mods = 'activity-users'
+ title = {lang 'activity.users.title'}
+ content = {component 'activity' template='users' users=$users}}
\ No newline at end of file
diff --git a/application/frontend/components/activity/blocks/recent-comments.tpl b/application/frontend/components/activity/blocks/recent-comments.tpl
new file mode 100644
index 0000000..a21deaa
--- /dev/null
+++ b/application/frontend/components/activity/blocks/recent-comments.tpl
@@ -0,0 +1,26 @@
+{**
+ * Последняя активность
+ * Топики отсортированные по времени последнего комментария
+ *}
+
+{component_define_params params=[ 'comments' ]}
+
+{capture 'items'}
+ {foreach $comments as $comment}
+ {$topic = $comment->getTarget()}
+
+ {component 'activity' template='recent-item'
+ user = $comment->getUser()
+ comment = $comment
+ topic = $topic
+ date = $comment->getDate()
+ classes = 'js-title-comment'
+ attributes = [
+ title => {$comment->getText()|strip_tags|trim|truncate:100:'...'|escape}
+ ]}
+ {foreachelse}
+ {component 'blankslate' text={lang 'common.empty'} mods='no-background'}
+ {/foreach}
+{/capture}
+
+{component 'item' template='group' items=$smarty.capture.items}
\ No newline at end of file
diff --git a/application/frontend/components/activity/blocks/recent-item.tpl b/application/frontend/components/activity/blocks/recent-item.tpl
new file mode 100644
index 0000000..0980b5d
--- /dev/null
+++ b/application/frontend/components/activity/blocks/recent-item.tpl
@@ -0,0 +1,27 @@
+{component_define_params params=[ 'user', 'topic', 'date' ]}
+
+{capture 'item_content'}
+ {$user->getDisplayName()} →
+ {$topic->getTitle()|escape}
+
+
+
+ {date_format date=$date hours_back="12" minutes_back="60" now="60" day="day H:i" format="j F Y"}
+
+
+
+
+{/capture}
+
+{component 'item'
+ element = 'li'
+ mods = 'image-rounded'
+ desc = $smarty.capture.item_content
+ image=[
+ 'path' => $user->getProfileAvatarPath(48),
+ 'url' => $user->getUserWebPath()
+ ]
+ params=$params}
\ No newline at end of file
diff --git a/application/frontend/components/activity/blocks/recent-topics.tpl b/application/frontend/components/activity/blocks/recent-topics.tpl
new file mode 100644
index 0000000..c84b121
--- /dev/null
+++ b/application/frontend/components/activity/blocks/recent-topics.tpl
@@ -0,0 +1,23 @@
+{**
+ * Последняя активность
+ * Последние топики
+ *}
+
+{component_define_params params=[ 'topics' ]}
+
+{capture 'items'}
+ {foreach $topics as $topic}
+ {component 'activity' template='recent-item'
+ user = $topic->getUser()
+ topic = $topic
+ date = $topic->getDatePublish()
+ classes = 'js-title-topic'
+ attributes = [
+ title => {$topic->getText()|strip_tags|trim|truncate:150:'...'|escape}
+ ]}
+ {foreachelse}
+ {component 'blankslate' text={lang 'common.empty'} mods='no-background'}
+ {/foreach}
+{/capture}
+
+{component 'item' template='group' items=$smarty.capture.items}
\ No newline at end of file
diff --git a/application/frontend/components/activity/component.json b/application/frontend/components/activity/component.json
new file mode 100644
index 0000000..9fd003c
--- /dev/null
+++ b/application/frontend/components/activity/component.json
@@ -0,0 +1,33 @@
+{
+ "name": "activity",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-component": "*",
+ "user-list-add": "*",
+ "block": "*",
+ "button": "*",
+ "field": "*",
+ "alert": "*"
+ },
+ "templates": {
+ "activity": "activity.tpl",
+ "event-list": "event-list.tpl",
+ "event": "event.tpl",
+ "users": "users.tpl",
+ "settings": "settings.tpl",
+ "block.recent": "blocks/block.activity-recent.tpl",
+ "block.users": "blocks/block.activity-users.tpl",
+ "block.settings": "blocks/block.activity-settings.tpl",
+ "recent-item": "blocks/recent-item.tpl",
+ "recent-comments": "blocks/recent-comments.tpl",
+ "recent-topics": "blocks/recent-topics.tpl"
+ },
+ "scripts": {
+ "activity-settings": "js/activity-settings.js",
+ "activity": "js/activity.js"
+ },
+ "styles": {
+ "activity": "css/activity.css",
+ "blocks": "css/blocks.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/activity/css/activity.css b/application/frontend/components/activity/css/activity.css
new file mode 100644
index 0000000..d45f308
--- /dev/null
+++ b/application/frontend/components/activity/css/activity.css
@@ -0,0 +1,47 @@
+/**
+ * Активность
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+
+/**
+ * Список событий
+ */
+.activity-event-list {
+ margin: 0 0 20px;
+}
+
+/* Дата-заголовок */
+.activity-date {
+ font: 400 18px/1.3em 'Open Sans', sans-serif;
+ padding: 10px 0;
+ border-bottom: 1px solid #E7E7E7;
+}
+.activity-event + .activity-date {
+ margin-top: 30px;
+}
+
+/**
+ * Событие
+ */
+.activity-event {
+ padding: 15px;
+}
+.activity-event + .activity-event {
+ border-top: 1px solid #f2f2f2;
+}
+.activity-event-date {
+ display: block;
+ color: #999;
+ margin-bottom: 4px;
+}
+.activity-event-text {
+ padding: 10px 15px;
+ margin-top: 10px;
+ color: #777;
+ background: #fafafa;
+ font-size: 13px;
+}
\ No newline at end of file
diff --git a/application/frontend/components/activity/css/blocks.css b/application/frontend/components/activity/css/blocks.css
new file mode 100644
index 0000000..7ea7b39
--- /dev/null
+++ b/application/frontend/components/activity/css/blocks.css
@@ -0,0 +1,26 @@
+/**
+ * Последняя активность
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+.ls-activity-block-recent-user {
+ font-weight: bold;
+ color: #333;
+}
+
+.ls-activity-block-recent-info {
+ margin-top: 5px;
+ color: #999;
+ font-size: 12px;
+}
+
+.ls-activity-block-recent-comments {
+ margin-left: 10px;
+ color: #666;
+}
+.ls-activity-block-recent-comments:hover {
+ color: #444;
+}
\ No newline at end of file
diff --git a/application/frontend/components/activity/event-list.tpl b/application/frontend/components/activity/event-list.tpl
new file mode 100644
index 0000000..d69aeb0
--- /dev/null
+++ b/application/frontend/components/activity/event-list.tpl
@@ -0,0 +1,32 @@
+{**
+ * События
+ *
+ * @param array $events
+ * @param string $dateLast Дата предыдущего сообщения
+ *}
+
+{component_define_params params=[ 'dateLast', 'events' ]}
+
+{* Дата последнего события *}
+{$dateLast = ( $dateLast ) ? {date_format date=$dateLast format="Y-m-d" notz=1} : false}
+{$dateNow = {date_format date=$smarty.now format="Y-m-d" notz=1}}
+
+{foreach $events as $event}
+ {$dateAdded = {date_format date=$event->getDateAdded() format="Y-m-d" notz=1}}
+
+ {* Дата группы событий *}
+ {if $dateAdded != $dateLast}
+ {$dateLast = $dateAdded}
+
+
+ {if $dateNow == $dateLast}
+ {$aLang.date.today}
+ {else}
+ {date_format date=$event->getDateAdded() format="j F Y"}
+ {/if}
+
+ {/if}
+
+ {* Событие *}
+ {component 'activity' template='event' event=$event}
+{/foreach}
\ No newline at end of file
diff --git a/application/frontend/components/activity/event.tpl b/application/frontend/components/activity/event.tpl
new file mode 100644
index 0000000..604e4b3
--- /dev/null
+++ b/application/frontend/components/activity/event.tpl
@@ -0,0 +1,93 @@
+{**
+ * Событие
+ *
+ * @param object $event
+ *}
+
+{$component = 'activity-event'}
+{component_define_params params=[ 'event' ]}
+
+{$type = $event->getEventType()}
+{$target = $event->getTarget()}
+{$user = $event->getUser()}
+{$gender = ( $user->getProfileSex() == 'woman' ) ? 'female' : 'male'}
+
+{**
+ * Вывод текста
+ *
+ * @param $text Текст
+ *}
+{function activity_event_text text=''}
+ {if trim($text)}
+ {$text}
+ {/if}
+{/function}
+
+
+{* Событие *}
+{capture 'event_content'}
+ {* Дата *}
+
+ {date_format date=$event->getDateAdded() hours_back="12" minutes_back="60" now="60" day="day H:i" format="j F Y, H:i"}
+
+
+ {* Логин *}
+ {$user->getDisplayName()}
+
+ {* Текст события *}
+ {if $type == 'add_topic'}
+ {* Добавлен топик *}
+ {lang "activity.events.{$type}_{$gender}" topic="getUrl()}\">{$target->getTitle()|escape} "}
+ {elseif $type == 'add_comment'}
+ {* Добавлен комментарий *}
+ {lang "activity.events.{$type}_{$gender}" topic="getTarget()->getUrl()}#comment{$target->getId()}\">{$target->getTarget()->getTitle()|escape} "}
+
+ {activity_event_text text=$target->getText()}
+ {elseif $type == 'add_blog'}
+ {* Создан блог *}
+ {lang "activity.events.{$type}_{$gender}" blog="getUrlFull()}\">{$target->getTitle()|escape} "}
+ {elseif $type == 'vote_blog'}
+ {* Проголосовали за блог *}
+ {lang "activity.events.{$type}_{$gender}" blog="getUrlFull()}\">{$target->getTitle()|escape} "}
+ {elseif $type == 'vote_topic'}
+ {* Проголосовали за топик *}
+ {lang "activity.events.{$type}_{$gender}" topic="getUrl()}\">{$target->getTitle()|escape} "}
+ {elseif $type == 'vote_comment_topic'}
+ {* Проголосовали за комментарий *}
+ {lang "activity.events.{$type}_{$gender}" topic="getTarget()->getUrl()}#comment{$target->getId()}\">{$target->getTarget()->getTitle()|escape} "}
+ {elseif $type == 'vote_user'}
+ {* Проголосовали за пользователя *}
+ {lang "activity.events.{$type}_{$gender}" user="getUserWebPath()}\">{$target->getDisplayName()} "}
+ {elseif $type == 'join_blog'}
+ {* Вступили в блог *}
+ {lang "activity.events.{$type}_{$gender}" blog="getUrlFull()}\">{$target->getTitle()|escape} "}
+ {elseif $type == 'add_friend'}
+ {* Добавили в друзья *}
+ {lang "activity.events.{$type}_{$gender}" user="getUserWebPath()}\">{$target->getDisplayName()} "}
+ {elseif $type == 'add_wall'}
+ {* Написали на стене *}
+ {if $target->getWallUser()->getId() == $user->getId()}
+ {lang "activity.events.{$type}_self_{$gender}" url=$target->getUrlWall()}
+ {else}
+ {lang "activity.events.{$type}_{$gender}" url=$target->getUrlWall() user=$target->getWallUser()->getDisplayName()}
+ {/if}
+
+ {activity_event_text text=$target->getText()}
+ {else}
+ {hook run="activity_event_`$type`" event=$event}
+ {/if}
+{/capture}
+
+{component 'item'
+ element='li'
+ classes="{$component} {cmods name=$component mods=$type} js-activity-event"
+ mods='image-rounded'
+ desc=$smarty.capture.event_content
+ image=[
+ 'url' => $user->getUserWebPath(),
+ 'path' => $user->getProfileAvatarPath(48),
+ 'alt' => $user->getDisplayName()
+ ]}
\ No newline at end of file
diff --git a/application/frontend/components/activity/js/activity-settings.js b/application/frontend/components/activity/js/activity-settings.js
new file mode 100644
index 0000000..d7e6aa8
--- /dev/null
+++ b/application/frontend/components/activity/js/activity-settings.js
@@ -0,0 +1,51 @@
+/**
+ * Activity settings
+ *
+ * @module ls/activity/settings
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsActivitySettings", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ toggle_type: null
+ },
+
+ // Селекторы
+ selectors: {
+ type_checkbox: '.js-activity-settings-type-checkbox'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ this._on( this.elements.type_checkbox, { change: 'toggleEventType' } );
+ },
+
+ /**
+ * Сохранение настроек
+ */
+ toggleEventType: function( event ) {
+ this.option( 'params.type', $( event.target ).data( 'type' ) );
+
+ this._load( 'toggle_type', function( response ) {} );
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/activity/js/activity.js b/application/frontend/components/activity/js/activity.js
new file mode 100644
index 0000000..6ee1009
--- /dev/null
+++ b/application/frontend/components/activity/js/activity.js
@@ -0,0 +1,65 @@
+/**
+ * Активность
+ *
+ * @module ls/activity
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsActivity", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ // Подгрузка событий
+ more: null
+ },
+
+ // Селекторы
+ selectors: {
+ // Список событий
+ list: '.js-activity-event-list',
+ // Событие
+ event: '.js-activity-event',
+ // Кнопка подгрузки событий
+ more: '.js-activity-more'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ // Подгрузка событий
+ this.elements.more.lsMore({
+ urls: {
+ load: this.option( 'urls.more' ),
+ },
+ proxy: [ 'last_id' ],
+ target: this.elements.list,
+ beforeload: function (e, context) {
+ context._setParam( 'date_last', this.getDateLast() );
+ }.bind( this )
+ });
+ },
+
+ /**
+ * Получает дату последнего подгруженного события
+ */
+ getDateLast: function() {
+ return this.elements.list.find( this.option( 'selectors.event' ) ).last().find( 'time' ).data( 'date' );
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/activity/settings.tpl b/application/frontend/components/activity/settings.tpl
new file mode 100644
index 0000000..2c57473
--- /dev/null
+++ b/application/frontend/components/activity/settings.tpl
@@ -0,0 +1,28 @@
+{**
+ * Настройки активности
+ *
+ * @param array $types
+ * @param array $typesActive
+ *}
+
+{component_define_params params=[ 'types', 'typesActive' ]}
+
+{if $oUserCurrent}
+
+
+ {$aLang.activity.settings.note}
+
+
+
+ {foreach $types as $type => $data}
+ {if ! (Config::Get('module.stream.disable_vote_events') && substr($type, 0, 4) == 'vote')}
+ {component 'field' template='checkbox'
+ inputClasses = 'js-activity-settings-type-checkbox'
+ inputAttributes = [ 'data-type' => $type ]
+ checked = in_array( $type, $typesActive )
+ label = $aLang.activity.settings.options[ $type ]}
+ {/if}
+ {/foreach}
+
+
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/activity/users.tpl b/application/frontend/components/activity/users.tpl
new file mode 100644
index 0000000..65fb3ed
--- /dev/null
+++ b/application/frontend/components/activity/users.tpl
@@ -0,0 +1,12 @@
+{**
+ * Список пользователей на которых подписан текущий пользователь
+ *
+ * @param array $users
+ *}
+
+{component_define_params params=[ 'users' ]}
+
+{component 'user-list-add'
+ users = $users
+ classes = 'js-activity-users'
+ note = $aLang.activity.users.note}
\ No newline at end of file
diff --git a/application/frontend/components/admin/README.md b/application/frontend/components/admin/README.md
new file mode 100644
index 0000000..2bb4e01
--- /dev/null
+++ b/application/frontend/components/admin/README.md
@@ -0,0 +1 @@
+# Компонент admin
\ No newline at end of file
diff --git a/application/frontend/components/admin/component.json b/application/frontend/components/admin/component.json
new file mode 100644
index 0000000..1318f9e
--- /dev/null
+++ b/application/frontend/components/admin/component.json
@@ -0,0 +1,14 @@
+{
+ "name": "admin",
+ "version": "1.0.0",
+ "dependencies": {
+ "button": "*"
+ },
+ "templates": {
+ "plugins": "plugins.tpl",
+ "toolbar.admin": "toolbar.admin.tpl"
+ },
+ "styles": {
+ "admin": "css/admin.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/admin/css/admin.css b/application/frontend/components/admin/css/admin.css
new file mode 100644
index 0000000..25c6527
--- /dev/null
+++ b/application/frontend/components/admin/css/admin.css
@@ -0,0 +1,32 @@
+/**
+ * Админка
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+/**
+ * Управление плагинами
+ *
+ * @template plugins.tpl
+ */
+.admin-plugins h3 {
+ font-weight: bold;
+ font-size: 17px;
+ line-height: 20px;
+ margin-bottom: 7px;
+}
+.admin-plugins p {
+ margin-bottom: 15px;
+}
+.admin-plugins td {
+ vertical-align: top;
+}
+.admin-plugins-actions li {
+ margin-bottom: 5px;
+ text-align: right;
+}
+.admin-plugins-actions li:last-child {
+ margin-bottom: 0;
+}
\ No newline at end of file
diff --git a/application/frontend/components/admin/plugins.tpl b/application/frontend/components/admin/plugins.tpl
new file mode 100644
index 0000000..2c12e3a
--- /dev/null
+++ b/application/frontend/components/admin/plugins.tpl
@@ -0,0 +1,72 @@
+{**
+ * Список плагинов
+ *
+ * @param array $plugins Список плагинов
+ *}
+
+{component_define_params params=[ 'plugins' ]}
+
+
+
+ {foreach $plugins as $plugin}
+
+ {* Название и описание плагина *}
+
+ {$plugin.property->name->data}
+ {$plugin.property->description->data}
+
+ {component 'info-list' list=[
+ [ 'label' => {lang 'admin.plugins.plugin.version'}, 'content' => $plugin.property->version|escape ],
+ [ 'label' => {lang 'admin.plugins.plugin.author'}, 'content' => $plugin.property->author->data ],
+ [ 'label' => {lang 'admin.plugins.plugin.url'}, 'content' => $plugin.property->homepage ]
+ ]}
+
+
+ {* Действия *}
+
+
+ {* Активировать/деактивировать *}
+
+ {if $plugin.is_active}
+ {component 'button'
+ url = "{router page='admin'}plugins/?plugin={$plugin.code}&action=deactivate&security_ls_key={$LIVESTREET_SECURITY_KEY}"
+ text = {lang 'admin.plugins.plugin.deactivate'}}
+ {else}
+ {component 'button'
+ url = "{router page='admin'}plugins/?plugin={$plugin.code}&action=activate&security_ls_key={$LIVESTREET_SECURITY_KEY}"
+ mods = 'primary'
+ text = {lang 'admin.plugins.plugin.activate'}}
+ {/if}
+
+
+ {* Применить обновление *}
+ {if $plugin.apply_update && $plugin.is_active}
+
+ {component 'button'
+ url = "{router page='admin'}plugins/?plugin={$plugin.code}&action=apply_update&security_ls_key={$LIVESTREET_SECURITY_KEY}"
+ text = {lang 'admin.plugins.plugin.apply_update'}}
+
+ {/if}
+
+ {* Ссылка на страницу настроек *}
+ {if $plugin.property->settings != "" && $plugin.is_active}
+
+ {component 'button'
+ url = $plugin.property->settings
+ text = {lang 'admin.plugins.plugin.settings'}}
+
+ {/if}
+
+ {* Удалить *}
+
+ {component 'button'
+ url = "{router page='admin'}plugins/?plugin={$plugin.code}&action=remove&security_ls_key={$LIVESTREET_SECURITY_KEY}"
+ attributes = [ 'onclick' => "return confirm('{lang 'common.remove_confirm'}');" ]
+ text = {lang 'admin.plugins.plugin.remove'}}
+
+
+
+
+ {/foreach}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/admin/toolbar.admin.tpl b/application/frontend/components/admin/toolbar.admin.tpl
new file mode 100644
index 0000000..6d4b628
--- /dev/null
+++ b/application/frontend/components/admin/toolbar.admin.tpl
@@ -0,0 +1,12 @@
+{**
+ * Тулбар
+ * Кнопка перехода в админку
+ *}
+
+{if $oUserCurrent && $oUserCurrent->isAdministrator()}
+ {component 'toolbar.item'
+ icon='cog'
+ url={router 'admin'}
+ attributes=[ 'title' => {lang 'admin.title'} ]
+ mods='admin'}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/auth/README.md b/application/frontend/components/auth/README.md
new file mode 100644
index 0000000..adf205f
--- /dev/null
+++ b/application/frontend/components/auth/README.md
@@ -0,0 +1 @@
+# Компонент auth
\ No newline at end of file
diff --git a/application/frontend/components/auth/auth.invite.tpl b/application/frontend/components/auth/auth.invite.tpl
new file mode 100644
index 0000000..ab5042f
--- /dev/null
+++ b/application/frontend/components/auth/auth.invite.tpl
@@ -0,0 +1,12 @@
+{**
+ * Форма регистрации через инвайт
+ *}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/auth/auth.login.tpl b/application/frontend/components/auth/auth.login.tpl
new file mode 100644
index 0000000..828ef2f
--- /dev/null
+++ b/application/frontend/components/auth/auth.login.tpl
@@ -0,0 +1,61 @@
+{**
+ * Форма входа
+ *
+ * @param string $redirectUrl
+ * @param boolean $showExtra
+ *}
+
+{component_define_params params=[ 'redirectUrl', 'showExtra' ]}
+
+{$redirectUrl = $redirectUrl|default:$PATH_WEB_CURRENT}
+
+{hook run='login_begin'}
+
+
+
+{if $showExtra}
+
+{/if}
+
+{hook run='login_end'}
\ No newline at end of file
diff --git a/application/frontend/components/auth/auth.reactivation.tpl b/application/frontend/components/auth/auth.reactivation.tpl
new file mode 100644
index 0000000..858f8f3
--- /dev/null
+++ b/application/frontend/components/auth/auth.reactivation.tpl
@@ -0,0 +1,10 @@
+{**
+ * Форма запроса повторной активации аккаунта
+ *}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/auth/auth.registration.tpl b/application/frontend/components/auth/auth.registration.tpl
new file mode 100644
index 0000000..da74e8d
--- /dev/null
+++ b/application/frontend/components/auth/auth.registration.tpl
@@ -0,0 +1,58 @@
+{**
+ * Форма регистрации
+ *
+ * @param string $redirectUrl
+ *}
+
+{component_define_params params=[ 'redirectUrl' ]}
+
+{$redirectUrl = $redirectUrl|default:$PATH_WEB_CURRENT}
+
+{hook run='registration_begin'}
+
+
+
+{hook run='registration_end'}
diff --git a/application/frontend/components/auth/auth.reset.tpl b/application/frontend/components/auth/auth.reset.tpl
new file mode 100644
index 0000000..d8f12e7
--- /dev/null
+++ b/application/frontend/components/auth/auth.reset.tpl
@@ -0,0 +1,10 @@
+{**
+ * Форма восстановления пароля
+ *}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/auth/component.json b/application/frontend/components/auth/component.json
new file mode 100644
index 0000000..b0daf66
--- /dev/null
+++ b/application/frontend/components/auth/component.json
@@ -0,0 +1,21 @@
+{
+ "name": "auth",
+ "version": "1.0.0",
+ "dependencies": {
+ "css-reset": "*",
+ "form": "*",
+ "modal": "*",
+ "field": "*"
+ },
+ "templates": {
+ "invite": "auth.invite.tpl",
+ "login": "auth.login.tpl",
+ "reactivation": "auth.reactivation.tpl",
+ "registration": "auth.registration.tpl",
+ "reset": "auth.reset.tpl",
+ "modal": "modal.auth.tpl"
+ },
+ "scripts": {
+ "auth": "js/auth.js"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/auth/js/auth.js b/application/frontend/components/auth/js/auth.js
new file mode 100644
index 0000000..ff72f84
--- /dev/null
+++ b/application/frontend/components/auth/js/auth.js
@@ -0,0 +1,67 @@
+/**
+ * Авторизация
+ *
+ * @module ls/auth
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+var ls = ls || {};
+
+ls.auth = (function ($) {
+ "use strict";
+
+ /**
+ * Инициализация
+ */
+ this.init = function() {
+ /* Авторизация */
+ $('.js-auth-login-form').on('submit', function (e) {
+ ls.ajax.submit(aRouter.auth + 'ajax-login', $(this), function ( response ) {
+ response.sUrlRedirect && (window.location = response.sUrlRedirect);
+ });
+
+ e.preventDefault();
+ });
+
+ /* Регистрация */
+ $('.js-auth-registration-form').on('submit', function (e) {
+ ls.ajax.submit(aRouter.auth + 'ajax-register', $(this), function ( response ) {
+ response.sUrlRedirect && (window.location = response.sUrlRedirect);
+ });
+
+ e.preventDefault();
+ });
+
+ /* Восстановление пароля */
+ $('.js-auth-reset-form').on('submit', function (e) {
+ ls.ajax.submit(aRouter.auth + 'ajax-password-reset', $(this), function ( response ) {
+ response.sUrlRedirect && (window.location = response.sUrlRedirect);
+ });
+
+ e.preventDefault();
+ });
+
+ /* Повторный запрос на ссылку активации */
+ ls.ajax.form(aRouter.auth + 'ajax-reactivation', '.js-form-reactivation', function (result, status, xhr, form) {
+ form.find('input').val('');
+ ls.hook.run('ls_user_reactivation_after', [form, result]);
+ });
+
+ $('.js-modal-toggle-registration').on('click', function (e) {
+ $('.js-auth-tab-reg').lsTab('activate');
+ $('#modal-login').lsModal('show');
+ e.preventDefault();
+ });
+
+ $('.js-modal-toggle-login').on('click', function (e) {
+ $('.js-auth-tab-login').lsTab('activate');
+ $('#modal-login').lsModal('show');
+ e.preventDefault();
+ });
+ };
+
+ return this;
+}).call(ls.auth || {}, jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/auth/modal.auth.tpl b/application/frontend/components/auth/modal.auth.tpl
new file mode 100644
index 0000000..5d0b88e
--- /dev/null
+++ b/application/frontend/components/auth/modal.auth.tpl
@@ -0,0 +1,22 @@
+{**
+ * Модальное окно с формами входа, регистрации и напоминанием пароля
+ *}
+
+{if ! Config::Get('general.reg.invite')}
+ {component 'auth' template='registration' assign=auth_tab_reg}
+{else}
+ {component 'auth' template='invite' assign=auth_tab_reg}
+{/if}
+
+{component 'modal'
+ title = {lang 'auth.authorization'}
+ options = [ 'center' => 'false' ]
+ showFooter = false
+ classes = 'js-modal-default'
+ mods = 'auth'
+ id = 'modal-login'
+ tabs = [ 'tabs' => [
+ [ 'text' => {lang 'auth.login.title'}, 'content' => {component 'auth' template='login'}, 'classes' => 'js-auth-tab-login' ],
+ [ 'text' => {lang 'auth.registration.title'}, 'content' => $auth_tab_reg, 'classes' => 'js-auth-tab-reg' ],
+ [ 'text' => {lang 'auth.reset.title'}, 'content' => {component 'auth' template='reset'} ]
+ ]]}
\ No newline at end of file
diff --git a/application/frontend/components/blog/README.md b/application/frontend/components/blog/README.md
new file mode 100644
index 0000000..3d51cf3
--- /dev/null
+++ b/application/frontend/components/blog/README.md
@@ -0,0 +1 @@
+# Компонент blog
\ No newline at end of file
diff --git a/application/frontend/components/blog/add.tpl b/application/frontend/components/blog/add.tpl
new file mode 100644
index 0000000..a63983f
--- /dev/null
+++ b/application/frontend/components/blog/add.tpl
@@ -0,0 +1,84 @@
+{**
+ * Форма добавления/редактирования
+ *
+ * @param object $blog
+ *}
+
+{component_define_params params=[ 'blog' ]}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/blog/admin.tpl b/application/frontend/components/blog/admin.tpl
new file mode 100644
index 0000000..d365ec8
--- /dev/null
+++ b/application/frontend/components/blog/admin.tpl
@@ -0,0 +1,55 @@
+{**
+ * Управление пользователями блога
+ *
+ * @param object $users
+ * @param array $pagination
+ *}
+
+{component_define_params params=[ 'users', 'pagination' ]}
+
+{if $users}
+
+
+ {component 'pagination' total=+$pagination.iCountPage current=+$pagination.iCurrentPage url="{$pagination.sBaseUrl}/page__page__/{$pagination.sGetParams}"}
+{else}
+ {component 'blankslate' text=$aLang.blog.admin.alerts.empty}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/blog/blocks/block.blog-actions.tpl b/application/frontend/components/blog/blocks/block.blog-actions.tpl
new file mode 100644
index 0000000..691fdf8
--- /dev/null
+++ b/application/frontend/components/blog/blocks/block.blog-actions.tpl
@@ -0,0 +1,60 @@
+{**
+ * Действия
+ *}
+
+{capture 'block_content'}
+ {* Список экшенов *}
+ {$actions = []}
+
+ {* Вступить/покинуть *}
+ {if $oUserCurrent && $oUserCurrent->getId() != $blog->getOwnerId() && $blog->getType() == 'open'}
+ {$actions[] = [
+ 'classes' => 'js-blog-profile-join',
+ 'attributes' => [ 'data-blog-id' => $blog->getId() ],
+ 'text' => {($blog->getUserIsJoin()) ? {lang 'blog.actions.leave'} : {lang 'blog.actions.join'}}
+ ]}
+ {/if}
+
+ {* Написать в блог *}
+ {if $oUserCurrent && ( ( $blog->getUserIsJoin() && $oUserCurrent->getRating() >= $blog->getLimitRatingTopic() ) || $blog->isAllowEdit() )}
+ {$topicType=$LS->Topic_GetTopicTypeFirst()}
+ {if $topicType}
+ {$actions[] = [
+ 'url' => "{$topicType->getUrlForAdd()}?blog_id={$blog->getId()}",
+ 'text' => {lang 'blog.actions.write'}
+ ]}
+ {/if}
+ {/if}
+
+ {* Подписаться через RSS *}
+ {$actions[] = [
+ 'url' => "{router page='rss'}blog/{$blog->getUrl()}/",
+ 'text' => {lang 'blog.actions.rss'}
+ ]}
+
+ {if $blog->isAllowEdit()}
+ {* Редактировать *}
+ {$actions[] = [ 'url' => "{router page='blog'}edit/{$blog->getId()}/", 'text' => $aLang.common.edit ]}
+
+ {* Удалить *}
+ {if $oUserCurrent->isAdministrator()}
+ {$actions[] = [
+ 'classes' => 'js-modal-toggle-default',
+ 'attributes' => [ 'data-lsmodaltoggle-modal' => 'modal-blog-delete' ],
+ 'text' => $aLang.common.remove
+ ]}
+ {else}
+ {$actions[] = [
+ 'url' => "{router page='blog'}delete/{$blog->getId()}/?security_ls_key={$LIVESTREET_SECURITY_KEY}",
+ 'classes' => 'js-confirm-remove-default',
+ 'text' => $aLang.common.remove
+ ]}
+ {/if}
+ {/if}
+
+ {component 'nav' hook='blog_actions' items=$actions mods='stacked' classes='profile-actions'}
+{/capture}
+
+{component 'block'
+ mods = 'nopadding transparent user-actions'
+ content = $smarty.capture.block_content}
\ No newline at end of file
diff --git a/application/frontend/components/blog/blocks/block.blog-add.tpl b/application/frontend/components/blog/blocks/block.blog-add.tpl
new file mode 100644
index 0000000..d0628ff
--- /dev/null
+++ b/application/frontend/components/blog/blocks/block.blog-add.tpl
@@ -0,0 +1,19 @@
+{**
+ * Блок с кнопкой добавления блога
+ *}
+
+{if $oUserCurrent}
+ {capture 'block_content'}
+ {if $oUserCurrent && $oUserCurrent->isAllowCreateBlog()}
+ {$aLang.blog.can_add}
+
+ {component 'button' url="{router page='blog'}add/" mods='primary large' text=$aLang.blog.create_blog}
+ {else}
+ {lang name='blog.cant_add' rating=Config::Get('acl.create.blog.rating')}
+
+ {component 'button' mods='primary large' text=$aLang.blog.create_blog isDisabled=true}
+ {/if}
+ {/capture}
+
+ {component 'block' mods='blog-add' content=$smarty.capture.block_content}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/blog/blocks/block.blog-admins.tpl b/application/frontend/components/blog/blocks/block.blog-admins.tpl
new file mode 100644
index 0000000..7f2f8ea
--- /dev/null
+++ b/application/frontend/components/blog/blocks/block.blog-admins.tpl
@@ -0,0 +1,23 @@
+{**
+ * Список управляющих блога
+ *}
+
+{capture 'block_content'}
+ {* Создатель *}
+ {component 'user' template='list-small' users=[ $blog->getOwner() ] title=$aLang.blog.owner}
+
+ {* Администраторы *}
+ {if count($blogAdministrators)}
+ {component 'user' template='list-small' users=$blogAdministrators title="{$aLang.blog.administrators} ({count($blogAdministrators)})"}
+ {/if}
+
+ {* Модераторы *}
+ {if count($blogModerators)}
+ {component 'user' template='list-small' users=$blogModerators title="{$aLang.blog.moderators} ({count($blogModerators)})"}
+ {/if}
+{/capture}
+
+{component 'block'
+ mods = 'blog-admins'
+ title = {lang 'blog.administrators'}
+ content = $smarty.capture.block_content}
\ No newline at end of file
diff --git a/application/frontend/components/blog/blocks/block.blog-info-note.tpl b/application/frontend/components/blog/blocks/block.blog-info-note.tpl
new file mode 100644
index 0000000..29a1550
--- /dev/null
+++ b/application/frontend/components/blog/blocks/block.blog-info-note.tpl
@@ -0,0 +1,8 @@
+{**
+ * Подсказка отображаемая при создании топика
+ *}
+
+{component 'block'
+ mods = 'info'
+ title = {lang 'topic.blocks.tip.title'}
+ content = {lang 'topic.blocks.tip.text'}}
\ No newline at end of file
diff --git a/application/frontend/components/blog/blocks/block.blog-invite.tpl b/application/frontend/components/blog/blocks/block.blog-invite.tpl
new file mode 100644
index 0000000..2af5c85
--- /dev/null
+++ b/application/frontend/components/blog/blocks/block.blog-invite.tpl
@@ -0,0 +1,15 @@
+{**
+ * Приглашение пользователей в закрытый блог.
+ * Выводится на странице администрирования пользователей закрытого блога.
+ *}
+
+{component 'blog' template='invite'
+ users = $blogUsersInvited
+ classes = 'js-user-list-add-blog-invite'
+ attributes = [ 'data-param-target_id' => $blogEdit->getId() ]
+ assign = blockContent}
+
+{component 'block'
+ mods = 'blog-invite'
+ title = {lang 'blog.invite.invite_users'}
+ content = $blockContent}
\ No newline at end of file
diff --git a/application/frontend/components/blog/blocks/block.blog-photo.tpl b/application/frontend/components/blog/blocks/block.blog-photo.tpl
new file mode 100644
index 0000000..bcdf912
--- /dev/null
+++ b/application/frontend/components/blog/blocks/block.blog-photo.tpl
@@ -0,0 +1,18 @@
+{**
+ * Аватара блога
+ *}
+
+{component 'photo'
+ classes = 'js-blog-avatar'
+ useAvatar = false
+ hasPhoto = $blog->getAvatar()
+ editable = $blog->isAllowEdit()
+ targetId = $blog->getId()
+ url = $blog->getUrlFull()
+ photoPath = $blog->getAvatarBig()
+ photoAltText = $blog->getTitle()|escape
+ assign = blockContent}
+
+{component 'block'
+ mods = 'nopadding transparent blog-photo'
+ content = $blockContent}
\ No newline at end of file
diff --git a/application/frontend/components/blog/blocks/block.blog-users.tpl b/application/frontend/components/blog/blocks/block.blog-users.tpl
new file mode 100644
index 0000000..fb74d42
--- /dev/null
+++ b/application/frontend/components/blog/blocks/block.blog-users.tpl
@@ -0,0 +1,17 @@
+{**
+ * Список пользователей блога
+ *}
+
+{component_define_params params=[ 'titleLang' ]}
+
+{capture 'block_title'}
+ {$countBlogUsers} {lang "{$titleLang|default:'blog.readers_declension'}" count=$countBlogUsers plural=true}
+{/capture}
+
+{if $countBlogUsers}
+ {component 'block'
+ mods = 'blog-users'
+ title = $smarty.capture.block_title
+ titleUrl = "{$blog->getUrlFull()}users/"
+ content = {component 'user' template='avatar-list' users=$blogUsers blankslateParams=[ 'mods' => 'no-background' ]}}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/blog/blocks/block.blogs-search.tpl b/application/frontend/components/blog/blocks/block.blogs-search.tpl
new file mode 100644
index 0000000..5f658df
--- /dev/null
+++ b/application/frontend/components/blog/blocks/block.blogs-search.tpl
@@ -0,0 +1,67 @@
+{**
+ * Фильтр блогов
+ *}
+
+{capture 'block_content'}
+
+ {* Категории *}
+ {if $aBlogCategories}
+
+ {lang 'blog.blocks.search.categories.title'}
+
+ {$items = [[
+ 'name' => 'all',
+ 'text' => {lang 'blog.blocks.search.categories.all'},
+ 'url' => {router page='blogs'},
+ 'attributes' => [ 'data-value' => '0' ],
+ 'count' => $iCountBlogsAll
+ ]]}
+
+ {foreach $aBlogCategories as $category}
+ {$oCategory = $category.entity}
+
+ {$items[] = [
+ 'text' => ($oCategory->getTitle()),
+ 'url' => '#',
+ 'attributes' => [ 'data-value' => $oCategory->getId(), 'style' => "margin-left: {$category.level * 20}px;" ],
+ 'count' => $oCategory->getCountTargetOfDescendants()
+ ]}
+ {/foreach}
+
+ {component 'nav'
+ name = 'blogs_categories'
+ classes = 'actionbar-item-link'
+ attributes = [ 'id' => 'js-search-ajax-blog-category' ]
+ activeItem = 'all'
+ mods = 'stacked pills'
+ items = $items}
+
+
+ {/if}
+
+ {* Тип блога *}
+ {lang 'blog.blocks.search.type.title'}
+
+
+ {component 'field' template='radio' inputClasses='js-search-ajax-blog-type' name='blog_search_type' value='' label={lang 'blog.search.form.type.any'} checked=true}
+ {component 'field' template='radio' inputClasses='js-search-ajax-blog-type' name='blog_search_type' value='open' label={lang 'blog.search.form.type.public'}}
+ {component 'field' template='radio' inputClasses='js-search-ajax-blog-type' name='blog_search_type' value='close' label={lang 'blog.search.form.type.private'}}
+
+
+ {* Тип принадлежности блога *}
+ {if $oUserCurrent}
+ {lang 'blog.blocks.search.relation.title'}
+
+
+ {component 'field' template='radio' inputClasses='js-search-ajax-blog-relation' name='blog_search_relation' value='all' label={lang 'blog.search.form.relation.all'} checked=true}
+ {component 'field' template='radio' inputClasses='js-search-ajax-blog-relation' name='blog_search_relation' value='my' label={lang 'blog.search.form.relation.my'}}
+ {component 'field' template='radio' inputClasses='js-search-ajax-blog-relation' name='blog_search_relation' value='join' label={lang 'blog.search.form.relation.joined'}}
+
+ {/if}
+
+{/capture}
+
+{component 'block'
+ mods = 'blogs-search'
+ title = {lang 'blog.blocks.search.title'}
+ content = $smarty.capture.block_content}
\ No newline at end of file
diff --git a/application/frontend/components/blog/blocks/block.blogs.tpl b/application/frontend/components/blog/blocks/block.blogs.tpl
new file mode 100644
index 0000000..4024c0a
--- /dev/null
+++ b/application/frontend/components/blog/blocks/block.blogs.tpl
@@ -0,0 +1,17 @@
+{**
+ * Блок со списком блогов
+ *}
+
+{component 'block'
+ mods = 'blogs'
+ classes = 'blog-block-blogs js-block-default'
+ title = {lang 'blog.blocks.blogs.title'}
+ titleUrl = {router page='blogs'}
+ tabs = [
+ 'classes' => 'js-tabs-block',
+ 'tabs' => [
+ [ 'text' => {lang 'blog.blocks.blogs.nav.top'}, 'url' => "{router page='ajax'}blogs/top", 'list' => $sBlogsTop ],
+ [ 'text' => {lang 'blog.blocks.blogs.nav.joined'}, 'url' => "{router page='ajax'}blogs/join", 'is_enabled' => !! $oUserCurrent ],
+ [ 'text' => {lang 'blog.blocks.blogs.nav.self'}, 'url' => "{router page='ajax'}blogs/self", 'is_enabled' => !! $oUserCurrent ]
+ ]
+ ]}
\ No newline at end of file
diff --git a/application/frontend/components/blog/blocks/blogs-top.tpl b/application/frontend/components/blog/blocks/blogs-top.tpl
new file mode 100644
index 0000000..23b60a2
--- /dev/null
+++ b/application/frontend/components/blog/blocks/blogs-top.tpl
@@ -0,0 +1,27 @@
+{**
+ * Блок со списоком блогов
+ * Список блогов
+ *}
+
+{$items = []}
+
+{foreach $aBlogs as $blog}
+ {capture 'item_content'}
+ {lang 'blog.users.readers_total'}: {$blog->getCountUser()}
+ {lang 'blog.topics_total'}: {$blog->getCountTopic()}
+ {/capture}
+
+ {$items[] = [
+ 'title' => $blog->getTitle()|escape,
+ 'titleUrl' => $blog->getUrlFull(),
+ 'mods' => 'blog',
+ 'content' => $smarty.capture.item_content,
+ 'image' => [
+ 'path' => $blog->getAvatarPath(48),
+ 'url' => $blog->getUrlFull(),
+ 'alt' => $blog->getTitle()|escape
+ ]
+ ]}
+{/foreach}
+
+{component 'item' template='group' items=$items}
diff --git a/application/frontend/components/blog/blog.tpl b/application/frontend/components/blog/blog.tpl
new file mode 100644
index 0000000..62365f2
--- /dev/null
+++ b/application/frontend/components/blog/blog.tpl
@@ -0,0 +1,56 @@
+{**
+ * Блог
+ *
+ * @param object $blog Блог
+ * @param object $blogs Список блогов для переноса топиков (для модальника удаления)
+ * @param string $mods Модификаторы
+ * @param string $attributes Дополнительные атрибуты основного блока
+ * @param string $classes Дополнительные классы
+ *}
+
+{* Название компонента *}
+{$component = 'blog'}
+{component_define_params params=[ 'blog', 'blogs', 'mods', 'classes', 'attributes' ]}
+
+{* Подключаем модальное окно удаления блога если пользователь админ *}
+{if $oUserCurrent && $oUserCurrent->isAdministrator()}
+ {component 'blog' template='modal.delete' blog=$blog blogs=$blogs}
+{/if}
+
+{* Является ли пользователь администратором или управляющим блога *}
+{$isBlogAdmin = $oUserCurrent && ($oUserCurrent->getId() == $blog->getOwnerId() || $oUserCurrent->isAdministrator() || $blog->getUserIsAdministrator())}
+
+{* Блог *}
+
+
+
+ {* Информация о блоге *}
+
+ {* Описание *}
+
+ {$blog->getDescription()}
+
+
+ {* Информация *}
+ {$info = [
+ [ 'label' => $aLang.blog.date_created, 'content' => "{date_format date=$blog->getDateAdd() hours_back='12' minutes_back='60' now='60' day='day H:i' format='j F Y'}" ],
+ [ 'label' => $aLang.blog.topics_total, 'content' => $blog->getCountTopic() ],
+ [ 'label' => $aLang.blog.rating_limit, 'content' => $blog->getLimitRatingTopic() ]
+ ]}
+
+ {if $blog->category->getCategory()}
+ {$info[] = [ 'label' => "{$aLang.blog.categories.category}:", 'content' => $blog->category->getCategory()->getTitle() ]}
+ {/if}
+
+ {component 'info-list' list=$info}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/blog/component.json b/application/frontend/components/blog/component.json
new file mode 100644
index 0000000..7fa0256
--- /dev/null
+++ b/application/frontend/components/blog/component.json
@@ -0,0 +1,50 @@
+{
+ "name": "blog",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-component": "*",
+ "user-list-add": "*",
+ "search-form": "*",
+ "search-ajax": "*",
+ "button": "*",
+ "modal": "*",
+ "block": "*",
+ "item": "*",
+ "field": "*",
+ "alert": "*"
+ },
+ "templates": {
+ "add": "add.tpl",
+ "admin": "admin.tpl",
+ "list-item": "list/blog-list-item.tpl",
+ "list-loop": "list/blog-list-loop.tpl",
+ "list": "list/blog-list.tpl",
+ "blog": "blog.tpl",
+ "join": "join.tpl",
+ "search-form": "search-form.blogs.tpl",
+ "block.actions": "blocks/block.blog-actions.tpl",
+ "block.add": "blocks/block.blog-add.tpl",
+ "block.admins": "blocks/block.blog-admins.tpl",
+ "block.info-note": "blocks/block.blog-info-note.tpl",
+ "block.invite": "blocks/block.blog-invite.tpl",
+ "block.photo": "blocks/block.blog-photo.tpl",
+ "block.users": "blocks/block.blog-users.tpl",
+ "block.search": "blocks/block.blogs-search.tpl",
+ "block.blogs": "blocks/block.blogs.tpl",
+ "top": "blocks/blogs-top.tpl",
+ "invite-item": "invite/invite-item.tpl",
+ "invite-list": "invite/invite-list.tpl",
+ "invite": "invite/invite.tpl",
+ "modal.delete": "modals/modal.blog-delete.tpl",
+ "modal.crop-avatar": "modals/modal.crop-avatar.tpl"
+ },
+ "scripts": {
+ "blog-add": "js/blog-add.js",
+ "blog-invites": "js/blog-invites.js",
+ "blog-join": "js/blog-join.js"
+ },
+ "styles": {
+ "blog-blocks": "css/blog-blocks.css",
+ "blog": "css/blog.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/blog/css/blog-blocks.css b/application/frontend/components/blog/css/blog-blocks.css
new file mode 100644
index 0000000..6eb8c47
--- /dev/null
+++ b/application/frontend/components/blog/css/blog-blocks.css
@@ -0,0 +1,23 @@
+/**
+ * Блок с кнопкой "Создать блог"
+ *
+ * @modifier blog-add
+ * @template blocks/block.blog-add.tpl
+ */
+.ls-block--blog-add .ls-block-content {
+ padding: 30px;
+ text-align: center;
+}
+
+/**
+ * Список блогов
+ *
+ * @template blocks/block.blogs.tpl
+ */
+.ls-block.blog-block-blogs .ls-item-title {
+ font-size: 16px;
+ margin-bottom: 7px;
+}
+.ls-block.blog-block-blogs .ls-item-image {
+ border-radius: 3px;
+}
\ No newline at end of file
diff --git a/application/frontend/components/blog/css/blog.css b/application/frontend/components/blog/css/blog.css
new file mode 100644
index 0000000..908b798
--- /dev/null
+++ b/application/frontend/components/blog/css/blog.css
@@ -0,0 +1,54 @@
+/**
+ * Блог
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+.blog {
+ width: 100%;
+ padding: 40px;
+ margin: -40px 0 40px -40px;
+ overflow: hidden;
+ background: #fff;
+ border-bottom: 1px solid #eee;
+}
+
+/* Хидер */
+.blog-header {
+ position: relative;
+ padding: 0 0 15px 0;
+}
+.blog-header .blog-title {
+ margin-bottom: 7px;
+}
+.blog-header .blog-title i {
+ position: relative;
+ top: -2px;
+ vertical-align: middle;
+}
+.blog-header .vote {
+ position: absolute;
+ top: -15px;
+ right: 0;
+}
+
+/* Контент */
+.blog .ls-actionbar {
+ margin-bottom: 0;
+ margin-top: 30px;
+}
+.blog-description {
+ margin-bottom: 20px;
+}
+
+
+/**
+ * Список блогов
+ */
+.blog-list-item-actions {
+ position: absolute;
+ top: 15px;
+ right: 15px;
+}
\ No newline at end of file
diff --git a/application/frontend/components/blog/invite/invite-item.tpl b/application/frontend/components/blog/invite/invite-item.tpl
new file mode 100644
index 0000000..03cf156
--- /dev/null
+++ b/application/frontend/components/blog/invite/invite-item.tpl
@@ -0,0 +1,12 @@
+{**
+ * Пользователь
+ *}
+
+{extends 'component@user-list-add.item'}
+
+{block 'user_list_add_item_actions' prepend}
+ {* Кнопка "Повторно отправить инвайт" *}
+
+ {component 'icon' icon='repeat'}
+
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/blog/invite/invite-list.tpl b/application/frontend/components/blog/invite/invite-list.tpl
new file mode 100644
index 0000000..f4c5f29
--- /dev/null
+++ b/application/frontend/components/blog/invite/invite-list.tpl
@@ -0,0 +1,9 @@
+{**
+ * Список пользователей
+ *}
+
+{extends 'component@user-list-add.list'}
+
+{block 'user_list_add_item'}
+ {component 'blog' template='invite-item' user=$user showActions=true}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/blog/invite/invite.tpl b/application/frontend/components/blog/invite/invite.tpl
new file mode 100644
index 0000000..2d607a5
--- /dev/null
+++ b/application/frontend/components/blog/invite/invite.tpl
@@ -0,0 +1,15 @@
+{**
+ * Приглашение пользователей в закрытый блог
+ *}
+
+{extends 'component@user-list-add.user-list-add'}
+
+{block 'user_list_add_list'}
+ {component 'blog' template='invite-list'
+ hideableEmptyAlert = true
+ users = $users
+ showActions = true
+ show = !! $users
+ classes = "js-$component-users"
+ itemClasses = "js-$component-user"}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/blog/join.tpl b/application/frontend/components/blog/join.tpl
new file mode 100644
index 0000000..17e5d04
--- /dev/null
+++ b/application/frontend/components/blog/join.tpl
@@ -0,0 +1,15 @@
+{**
+ * Кнопка Вступить / Покинуть блог
+ *
+ * @param object $blog Блог
+ *}
+
+{component_define_params params=[ 'blog' ]}
+
+{if $oUserCurrent && $oUserCurrent->getId() != $blog->getOwnerId() && $blog->getType() == 'open'}
+ {component 'button'
+ attributes = [ 'data-blog-id' => $blog->getId() ]
+ classes = 'js-blog-join'
+ text = ($blog->getUserIsJoin()) ? $aLang.blog.join.leave : $aLang.blog.join.join
+ mods = ($blog->getUserIsJoin()) ? false : 'primary'}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/blog/js/blog-add.js b/application/frontend/components/blog/js/blog-add.js
new file mode 100644
index 0000000..567ff59
--- /dev/null
+++ b/application/frontend/components/blog/js/blog-add.js
@@ -0,0 +1,54 @@
+/**
+ * Форма добавления блога
+ *
+ * @module ls/blog/add
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsBlogAdd", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Селекторы
+ selectors: {
+ type: '.js-blog-add-type',
+ type_note: '.js-blog-add-field-type .js-field-note'
+ },
+ i18n: {
+ type_open: '@blog.add.fields.type.note_open',
+ type_closed: '@blog.add.fields.type.note_closed'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ var _this = this;
+
+ this._super();
+
+ // Подгрузка информации о выбранном типе блога при создании блога
+ this.elements.type.on( 'change' + this.eventNamespace, function () {
+ _this.setTypeNote( $( this ).val() );
+ });
+ },
+
+ /**
+ *
+ */
+ setTypeNote: function( type ) {
+ this.elements.type_note.text( this._i18n( 'type_' + type ) );
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/blog/js/blog-invites.js b/application/frontend/components/blog/js/blog-invites.js
new file mode 100644
index 0000000..c4a86d1
--- /dev/null
+++ b/application/frontend/components/blog/js/blog-invites.js
@@ -0,0 +1,57 @@
+/**
+ * Приглашение пользователей в закрытый блог
+ *
+ * @module blog_invite_users
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsBlogInvites", $.livestreet.lsUserListAdd, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ urls: {
+ add: aRouter['blog'] + 'ajaxaddbloginvite/',
+ remove: aRouter['blog'] + 'ajaxremovebloginvite/',
+ reinvite: aRouter['blog'] + 'ajaxrebloginvite/'
+ },
+ selectors: {
+ // Кнопка повторного отправления инвайта
+ item_reinvite: '.js-blog-invite-user-repeat'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ var _this = this;
+
+ this._super();
+
+ // Повторная отправка инвайта
+ this.elements.list.on('click' + this.eventNamespace, this.options.selectors.item_reinvite, function (e) {
+ _this.reinvite( $(this).data('user-id') );
+ e.preventDefault();
+ });
+ },
+
+ /**
+ * Отправляет инвайт заново
+ */
+ reinvite: function ( userId ) {
+ this._load( 'reinvite', { user_id: userId }, function( response ) {
+ this._trigger( "afterreinvite", null, { context: this, response: response } );
+ });
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/blog/js/blog-join.js b/application/frontend/components/blog/js/blog-join.js
new file mode 100644
index 0000000..2daf6b4
--- /dev/null
+++ b/application/frontend/components/blog/js/blog-join.js
@@ -0,0 +1,89 @@
+/**
+ * Кнопка "Вступить в блог"
+ *
+ * @module ls/blog/join
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsBlogJoin", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ toggle: null
+ },
+ // Селекторы
+ selectors: {
+ count: '.js-blog-users-count',
+ text: null
+ },
+ // Классы
+ classes : {
+ active: 'ls-button--primary',
+ loading: null
+ },
+ // Ajax параметры
+ params : {},
+
+ i18n: {
+ join: '@blog.join.join',
+ leave: '@blog.join.leave'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ if ( ! this.elements.text.length ) this.elements.text = this.element;
+
+ this.option( 'params.blog_id', this.element.data( 'blog-id' ) );
+
+ this._on({ click: 'onClick' });
+ },
+
+ /**
+ *
+ */
+ onClick: function( event ) {
+ this.toggle();
+ event.preventDefault();
+ },
+
+ /**
+ *
+ */
+ toggle: function() {
+ this.element.addClass( this.option( 'classes.loading' ) );
+
+ this._load('toggle', function( response ) {
+ this.onToggle( response );
+
+ this.element.removeClass( this.option( 'classes.loading' ) );
+ }.bind( this ));
+ },
+
+ /**
+ *
+ */
+ onToggle: function( response ) {
+ this.element.toggleClass( this.option( 'classes.active' ) );
+ this.elements.text.text( this._i18n( response.bState ? 'leave' : 'join' ) );
+
+ $( this.option( 'selectors.count' ) + '[data-blog-id=' + this.option( 'params.blog_id' ) + ']' ).text( response.iCountUser );
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/blog/list/blog-list-item.tpl b/application/frontend/components/blog/list/blog-list-item.tpl
new file mode 100644
index 0000000..bd7dfee
--- /dev/null
+++ b/application/frontend/components/blog/list/blog-list-item.tpl
@@ -0,0 +1,53 @@
+{**
+ * Блог в списке блогов
+ *
+ * @param object $blog
+ *}
+
+{$component = 'blog-list-item'}
+{component_define_params params=[ 'blog' ]}
+
+{* Заголовок *}
+{capture 'title'}
+ {if $blog->getType() == 'close'}
+ {component 'icon' icon='lock' attributes=[ title => {lang 'blog.private'} ]}
+ {/if}
+
+ {$blog->getTitle()|escape}
+{/capture}
+
+{* Описание *}
+{capture 'desc'}
+ {$blog->getDescription()|strip_tags|truncate:120}
+{/capture}
+
+{* Описание *}
+{capture 'content'}
+ {* Действия *}
+
+ {* Вступить/покинуть блог *}
+ {component 'blog' template='join' blog=$blog}
+
+
+ {* Информация *}
+ {$info = [
+ [ 'label' => "{$aLang.blog.users.readers_total}:", 'content' => "getId()}\">{$blog->getCountUser()} " ],
+ [ 'label' => "{$aLang.blog.topics_total}:", 'content' => $blog->getCountTopic() ]
+ ]}
+
+ {if $blog->category->getCategory()}
+ {$info[] = [ 'label' => "{$aLang.blog.categories.category}:", 'content' => $blog->category->getCategory()->getTitle() ]}
+ {/if}
+
+ {component 'info-list' list=$info classes='object-list-item-info'}
+{/capture}
+
+{component 'item'
+ title=$smarty.capture.title
+ desc=$smarty.capture.desc
+ content=$smarty.capture.content
+ image=[
+ 'url' => $blog->getUrlFull(),
+ 'path' => $blog->getAvatarPath( 100 ),
+ 'alt' => $blog->getTitle()|escape
+ ]}
\ No newline at end of file
diff --git a/application/frontend/components/blog/list/blog-list-loop.tpl b/application/frontend/components/blog/list/blog-list-loop.tpl
new file mode 100644
index 0000000..d6c293c
--- /dev/null
+++ b/application/frontend/components/blog/list/blog-list-loop.tpl
@@ -0,0 +1,11 @@
+{**
+ * Список блогов
+ *
+ * @param array $blogs
+ *}
+
+{component_define_params params=[ 'blogs' ]}
+
+{foreach $blogs as $blog}
+ {component 'blog' template='list-item' blog=$blog}
+{/foreach}
\ No newline at end of file
diff --git a/application/frontend/components/blog/list/blog-list.tpl b/application/frontend/components/blog/list/blog-list.tpl
new file mode 100644
index 0000000..7eab18c
--- /dev/null
+++ b/application/frontend/components/blog/list/blog-list.tpl
@@ -0,0 +1,32 @@
+{**
+ * Список блогов
+ *
+ * @param array $blogs
+ * @param array $pagination
+ * @param boolean $useMore
+ * @param boolean $hideMore
+ * @param string $textEmpty
+ *}
+
+{component_define_params params=[ 'blogs', 'pagination', 'useMore', 'hideMore', 'textEmpty' ]}
+
+{if $blogs}
+ {* Список блогов *}
+ {component 'item' template='group'
+ classes = 'js-more-blogs-container'
+ items = {component 'blog' template='list-loop' blogs=$blogs}}
+
+ {* Кнопка подгрузки *}
+ {if $useMore}
+ {if ! $hideMore}
+ {component 'more'
+ classes = 'js-more-search'
+ target = '.js-more-blogs-container'
+ ajaxParams = [ 'next_page' => 2 ]}
+ {/if}
+ {else}
+ {component 'pagination' total=+$pagination.iCountPage current=+$pagination.iCurrentPage url="{$pagination.sBaseUrl}/page__page__/{$pagination.sGetParams}"}
+ {/if}
+{else}
+ {component 'blankslate' text=$textEmpty|default:{lang name='blog.alerts.empty'}}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/blog/modals/modal.blog-delete.tpl b/application/frontend/components/blog/modals/modal.blog-delete.tpl
new file mode 100644
index 0000000..fb86946
--- /dev/null
+++ b/application/frontend/components/blog/modals/modal.blog-delete.tpl
@@ -0,0 +1,43 @@
+{**
+ * Удаление блога
+ *
+ * @param object $blog
+ * @param array $blogs
+ *}
+
+{component_define_params params=[ 'blog', 'blogs' ]}
+
+{capture 'modal_content'}
+
+{/capture}
+
+{component 'modal'
+ title = {lang 'blog.remove.title'}
+ content = $smarty.capture.modal_content
+ classes = 'js-modal-default'
+ mods = 'blog-delete'
+ id = 'modal-blog-delete'
+ primaryButton = [
+ 'text' => {lang 'common.remove'},
+ 'form' => 'js-blog-remove-form'
+ ]}
\ No newline at end of file
diff --git a/application/frontend/components/blog/modals/modal.crop-avatar.tpl b/application/frontend/components/blog/modals/modal.crop-avatar.tpl
new file mode 100644
index 0000000..0e24aa6
--- /dev/null
+++ b/application/frontend/components/blog/modals/modal.crop-avatar.tpl
@@ -0,0 +1,11 @@
+{**
+ * Кроп фотографии
+ *}
+
+{extends 'component@crop.crop'}
+
+{block 'modal_options' append}
+ {$title = {lang 'user.photo.crop_avatar.title'}}
+ {$desc = {lang 'user.photo.crop_avatar.desc'}}
+ {$usePreview = true}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/blog/search-form.blogs.tpl b/application/frontend/components/blog/search-form.blogs.tpl
new file mode 100644
index 0000000..59bae35
--- /dev/null
+++ b/application/frontend/components/blog/search-form.blogs.tpl
@@ -0,0 +1,11 @@
+{**
+ * Форма поиска блогов
+ *}
+
+{component 'search-form'
+ name = 'blog'
+ method = 'post'
+ placeholder = $aLang.blog.search.placeholder
+ inputClasses = 'js-search-text-main'
+ inputName = 'blog_title'
+ noSubmitButton = true}
\ No newline at end of file
diff --git a/application/frontend/components/comment/README.md b/application/frontend/components/comment/README.md
new file mode 100644
index 0000000..e34082a
--- /dev/null
+++ b/application/frontend/components/comment/README.md
@@ -0,0 +1 @@
+# Компонент comment
\ No newline at end of file
diff --git a/application/frontend/components/comment/comment-actions-item.tpl b/application/frontend/components/comment/comment-actions-item.tpl
new file mode 100644
index 0000000..f433b46
--- /dev/null
+++ b/application/frontend/components/comment/comment-actions-item.tpl
@@ -0,0 +1,16 @@
+{**
+ * Действие
+ *}
+
+{$component = 'ls-comment-actions-item'}
+{component_define_params params=[ 'text', 'link', 'mods', 'classes', 'attributes' ]}
+
+
+ {if $link}
+
+ {$text}
+
+ {else}
+ {$text}
+ {/if}
+
\ No newline at end of file
diff --git a/application/frontend/components/comment/comment-form.tpl b/application/frontend/components/comment/comment-form.tpl
new file mode 100644
index 0000000..85cd327
--- /dev/null
+++ b/application/frontend/components/comment/comment-form.tpl
@@ -0,0 +1,62 @@
+{**
+ * Форма комментирования
+ *
+ * @param integer $targetId
+ * @param string $targetType
+ * @param string $editorSet (light) Стиль редактора
+ *
+ * @param string $classes Дополнительные классы
+ * @param string $attributes Атрибуты
+ * @param string $mods Модификаторы
+ *}
+
+{* Название компонента *}
+{$component = 'ls-comment-form'}
+{component_define_params params=[ 'editorSet', 'targetId', 'targetType', 'mods', 'classes', 'attributes' ]}
+
+{* Форма *}
+
\ No newline at end of file
diff --git a/application/frontend/components/comment/comment-info-item.tpl b/application/frontend/components/comment/comment-info-item.tpl
new file mode 100644
index 0000000..c9445d9
--- /dev/null
+++ b/application/frontend/components/comment/comment-info-item.tpl
@@ -0,0 +1,16 @@
+{**
+ * Пункт с информацией
+ *}
+
+{$component = 'ls-comment-info-item'}
+{component_define_params params=[ 'text', 'link', 'mods', 'classes', 'attributes' ]}
+
+
+ {if $link}
+
+ {$text}
+
+ {else}
+ {$text}
+ {/if}
+
\ No newline at end of file
diff --git a/application/frontend/components/comment/comment-list.tpl b/application/frontend/components/comment/comment-list.tpl
new file mode 100644
index 0000000..75498d1
--- /dev/null
+++ b/application/frontend/components/comment/comment-list.tpl
@@ -0,0 +1,27 @@
+{**
+ * Список комментариев
+ *
+ * @param array comments Комментарии
+ *}
+
+{$component = 'ls-comment-list'}
+{component_define_params params=[ 'hookPrefixComment', 'comments', 'mods', 'classes', 'attributes' ]}
+
+{if $comments}
+
+ {component 'comment' template='tree'
+ hookPrefixComment = $hookPrefixComment
+ comments = $comments
+ forbidAdd = true
+ maxLevel = 0
+ commentParams = [
+ useFavourite => true,
+ useEdit => false,
+ useVote => false,
+ useScroll => false,
+ showPath => true
+ ]}
+
+{else}
+ {component 'blankslate' text=$aLang.common.empty}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/comment/comment-tree.tpl b/application/frontend/components/comment/comment-tree.tpl
new file mode 100644
index 0000000..08161b8
--- /dev/null
+++ b/application/frontend/components/comment/comment-tree.tpl
@@ -0,0 +1,57 @@
+{**
+ * Дерево комментариев
+ *
+ * @param array $comments Комментарии
+ * @param integer $maxLevel
+ *
+ * @param array $commentParams
+ * @param boolean $useVote Показывать или нет голосование
+ * @param boolean $showReply Показывать или нет кнопку Ответить
+ * @param boolean $useScroll
+ * @param integer $authorId
+ * @param string $dateReadLast
+ * @param boolean $forbidAdd
+ *}
+
+{component_define_params params=[ 'hookPrefixComment', 'authorId', 'authorText', 'commentParams', 'comments', 'dateReadLast', 'forbidAdd', 'maxLevel', 'showReply' ]}
+
+{* Текущая вложенность *}
+{$currentLevel = -1}
+
+{* Построение дерева комментариев *}
+{foreach $comments as $comment}
+ {* Ограничиваем вложенность комментария максимальным значением *}
+ {$commentLevel = ( $comment->getLevel() > $maxLevel ) ? $maxLevel : $comment->getLevel()}
+
+ {* Закрываем блоки-обертки *}
+ {if $currentLevel > $commentLevel}
+ {section closewrappers1 loop=$currentLevel - $commentLevel + 1}{/section}
+ {elseif $currentLevel == $commentLevel && ! $comment@first}
+
+ {/if}
+
+ {* Устанавливаем текущий уровень вложенности *}
+ {$currentLevel = $commentLevel}
+
+ {* Вспомогательный блок-обертка *}
+
+ {/section}
+ {/if}
+{/foreach}
\ No newline at end of file
diff --git a/application/frontend/components/comment/comment.tpl b/application/frontend/components/comment/comment.tpl
new file mode 100644
index 0000000..2c77318
--- /dev/null
+++ b/application/frontend/components/comment/comment.tpl
@@ -0,0 +1,223 @@
+{**
+ * Комментарий
+ *
+ * @param object $comment Комментарий
+ * @param boolean $useVote (true) Показывать или нет голосование
+ * @param boolean $useFavourite
+ * @param boolean $useScroll
+ * @param boolean $showReply (true) Показывать или нет кнопку Ответить
+ * @param integer $authorId
+ * @param string $authorText
+ * @param string $dateReadLast
+ *
+ * @param string $classes Дополнительные классы
+ * @param string $attributes Атрибуты
+ * @param string $mods Модификаторы
+ *}
+
+{* Название компонента *}
+{$component = 'ls-comment'}
+{component_define_params params=[ 'hookPrefix', 'dateReadLast', 'showPath', 'showReply', 'authorId', 'comment', 'useFavourite', 'useScroll', 'useVote', 'useEdit', 'mods', 'classes', 'attributes' ]}
+
+{* Переменные *}
+{$useEdit = $useEdit|default:true}
+{$hookPrefix = $hookPrefix|default:'comment'}
+{$isDeleted = $comment->getDelete()}
+{$user = $comment->getUser()}
+{$commentId = $comment->getId()}
+{$target = $comment->getTarget()}
+
+{* Получаем ссылку на комментарий *}
+{* TODO: Вынести в бэкенд *}
+{$permalink = ( Config::Get('module.comment.use_nested') ) ? "{router page='comments'}{$commentId}" : "{if $target}{$target->getUrl()}{/if}#comment{$commentId}"}
+
+{**
+ * Добавляем модификаторы
+ *}
+
+{* Комментарий с отрицательным рейтингом *}
+{if $useVote && $comment->isBad()}
+ {$mods = "$mods bad"}
+{/if}
+
+{* Автор комментария является автором объекта к которому оставлен комментарий *}
+{if $authorId == $user->getId()}
+ {$mods = "$mods author"}
+{/if}
+
+{* Комментарий удален *}
+{if $isDeleted}
+ {$mods = "$mods deleted"}
+
+{* Комментарий текущего залогиненого пользователя *}
+{elseif $oUserCurrent && $comment->getUserId() == $oUserCurrent->getId()}
+ {$mods = "$mods self"}
+
+{* Непрочитанный комментарий *}
+{elseif $dateReadLast && strtotime($dateReadLast) <= strtotime($comment->getDate())}
+ {$mods = "$mods new"}
+{/if}
+
+
+{**
+ * Комментарий
+ * Атрибут id используется для ссылки на комментарий через хэш в урл (например #comment123)
+ *}
+
\ No newline at end of file
diff --git a/application/frontend/components/comment/comments.tpl b/application/frontend/components/comment/comments.tpl
new file mode 100644
index 0000000..c789183
--- /dev/null
+++ b/application/frontend/components/comment/comments.tpl
@@ -0,0 +1,158 @@
+{**
+ * Комментарии
+ *
+ * @param array $comments
+ * @param integer $count
+ * @param integer $targetId
+ * @param string $targetType
+ * @param string $dateReadLast
+ * @param boolean $forbidAdd
+ * @param integer $authorId
+ * @param integer $lastCommentId
+ * @param array $pagination
+ * @param boolean $isSubscribed
+ * @param integer $maxLevel
+ *
+ * @param array $commentParams
+ * @param boolean $useSubscribe
+ *
+ * @param string $forbidText
+ * @param string $authorText
+ * @param string $addCommentText
+ * @param string $title
+ * @param string $titleNoComments
+ *
+ * @param string $classes
+ * @param array $attributes
+ * @param string $mods
+ *}
+
+{$component = 'ls-comments'}
+{component_define_params params=[ 'hookPrefix', 'hookPrefixComment', 'addCommentText', 'authorId', 'authorText', 'commentParams', 'comments', 'count', 'dateReadLast', 'forbidAdd',
+ 'forbidText', 'isSubscribed', 'lastCommentId', 'maxLevel', 'pagination', 'targetId', 'targetType', 'title', 'titleNoComments',
+ 'useSubscribe', 'mods', 'classes', 'attributes' ]}
+
+{block 'comment-list-options'}
+ {* Максимальная вложенность *}
+ {$maxLevel = $maxLevel|default:Config::Get('module.comment.max_tree')}
+ {$hookPrefix = $hookPrefix|default:'comments'}
+
+ {if $forbidAdd}
+ {$mods = "$mods forbid"}
+ {/if}
+{/block}
+
+{if $oUserCurrent && ! $pagination['total']}
+ {add_block group='toolbar' name='component@comment.toolbar'}
+{/if}
+
+
diff --git a/application/frontend/components/comment/component.json b/application/frontend/components/comment/component.json
new file mode 100644
index 0000000..02c641c
--- /dev/null
+++ b/application/frontend/components/comment/component.json
@@ -0,0 +1,33 @@
+{
+ "name": "comment",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-core": "*",
+ "actionbar": "*",
+ "favourite": "*",
+ "vote": "*",
+ "editor": "*"
+ },
+ "templates": {
+ "form": "comment-form.tpl",
+ "list": "comment-list.tpl",
+ "tree": "comment-tree.tpl",
+ "comments": "comments.tpl",
+ "comment": "comment.tpl",
+ "actions-item": "comment-actions-item.tpl",
+ "info-item": "comment-info-item.tpl",
+ "toolbar": "toolbar.comment.tpl"
+ },
+ "scripts": {
+ "toolbar": "js/comments-toolbar.js",
+ "form": "js/comment-form.js",
+ "comment": "js/comment.js",
+ "comments": "js/comments.js"
+ },
+ "styles": {
+ "form": "css/comment-form.css",
+ "comment": "css/comment.css",
+ "comments": "css/comments.css",
+ "toolbar": "css/comments-toolbar.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/comment/css/comment-form.css b/application/frontend/components/comment/css/comment-form.css
new file mode 100644
index 0000000..6e163bb
--- /dev/null
+++ b/application/frontend/components/comment/css/comment-form.css
@@ -0,0 +1,21 @@
+/**
+ * Форма комментирования
+ *
+ * @template comment-form.tpl
+ */
+
+.ls-comment-form {
+ padding: 15px;
+ margin-bottom: 2px;
+ background: #fafafa;
+}
+.ls-comment-form textarea {
+ height: 150px;
+}
+
+/* Предпросмотр текста комментария */
+.ls-comment-preview {
+ padding: 15px;
+ margin: 10px 0 10px 0;
+ border: 1px solid #eee;
+}
\ No newline at end of file
diff --git a/application/frontend/components/comment/css/comment.css b/application/frontend/components/comment/css/comment.css
new file mode 100644
index 0000000..ecf232c
--- /dev/null
+++ b/application/frontend/components/comment/css/comment.css
@@ -0,0 +1,166 @@
+/**
+ * Комментарий
+ *
+ * @modifier deleted Удаленный комментарий
+ * @modifier self Ваш комментарий
+ * @modifier new Новый, непрочитанный комментарий
+ * @modifier current Активный комментарий, который выделяется при исползование кнопки обновления в тулбаре
+ * @modifier list-item Комментарий выводимый в списках
+ *
+ * @template comment.tpl
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+.ls-comment {
+ min-height: 48px;
+ padding: 15px 15px 15px 80px;
+ margin-bottom: 2px;
+ position: relative;
+ background: #fafafa;
+ word-wrap: break-word;
+}
+
+.ls-comment--self {
+ background: #c5f7ea;
+}
+.ls-comment--new {
+ background: #fbfba8;
+}
+.ls-comment--current {
+ background: #a5e7fa;
+}
+.ls-comment--bad {
+ opacity: 0.3;
+ filter: alpha(opacity=30);
+}
+.ls-comment--bad:hover {
+ opacity: 1;
+ filter: alpha(opacity=100);
+}
+
+.ls-comment.ls-comment--deleted { background: #efd5d5; }
+.ls-user-role-not-admin .ls-comment.ls-comment--deleted {
+ padding: 10px 15px;
+ min-height: 0;
+ background: #f7f7f7;
+ color: #888;
+}
+
+.ls-comment.ls-comment-list-item { margin-bottom: 20px; }
+.ls-comment.ls-comment-list-item .vote .vote-up,
+.ls-comment.ls-comment-list-item .vote .vote-down { display: none; }
+
+/* Аватар */
+.ls-comment-avatar {
+ position: absolute;
+ top: 15px;
+ left: 15px;
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ overflow: hidden;
+}
+.ls-comment-avatar img {
+ width: 50px;
+ height: 50px;
+}
+
+/* Информация */
+.ls-comment-info {
+ padding: 0 70px 0 0;
+ margin-bottom: 15px;
+ line-height: 1.3em;
+ position: relative;
+}
+.ls-comment-info li {
+ float: left;
+ margin-right: 10px;
+}
+.ls-comment-info a {
+ text-decoration: none;
+}
+
+/* Логин */
+.ls-comment-info .ls-comment-username {
+ float: none;
+ font: 20px/1.3em "Open Sans", sans-serif;
+ margin-bottom: 5px;
+}
+
+/* Избранное */
+.ls-comment-favourite {
+ display: none;
+ position: absolute;
+ width: 50px;
+ top: 80px;
+ left: 15px;
+ text-align: center;
+}
+
+.ls-comment-favourite.ls-favourite--added,
+.ls-comment-favourite.ls-favourite--has-counter,
+.ls-comment:hover .ls-comment-favourite { display: block; }
+
+/* Дата */
+.ls-comment-date a { color: #999; }
+.ls-comment-date a:hover { color: #777; }
+
+/* Голосование */
+.ls-comment-vote {
+ position: absolute;
+ top: 0;
+ right: 0;
+ margin: 0;
+}
+
+.ls-comment-vote.vote--not-voted.vote--count-zero { display: none; }
+.ls-comment:hover .ls-comment-vote { display: block; }
+
+/* Прокрутка к дочернему комментарию */
+.ls-comment-scroll-to { cursor: pointer; }
+.ls-comment-scroll-to-child { display: none; }
+
+/* Текст комментария */
+.ls-comment-text.ls-text {
+ font-size: 13px;
+ line-height: 1.7em;
+}
+.ls-comment-text.ls-text blockquote {
+ background: #fff;
+ border-color: #ccc;
+ padding: 5px 10px;
+ margin-bottom: 5px;
+}
+
+/* Действия */
+.ls-comment-actions li {
+ float: left;
+ margin: 10px 10px 0 0;
+}
+
+/* Сворачивание */
+.ls-comment-fold {
+ display: none;
+}
+.ls-comment--folded .ls-comment-fold {
+ background: #CBCBF3;
+}
+
+/* Информация о редактировании */
+.ls-comment-edit-info {
+ margin-top: 10px;
+ font-size: 11px;
+ opacity: .5;
+}
+
+/* Путь до комментария */
+.ls-comment-path {
+ background: #fff;
+ color: #aaa;
+ border-radius: 3px;
+ padding: 3px 5px 2px;
+ margin-bottom: 10px;
+}
\ No newline at end of file
diff --git a/application/frontend/components/comment/css/comments-toolbar.css b/application/frontend/components/comment/css/comments-toolbar.css
new file mode 100644
index 0000000..97aa8a6
--- /dev/null
+++ b/application/frontend/components/comment/css/comments-toolbar.css
@@ -0,0 +1,13 @@
+/**
+ * Кнопка обновления комментариев
+ *
+ * @template toolbar.comment.tpl
+ */
+
+.ls-toolbar-item--comments .ls-comments-toolbar-count {
+ margin-top: 5px;
+ color: #333;
+ text-align: center;
+ font-size: 11px;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/application/frontend/components/comment/css/comments.css b/application/frontend/components/comment/css/comments.css
new file mode 100644
index 0000000..edb291f
--- /dev/null
+++ b/application/frontend/components/comment/css/comments.css
@@ -0,0 +1,65 @@
+/**
+ * Блок со списком комментариев
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+/**
+ * @modifier forbid Комментирование запрещено
+ */
+.ls-comments--forbid .ls-comment-form {
+ display: none;
+}
+
+/**
+ * Блок с комментариями
+ */
+.ls-comment-list {
+ margin-bottom: 30px;
+}
+
+/**
+ * Хидер
+ */
+.ls-comments-header {
+ margin-bottom: 20px;
+}
+
+/* Заголовок */
+.ls-comments-title {
+ font-size: 24px;
+ margin-bottom: 5px;
+}
+
+/* Действия */
+.ls-comments-actions {
+ padding: 0;
+ background-color: transparent;
+}
+
+/**
+ * Кнопка "Комментировать"
+ */
+.ls-comment-reply-root {
+ font-size: 20px;
+ margin-bottom: 0;
+}
+.ls-comment-reply-root + .ls-comment-form {
+ margin-top: 15px;
+}
+
+/**
+ * Пагинация
+ */
+.ls-comments-pagination {
+ margin-bottom: 30px;
+}
+
+/**
+ * Вспомогательный блок-обертка
+ */
+.ls-comment-wrapper .ls-comment-wrapper { padding-left: 25px; }
+.ls-comment-wrapper .ls-comment-form,
+.ls-comment-wrapper .ls-comment-preview { margin-left: 25px; }
\ No newline at end of file
diff --git a/application/frontend/components/comment/js/comment-form.js b/application/frontend/components/comment/js/comment-form.js
new file mode 100644
index 0000000..2259088
--- /dev/null
+++ b/application/frontend/components/comment/js/comment-form.js
@@ -0,0 +1,333 @@
+/**
+ * Comment form
+ *
+ * @module ls/comment/form
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsCommentForm", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ comments: $(),
+
+ // Ссылки
+ urls: {
+ text: null,
+ add: null,
+ update: null
+ },
+
+ // Селекторы
+ selectors: {
+ text: '.js-comment-form-text',
+ submit: '.js-comment-form-submit',
+ show_preview: '.js-comment-form-preview',
+ update_submit: '.js-comment-form-update-submit',
+ cancel: '.js-comment-form-update-cancel',
+ comment_id: '.js-comment-form-id'
+ },
+
+ // Классы
+ classes : {
+ locked: 'ls-comment-form--locked'
+ },
+
+ html: {
+ preview: ''
+ },
+
+ params: {}
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ // ID комментария к которому прикреплена форма
+ this._targetId = 0;
+
+ // Заблокирована форма или нет
+ this._locked = false;
+
+ this.setModeAdd();
+
+ // Иниц-ия редактора
+ this.elements.text.lsEditor({
+ submitted: function () {
+ this.element.submit();
+ }.bind(this)
+ });
+
+ //
+ // СОБЫТИЯ
+ //
+
+ // Отправка формы
+ this._on({ submit: 'submit' });
+
+ // Скрытие формы
+ this._on( this.elements.cancel, { click: 'hide' } );
+
+ // Превью текста
+ this._on( this.elements.show_preview, { click: 'previewShow' } );
+ },
+
+ /**
+ * Отправляет форму
+ */
+ submit: function( event ) {
+ event.preventDefault();
+
+ // Получаем данные формы до ее блокировки
+ var data = this.element.serializeJSON();
+
+ this.lock();
+ this[ this.getMode() === this.MODE.ADD ? 'add' : 'update' ]( data );
+ },
+
+ /**
+ * Добавляет комментарий
+ */
+ add: function( data ) {
+ this._load( 'add', data, 'onAdd', { onComplete: this.unlock.bind( this ) });
+ },
+
+ /**
+ * Обновляет комментарий
+ */
+ update: function( data ) {
+ this.emptyText();
+ this._load( 'update', data, 'onUpdate', { onComplete: this.unlock.bind( this ) });
+ },
+
+ /**
+ * Коллбэк вызываемый после успешного добавления комментария
+ */
+ onAdd: function( response ) {
+ this.emptyText();
+ this.option( 'comments' ).lsComments( 'load', response.sCommentId, false );
+ },
+
+ /**
+ * Коллбэк вызываемый после успешного обновления комментария
+ */
+ onUpdate: function( response ) {
+ var comment = this.option( 'comments' ).lsComments( 'getCommentById', this.getTargetId() ),
+ commentNew = this.option( 'comments' ).lsComments( 'initComments', $( $.trim( response.html ) ) );
+
+ this.option( 'comments' )
+ .lsComments( 'removeCommentById', this.getTargetId() )
+ .lsComments( 'addComments', commentNew );
+
+ comment.replaceWith( commentNew );
+
+ this.hide();
+ this.emptyText();
+
+ this.option( 'comments' ).lsComments( 'scrollToComment', commentNew );
+ },
+
+ /**
+ * Подгружает текст комментария
+ */
+ loadCommentText: function() {
+ this._load( 'text', { comment_id: this.getTargetId() }, function( response ) {
+ this.setText( response.text );
+ this.unlock();
+ this.elements.text.lsEditor( 'focus' );
+ });
+ },
+
+ /**
+ * Очищает текстовое поле
+ */
+ emptyText: function() {
+ this.setText( '' );
+ },
+
+ /**
+ * Получает текст из текстового поля
+ */
+ getText: function() {
+ return this.elements.text.lsEditor( 'getText' );
+ },
+
+ /**
+ * Устанавливает текст
+ */
+ setText: function( text ) {
+ this.elements.text.lsEditor( 'setText', text );
+ },
+
+ /**
+ * Показывает/скрывает форму
+ */
+ toggle: function( commentId, edit, focus ) {
+ if ( this.getTargetId() === commentId && this.element.is( ':visible' ) ) {
+ if ( ( edit && this.getMode() === this.MODE.ADD ) || ( ! edit && this.getMode() === this.MODE.EDIT ) ) {
+ this[ edit ? 'setModeEdit' : 'setModeAdd' ]();
+ } else if ( ! this.isLocked() ) {
+ this.hide();
+ }
+ } else {
+ this.show( commentId, edit, focus );
+ }
+ },
+
+ /**
+ * Показывает форму
+ */
+ show: function( commentId, edit, focus ) {
+ this.setTargetId( commentId );
+
+ this[ edit ? 'setModeEdit' : 'setModeAdd' ]();
+
+ var element = commentId
+ ? this.option( 'comments' ).lsComments( 'getCommentById', commentId )
+ : this.option( 'comments' ).lsComments( 'getElement', 'reply_root' );
+
+ this.element.insertAfter( element ).show();
+ this.elements.text.lsEditor( 'onShow' );
+ if ( focus ) this.elements.text.lsEditor( 'focus' );
+ },
+
+ /**
+ * Скрывает форму
+ */
+ hide: function() {
+ if ( this.getMode() === this.MODE.EDIT ) {
+ this.emptyText();
+ }
+
+ this.element.hide();
+ this.previewHide();
+ },
+
+ /**
+ * Блокирует форму
+ */
+ lock: function() {
+ this._locked = true;
+ this._addClass( 'locked' );
+ ls.utils.formLock( this.element );
+ },
+
+ /**
+ * Разблокировывает форму
+ */
+ unlock: function() {
+ this._locked = false;
+ this._removeClass( 'locked' );
+ ls.utils.formUnlock( this.element );
+ },
+
+ /**
+ * Проверяет заблокирована форма или нет
+ */
+ isLocked: function() {
+ return this._locked;
+ },
+
+ /**
+ * Предпросмотр комментария
+ */
+ previewShow: function() {
+ if ( ! this.elements.text.val() ) return;
+
+ this.previewHide();
+
+ this._preview = $( this.option( 'html.preview' ) );
+
+ this.element.before( this._preview );
+ ls.utils.textPreview( this.elements.text, this._preview, false);
+ },
+
+ /**
+ * Предпросмотр комментария
+ */
+ previewHide: function() {
+ if ( ! this._preview ) return;
+
+ this._preview.remove();
+ this._preview = null;
+ },
+
+ /**
+ * Устанавливает режим в "Добавление"
+ */
+ setModeAdd: function() {
+ if ( this.getMode() === this.MODE.EDIT ) this.emptyText();
+
+ this.setMode( this.MODE.ADD );
+
+ this.elements.update_submit.hide();
+ this.elements.submit.show();
+ },
+
+ /**
+ * Устанавливает режим в "Редактирование"
+ */
+ setModeEdit: function() {
+ this.setMode( this.MODE.EDIT );
+
+ this.elements.update_submit.show();
+ this.elements.submit.hide();
+
+ this.lock();
+ this.loadCommentText();
+ },
+
+ /**
+ * Получает режим формы
+ */
+ getMode: function() {
+ return this._mode;
+ },
+
+ /**
+ * Устанавливает режим
+ */
+ setMode: function( mode ) {
+ this._mode = mode;
+ },
+
+ /**
+ * Получает ID комментария к которому прикреплена форма
+ */
+ getTargetId: function() {
+ return this._targetId;
+ },
+
+ /**
+ * Устанавливает ID комментария к которому прикреплена форма
+ */
+ setTargetId: function( id ) {
+ this.elements.comment_id.val( id );
+ this._targetId = id;
+ },
+
+ /**
+ * Режимы
+ *
+ * @readonly
+ * @enum {String}
+ */
+ MODE: {
+ EDIT: 'EDIT',
+ ADD: 'ADD'
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/comment/js/comment.js b/application/frontend/components/comment/js/comment.js
new file mode 100644
index 0000000..1a3a82c
--- /dev/null
+++ b/application/frontend/components/comment/js/comment.js
@@ -0,0 +1,316 @@
+/**
+ * Comment
+ *
+ * @module ls/comment
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsComment", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ comments: $(),
+ form: $(),
+ folding: true,
+
+ // Ссылки
+ urls: {
+ vote: aRouter.ajax + 'vote/comment/',
+ favourite: aRouter.ajax + 'favourite/comment/',
+ // Показать/скрыть комментарий
+ toggle: aRouter.ajax + 'comment/delete/'
+ },
+
+ // Селекторы
+ selectors: {
+ wrapper: '.js-comment-wrapper',
+ vote: '.js-comment-vote',
+ favourite: '.js-comment-favourite',
+ reply: '.js-comment-reply',
+ fold: '.js-comment-fold',
+ remove: '.js-comment-remove',
+ edit: '.js-comment-update',
+ update_timer: '.js-comment-update-timer',
+ scroll_to_child: '.js-comment-scroll-to-child',
+ scroll_to_parent: '.js-comment-scroll-to-parent'
+ },
+
+ // Классы
+ classes : {
+ folded: 'ls-comment--folded',
+ current: 'ls-comment--current',
+ new: 'ls-comment--new',
+ deleted: 'ls-comment--deleted',
+ self: 'ls-comment--self'
+ },
+ params: {},
+ i18n: {
+ fold: '@comments.folding.fold',
+ unfold: '@comments.folding.unfold'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ this._id = this.element.data( 'id' );
+ this._parentId = this.element.data( 'parent-id' );
+ this._parent = null;
+ this._scrollChild = null;
+ this._countdown = this.elements.update_timer.data( 'seconds' );
+
+ // Голосование за комментарий
+ this.elements.vote.lsVote({
+ urls: {
+ vote: this.option( 'urls.vote' )
+ }
+ });
+
+ // Избранное
+ this.elements.favourite.lsFavourite({
+ urls: {
+ toggle: this.option( 'urls.favourite' )
+ }
+ });
+
+ // Сворчивание
+ if ( this.options.folding ) {
+ if ( this.hasChildren() ) this.elements.fold.parent().show();
+
+ this.elements.fold.on( 'click' + this.eventNamespace, this.foldToggle.bind( this ) );
+ }
+
+ // Навигация по комментариям
+ this.elements.scroll_to_parent.on( 'click' + this.eventNamespace, this.scrollToParent.bind( this ) );
+
+ // Ответить
+ this.elements.reply.on( 'click' + this.eventNamespace, this.reply.bind( this ) );
+
+ // Удалить
+ this.elements.remove.on( 'click' + this.eventNamespace, this.toggle.bind( this ) );
+
+ // Редактировать
+ this.elements.edit.on( 'click' + this.eventNamespace, this.edit.bind( this ) );
+
+ if ( this._countdown ) {
+ this.updateTimer();
+ this.elements.update_timer.everyTime( 1000, this.updateTimer.bind( this ) );
+ }
+ },
+
+ /**
+ * Прокрутка к родительскому комментарию
+ */
+ updateTimer: function() {
+ if ( this._countdown-- ) {
+ this.elements.update_timer.text( ls.utils.timeRemaining( this._countdown ) );
+ } else {
+ this.elements.update_timer.stopTime();
+ this.elements.edit.remove();
+ }
+ },
+
+ /**
+ * Прокрутка к родительскому комментарию
+ */
+ scrollToParent: function() {
+ this.getParent().lsComment( 'setScrollChild', this.element );
+ this.option( 'comments' ).lsComments( 'scrollToComment', this.getParent() );
+ },
+
+ /**
+ * Прокрутка обратно к дочернему комментарию
+ */
+ scrollToChild: function() {
+ this.option( 'comments' ).lsComments( 'scrollToComment', this.getScrollChild() );
+ this.setScrollChild( null );
+ },
+
+ /**
+ * Редактировать
+ */
+ edit: function( event ) {
+ event.preventDefault();
+
+ this.option( 'comments' ).lsComments( 'getForm' ).lsCommentForm( 'toggle', this.getId(), true, true );
+ },
+
+ /**
+ * Ответить
+ */
+ reply: function( event ) {
+ event.preventDefault();
+
+ this.option( 'comments' ).lsComments( 'getForm' ).lsCommentForm( 'toggle', this.getId(), false, true );
+ },
+
+ /**
+ * Скрыть/восстановить комментарий
+ */
+ toggle: function( event ) {
+ event.preventDefault();
+
+ this._load( 'toggle', { comment_id: this.getId() }, function( response ) {
+ this._removeClass( 'self new deleted current' );
+
+ if ( response.state ) {
+ this._addClass( 'deleted' );
+ }
+
+ this.elements.remove.text( response.toggle_text );
+ });
+ },
+
+ /**
+ * Помечает комментарий как текущий
+ */
+ markAsCurrent: function() {
+ this._addClass( 'current' );
+ },
+
+ /**
+ * Убирает пометку о том, что комментарий текущий, если она есть
+ */
+ notCurrent: function() {
+ this._removeClass( 'current' );
+ this.setScrollChild( null );
+ },
+
+ /**
+ * Проверяет комментарий текущий или нет
+ *
+ * @return {Boolean}
+ */
+ isCurrent: function() {
+ return this._hasClass( 'current' );
+ },
+
+ /**
+ * Помечает комментарий как новый
+ */
+ markAsNew: function() {
+ this._addClass( 'new' );
+ },
+
+ /**
+ * Убирает пометку о том, что комментарий новый, если она есть
+ */
+ notNew: function() {
+ this._removeClass( 'new' );
+ },
+
+ /**
+ * Проверяет комментарий новый или нет
+ *
+ * @return {Boolean}
+ */
+ isNew: function() {
+ return this._hasClass( 'new' );
+ },
+
+ /**
+ * Сворачивает/разворачивает ветку комментариев
+ */
+ foldToggle: function( event ) {
+ event.preventDefault();
+
+ this[ this._hasClass( 'folded' ) ? 'unfold' : 'fold' ]();
+ },
+
+ /**
+ * Сворачивает ветку комментариев
+ */
+ fold: function() {
+ this._addClass( 'folded' )
+ this.element.nextAll( this.options.selectors.wrapper ).hide();
+ this.onFold();
+ },
+
+ /**
+ * Разворачивает ветку комментариев
+ */
+ unfold: function() {
+ this._removeClass( 'folded' )
+ this.element.nextAll( this.options.selectors.wrapper ).show();
+ this.onUnfold();
+ },
+
+ /**
+ * Коллбэк вызываемый после сворачивания ветки комментариев
+ */
+ onFold: function() {
+ this.elements.fold.text(this._i18n('unfold'));
+ },
+
+ /**
+ * Коллбэк вызываемый после разворачивания ветки комментариев
+ */
+ onUnfold: function() {
+ this.elements.fold.text(this._i18n('fold'));
+ },
+
+ /**
+ * Проверяет наличие дочерних комментариев
+ *
+ * @return {Boolean}
+ */
+ hasChildren: function() {
+ return this.element.next( this.options.selectors.wrapper ).length;
+ },
+
+ /**
+ * Получает ID комментария
+ *
+ * @return {Number} ID комментария
+ */
+ getId: function() {
+ return this._id;
+ },
+
+ /**
+ * Получает родительский комментарий
+ *
+ * @return {jQuery} Родительский комментарий
+ */
+ getParent: function() {
+ return this._parent || ( this._parent = this.option( 'comments' ).lsComments( 'getCommentById', this._parentId ) );
+ },
+
+ /**
+ *
+ *
+ * @return {jQuery}
+ */
+ getScrollChild: function() {
+ return this._scrollChild;
+ },
+
+ /**
+ *
+ */
+ setScrollChild: function( comment ) {
+ this._scrollChild = comment;
+
+ this.elements.scroll_to_child.off();
+
+ if ( comment ) {
+ this.elements.scroll_to_child.show().one( 'click' + this.eventNamespace, this.scrollToChild.bind( this ) );
+ } else {
+ this.elements.scroll_to_child.hide();
+ }
+ },
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/comment/js/comments-toolbar.js b/application/frontend/components/comment/js/comments-toolbar.js
new file mode 100644
index 0000000..2b26a85
--- /dev/null
+++ b/application/frontend/components/comment/js/comments-toolbar.js
@@ -0,0 +1,131 @@
+/**
+ * Кнопка подгрузки и навигации по новым комментариям
+ *
+ * @module ls/toolbar/comments
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsCommentsToolbar", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Блок с комментариями
+ comments: '.js-comments',
+
+ // Селекторы
+ selectors: {
+ // Кнопка обновления
+ update: '.js-toolbar-comments-update',
+
+ // Счетчик новых комментариев
+ counter: '.js-toolbar-comments-count',
+
+ // Иконка
+ icon: '.js-toolbar-comments-update .fa'
+ },
+
+ classes: {
+ active: 'active',
+ 'fa-spin': 'fa-spin'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ this.options.comments = typeof target === 'string' ? $( this.options.comments ) : this.options.comments;
+
+ // Обновляем счетчик новых комментариев
+ this.updateCounter();
+
+ //
+ // События
+ //
+
+ // Обновление
+ this._on( this.elements.update, { click: 'update' } );
+
+ // Прокрутка к следующему новому комментарию
+ this._on( this.elements.counter, { click: 'scroll' } );
+ },
+
+ /**
+ * Обновление счетчика
+ *
+ * @param {Number} count (optional) Кол-во новых комментариев
+ */
+ updateCounter: function(count) {
+ count = typeof count === 'undefined' ? this.options.comments.lsComments( 'getCommentsNew' ).length : count;
+
+ if ( count ) {
+ this.showCounter();
+ this.elements.counter.text( count );
+ } else {
+ this.hideCounter();
+ }
+ },
+
+ /**
+ * Обновление
+ */
+ update: function() {
+ this._addClass( this.elements.update, 'active' );
+ this._addClass( this.elements.icon, 'fa-spin' );
+
+ this.options.comments.lsComments( 'load', false, false, function () {
+ this.updateCounter();
+ this._removeClass( this.elements.update, 'active' );
+ this._removeClass( this.elements.icon, 'fa-spin' );
+ }.bind( this ));
+ },
+
+ /**
+ * Показывает счетчик
+ */
+ showCounter: function() {
+ if ( this.elements.counter.is( ':visible' ) ) return;
+
+ this.elements.counter.show();
+ },
+
+ /**
+ * Скрывает счетчик
+ */
+ hideCounter: function() {
+ this.elements.counter.hide();
+ },
+
+ /**
+ * Прокрутка к следующему новому комментарию
+ */
+ scroll: function() {
+ var commentsNew = this.options.comments.lsComments('getCommentsNew'),
+ comment = commentsNew.eq(0);
+
+ if ( ! commentsNew.length ) return;
+
+ // Если новый комментарий находится в свернутой ветке разворачиваем все ветки
+ if ( ! comment.is(':visible') ) this.options.comments.lsComments('unfoldAll');
+
+ // Обновляем счетчик новых комментариев
+ this.updateCounter( commentsNew.length - 1 );
+
+ comment.lsComment( 'notNew' );
+ this.options.comments.lsComments('scrollToComment', comment);
+ }
+ });
+})(jQuery);
diff --git a/application/frontend/components/comment/js/comments.js b/application/frontend/components/comment/js/comments.js
new file mode 100644
index 0000000..e4f1aa4
--- /dev/null
+++ b/application/frontend/components/comment/js/comments.js
@@ -0,0 +1,401 @@
+/**
+ * Комментарии
+ *
+ * @module ls/comments
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsComments", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ // Добавление комментария
+ add: null,
+ // Подгрузка новых комментариев
+ load: null,
+ // Показать/скрыть комментарий
+ hide: aRouter.ajax + 'comment/delete/',
+ // Обновление текста комментария
+ text: aRouter.ajax + 'comment/load/',
+ // Обновление комментария
+ update: aRouter.ajax + 'comment/update/'
+ },
+
+ // Селекторы
+ selectors: {
+ comment: '.js-comment',
+ comment_wrapper: '.js-comment-wrapper',
+ form: '.js-comment-form',
+ // Блок с превью текста
+ preview: '.js-comment-preview',
+ // Кнопка свернуть/развернуть все
+ fold_all_toggle: '.js-comments-fold-all-toggle',
+ // Заголовок
+ title: '.js-comments-title',
+ // Кнопка "Оставить комментарий"
+ reply_root: '.js-comment-reply-root',
+ // Блок с комментариями
+ comment_list: '.js-comment-list',
+ // Подписаться на новые комментарии
+ subscribe: '.js-comments-subscribe',
+ // Сообщение о пустом списке
+ empty: '.js-comments-empty'
+ },
+
+ // Использовать визуальный редактор или нет
+ wysiwyg: null,
+ // Включить/выключить функцию сворачивания
+ folding: true,
+ // Показать/скрыть форму по умолчанию
+ show_form: false,
+ // Включена или нет пагинация
+ use_paging: false,
+ // Ajax параметры
+ params: {},
+ i18n: {
+ fold_all: '@comments.folding.fold_all',
+ unfold_all: '@comments.folding.unfold_all',
+ subscribe: '@comments.subscribe',
+ unsubscribe: '@comments.unsubscribe',
+ comments: '@comments.comments_declension'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ var _this = this;
+
+ this._super();
+
+ this.initComments( this.getComments() );
+
+ this.getForm().lsCommentForm({
+ urls: {
+ text: this.option( 'urls.text' ),
+ add: this.option( 'urls.add' ),
+ update: this.option( 'urls.update' )
+ },
+ comments: this.element
+ });
+
+ this._currentComment = $();
+
+ // Получаем ID объекта к которому оставлен комментарий
+ this._targetId = this.element.data( 'target-id' );
+
+ // Получаем тип объекта
+ this._targetType = this.element.data( 'target-type' );
+
+ // ID последнего добавленного комментария
+ this.setLastCommentId( this.element.data('comment-last-id') );
+
+
+ this.elements.reply_root.on( 'click' + this.eventNamespace, function ( event ) {
+ event.preventDefault();
+ _this.getForm().lsCommentForm( 'show', 0 );
+ });
+
+ if ( ! this.option( 'show_form' ) ) this.getForm().hide();
+
+ //
+ // ЭКШНБАР
+ //
+
+ // Сворачивание
+ if ( this.options.folding ) {
+ this.elements.fold_all_toggle.on( 'click' + this.eventNamespace, this.foldAllToggle.bind( this ) );
+ }
+
+ // Подписаться/отписаться от новых комментариев
+ this.elements.subscribe.on( 'click' + this.eventNamespace, this.subscribeToggle.bind(this));
+ },
+
+ /**
+ * Подписаться/отписаться от комментариев
+ */
+ subscribeToggle: function() {
+ var isActive = this.elements.subscribe.hasClass('active');
+
+ ls.subscribe.toggle( this._targetType + '_new_comment', this._targetId, '', ! isActive );
+
+ if ( isActive ) {
+ this.elements.subscribe.removeClass( 'active' ).text( this._i18n('subscribe') );
+ } else {
+ this.elements.subscribe.addClass( 'active' ).text( this._i18n('unsubscribe') );
+ }
+ },
+
+ /**
+ * Свернуть/развернуть все ветки комментариев
+ */
+ foldAllToggle: function() {
+ this[ this.elements.fold_all_toggle.hasClass( 'active' ) ? 'unfoldAll' : 'foldAll' ]();
+ },
+
+ /**
+ * Сворачивает все ветки комментариев
+ */
+ foldAll: function() {
+ this.getComments().lsComment( 'fold' );
+ this.elements.fold_all_toggle.addClass( 'active' ).text( this._i18n('unfold_all') );
+ },
+
+ /**
+ * Разворачивает все ветки комментариев
+ */
+ unfoldAll: function() {
+ this.getComments().lsComment( 'unfold' );
+ this.elements.fold_all_toggle.removeClass( 'active' ).text( this._i18n('fold_all') );
+ },
+
+ /**
+ * Подгрузка новых комментариев
+ */
+ load: function( commentSelfId, flush, callback ) {
+ flush = typeof flush === 'undefined' ? true : flush;
+
+ var params = {
+ target_id: this._targetId,
+ target_type: this._targetType,
+ last_comment_id: this.getLastCommentId(),
+ self_comment_id: commentSelfId || undefined,
+ use_paging: this.option( 'use_paging' )
+ };
+
+ this._load( 'load', params, function( response ) {
+ var commentsLoaded = response.comments,
+ countLoaded = commentsLoaded.length;
+
+ // Убираем подсветку у новых комментариев
+ if ( flush ) this.getCommentsNew().lsComment( 'notNew' );
+
+ // Скрываем сообщение о пустом списке
+ if ( ~ this.getComments().length && countLoaded ) this.elements.empty.hide();
+
+ // Вставляем новые комментарии
+ $.each( commentsLoaded, function( index, item ) {
+ var comment = this.initComments( $( $.trim( item.html ) ) );
+
+ this.elements.comment = this.elements.comment.add( comment );
+ this.insert( comment, item.id, item.parent_id );
+ }.bind( this ));
+
+ // Обновляем данные
+ if ( countLoaded && response.last_comment_id ) {
+ this.setLastCommentId( response.last_comment_id );
+
+ // Обновляем кол-во комментариев в заголовке
+ this.elements.title.text( this._i18n( 'comments', this.getComments().length ) );
+ }
+
+ // Разворачиваем все ветки если идет просто подгрузка комментариев
+ // или если при добавления комментария текущим пользователем
+ // помимо этого комментария подгружаются еще и ранее добавленные комментарии
+ if ( this.options.folding && ( ( ! commentSelfId && countLoaded ) || ( commentSelfId && countLoaded - 1 > 0 ) ) ) {
+ this.unfoldAll();
+ }
+
+ // Прокручиваем к комментарию который оставил текущий пользователь
+ if ( commentSelfId ) {
+ this.getForm().lsCommentForm( 'hide' );
+ this.scrollToComment( this.getCommentById( commentSelfId ) );
+ }
+
+ if ( $.isFunction( callback ) ) callback.call( this );
+ });
+
+ this._trigger('loaded');
+ },
+
+ /**
+ * Вставка комментария
+ *
+ * @param {jQuery} comment Комментарий
+ * @param {Number} commentId ID добавляемого комментария
+ * @param {Number} commentParentId (optional) ID родительского комментария
+ */
+ insert: function( comment, commentId, commentParentId ) {
+ var commentWrapper = $( '' ).append( comment );
+
+ this.elements.comment_list.show();
+
+ if ( commentParentId ) {
+ // Получаем обертку родительского комментария
+ var wrapper = $( this.options.selectors.comment_wrapper + '[data-id=' + commentParentId + ']');
+
+ // Проверяем чтобы уровень вложенности комментариев был не больше значения заданного в конфиге
+ if (wrapper.parentsUntil(this.elements.comment_list).length == ls.registry.get('comment_max_tree')) {
+ wrapper = wrapper.parent(this.options.selectors.comment_wrapper);
+ }
+
+ wrapper.append( commentWrapper );
+ } else {
+ this.elements.comment_list.append( commentWrapper );
+ }
+ },
+
+ /**
+ * Сбрасывает текущий комментарий
+ */
+ resetCommentCurrent: function() {
+ if ( this._currentComment.length ) this._currentComment.lsComment( 'notCurrent' );
+ this._currentComment = $();
+ },
+
+ /**
+ * Получает текущий комментарий
+ *
+ * @return {jQuery} Текущий комментарий
+ */
+ getCommentCurrent: function() {
+ return this._currentComment;
+ },
+
+ /**
+ * Устанавливает текущий комментарий
+ *
+ * @param {Object} comment
+ */
+ setCommentCurrent: function( comment ) {
+ if ( this.getCommentCurrent().is( comment ) ) return;
+
+ if ( this.getCommentCurrent().length ) {
+ this.getCommentCurrent().lsComment( 'notCurrent' );
+ }
+
+ comment.lsComment( 'markAsCurrent' );
+ this._currentComment = comment;
+ },
+
+ /**
+ * Прокрутка к комментарию
+ *
+ * @param {jQuery} comment Комментарий
+ */
+ scrollToComment: function( comment ) {
+ this.setCommentCurrent( comment );
+ $.scrollTo( comment, 1000, { offset: -250 } );
+ },
+
+ /**
+ * Получает форму комментирования
+ *
+ * @return {jQuery} Форма комментирования
+ */
+ getForm: function() {
+ return this.elements.form;
+ },
+
+ /**
+ * Получает комментарии
+ *
+ * @return {Array} Массив с комментариями
+ */
+ getComments: function() {
+ return this.elements.comment;
+ },
+
+ /**
+ * Добавляет комментарий в массив с другими
+ *
+ * @param {jQuery} comments Комментарии
+ */
+ addComments: function( comments ) {
+ this.elements.comment = this.elements.comment.add( comments );
+ },
+
+ /**
+ * Получает комментарий по его ID
+ *
+ * @param {Number} commentId ID комментария
+ * @return {jQuery} Комментарий
+ */
+ getCommentById: function( commentId ) {
+ if ( ! commentId ) return;
+
+ for ( var i = 0, len = this.getComments().length; i < len; i++ ) {
+ if ( $( this.getComments()[ i ] ).lsComment( 'getId' ) == commentId ) {
+ return $( this.getComments()[ i ] );
+ }
+ };
+
+ return $();
+ },
+
+ /**
+ * Удаляет комментарий по его ID
+ *
+ * @param {Number} commentId ID комментария
+ */
+ removeCommentById: function( commentId ) {
+ var _this = this;
+
+ this.elements.comment = this.getComments().filter(function () {
+ var comment = $( this );
+
+ if ( comment.lsComment( 'getId' ) == commentId ) {
+ if ( comment.lsComment( 'isCurrent' ) ) _this.resetCommentCurrent();
+
+ comment.lsComment( 'destroy' );
+
+ return false;
+ }
+
+ return true;
+ });
+ },
+
+ /**
+ * Получает новые комментарии
+ *
+ * @return {Array} Массив с новыми комментариями
+ */
+ getCommentsNew: function() {
+ return this.getComments().filter(function () {
+ return $( this ).lsComment( 'isNew' );
+ });
+ },
+
+ /**
+ * Иниц-ия комментариев
+ *
+ * @param {jQuery} comments Комментарии
+ */
+ initComments: function( comments ) {
+ return comments.lsComment({
+ comments: this.element,
+ folding: this.options.folding
+ });
+ },
+
+ /**
+ * Получает ID последнего добавленного комментария
+ */
+ getLastCommentId: function() {
+ return this._commentLastId;
+ },
+
+ /**
+ * Устанавливает ID последнего добавленного комментария
+ *
+ * @param {Number} id ID комментария
+ */
+ setLastCommentId: function( id ) {
+ this._commentLastId = id;
+ },
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/comment/toolbar.comment.tpl b/application/frontend/components/comment/toolbar.comment.tpl
new file mode 100644
index 0000000..1e41899
--- /dev/null
+++ b/application/frontend/components/comment/toolbar.comment.tpl
@@ -0,0 +1,16 @@
+{**
+ * Тулбар
+ * Кнопка обновления комментариев
+ *}
+
+{capture toolbar_comments}
+
+
+{/capture}
+
+{component 'toolbar.item'
+ html=$smarty.capture.toolbar_comments
+ classes='js-comments-toolbar'
+ mods='comments'}
\ No newline at end of file
diff --git a/application/frontend/components/content/README.md b/application/frontend/components/content/README.md
new file mode 100644
index 0000000..d83842d
--- /dev/null
+++ b/application/frontend/components/content/README.md
@@ -0,0 +1 @@
+# Компонент content
\ No newline at end of file
diff --git a/application/frontend/components/content/component.json b/application/frontend/components/content/component.json
new file mode 100644
index 0000000..e57f898
--- /dev/null
+++ b/application/frontend/components/content/component.json
@@ -0,0 +1,12 @@
+{
+ "name": "content",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-vendor": "*",
+ "ls-core": "*",
+ "ls-component": "*"
+ },
+ "scripts": {
+ "content": "js/content.js"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/content/js/content.js b/application/frontend/components/content/js/content.js
new file mode 100644
index 0000000..f1455c7
--- /dev/null
+++ b/application/frontend/components/content/js/content.js
@@ -0,0 +1,68 @@
+/**
+ * Content
+ *
+ * @module ls/content
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsContent", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ add: null,
+ edit: null
+ },
+
+ // Ajax параметры
+ params: {}
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ this.action = this.element.data( 'content-action' );
+
+ this._on({ submit: 'onSubmit' });
+ },
+
+ /**
+ * Коллбэк вызываемый при отправке формы
+ */
+ onSubmit: function( event ) {
+ this.submit();
+ event.preventDefault();
+ },
+
+ /**
+ * Отправка формы
+ */
+ submit: function( params ) {
+ $.extend( this.option( 'params' ), params || {} );
+
+ this._trigger( 'beforesubmit', null, this );
+
+ this._submit( this.action, this.element, function( response ) {
+ this._trigger( 'aftersubmit', null, this );
+
+ if ( response.sUrlRedirect ) {
+ window.location.href = response.sUrlRedirect;
+ }
+ });
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/favourite/README.md b/application/frontend/components/favourite/README.md
new file mode 100644
index 0000000..1039ff0
--- /dev/null
+++ b/application/frontend/components/favourite/README.md
@@ -0,0 +1,24 @@
+# Компонент favourite
+
+Избранное
+
+
+## Использование
+
+Пример использования в плагине.
+
+_Шаблон с изображением_ **image.tpl**
+```smarty
+...
+{include 'components/favourite/favourite.tpl' classes='js-plugin-gallery-image-favourite' target=$image}
+...
+```
+
+_Файл иниц-ии js плагина_ **init.js**
+```js
+$('.js-plugin-gallery-image-favourite').lsFavourite({
+ urls: {
+ toggle: aRouter['gallery'] + 'image/favourite/',
+ }
+});
+```
\ No newline at end of file
diff --git a/application/frontend/components/favourite/component.json b/application/frontend/components/favourite/component.json
new file mode 100644
index 0000000..ff39f95
--- /dev/null
+++ b/application/frontend/components/favourite/component.json
@@ -0,0 +1,17 @@
+{
+ "name": "favourite",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-component": "*",
+ "icon": "*"
+ },
+ "templates": {
+ "favourite": "favourite.tpl"
+ },
+ "scripts": {
+ "favourite": "js/favourite.js"
+ },
+ "styles": {
+ "favourite": "css/favourite.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/favourite/css/favourite.css b/application/frontend/components/favourite/css/favourite.css
new file mode 100644
index 0000000..75f1d14
--- /dev/null
+++ b/application/frontend/components/favourite/css/favourite.css
@@ -0,0 +1,33 @@
+/**
+ * Избранное
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+.ls-favourite {
+ cursor: pointer;
+ display: inline-block;
+}
+.ls-favourite:hover {
+ opacity: .8;
+ filter: alpha(opacity=80);
+}
+.ls-favourite-toggle {
+ display: inline-block;
+ opacity: .3;
+ filter: alpha(opacity=30);
+}
+.ls-favourite--added .ls-favourite-toggle {
+ opacity: 1;
+ filter: alpha(opacity=100);
+}
+.ls-favourite-count {
+ display: none;
+ margin-left: 0;
+ font-weight: bold;
+}
+.ls-favourite--has-counter .ls-favourite-count {
+ display: inline-block;
+}
\ No newline at end of file
diff --git a/application/frontend/components/favourite/favourite.tpl b/application/frontend/components/favourite/favourite.tpl
new file mode 100644
index 0000000..d36f172
--- /dev/null
+++ b/application/frontend/components/favourite/favourite.tpl
@@ -0,0 +1,42 @@
+{**
+ * Добавление в избранное
+ *
+ * @param object $target Объект который добавляется в избранное
+ * @param boolean $hideZeroCounter
+ *}
+
+{* Название компонента *}
+{$component = 'ls-favourite'}
+{component_define_params params=[ 'target', 'hideZeroCounter', 'mods', 'classes', 'attributes' ]}
+
+{* True если объект находится в избранном *}
+{$isActive = $target && $target->getIsFavourite()}
+
+{* Кол-во объектов в избранном *}
+{$count = $target->getCountFavourite()}
+
+{* Добавляем модификаторы *}
+{if $count}
+ {$mods = "$mods has-counter"}
+{/if}
+
+{if $isActive}
+ {$mods = "$mods added"}
+{/if}
+
+
+
+
+ {* Кнопка добавления/удаления из избранного *}
+ {component 'icon' icon='heart' classes="{$component}-toggle js-favourite-toggle"}
+
+ {* Кол-во объектов в избранном *}
+ {if isset( $count )}
+
+ {$count}
+
+ {/if}
+
\ No newline at end of file
diff --git a/application/frontend/components/favourite/js/favourite.js b/application/frontend/components/favourite/js/favourite.js
new file mode 100644
index 0000000..156d980
--- /dev/null
+++ b/application/frontend/components/favourite/js/favourite.js
@@ -0,0 +1,105 @@
+/**
+ * Избранное
+ *
+ * @module ls/favourite
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsFavourite", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ // Добавить/удалить из избранного
+ toggle: null
+ },
+
+ // Селекторы
+ selectors: {
+ // Кнопка добавить/удалить из избранного
+ toggle: '.js-favourite-toggle',
+ // Счетчик
+ count: '.js-favourite-count'
+ },
+
+ // Классы
+ classes: {
+ // Добавлено в избранное
+ added: 'ls-favourite--added',
+ // Кол-во добавивших в избранное больше нуля
+ has_counter: 'ls-favourite--has-counter'
+ },
+
+ // Параметры отправляемые при каждом аякс запросе
+ params: {},
+
+ // Коллбэки
+
+ // После успешного изменения состояния
+ // aftertogglesuccess: null,
+
+ i18n: {
+ remove: '@favourite.remove',
+ add: '@favourite.add'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ // Обработка кликов по кнопкам голосования
+ this._on({ click: 'toggle' });
+ },
+
+ /**
+ * Добавить/удалить из избранного
+ */
+ toggle: function() {
+ this.options.params.type = ! this._hasClass( 'added' );
+
+ this._load( 'toggle', this.onToggleSuccess );
+ },
+
+ /**
+ *
+ */
+ onToggleSuccess: function( response ) {
+ // Обновляем состояние
+ this._removeClass( 'added' );
+
+ if ( response.bState ) {
+ this._addClass( 'added' );
+ this.element.attr( 'title', this._i18n( 'remove' ) );
+ } else {
+ this.element.attr( 'title', this._i18n( 'add' ) );
+ }
+
+ // Обновляем счетчик
+ if ( this.elements.count ) {
+ if ( response.iCount > 0 ) {
+ this._addClass( 'has_counter' );
+ this.elements.count.show().text( response.iCount );
+ } else {
+ this._removeClass( 'has_counter' );
+ }
+ }
+
+ this._trigger( 'aftertogglesuccess', null, { context: this, response: response } );
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/feed/README.md b/application/frontend/components/feed/README.md
new file mode 100644
index 0000000..02deffd
--- /dev/null
+++ b/application/frontend/components/feed/README.md
@@ -0,0 +1,3 @@
+# Компонент feed
+
+Лента
\ No newline at end of file
diff --git a/application/frontend/components/feed/blocks/block.userfeed-blogs.tpl b/application/frontend/components/feed/blocks/block.userfeed-blogs.tpl
new file mode 100644
index 0000000..5688dd5
--- /dev/null
+++ b/application/frontend/components/feed/blocks/block.userfeed-blogs.tpl
@@ -0,0 +1,8 @@
+{**
+ * Выбор блогов для чтения в ленте
+ *}
+
+{component 'block'
+ mods = 'feed-blogs'
+ title = {lang 'feed.blogs.title'}
+ content = {component 'feed' template='blogs' blogsJoined=$blogsJoined blogsSubscribed=$blogsSubscribed}}
\ No newline at end of file
diff --git a/application/frontend/components/feed/blocks/block.userfeed-users.tpl b/application/frontend/components/feed/blocks/block.userfeed-users.tpl
new file mode 100644
index 0000000..799a035
--- /dev/null
+++ b/application/frontend/components/feed/blocks/block.userfeed-users.tpl
@@ -0,0 +1,8 @@
+{**
+ * Выбор пользователей для чтения в ленте
+ *}
+
+{component 'block'
+ mods = 'feed-users'
+ title = {lang 'feed.users.title'}
+ content = {component 'feed' template='users' users=$users}}
\ No newline at end of file
diff --git a/application/frontend/components/feed/blogs.tpl b/application/frontend/components/feed/blogs.tpl
new file mode 100644
index 0000000..c8a7cf5
--- /dev/null
+++ b/application/frontend/components/feed/blogs.tpl
@@ -0,0 +1,30 @@
+{**
+ * Выбор блогов для чтения в ленте
+ *
+ * @param array $blogsSubscribed
+ * @param array $blogsJoined
+ *}
+
+{component_define_params params=[ 'blogsSubscribed', 'blogsJoined' ]}
+
+{if $oUserCurrent}
+
+
+ {$aLang.feed.blogs.note}
+
+
+ {if $blogsJoined}
+
+ {foreach $blogsJoined as $blog}
+ {component 'field' template='checkbox'
+ inputClasses = 'js-feed-blogs-subscribe'
+ inputAttributes = [ 'data-id' => $blog->getId() ]
+ checked = isset($blogsSubscribed[ $blog->getId() ])
+ label = "
getUrlFull()}\">{$blog->getTitle()|escape} "}
+ {/foreach}
+
+ {else}
+ {component 'blankslate' text=$aLang.feed.blogs.empty}
+ {/if}
+
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/feed/component.json b/application/frontend/components/feed/component.json
new file mode 100644
index 0000000..c76bf12
--- /dev/null
+++ b/application/frontend/components/feed/component.json
@@ -0,0 +1,21 @@
+{
+ "name": "feed",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-component": "*",
+ "user-list-add": "*",
+ "block": "*",
+ "button": "*",
+ "field": "*",
+ "alert": "*"
+ },
+ "templates": {
+ "blogs": "blogs.tpl",
+ "users": "users.tpl",
+ "block.blogs": "blocks/block.userfeed-blogs.tpl",
+ "block.users": "blocks/block.userfeed-users.tpl"
+ },
+ "scripts": {
+ "feed-blogs": "js/feed-blogs.js"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/feed/js/feed-blogs.js b/application/frontend/components/feed/js/feed-blogs.js
new file mode 100644
index 0000000..33349da
--- /dev/null
+++ b/application/frontend/components/feed/js/feed-blogs.js
@@ -0,0 +1,54 @@
+/**
+ * Управление блогами в ленте
+ *
+ * @module ls/feed/blogs
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsFeedBlogs", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ subscribe: null,
+ unsubscribe: null
+ },
+
+ // Селекторы
+ selectors: {
+ checkbox: '.js-feed-blogs-subscribe'
+ },
+
+ params: {}
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ this._on( this.getElement( 'checkbox' ), { change: this.toggleSubscribe } );
+ },
+
+ /**
+ * Сохранение настроек
+ */
+ toggleSubscribe: function( event ) {
+ var checkbox = $( event.target );
+
+ this._load( checkbox.is(':checked') ? 'subscribe' : 'unsubscribe', { type: 'blogs', id: checkbox.data( 'id' ) } );
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/feed/users.tpl b/application/frontend/components/feed/users.tpl
new file mode 100644
index 0000000..1ac9a65
--- /dev/null
+++ b/application/frontend/components/feed/users.tpl
@@ -0,0 +1,13 @@
+{**
+ * Выбор пользователей для чтения в ленте
+ *
+ * @param array $users
+ *}
+
+{component_define_params params=[ 'users' ]}
+
+{component 'user-list-add'
+ users = $users
+ classes = 'js-feed-users'
+ attributes = [ 'data-param-type' => 'users' ]
+ note = $aLang.feed.users.note}
\ No newline at end of file
diff --git a/application/frontend/components/media/README.md b/application/frontend/components/media/README.md
new file mode 100644
index 0000000..fec8423
--- /dev/null
+++ b/application/frontend/components/media/README.md
@@ -0,0 +1,3 @@
+# Компонент media
+
+Загрузка/управление медиа-файлами для последующей вставки в редактор (компонент editor).
\ No newline at end of file
diff --git a/application/frontend/components/media/component.json b/application/frontend/components/media/component.json
new file mode 100644
index 0000000..9a6d94e
--- /dev/null
+++ b/application/frontend/components/media/component.json
@@ -0,0 +1,27 @@
+{
+ "name": "media",
+ "version": "1.0.0",
+ "dependencies": {
+ "modal": "*",
+ "tabs": "*",
+ "uploader": "*"
+ },
+ "templates": {
+ "content": "media-content.tpl",
+ "media": "media.tpl",
+ "pane.insert": "panes/pane.insert.tpl",
+ "pane.photoset": "panes/pane.photoset.tpl",
+ "pane.preview": "panes/pane.preview.tpl",
+ "pane": "panes/pane.tpl",
+ "pane.url": "panes/pane.url.tpl",
+ "uploader-block.insert.image": "uploader/uploader-block.insert.image.tpl",
+ "uploader-block.photoset": "uploader/uploader-block.photoset.tpl",
+ "uploader": "uploader/uploader.tpl"
+ },
+ "scripts": {
+ "media": "js/media.js"
+ },
+ "styles": {
+ "media": "css/media.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/media/css/media.css b/application/frontend/components/media/css/media.css
new file mode 100644
index 0000000..39270f5
--- /dev/null
+++ b/application/frontend/components/media/css/media.css
@@ -0,0 +1,47 @@
+/**
+ * Media
+ */
+
+.ls-modal.ls-media {
+ max-width: 1130px;
+ border: none;
+ background: #222;
+}
+
+/* Nav */
+.ls-media-nav .ls-tab-list {
+ width: 160px;
+ padding-top: 15px;
+}
+.ls-media-nav .ls-tab {
+ padding: 10px 15px;
+ background: #222;
+ color: #eee;
+}
+.ls-media-nav .ls-tab:hover {
+ background: #2a2a2a;
+}
+.ls-media-nav .ls-tab.active {
+ background: #333;
+}
+
+/* Panes */
+.ls-media-nav .ls-tabs-panes {
+ overflow: hidden;
+ margin-left: 160px;
+ background: #fff;
+}
+
+/* Content */
+.ls-media-pane-content {
+ min-height: 300px;
+ padding: 15px;
+}
+
+/* Footer */
+.ls-media-pane-footer {
+ padding: 15px;
+ background: #fafafa;
+ border-top: 1px solid #eee;
+ text-align: right;
+}
\ No newline at end of file
diff --git a/application/frontend/components/media/js/media.js b/application/frontend/components/media/js/media.js
new file mode 100644
index 0000000..b896c41
--- /dev/null
+++ b/application/frontend/components/media/js/media.js
@@ -0,0 +1,351 @@
+/**
+ * Media
+ *
+ * @module ls/media
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ *
+ * TODO: Фильтрация файлов по типу при переключении табов
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsMedia", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Редактор к которому привязано текущее окно
+ editor: $(),
+
+ // Ссылки
+ urls: {
+ // Вставка файла
+ insert: aRouter.ajax + 'media/submit-insert/',
+ // Вставка фотосета
+ photoset: aRouter.ajax + 'media/submit-create-photoset',
+ // Загрузка файла по ссылке
+ url_upload: aRouter.ajax + 'media/upload-link/',
+ // Вставка файла по ссылке
+ url_insert: aRouter.ajax + 'media/upload-insert/'
+ },
+
+ // Селекторы
+ selectors: {
+ nav: '.js-media-nav',
+ uploader: '.js-media-uploader',
+ block: '.js-media-info-block',
+ blocks: '.js-media-uploader .js-media-info-block',
+ insert_submit: '.js-media-insert-submit',
+ photoset_submit: '.js-media-photoset-submit',
+ url: {
+ form: '.js-media-url-form',
+ url: '.js-media-url-form-url',
+ block_container: '.js-media-url-settings-blocks',
+ blocks: '.js-media-url-settings-blocks .js-media-info-block',
+ submit_upload: '.js-media-url-submit-upload',
+ submit_insert: '.js-media-url-submit-insert',
+ image_preview: '.js-media-url-image-preview'
+ }
+ },
+
+ uploader_options: {},
+
+ params: {}
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ // Получаем редактор
+ ! this.option( 'editor' ).length && this.option( 'editor', $( '#' + this.element.data( 'media-editor') ) );
+
+ // Иниц-ия загрузчика
+ this.elements.uploader.lsUploader( $.extend( {}, this.option( 'uploader_options' ), {
+ autoload: false,
+ params: {
+ security_ls_key: LIVESTREET_SECURITY_KEY
+ },
+ filebeforeactivate: this._onFileBeforeActivate.bind( this )
+ }));
+
+ this._list = this.elements.uploader.lsUploader( 'getElement', 'list' );
+ this._originalTargetType = this.elements.uploader.lsUploader( 'option', 'params.target_type' );
+
+ // Табы
+ this.elements.nav.lsTabs({
+ tabactivate: this._onTabActivate.bind( this )
+ });
+
+ // Иниц-ия модального окна
+ this.element.lsModal({
+ aftershow: this.reload.bind( this )
+ });
+
+ //
+ // INSERT
+ //
+
+ this._on( this.elements.insert_submit, { click: '_onInsertSubmit' } );
+ this._on( this.elements.photoset_submit, { click: '_onPhotosetSubmit' } );
+
+ //
+ // INSERT FROM URL
+ //
+
+ this._on( this.elements.url.type, { click: 'onUrlTypeChange' } );
+ this._on( this.elements.url.url, { keyup: 'onUrlChange', change: 'onUrlChange' } );
+ this._on( this.elements.url.submit_upload, { click: this.urlInsert.bind( this, true ) } );
+ this._on( this.elements.url.submit_insert, { click: this.urlInsert.bind( this, false ) } );
+ },
+
+ /**
+ *
+ */
+ _onInsertSubmit: function( event ) {
+ this.insertSelectedFiles( 'insert', this.getSettings() );
+ },
+
+ /**
+ *
+ */
+ _onPhotosetSubmit: function( event ) {
+ this.insertSelectedFiles( 'photoset', this.getSettings() );
+ },
+
+ /**
+ *
+ */
+ _onFileBeforeActivate: function( event, data ) {
+ this.activateInfoBlock( data.element );
+ },
+
+ /**
+ *
+ */
+ _onTabActivate: function( event, data ) {
+ var type = data.element.data( 'media-name' );
+
+ this.moveUploader( data );
+
+ if ( type === 'photoset' ) {
+ this._list.lsUploaderFileList( 'option', 'multiselect_ctrl', false );
+ this.elements.uploader.lsUploader( 'filterFilesByType', [ '1' ] );
+ }
+
+ if ( type === 'url' ) {
+ this.disableUrlButtons( ! this.elements.url.url.val());
+ }
+ },
+
+ /**
+ * Перемещение uploader'а из одного таба в другой
+ */
+ moveUploader: function( tab ) {
+ this.resetUploader();
+
+ // Перемещение
+ if ( tab.element.hasClass( 'js-tab-show-gallery' ) ) {
+ this.elements.uploader
+ .lsUploader( 'resetFilter' )
+ .lsUploader( 'unselectAll' )
+ .lsUploader( 'setTargetTypeFilter', 'uploaded' )
+ .appendTo( this.getPaneContent( tab ) );
+ }
+ },
+
+ /**
+ *
+ */
+ resetUploader: function() {
+ this._list.lsUploaderFileList( 'option', 'params.target_type', this._originalTargetType );
+ this._list.lsUploaderFileList( 'option', 'multiselect_ctrl', true );
+ },
+
+ /**
+ *
+ */
+ getPaneContent: function( tab ) {
+ return tab.getPane().find( '.js-media-pane-content' );
+ },
+
+ /**
+ *
+ */
+ show: function() {
+ this.element.lsModal( 'show' );
+ },
+
+ /**
+ *
+ */
+ hide: function() {
+ this.element.lsModal( 'hide' );
+ },
+
+ /**
+ *
+ */
+ getSettings: function() {
+ return this.elements.blocks
+ .filter( ':visible' )
+ .find( 'form' )
+ .serializeJSON();
+ },
+
+ /**
+ *
+ */
+ insertSelectedFiles: function( url, params ) {
+ this.insertFiles( url, params, this.elements.uploader.lsUploader( 'getSelectedFiles' ) );
+ },
+
+ /**
+ * Вставляет выделенные файлы в редактор
+ */
+ insertFiles: function( url, params, files ) {
+ if ( ! files.length ) return;
+
+ // Формируем список ID файлов
+ var ids = $.map( files, function ( file ) {
+ return $( file ).lsUploaderFile( 'getProperty', 'id' );
+ });
+
+ this._load( url, $.extend( true, {}, { ids: ids }, params || {} ), function( response ) {
+ this.option( 'editor' ).lsEditor( 'insert', response.sTextResult );
+ this.element.lsModal( 'hide' );
+ });
+ },
+
+ /**
+ *
+ */
+ activateInfoBlock: function( file ) {
+ this.elements.blocks.hide();
+
+ // Показываем блок настроек только для активного типа файла
+ this.elements.blocks
+ .filter( '[data-type=' + this.getActiveTabName() + ']' )
+ .filter( '[data-filetype=' + file.lsUploaderFile( 'getProperty', 'type' ) + ']' )
+ .show();
+
+ // Обновляем настройки
+ if ( this.getActiveTabName() == 'insert' && file.lsUploaderFile( 'getProperty', 'type' ) == '1' ) {
+ var block = this.elements.blocks.filter('.js-media-info-block-image-options');
+ var sizes = block.find( 'select[name=size]' );
+
+ sizes.find( 'option:not([value=original])' ).remove();
+ sizes.append($.map( file.data('mediaImageSizes'), function ( v, k ) {
+ // Расчитываем пропорциональную высоту изображения
+ var height = v.h || parseInt( v.w * file.lsUploaderFile( 'getProperty', 'height' ) / file.lsUploaderFile( 'getProperty', 'width' ) );
+
+ return '' + v.w + ' × ' + height + ' ';
+ }).join( '' ));
+ }
+
+ // TODO: Add hook
+ },
+
+ /**
+ *
+ */
+ reload: function() {
+ this.elements.uploader.lsUploader( 'reload' );
+ },
+
+ /**
+ *
+ */
+ getActiveTab: function() {
+ return this.elements.nav.lsTabs( 'getActiveTab' );
+ },
+
+ /**
+ *
+ */
+ getActiveTabName: function() {
+ return this.getActiveTab().data( 'media-name' );
+ },
+
+ //
+ // INSERT FROM URL
+ //
+
+ /**
+ *
+ */
+ onUrlTypeChange: function ( event ) {
+ this.elements.url.blocks.hide();
+ this.elements.url.blocks.filter( '[data-filetype=' + this.elements.url.type.val() + ']' ).show();
+ this.elements.url.url.val( '' );
+ this.elements.url.image_preview.hide().empty();
+ },
+
+ /**
+ *
+ */
+ onUrlChange: function ( event ) {
+ var _this = this,
+ url = this.elements.url.url.val();
+
+ this.disableUrlButtons( ! url);
+
+ $(' ', {
+ src: url,
+ style: 'max-width: 50%',
+ error: function () {
+ _this.elements.url.image_preview.hide().empty();
+ },
+ load: function () {
+ _this.elements.url.image_preview.show().html( $( this ) );
+ }
+ });
+ },
+
+ /**
+ *
+ */
+ disableUrlButtons: function ( disable ) {
+ this.elements.url.submit_insert.prop( 'disabled', disable );
+ this.elements.url.submit_upload.prop( 'disabled', disable );
+ },
+
+ /**
+ *
+ */
+ urlInsert: function ( upload ) {
+ var upload = upload || false,
+ params = $.extend(
+ {},
+ { upload: upload },
+ this.elements.url.form.serializeJSON(),
+ this.elements.url.blocks.filter( ':visible' ).find('form').serializeJSON(),
+ this.elements.uploader.lsUploader( 'option', 'params' )
+ );
+
+ this.disableUrlButtons(true);
+
+ this._load( 'url_upload', params, function ( response ) {
+ this.option( 'editor' ).lsEditor( 'insert', response.sText );
+ this.element.lsModal( 'hide' );
+ this.reload();
+ }, {
+ // TODO: Fix validation
+ validate: false,
+ submitButton: this.elements.url[ upload ? 'submit_upload' : 'submit_insert' ],
+ onComplete: function () {
+ this.disableUrlButtons(false);
+ }.bind(this)
+ });
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/media/media-content.tpl b/application/frontend/components/media/media-content.tpl
new file mode 100644
index 0000000..6add8ef
--- /dev/null
+++ b/application/frontend/components/media/media-content.tpl
@@ -0,0 +1,11 @@
+{**
+ * Media content
+ *}
+
+
+ {component 'tabs' classes='ls-media-nav js-media-nav' mods='align-left' tabs=[
+ [ 'text' => {lang 'media.nav.insert'}, 'body' => {component 'media' template='pane.insert'}, 'classes' => 'js-tab-show-gallery', 'attributes' => [ 'data-media-name' => 'insert' ] ],
+ [ 'text' => {lang 'media.nav.photoset'}, 'body' => {component 'media' template='pane.photoset'}, 'classes' => 'js-tab-show-gallery', 'attributes' => [ 'data-media-name' => 'photoset' ] ],
+ [ 'text' => {lang 'media.nav.url'}, 'body' => {component 'media' template='pane.url'}, 'attributes' => [ 'data-media-name' => 'url' ] ]
+ ]}
+
\ No newline at end of file
diff --git a/application/frontend/components/media/media.tpl b/application/frontend/components/media/media.tpl
new file mode 100644
index 0000000..5bf5ed7
--- /dev/null
+++ b/application/frontend/components/media/media.tpl
@@ -0,0 +1,13 @@
+{**
+ * Загрузка медиа-файлов
+ *}
+
+{extends 'Component@modal.modal'}
+
+{block 'modal_options' append}
+ {$classes = "$classes ls-media js-modal-media"}
+ {$title = {lang name='media.title'}}
+ {$options = array_merge( $options|default:[], [ 'center' => 'false' ] )}
+ {$showFooter = false}
+ {$body = {component 'media' template='content'}}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/media/panes/pane.insert.tpl b/application/frontend/components/media/panes/pane.insert.tpl
new file mode 100644
index 0000000..bbc1b5a
--- /dev/null
+++ b/application/frontend/components/media/panes/pane.insert.tpl
@@ -0,0 +1,22 @@
+{extends './pane.tpl'}
+
+{block 'media_pane_options' append}
+ {$id = 'tab-media-insert'}
+{/block}
+
+{block 'media_pane_content'}
+ {component 'media' template='uploader'
+ attributes = [ 'id' => 'media-uploader' ]
+ classes = 'js-media-uploader'
+ targetParams = $aTargetParams
+ targetType = $sMediaTargetType
+ targetId = $sMediaTargetId
+ targetTmp = $sMediaTargetTmp}
+{/block}
+
+{block 'media_pane_footer' prepend}
+ {component 'button'
+ mods = 'primary'
+ classes = 'js-media-insert-submit'
+ text = {lang name='media.insert.submit'}}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/media/panes/pane.photoset.tpl b/application/frontend/components/media/panes/pane.photoset.tpl
new file mode 100644
index 0000000..0406e9a
--- /dev/null
+++ b/application/frontend/components/media/panes/pane.photoset.tpl
@@ -0,0 +1,12 @@
+{extends './pane.tpl'}
+
+{block 'media_pane_options' append}
+ {$id = 'tab-media-photoset'}
+{/block}
+
+{block 'media_pane_footer' prepend}
+ {component 'button'
+ mods = 'primary'
+ classes = 'js-media-photoset-submit'
+ text = {lang name='media.photoset.submit'}}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/media/panes/pane.preview.tpl b/application/frontend/components/media/panes/pane.preview.tpl
new file mode 100644
index 0000000..b2ae198
--- /dev/null
+++ b/application/frontend/components/media/panes/pane.preview.tpl
@@ -0,0 +1,23 @@
+{extends './pane.tpl'}
+
+{block 'media_pane_options' append}
+ {$id = 'tab-media-preview'}
+{/block}
+
+{block 'media_pane_content'}
+ {if $aTargetItems}
+ {foreach $aTargetItems as $oTarget}
+
+ Удалить превью
+
+
+ {$aPreview = $oTarget->getPreviewImageItemsWebPath()}
+
+ {foreach $aPreview as $sPreviewFile}
+
+ {/foreach}
+ {/foreach}
+ {else}
+ Превью можно выбрать из галереи .
+ {/if}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/media/panes/pane.tpl b/application/frontend/components/media/panes/pane.tpl
new file mode 100644
index 0000000..162f909
--- /dev/null
+++ b/application/frontend/components/media/panes/pane.tpl
@@ -0,0 +1,11 @@
+{block 'media_pane_options'}
+ {component_define_params params=[ 'id' ]}
+{/block}
+
+
+ {block 'media_pane_content'}{/block}
+
+
+
\ No newline at end of file
diff --git a/application/frontend/components/media/panes/pane.url.tpl b/application/frontend/components/media/panes/pane.url.tpl
new file mode 100644
index 0000000..c79091c
--- /dev/null
+++ b/application/frontend/components/media/panes/pane.url.tpl
@@ -0,0 +1,43 @@
+{extends './pane.tpl'}
+
+{block 'media_pane_options' append}
+ {$id = 'tab-media-url'}
+{/block}
+
+{block 'media_pane_content'}
+
+
+
+
+
+ {component 'media' template='uploader-block.insert.image' useSizes=false}
+
+{/block}
+
+{block 'media_pane_footer' prepend}
+ {component 'button'
+ mods = 'primary'
+ classes = 'js-media-url-submit-insert'
+ text = {lang 'media.url.submit_insert'}}
+
+ {component 'button'
+ mods = 'primary'
+ classes = 'js-media-url-submit-upload'
+ text = {lang 'media.url.submit_upload'}}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/media/uploader/uploader-block.insert.image.tpl b/application/frontend/components/media/uploader/uploader-block.insert.image.tpl
new file mode 100644
index 0000000..2794d4d
--- /dev/null
+++ b/application/frontend/components/media/uploader/uploader-block.insert.image.tpl
@@ -0,0 +1,36 @@
+{**
+ * Опции вставки
+ *
+ * @param boolean $useSizes
+ *}
+
+{component_define_params params=[ 'useSizes' ]}
+
+{capture 'block_content'}
+
+{/capture}
+
+{component 'uploader' template='block'
+ title = {lang 'media.insert.settings.title'}
+ content = $smarty.capture.block_content
+ classes = 'js-media-info-block js-media-info-block-image-options'
+ attributes = [ 'data-type' => 'insert', 'data-filetype' => '1' ]}
\ No newline at end of file
diff --git a/application/frontend/components/media/uploader/uploader-block.photoset.tpl b/application/frontend/components/media/uploader/uploader-block.photoset.tpl
new file mode 100644
index 0000000..ed734b1
--- /dev/null
+++ b/application/frontend/components/media/uploader/uploader-block.photoset.tpl
@@ -0,0 +1,24 @@
+{**
+ * Опции фотосета
+ *}
+
+{capture 'block_content'}
+
+{/capture}
+
+{component 'uploader' template='block'
+ title = {lang 'media.photoset.settings.title'}
+ content = $smarty.capture.block_content
+ classes = 'js-media-info-block'
+ attributes = [ 'data-type' => 'photoset', 'data-filetype' => '1' ]}
\ No newline at end of file
diff --git a/application/frontend/components/media/uploader/uploader.tpl b/application/frontend/components/media/uploader/uploader.tpl
new file mode 100644
index 0000000..c9ed614
--- /dev/null
+++ b/application/frontend/components/media/uploader/uploader.tpl
@@ -0,0 +1,19 @@
+{extends 'Component@uploader.uploader'}
+
+{block 'uploader_options' append}
+ {component_define_params params=[ 'targetType', 'targetId', 'targetTmp' ]}
+
+ {$attributes = array_merge( $attributes|default:[], [
+ 'data-param-target_type' => {json var=$targetType},
+ 'data-param-target_id' => {json var=$targetId},
+ 'data-param-target_tmp' => {json var=$targetTmp}
+ ])}
+{/block}
+
+{block 'uploader_aside' append}
+ {* Основные настройки *}
+ {component 'media' template='uploader-block.insert.image'}
+
+ {* Опции фотосета *}
+ {component 'media' template='uploader-block.photoset'}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/menu/README.md b/application/frontend/components/menu/README.md
new file mode 100644
index 0000000..40ed46d
--- /dev/null
+++ b/application/frontend/components/menu/README.md
@@ -0,0 +1,3 @@
+# Компонент menu
+
+Меню
\ No newline at end of file
diff --git a/application/frontend/components/menu/component.json b/application/frontend/components/menu/component.json
new file mode 100644
index 0000000..a63c3e9
--- /dev/null
+++ b/application/frontend/components/menu/component.json
@@ -0,0 +1,17 @@
+{
+ "name": "menu",
+ "version": "1.0.0",
+ "dependencies": {
+ },
+ "templates": {
+ "menu": "menu.tpl",
+ "main":"main.tpl",
+ "user":"user.tpl"
+ },
+ "scripts": {
+
+ },
+ "styles": {
+
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/menu/main.tpl b/application/frontend/components/menu/main.tpl
new file mode 100644
index 0000000..4324a1a
--- /dev/null
+++ b/application/frontend/components/menu/main.tpl
@@ -0,0 +1,6 @@
+{**
+ * Главное меню
+ *
+ *
+ *}
+{component 'nav' hook='main' params=$params}
diff --git a/application/frontend/components/menu/menu.tpl b/application/frontend/components/menu/menu.tpl
new file mode 100644
index 0000000..cc5a232
--- /dev/null
+++ b/application/frontend/components/menu/menu.tpl
@@ -0,0 +1,11 @@
+{**
+ * Меню
+ *
+ * @param string $mods
+ * @param string $classes
+ * @param array $attributes
+ *}
+
+{component_define_params params=[ 'activeItem', 'mods', 'classes', 'template' ]}
+
+{component "menu.{$template}" params=$params activeItem=$activeItem mods=$mods classes=$classes}
diff --git a/application/frontend/components/menu/user.tpl b/application/frontend/components/menu/user.tpl
new file mode 100644
index 0000000..153fb93
--- /dev/null
+++ b/application/frontend/components/menu/user.tpl
@@ -0,0 +1,6 @@
+{**
+ * Меню пользователя
+ *
+ *
+ *}
+{component 'nav' params = $params}
diff --git a/application/frontend/components/modal-create/README.md b/application/frontend/components/modal-create/README.md
new file mode 100644
index 0000000..837c62c
--- /dev/null
+++ b/application/frontend/components/modal-create/README.md
@@ -0,0 +1 @@
+# Компонент modal-create
\ No newline at end of file
diff --git a/application/frontend/components/modal-create/component.json b/application/frontend/components/modal-create/component.json
new file mode 100644
index 0000000..b87c836
--- /dev/null
+++ b/application/frontend/components/modal-create/component.json
@@ -0,0 +1,13 @@
+{
+ "name": "modal-create",
+ "version": "1.0.0",
+ "dependencies": {
+ "modal": "*"
+ },
+ "templates": {
+ "modal-create": "modal-create.tpl"
+ },
+ "styles": {
+ "modal-create": "css/modal-create.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/modal-create/css/modal-create.css b/application/frontend/components/modal-create/css/modal-create.css
new file mode 100644
index 0000000..8e2fd88
--- /dev/null
+++ b/application/frontend/components/modal-create/css/modal-create.css
@@ -0,0 +1,37 @@
+/**
+ * Создать
+ */
+
+.ls-modal.ls-modal--create {
+ max-width: 525px;
+}
+.ls-modal.ls-modal--create .ls-modal-content {
+ padding: 20px 10px 0;
+}
+
+.write-list li {
+ width: 100px;
+ margin: 0 10px 20px;
+ text-align: center;
+ overflow: hidden;
+ float: left;
+}
+.write-list li a {
+ color: #39576B;
+}
+.write-list li .write-item-image {
+ display: block;
+ width: 100px;
+ height: 100px;
+ border-radius: 3px;
+ text-align: center;
+ background: url(../images/modal-create.png) no-repeat;
+ margin-bottom: 10px;
+}
+
+.write-list li.write-item-type-topic .write-item-image { background-position: 0 0; }
+.write-list li.write-item-type-poll .write-item-image { background-position: -100px 0; }
+.write-list li.write-item-type-link .write-item-image { background-position: -200px 0; }
+.write-list li.write-item-type-photoset .write-item-image { background-position: -300px 0; }
+.write-list li.write-item-type-blog .write-item-image { background-position: -400px 0; }
+.write-list li.write-item-type-draft .write-item-image { background-position: -500px 0; }
\ No newline at end of file
diff --git a/application/frontend/components/modal-create/images/modal-create.png b/application/frontend/components/modal-create/images/modal-create.png
new file mode 100644
index 0000000..3ef9c76
Binary files /dev/null and b/application/frontend/components/modal-create/images/modal-create.png differ
diff --git a/application/frontend/components/modal-create/modal-create.tpl b/application/frontend/components/modal-create/modal-create.tpl
new file mode 100644
index 0000000..d1c47e1
--- /dev/null
+++ b/application/frontend/components/modal-create/modal-create.tpl
@@ -0,0 +1,33 @@
+{**
+ * Модальное с меню "Создать"
+ *}
+
+{capture 'modal_content'}
+ {function modal_create_item}
+
+ {$url = "{if ! $url}{router page=$item}add{else}{$url}{/if}"}
+
+
+ {$title}
+
+ {/function}
+
+
+ {foreach $LS->Topic_GetTopicTypes() as $type}
+ {modal_create_item item='topic' url=$type->getUrlForAdd() title=$type->getName()}
+ {/foreach}
+
+ {modal_create_item item='blog' title={lang 'modal_create.items.blog'}}
+ {modal_create_item item='talk' title={lang 'modal_create.items.talk'}}
+ {modal_create_item item='draft' url="{router page='content'}drafts/" title="{$aLang.topic.drafts} {if $iUserCurrentCountTopicDraft}({$iUserCurrentCountTopicDraft}){/if}"}
+
+ {hook run='write_item' isPopup=true}
+
+{/capture}
+
+{component 'modal'
+ title = {lang 'modal_create.title'}
+ content = $smarty.capture.modal_content
+ classes = 'js-modal-default'
+ mods = 'create'
+ id = 'modal-write'}
\ No newline at end of file
diff --git a/application/frontend/components/note/README.md b/application/frontend/components/note/README.md
new file mode 100644
index 0000000..be03b53
--- /dev/null
+++ b/application/frontend/components/note/README.md
@@ -0,0 +1 @@
+# Компонент note
\ No newline at end of file
diff --git a/application/frontend/components/note/component.json b/application/frontend/components/note/component.json
new file mode 100644
index 0000000..612af3c
--- /dev/null
+++ b/application/frontend/components/note/component.json
@@ -0,0 +1,20 @@
+{
+ "name": "note",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-vendor": "*",
+ "ls-core": "*",
+ "ls-component": "*",
+ "field": "*",
+ "button": "*"
+ },
+ "templates": {
+ "note": "note.tpl"
+ },
+ "scripts": {
+ "note": "js/note.js"
+ },
+ "styles": {
+ "note": "css/note.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/note/css/note.css b/application/frontend/components/note/css/note.css
new file mode 100644
index 0000000..b7000ee
--- /dev/null
+++ b/application/frontend/components/note/css/note.css
@@ -0,0 +1,52 @@
+/**
+ * Заметка
+ */
+
+.ls-note {
+ background: #F1F7AF;
+ padding: 20px 20px 18px;
+ margin-bottom: 15px;
+ border-radius: 5px;
+ box-shadow: 0 1px 0 #DBE28B;
+}
+.ls-note:last-child {
+ margin-bottom: 0;
+}
+
+/* Действия */
+.ls-note-actions {
+ margin: 10px 0 0 0;
+}
+.ls-note-actions li {
+ float: left;
+ margin-right: 15px;
+}
+.ls-note-actions li a {
+ text-decoration: none;
+ color: #B7BD79;
+ -webkit-transition: color .2s;
+ transition: color .2s;
+}
+.ls-note-actions li a:hover {
+ color: #A3A86B;
+}
+.ls-note-actions li a:focus {
+ outline: dotted 1px;
+}
+
+.ls-note-actions--add {
+ margin: 0;
+}
+.ls-note-actions--add li {
+ margin: 0;
+ float: none;
+ text-align: center;
+}
+
+/* Форма добавления/редактирования */
+.ls-note-form .ls-field {
+ margin-bottom: 15px;
+}
+.ls-note-form-text {
+ height: 5em;
+}
\ No newline at end of file
diff --git a/application/frontend/components/note/js/note.js b/application/frontend/components/note/js/note.js
new file mode 100644
index 0000000..b7e26ee
--- /dev/null
+++ b/application/frontend/components/note/js/note.js
@@ -0,0 +1,123 @@
+/**
+ * Заметки
+ *
+ * @module ls/usernote
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsNote", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ save: null,
+ remove: null
+ },
+
+ // Селекторы
+ selectors: {
+ body: '.js-note-body',
+ text: '.js-note-text',
+ add: '.js-note-add',
+ actions: '.js-note-actions',
+ actions_edit: '.js-note-actions-edit',
+ actions_remove: '.js-note-actions-remove',
+
+ form: '.js-note-form',
+ form_text: '.js-note-form-text',
+ form_cancel: '.js-note-form-cancel'
+ },
+
+ params: {}
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ // Добавление
+ this._on( this.elements.add, { click: 'onShowFormClick' } );
+
+ // Редактирование
+ this._on( this.elements.actions_edit, { click: 'onShowFormClick' } );
+
+ // Отмена редактирования
+ this._on( this.elements.form_cancel, { click: 'hideForm' } );
+
+ // Удаление
+ this.elements.actions_remove.on('click' + this.eventNamespace, function (e) {
+ this.remove();
+ e.preventDefault();
+ }.bind( this ));
+
+ // Сохранение
+ this.elements.form.on('submit' + this.eventNamespace, function (e) {
+ this.save();
+ e.preventDefault();
+ }.bind( this ));
+ },
+
+ /**
+ * Добавление/Редактирование
+ */
+ onShowFormClick: function( event ) {
+ event.preventDefault();
+ this.showForm();
+ },
+
+ /**
+ * Показывает форму редактирования
+ */
+ showForm: function( event ) {
+ this.elements.body.hide();
+ this.elements.form.show();
+ this.elements.form_text.val( $.trim(this.elements.text.html()) ).select();
+ },
+
+ /**
+ * Скрывает форму редактирования
+ */
+ hideForm: function() {
+ this.elements.body.show();
+ this.elements.form.hide();
+ },
+
+ /**
+ * Сохраняет заметку
+ */
+ save: function() {
+ this._setParam( 'text', this.elements.form_text.val() );
+
+ this._submit( 'save', this.elements.form, function ( response ) {
+ this.elements.text.html(response.sText).show();
+ this.elements.add.hide();
+ this.elements.actions.show();
+ this.hideForm();
+ });
+ },
+
+ /**
+ * Удаление заметки
+ */
+ remove: function() {
+ this._load( 'remove', function () {
+ this.elements.text.empty().hide();
+ this.elements.add.show();
+ this.elements.actions.hide();
+ });
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/note/note.tpl b/application/frontend/components/note/note.tpl
new file mode 100644
index 0000000..db16e28
--- /dev/null
+++ b/application/frontend/components/note/note.tpl
@@ -0,0 +1,49 @@
+{**
+ * Заметка
+ *
+ * @param object $note Заметка
+ * @param integer $targetId ID сущности
+ * @param boolean $isEditable Можно редактировать заметку или нет
+ *}
+
+{* Название компонента *}
+{$component = 'ls-note'}
+{component_define_params params=[ 'note', 'isEditable', 'targetId', 'mods', 'classes', 'attributes' ]}
+
+{* Установка дефолтных значений *}
+{$isEditable = $isEditable|default:true}
+
+
+ {* Заметка *}
+
+ {* Текст *}
+
+ {if $note}
+ {$note->getText()}
+ {/if}
+
+
+ {* Действия *}
+ {if $isEditable}
+
+
+ {* Добавить *}
+
+ {/if}
+
+
+ {* Форма редактирования *}
+ {if $isEditable}
+
+ {/if}
+
\ No newline at end of file
diff --git a/application/frontend/components/photo/README.md b/application/frontend/components/photo/README.md
new file mode 100644
index 0000000..82ab8f8
--- /dev/null
+++ b/application/frontend/components/photo/README.md
@@ -0,0 +1 @@
+# Компонент photo
\ No newline at end of file
diff --git a/application/frontend/components/photo/component.json b/application/frontend/components/photo/component.json
new file mode 100644
index 0000000..27a6ace
--- /dev/null
+++ b/application/frontend/components/photo/component.json
@@ -0,0 +1,18 @@
+{
+ "name": "photo",
+ "version": "1.0.0",
+ "dependencies": {
+ "crop": "*"
+ },
+ "templates": {
+ "photo": "photo.tpl",
+ "modal-photo": "modal-photo.tpl",
+ "modal-avatar": "modal-avatar.tpl"
+ },
+ "scripts": {
+ "photo": "js/photo.js"
+ },
+ "styles": {
+ "photo": "css/photo.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/photo/css/photo.css b/application/frontend/components/photo/css/photo.css
new file mode 100644
index 0000000..e5b8e4d
--- /dev/null
+++ b/application/frontend/components/photo/css/photo.css
@@ -0,0 +1,61 @@
+/**
+ * Фото
+ *
+ * @module ls/photo
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+.ls-photo {
+ position: relative;
+ min-width: 100%;
+ min-height: 50px;
+ overflow: hidden;
+}
+
+/* Изображение */
+.ls-photo-image {
+ vertical-align: top;
+ max-width: 100%;
+}
+
+/* Действия */
+.ls-photo-actions {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ padding: 5px 0;
+ background: rgba( 0, 0, 0, .7 );
+ opacity: 0;
+ -webkit-transition: opacity .3s;
+ transition: opacity .3s;
+}
+.ls-photo-actions li {
+ padding: 7px 15px;
+ color: #bbb;
+ font-size: 13px;
+ cursor: pointer;
+ -webkit-transition: color .2s;
+ transition: color .2s;
+}
+.ls-photo-actions li:hover {
+ color: #eee;
+}
+.ls-photo:hover .ls-photo-actions {
+ opacity: 1;
+}
+
+/* @modifier nophoto */
+.ls-photo--nophoto .ls-photo-image {
+ width: 100%;
+}
+.ls-photo--nophoto .ls-photo-actions {
+ opacity: 1;
+}
+.ls-photo--nophoto .ls-photo-actions-crop-avatar,
+.ls-photo--nophoto .ls-photo-actions-remove {
+ display: none;
+}
\ No newline at end of file
diff --git a/application/frontend/components/photo/js/photo.js b/application/frontend/components/photo/js/photo.js
new file mode 100644
index 0000000..8ac9892
--- /dev/null
+++ b/application/frontend/components/photo/js/photo.js
@@ -0,0 +1,197 @@
+/**
+ * Photo
+ *
+ * @module ls/photo
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsPhoto", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ upload: null,
+ remove: null,
+ crop_photo: null,
+ crop_avatar: null,
+ save_photo: null,
+ save_avatar: null,
+ cancel_photo: null
+ },
+ use_avatar: true,
+ crop_photo: {
+ },
+ crop_avatar: {
+ aspectRatio: 1
+ },
+ // Селекторы
+ selectors: {
+ image: '.js-photo-image',
+ action_upload: '.js-photo-actions-upload',
+ action_upload_label: '.js-photo-actions-upload-label',
+ action_upload_input: '.js-photo-actions-upload-input',
+ action_crop_avatar: '.js-photo-actions-crop-avatar',
+ action_remove: '.js-photo-actions-remove'
+ },
+ // Классы
+ classes: {
+ nophoto: 'ls-photo--nophoto'
+ },
+ // Параметры передаваемые в аякс запросах
+ params: {}
+
+ // Изменение аватара
+ // changeavatar: function() {}
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ var _this = this;
+
+ this.option( 'params.target_id', this.element.data( 'target-id' ) );
+
+ this.elements.action_upload_input.on( 'change' + this.eventNamespace, function () {
+ _this.upload( $( this ) );
+ });
+
+ // Удаление
+ this._on( this.elements.action_remove, { click: 'remove' } );
+
+ // Изменение аватара
+ if ( this.option( 'use_avatar' ) ) {
+ this._on( this.elements.action_crop_avatar, { click: 'cropAvatar' } );
+ }
+ },
+
+ /**
+ * Удаление фото
+ */
+ remove: function() {
+ this._load( 'remove', function( response ) {
+ this._addClass( 'nophoto' );
+ this.elements.image.attr( 'src', response.photo );
+ this.elements.action_upload_label.text( response.upload_text );
+
+ if ( this.option( 'use_avatar' ) ) {
+ this._trigger( 'changeavatar', null, [ this, response.avatars ] );
+ }
+ });
+ },
+
+ /**
+ * Загрузка фото
+ */
+ upload: function( input ) {
+ var form = $( '' ).hide().appendTo( 'body' );
+ input.clone( true ).insertAfter( input );
+ input.appendTo( form );
+ $( ' ').appendTo( form );
+
+ this._submit( 'upload', form, function ( response ) {
+ this.cropPhoto( response );
+ form.remove();
+ }, {
+ lock: false
+ });
+ },
+
+ /**
+ * Показывает модальное кропа фото
+ */
+ cropPhoto: function( image ) {
+ ls.modal.load(
+ this.option('urls.crop_photo'),
+ $.extend({}, this.option('params'), image),
+ {
+ aftershow: function (event, data) {
+ this._initPhotoModal(data.element, image);
+ }.bind(this),
+ afterhide: function () {
+ this._load('cancel_photo');
+ }.bind(this)
+ }
+ );
+ },
+
+ /**
+ * Иниц-ия модального окна с кропом фото
+ */
+ _initPhotoModal: function (modal, image) {
+ modal.lsCropModal($.extend({
+ urls: {
+ submit: this.option('urls.save_photo')
+ },
+ params: $.extend({}, this.option('params'), image),
+ cropOptions: this.option('crop_photo')
+ }));
+
+ // Сохранение
+ modal.on('lscropmodalsubmitted' + this.eventNamespace, function (event, data) {
+ this._removeClass( 'nophoto' );
+ this.elements.image.attr( 'src', data.response.photo + '?' + Math.random() );
+ this.elements.action_upload_label.text( data.response.upload_text );
+
+ if ( this.option( 'use_avatar' ) ) {
+ // TODO: Временный хак (модальное не показывается сразу после закрытия предыдущего окна)
+ setTimeout( this.cropAvatar.bind( this ), 300 );
+ }
+ }.bind(this));
+ },
+
+ /**
+ * Показывает модальное кропа аватара
+ */
+ cropAvatar: function() {
+ var image = {
+ path: this.elements.image.attr( 'src' ),
+ original_width: this.elements.image[0].naturalWidth,
+ original_height: this.elements.image[0].naturalHeight,
+ width: this.elements.image[0].naturalWidth,
+ height: this.elements.image[0].naturalHeight
+ };
+
+ ls.modal.load(
+ this.option('urls.crop_avatar'),
+ $.extend({}, this.option('params'), image),
+ {
+ aftershow: function (event, data) {
+ this._initAvatarModal(data.element, image);
+ }.bind(this)
+ }
+ );
+ },
+
+ /**
+ * Иниц-ия модального окна с кропом аватары
+ */
+ _initAvatarModal: function (modal, image) {
+ modal.lsCropModal($.extend({
+ urls: {
+ submit: this.option('urls.save_avatar')
+ },
+ params: $.extend({}, this.option('params'), image),
+ cropOptions: this.option('crop_avatar')
+ }));
+
+ // Сохранение
+ modal.on('lscropmodalsubmitted' + this.eventNamespace, function (event, data) {
+ this._trigger( 'changeavatar', null, [ this, data.response.avatars ] );
+ }.bind(this));
+ },
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/photo/modal-avatar.tpl b/application/frontend/components/photo/modal-avatar.tpl
new file mode 100644
index 0000000..ad944d9
--- /dev/null
+++ b/application/frontend/components/photo/modal-avatar.tpl
@@ -0,0 +1,6 @@
+{extends 'component@crop.crop'}
+
+{block 'crop_modal_options' append}
+ {$title = {lang 'user.photo.crop_avatar.title'}}
+ {$desc = {lang 'user.photo.crop_avatar.desc'}}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/photo/modal-photo.tpl b/application/frontend/components/photo/modal-photo.tpl
new file mode 100644
index 0000000..a234a46
--- /dev/null
+++ b/application/frontend/components/photo/modal-photo.tpl
@@ -0,0 +1,6 @@
+{extends 'component@crop.crop'}
+
+{block 'crop_modal_options' append}
+ {$title = {lang 'user.photo.crop_photo.title'}}
+ {$desc = {lang 'user.photo.crop_photo.desc'}}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/photo/photo.tpl b/application/frontend/components/photo/photo.tpl
new file mode 100644
index 0000000..906876d
--- /dev/null
+++ b/application/frontend/components/photo/photo.tpl
@@ -0,0 +1,61 @@
+{**
+ * Photo
+ *
+ * @param string $url
+ * @param integer $targetId
+ * @param boolean $hasPhoto
+ * @param boolean $photoPath
+ * @param boolean $photoAltText
+ * @param boolean $editable
+ * @param boolean $useAvatar
+ *}
+
+{$component = 'ls-photo'}
+{component_define_params params=[ 'url', 'photoPath', 'photoAltText', 'hasPhoto', 'useAvatar', 'targetId', 'editable', 'mods', 'classes', 'attributes' ]}
+
+{$useAvatar = $useAvatar|default:true}
+
+{if ! $hasPhoto}
+ {$mods = "$mods nophoto"}
+{/if}
+
+
+
+ {* Фото *}
+
+
+
+
+ {* Действия *}
+ {if $editable}
+
+ {/if}
+
\ No newline at end of file
diff --git a/application/frontend/components/poll/README.md b/application/frontend/components/poll/README.md
new file mode 100644
index 0000000..3aebfdb
--- /dev/null
+++ b/application/frontend/components/poll/README.md
@@ -0,0 +1 @@
+# Компонент poll
\ No newline at end of file
diff --git a/application/frontend/components/poll/component.json b/application/frontend/components/poll/component.json
new file mode 100644
index 0000000..c4e8f0f
--- /dev/null
+++ b/application/frontend/components/poll/component.json
@@ -0,0 +1,30 @@
+{
+ "name": "poll",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-component": "*",
+ "modal": "*",
+ "forms": "*",
+ "button": "*",
+ "field": "*"
+ },
+ "templates": {
+ "poll": "poll.tpl",
+ "form": "poll.form.tpl",
+ "form-item": "poll.form.item.tpl",
+ "list": "poll.list.tpl",
+ "manage.item": "poll.manage.item.tpl",
+ "manage.list": "poll.manage.list.tpl",
+ "manage": "poll.manage.tpl",
+ "result": "poll.result.tpl",
+ "modal.create": "modal.poll-create.tpl",
+ "vote": "poll.vote.tpl"
+ },
+ "scripts": {
+ "manage": "js/poll-manage.js",
+ "poll": "js/poll.js"
+ },
+ "styles": {
+ "poll": "css/poll.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/poll/css/poll.css b/application/frontend/components/poll/css/poll.css
new file mode 100644
index 0000000..02a135c
--- /dev/null
+++ b/application/frontend/components/poll/css/poll.css
@@ -0,0 +1,61 @@
+/**
+ * Опросы
+ *
+ * @template polls/*.tpl
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+.ls-poll { margin-bottom: 15px; background: #fafafa; padding: 15px; }
+
+.ls-poll-title { font-size: 20px; margin-bottom: 20px; }
+
+.ls-poll-answer-list { margin-bottom: 20px; margin-left: 0; list-style-type: none; }
+
+.ls-poll-answer-list-item { margin-bottom: 10px; }
+.ls-poll-answer-list-item:last-child { margin-bottom: 0; }
+.ls-poll-answer-list-item label { display: inline; }
+
+
+/**
+ * Результат опроса
+ *
+ * @template polls/poll.result.tpl
+ */
+.ls-poll-result { margin-bottom: 10px; padding: 15px 15px 0; background: #fff; border: 1px solid #eee; }
+.ls-poll-result-item { margin-bottom: 20px; overflow: hidden; zoom: 1; }
+.ls-poll-result-item-count { float: left; width: 50px; text-align: right; padding-right: 15px; }
+.ls-poll-result-item-count strong { display: block; }
+.ls-poll-result-item-count span { color: #aaa; }
+.ls-poll-result-item-chart { padding-left: 65px; }
+.ls-poll-result-item-bar { height: 10px; margin-top: 5px; background: #ccc; overflow: hidden; border-radius: 2px; }
+
+.ls-poll-result-item--most .ls-poll-result-item-bar { background: #AC90DF; }
+.ls-poll-result-item--voted .ls-poll-result-item-count strong { background: yellow; }
+
+.ls-poll-result-total { color: #aaa; margin-left: 10px; }
+
+
+/**
+ * Управление опросами
+ *
+ * @template polls/poll.form.tpl
+ */
+.ls-poll-manage .fieldset-body { padding-bottom: 0; }
+.ls-poll-manage-add { margin-bottom: 15px; }
+.ls-poll-manage-list { overflow: hidden; }
+.ls-poll-manage-item { padding: 10px 70px 10px 15px; background: #fff; margin-bottom: 1px; position: relative; }
+.ls-poll-manage-item:last-child { margin-bottom: 15px; }
+
+
+/**
+ * Форма добавления
+ *
+ * @template polls/poll.form.tpl
+ */
+.ls-poll-form-answer-item { margin-bottom: 10px; padding-right: 25px; position: relative; }
+.ls-poll-form-answer-item .ls-field,
+.ls-poll-form-answer-item:last-child { margin-bottom: 0; }
+.ls-poll-form-answer-item-remove { position: absolute; top: 7px; right: 0; cursor: pointer; }
diff --git a/application/frontend/components/poll/js/poll-manage.js b/application/frontend/components/poll/js/poll-manage.js
new file mode 100644
index 0000000..f05efaf
--- /dev/null
+++ b/application/frontend/components/poll/js/poll-manage.js
@@ -0,0 +1,263 @@
+/**
+ * Управление опросами
+ *
+ * @module ls/poll-manage
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsPollManage", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ // Мод. окно добавления опроса
+ modal_add: aRouter.ajax + 'poll/modal-create',
+
+ // Мод. окно редактирования опроса
+ modal_edit: aRouter.ajax + 'poll/modal-update',
+
+ // Добавление
+ add: aRouter.ajax + 'poll/create/',
+
+ // Редактирование
+ update: aRouter.ajax + 'poll/update/',
+
+ // Удаление
+ remove: aRouter.ajax + 'poll/remove/'
+ },
+
+ // Селекторы
+ selectors: {
+ // Список добавленных опросов
+ list: '.js-poll-manage-list',
+
+ // Опрос
+ item: '.js-poll-manage-item',
+
+ // Кнопка удаления опроса
+ item_remove: '.js-poll-manage-item-remove',
+
+ // Кнопка редактирования опроса
+ item_edit: '.js-poll-manage-item-edit',
+
+ // Кнопка добавления
+ add: '.js-poll-manage-add',
+
+ form: {
+ form: '#js-poll-form',
+ add: '.js-poll-form-answer-add',
+ list: '.js-poll-form-answer-list',
+ item: '.js-poll-form-answer-item',
+ item_id: '.js-poll-form-answer-item-id',
+ item_text: '.js-poll-form-answer-item-text',
+ item_remove: '.js-poll-form-answer-item-remove',
+ submit: '.js-poll-form-submit'
+ }
+ },
+ // Максимальное кол-во вариантов которое можно добавить в опрос
+ max: 20,
+
+ i18n: {
+ error_answers_max: '@poll.notices.error_answers_max'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ var _this = this;
+
+ this.elements = {
+ list: this.element.find( this.options.selectors.list ),
+ add: this.element.find( this.options.selectors.add ),
+ remove: this.element.find( this.options.selectors.item_remove ),
+ edit: this.element.find( this.options.selectors.item_edit )
+ };
+
+ this.id = this.element.data('target-id');
+ this.type = this.element.data('type');
+
+ //
+ // События
+ //
+
+ // Показывает форму добавления
+ this._on( this.elements.add, { 'click': this.formShowAdd } );
+
+ // Показывает форму редактирования опроса
+ this.element.on( 'click' + this.eventNamespace, this.options.selectors.item_edit, function () {
+ _this.formShowEdit( $(this).data('poll-id'), $(this).data('poll-target-tmp') )
+ });
+
+ // Удаляет опрос
+ this.element.on( 'click' + this.eventNamespace, this.options.selectors.item_remove, function () {
+ _this.remove( $(this) )
+ });
+ },
+
+ /**
+ * Показывает форму
+ *
+ * @param {String} url Ссылка возвращающая модальное окно
+ * @param {Object} params Параметры
+ */
+ formShow: function( url, params ) {
+ var _this = this;
+
+ ls.modal.load( url, params, {
+ aftershow: function ( e, modal ) {
+ var form = modal.element.find( _this.option( 'selectors.form.form' ) ),
+ list = form.find( _this.option( 'selectors.form.list' ) );
+
+ // Отправка формы
+ form.on( 'submit', function (e) {
+ _this[ form.data('action') ]( form, list, modal );
+ e.preventDefault();
+ });
+
+ // Добавление ответа
+ form.find( _this.option( 'selectors.form.add' ) ).on( 'click', _this.answerAdd.bind( _this, list ));
+ form.on( 'keydown', _this.option( 'selectors.form.item_text' ) , 'ctrl+return', _this.answerAdd.bind( _this, list ) );
+
+ // Удаление
+ form.on( 'click', _this.option( 'selectors.form.item_remove' ), function () {
+ _this.answerRemove( list, $( this ) );
+ });
+ },
+ center: false
+ });
+ },
+
+ /**
+ * Показывает форму добавления
+ */
+ formShowAdd: function() {
+ this.formShow( this.option( 'urls.modal_add' ), { target_type: this.type, target_id: this.id } );
+ },
+
+ /**
+ * Показывает форму редактирования
+ *
+ * @param {Number} id ID опроса
+ * @param {String} hash Хэш опроса
+ */
+ formShowEdit: function( id, hash ) {
+ this.formShow( this.option( 'urls.modal_edit' ), { id: id, target_tmp: hash } );
+ },
+
+ /**
+ * Добавляет вариант ответа
+ *
+ * @param {jQuery} list Список ответов
+ */
+ answerAdd: function( list ) {
+ var answers = list.find( this.option( 'selectors.form.item' ) );
+
+ // Ограничиваем кол-во добавляемых ответов
+ if ( answers.length == this.option( 'max' ) ) {
+ ls.msg.error( null, this._i18n( 'error_answers_max', { count: this.option( 'max' ) } ) );
+ return;
+ } else if ( answers.length == 2 ) {
+ answers.find( this.option( 'selectors.form.item_remove' ) ).show();
+ }
+
+ var item = $( this.option( 'selectors.form.item' ) + '[data-is-template=true]' ).clone().removeAttr( 'data-is-template' ).show();
+
+ list.append( item );
+ item.find( this.option( 'selectors.form.item_text' ) ).focus();
+ },
+
+ /**
+ * Удаляет вариант ответа
+ *
+ * @param {jQuery} list Список ответов
+ * @param {jQuery} button Кнопка удаления
+ */
+ answerRemove: function( list, button ) {
+ var answers = list.find( this.option( 'selectors.form.item' ) );
+
+ if ( answers.length == 3 ) {
+ answers.find( this.option( 'selectors.form.item_remove' ) ).hide();
+ }
+
+ button.closest( this.option( 'selectors.form.item' ) ).fadeOut(200, function () {
+ $(this).remove();
+ });
+ },
+
+ /**
+ * Проставляет индексы инпутам ответа
+ *
+ * @param {jQuery} list Список ответов
+ */
+ answerIndex: function( list ) {
+ list.find( this.option( 'selectors.form.item' ) ).each(function ( index, item ) {
+ var item = $(item),
+ id = item.find( this.option( 'selectors.form.item_id' ) ),
+ text = item.find( this.option( 'selectors.form.item_text' ) );
+
+ id.attr( 'name', 'answers[' + index + '][id]' );
+ text.attr( 'name', 'answers[' + index + '][title]' );
+ }.bind(this));
+ },
+
+ /**
+ * Добавляет опрос
+ *
+ * @param {jQuery} form Форма
+ * @param {jQuery} list Список ответов
+ * @param {jQuery} modal Модальное окно с формой
+ */
+ add: function( form, list, modal ) {
+ this.answerIndex( list );
+
+ this._submit( 'add', form, function( response ) {
+ this.elements.list.append( response.item );
+ modal.hide();
+ }.bind(this), { submitButton: modal.element.find( 'button[type=submit]' ) });
+ },
+
+ /**
+ * Обновление опроса
+ *
+ * @param {jQuery} form Форма
+ * @param {jQuery} list Список ответов
+ * @param {jQuery} modal Модальное окно с формой
+ */
+ update: function( form, list, modal ) {
+ this.answerIndex( list );
+
+ this._submit( 'update', form, function( response ) {
+ this.elements.list.find( this.option( 'selectors.item' ) + '[data-poll-id=' + response.id + ']' ).replaceWith( response.item );
+ modal.hide();
+ }.bind(this), { submitButton: modal.element.find( 'button[type=submit]' ) });
+ },
+
+ /**
+ * Удаляет опрос
+ *
+ * @param {jQuery} button Кнопка удаления
+ */
+ remove: function( button ) {
+ ls.ajax.load( this.option( 'urls.remove' ), { id: button.data('poll-id'), tmp: button.data('poll-target-tmp') }, function ( response ) {
+ button.closest( this.option( 'selectors.item' ) ).fadeOut('slow', function() {
+ $(this).remove();
+ });
+ }.bind(this));
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/poll/js/poll.js b/application/frontend/components/poll/js/poll.js
new file mode 100644
index 0000000..4beb5bc
--- /dev/null
+++ b/application/frontend/components/poll/js/poll.js
@@ -0,0 +1,109 @@
+/**
+ * Опрос
+ *
+ * @module ls/poll
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsPoll", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ // Голосование за вариант
+ vote: aRouter.ajax + 'poll/vote/'
+ },
+
+ // Селекторы
+ selectors: {
+ // Форма голосования
+ form: '.js-poll-vote-form',
+
+ // Кнопка проголосовать
+ vote: '.js-poll-vote',
+
+ // Кнопка воздержаться от голосования
+ abstain: '.js-poll-abstain',
+
+ // Результата опроса
+ result: '.js-poll-result',
+
+ // Результата опроса
+ resultContainer: '.js-poll-result-container',
+
+ // Вариант
+ item: '.js-poll-result-item',
+
+ // Кнопка сортировки вариантов
+ sort: '.js-poll-result-sort'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ ! this.elements.form.length && this.initResult();
+
+ //
+ // События
+ //
+
+ this._on( this.elements.vote, { 'click': this.vote.bind( this, false ) } );
+ this._on( this.elements.abstain, { 'click': this.vote.bind( this, true ) } );
+ this.element.on( 'click' + this.eventNamespace, this.option( 'selectors.sort' ), this.sort.bind(this) );
+ },
+
+ /**
+ * Иниц-ия результата
+ */
+ initResult: function() {
+ this.elements.sort = this.element.find( this.options.selectors.sort );
+ this.elements.items = this.element.find( this.options.selectors.item );
+ this.elements.result = this.element.find( this.options.selectors.result );
+ },
+
+ /**
+ * Голосование
+ */
+ vote: function( abstain ) {
+ this._submit( 'vote', this.elements.form, function( response ) {
+ this.elements.resultContainer.html( $.trim( response.sText ) );
+ this.initResult();
+
+ this._off( this.elements.vote, 'click' );
+ this._off( this.elements.abstain, 'click' );
+ }.bind(this), {
+ submitButton: this.elements.vote,
+ params: { abstain: abstain ? 1 : 0 }
+ });
+ },
+
+ /**
+ * Сортировка результата
+ */
+ sort: function() {
+ var type = this.elements.sort.hasClass( ls.options.classes.states.active ) ? 'position' : 'count';
+
+ this.elements.items.sort( function (a, b) {
+ return $(b).data(type) - $(a).data(type);
+ });
+
+ this.elements.sort.toggleClass( ls.options.classes.states.active );
+ this.elements.result.html( this.elements.items );
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/poll/modal.poll-create.tpl b/application/frontend/components/poll/modal.poll-create.tpl
new file mode 100644
index 0000000..b922948
--- /dev/null
+++ b/application/frontend/components/poll/modal.poll-create.tpl
@@ -0,0 +1,15 @@
+{**
+ * Создание опроса
+ *}
+
+{component 'modal'
+ title = ( $poll ) ? {lang 'poll.form.title.edit'} : {lang 'poll.form.title.add'}
+ content = {component 'poll' template='form'}
+ classes = 'js-modal-default'
+ mods = 'poll-create'
+ id = 'modal-poll-create'
+ primaryButton = [
+ 'text' => ($poll) ? $aLang.common.save : $aLang.common.add,
+ 'form' => 'js-poll-form',
+ 'classes' => 'js-poll-form-submit'
+ ]}
\ No newline at end of file
diff --git a/application/frontend/components/poll/poll.form.item.tpl b/application/frontend/components/poll/poll.form.item.tpl
new file mode 100644
index 0000000..d3a8df6
--- /dev/null
+++ b/application/frontend/components/poll/poll.form.item.tpl
@@ -0,0 +1,45 @@
+{**
+ * Блок добавления ответа
+ *
+ * @param boolean $item
+ * @param integer $index
+ * @param boolean $allowRemove
+ * @param boolean $showRemove
+ * @param boolean $isTemplate
+ *}
+
+{$component = 'ls-poll-form-answer-item'}
+{component_define_params params=[ 'item', 'index', 'allowRemove', 'showRemove', 'isTemplate' ]}
+
+{$allowUpdate = $allowUpdate|default:true}
+{$allowRemove = $allowRemove|default:true}
+{$showRemove = $showRemove|default:true}
+{$index = $index|default:0}
+
+
+
+ {* ID *}
+ {component 'field' template='hidden'
+ name = "answers[{$index}][id]"
+ value = "{if $item}{$item->getId()}{/if}"
+ classes = "js-poll-form-answer-item-id"}
+
+ {* Текст *}
+ {component 'field' template='text'
+ name = 'answers[]'
+ value = ($item) ? $item->getTitle() : ''
+ isDisabled = ! $allowUpdate
+ inputClasses = 'ls-width-full js-poll-form-answer-item-text'}
+
+ {* Кнопка удаления *}
+ {if $allowRemove}
+ {component 'icon'
+ icon='remove'
+ classes="{$component}-remove js-poll-form-answer-item-remove"
+ attributes=[
+ style => "{if ! $showRemove}display: none{/if}"
+ ]}
+ {/if}
+
\ No newline at end of file
diff --git a/application/frontend/components/poll/poll.form.tpl b/application/frontend/components/poll/poll.form.tpl
new file mode 100644
index 0000000..aed0fb9
--- /dev/null
+++ b/application/frontend/components/poll/poll.form.tpl
@@ -0,0 +1,105 @@
+{**
+ * Форма добавления опроса
+ *}
+
+
+
+{* Шаблон ответа для добавления с помощью js *}
+{component 'poll' template='form-item' isTemplate=true}
\ No newline at end of file
diff --git a/application/frontend/components/poll/poll.list.tpl b/application/frontend/components/poll/poll.list.tpl
new file mode 100644
index 0000000..baf692a
--- /dev/null
+++ b/application/frontend/components/poll/poll.list.tpl
@@ -0,0 +1,9 @@
+{**
+ * Список опросов
+ *
+ * @param array $polls
+ *}
+
+{foreach $polls as $poll}
+ {component 'poll' poll=$poll}
+{/foreach}
\ No newline at end of file
diff --git a/application/frontend/components/poll/poll.manage.item.tpl b/application/frontend/components/poll/poll.manage.item.tpl
new file mode 100644
index 0000000..7a0e6a2
--- /dev/null
+++ b/application/frontend/components/poll/poll.manage.item.tpl
@@ -0,0 +1,24 @@
+{**
+ * Добавленный опрос в блоке управления опросами
+ *
+ * @param ModulePoll_EntityPoll $poll Опрос
+ *}
+
+
+ {* Заголовок *}
+ {$poll->getTitle()}
+
+ {* Действия *}
+
+ {* Редактировать *}
+ {* Показывает модальное окно с формой редактирования опроса *}
+
+ {component 'icon' icon='edit'}
+
+
+ {* Удалить *}
+
+ {component 'icon' icon='remove'}
+
+
+
\ No newline at end of file
diff --git a/application/frontend/components/poll/poll.manage.list.tpl b/application/frontend/components/poll/poll.manage.list.tpl
new file mode 100644
index 0000000..d0474bd
--- /dev/null
+++ b/application/frontend/components/poll/poll.manage.list.tpl
@@ -0,0 +1,11 @@
+{**
+ * Список добавленных опросов в форме добавления
+ *}
+
+
+ {if $aPollItems}
+ {foreach $aPollItems as $poll}
+ {component 'poll' template='manage.item' poll=$poll}
+ {/foreach}
+ {/if}
+
\ No newline at end of file
diff --git a/application/frontend/components/poll/poll.manage.tpl b/application/frontend/components/poll/poll.manage.tpl
new file mode 100644
index 0000000..acd9778
--- /dev/null
+++ b/application/frontend/components/poll/poll.manage.tpl
@@ -0,0 +1,25 @@
+{**
+ * Управления опросами (добавление/удаление/редактирование)
+ *
+ * @param string $targetId
+ * @param string $targetType
+ *}
+
+{component_define_params params=[ 'targetId', 'targetType' ]}
+
+
+
+
+
+ {* Кнопка добавить *}
+ {component 'button' text=$aLang.common.add type='button' classes='ls-poll-manage-add js-poll-manage-add'}
+
+ {* Список добавленных опросов *}
+ {insert name="block" block="pollFormItems" params=[
+ 'target_type' => $targetType,
+ 'target_id' => $targetId
+ ]}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/poll/poll.result.tpl b/application/frontend/components/poll/poll.result.tpl
new file mode 100644
index 0000000..f735ad7
--- /dev/null
+++ b/application/frontend/components/poll/poll.result.tpl
@@ -0,0 +1,51 @@
+{**
+ * Результат опроса
+ *
+ * @param ModulePoll_EntityPoll $poll Опрос
+ *}
+
+{* Список ответов *}
+
+
+{* Кнопка сортировки *}
+{component 'button'
+ mods = 'icon'
+ classes = 'js-poll-result-sort'
+ icon = 'align-left'
+ attributes = [ 'title' => $aLang.poll.result.sort ]}
+
+{* Статистика голосования *}
+
+ {$aLang.poll.result.voted_total}: {$poll->getCountVote()} |
+ {$aLang.poll.result.abstained_total}: {$poll->getCountAbstain()}
+
\ No newline at end of file
diff --git a/application/frontend/components/poll/poll.tpl b/application/frontend/components/poll/poll.tpl
new file mode 100644
index 0000000..46875db
--- /dev/null
+++ b/application/frontend/components/poll/poll.tpl
@@ -0,0 +1,17 @@
+{**
+ * Опрос
+ *
+ * @param ModulePoll_EntityPoll $poll Опрос
+ *}
+
+
+
{$poll->getTitle()}
+
+
+ {if ! $poll->getVoteCurrent()}
+ {component 'poll' template='vote' poll=$poll}
+ {else}
+ {component 'poll' template='result' poll=$poll}
+ {/if}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/poll/poll.vote.tpl b/application/frontend/components/poll/poll.vote.tpl
new file mode 100644
index 0000000..b4a6464
--- /dev/null
+++ b/application/frontend/components/poll/poll.vote.tpl
@@ -0,0 +1,38 @@
+{**
+ * Форма голосования
+ *
+ * @param ModulePoll_EntityPoll $poll Опрос
+ *}
+
+{* Тип голосования *}
+{* Если можно выбрать больше одного варианта, то показываем чекбоксы, иначе радио-кнопки *}
+{$type = ( $poll->getCountAnswerMax() > 1 ) ? 'checkbox' : 'radio'}
+
+{* Форма *}
+
\ No newline at end of file
diff --git a/application/frontend/components/property/README.md b/application/frontend/components/property/README.md
new file mode 100644
index 0000000..c7323b0
--- /dev/null
+++ b/application/frontend/components/property/README.md
@@ -0,0 +1,6 @@
+# Компонент property
+
+Свойства добавленные пользователем.
+
+**input** — Поля форм.
+**output** — Вывод значений полей.
\ No newline at end of file
diff --git a/application/frontend/components/property/component.json b/application/frontend/components/property/component.json
new file mode 100644
index 0000000..12a2aca
--- /dev/null
+++ b/application/frontend/components/property/component.json
@@ -0,0 +1,34 @@
+{
+ "name": "property",
+ "version": "1.0.0",
+ "dependencies": {
+ "field": "*",
+ "modal": "*"
+ },
+ "templates": {
+ "input.item": "input/item.tpl",
+ "input.list": "input/list.tpl",
+ "input.property.date": "input/property.date.tpl",
+ "input.property.file": "input/property.file.tpl",
+ "input.property.checkbox": "input/property.checkbox.tpl",
+ "input.property.float": "input/property.float.tpl",
+ "input.property.image": "input/property.image.tpl",
+ "input.property.int": "input/property.int.tpl",
+ "input.property.select": "input/property.select.tpl",
+ "input.property.text": "input/property.text.tpl",
+ "input.property.tags": "input/property.tags.tpl",
+ "input.property.varchar": "input/property.varchar.tpl",
+ "input.property.video_link": "input/property.video_link.tpl",
+ "input.property.video-modal": "input/modal.property-input-video.tpl",
+ "input.property.imageset": "input/property.imageset.tpl",
+
+ "output.item": "output/item.tpl",
+ "output.list": "output/list.tpl",
+ "output.property.file": "output/property.file.tpl",
+ "output.property.image": "output/property.image.tpl",
+ "output.property.default": "output/property.default.tpl"
+ },
+ "styles": {
+ "property": "css/property.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/property/css/property.css b/application/frontend/components/property/css/property.css
new file mode 100644
index 0000000..d40e050
--- /dev/null
+++ b/application/frontend/components/property/css/property.css
@@ -0,0 +1,16 @@
+/**
+ * Media
+ */
+
+.ls-property-list {
+ margin-bottom: 30px;
+}
+
+.ls-property {
+ margin-bottom: 15px;
+ padding: 15px 15px;
+ border-bottom: 1px solid #eee;
+}
+.ls-property:last-child {
+ margin-bottom: 0;
+}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/item.tpl b/application/frontend/components/property/input/item.tpl
new file mode 100644
index 0000000..f9f45de
--- /dev/null
+++ b/application/frontend/components/property/input/item.tpl
@@ -0,0 +1 @@
+{component 'property' template="input.property.{$property->getType()}" property=$property}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/list.tpl b/application/frontend/components/property/input/list.tpl
new file mode 100644
index 0000000..ef4cfe5
--- /dev/null
+++ b/application/frontend/components/property/input/list.tpl
@@ -0,0 +1,9 @@
+{**
+ * Вывод дополнительных полей для ввода данных на странице создания нового объекта
+ *}
+
+{component_define_params params=[ 'properties' ]}
+
+{foreach $properties as $property}
+ {component 'property' template='input.item' property=$property}
+{/foreach}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/modal.property-input-video.tpl b/application/frontend/components/property/input/modal.property-input-video.tpl
new file mode 100644
index 0000000..cbc95b4
--- /dev/null
+++ b/application/frontend/components/property/input/modal.property-input-video.tpl
@@ -0,0 +1,10 @@
+{**
+ * Модальное окно с предпросмотром видео для свойства с типом video
+ *}
+
+{component 'modal'
+ title = {lang 'property.video.preview'}
+ content = $value->getValueTypeObject()->getVideoCodeFrame()
+ classes = 'js-modal-default'
+ mods = 'property property-video'
+ id = "modal-property-type-video-{$value->getId()}"}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.checkbox.tpl b/application/frontend/components/property/input/property.checkbox.tpl
new file mode 100644
index 0000000..7689063
--- /dev/null
+++ b/application/frontend/components/property/input/property.checkbox.tpl
@@ -0,0 +1,6 @@
+{component 'field' template='checkbox'
+ name = "property[{$property->getId()}]"
+ value = $property->getParam( 'default_value' )
+ checked = $property->getValue()->getValueForForm()
+ note = $property->getDescription()
+ label = $property->getTitle()}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.date.tpl b/application/frontend/components/property/input/property.date.tpl
new file mode 100644
index 0000000..845ccbe
--- /dev/null
+++ b/application/frontend/components/property/input/property.date.tpl
@@ -0,0 +1,23 @@
+{$_mods=''}
+{$desc = $property->getDescription()}
+
+{if $property->getParam('use_time')}
+ {$_mods='inline'}
+{/if}
+
+{component 'field.date' mods = $_mods
+ name = "property[{$property->getId()}][date]"
+ inputAttributes=[ "data-lsdate-format" => 'DD.MM.YYYY' ]
+ inputClasses = "js-field-date-default"
+ value = $property->getValue()->getValueForForm()
+ note = $desc
+ label = $property->getTitle()}
+
+{if $property->getParam('use_time')}
+ {component 'field.time' mods = $_mods
+ name = "property[{$property->getId()}][time]"
+ inputAttributes=[ "data-lstime-time-format" => 'H:i' ]
+ inputClasses = "js-field-time-default"
+ note = ($desc) ? ' ' : ''
+ value = $property->getValue()->getValueTypeObject()->getValueTimeForForm()}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.file.tpl b/application/frontend/components/property/input/property.file.tpl
new file mode 100644
index 0000000..81aa32f
--- /dev/null
+++ b/application/frontend/components/property/input/property.file.tpl
@@ -0,0 +1,7 @@
+{component 'field' template='file'
+ name = "property[{$property->getId()}][file]"
+ removeName = "property[{$property->getId()}][remove]"
+ classes = 'ls-width-300'
+ note = $property->getDescription()
+ label = $property->getTitle()
+ uploadedFiles = $property->getValue()->getDataOne( 'file' )}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.float.tpl b/application/frontend/components/property/input/property.float.tpl
new file mode 100644
index 0000000..07f219d
--- /dev/null
+++ b/application/frontend/components/property/input/property.float.tpl
@@ -0,0 +1,6 @@
+{component 'field' template='text'
+ name = "property[{$property->getId()}]"
+ value = $property->getValue()->getValueForForm()
+ classes = 'ls-width-150'
+ note = $property->getDescription()
+ label = $property->getTitle()}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.image.tpl b/application/frontend/components/property/input/property.image.tpl
new file mode 100644
index 0000000..df987a3
--- /dev/null
+++ b/application/frontend/components/property/input/property.image.tpl
@@ -0,0 +1,9 @@
+{$valueType = $property->getValue()->getValueTypeObject()}
+{$uploadedFiles = $valueType->getImageWebPath( $valueType->getImageSizeFirst() )}
+
+{component 'field' template='image'
+ name = "property[{$property->getId()}][file]"
+ removeName = "property[{$property->getId()}][remove]"
+ uploadedFiles = ( $uploadedFiles ) ? [ $uploadedFiles ] : false
+ note = $property->getDescription()
+ label = $property->getTitle()}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.imageset.tpl b/application/frontend/components/property/input/property.imageset.tpl
new file mode 100644
index 0000000..b3e1a7e
--- /dev/null
+++ b/application/frontend/components/property/input/property.imageset.tpl
@@ -0,0 +1,10 @@
+{$valueType = $property->getValueTypeObject()}
+{$imagePreviewItems = []}
+
+{component 'field' template='imageset-ajax'
+ name = "property[{$property->getId()}]"
+ label = $aLang.property.imageset.label
+ modalTitle = $aLang.property.imageset.modalTitle
+ targetType = 'imageset'
+ targetId = $valueType->getValueForForm()
+ classes = 'js-imageset-field'}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.int.tpl b/application/frontend/components/property/input/property.int.tpl
new file mode 100644
index 0000000..07f219d
--- /dev/null
+++ b/application/frontend/components/property/input/property.int.tpl
@@ -0,0 +1,6 @@
+{component 'field' template='text'
+ name = "property[{$property->getId()}]"
+ value = $property->getValue()->getValueForForm()
+ classes = 'ls-width-150'
+ note = $property->getDescription()
+ label = $property->getTitle()}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.select.tpl b/application/frontend/components/property/input/property.select.tpl
new file mode 100644
index 0000000..e9416c2
--- /dev/null
+++ b/application/frontend/components/property/input/property.select.tpl
@@ -0,0 +1,30 @@
+{* Формируем массив с активными пунктами *}
+{$selectedValues = []}
+
+{foreach $property->getValue()->getValueForForm() as $value}
+ {$selectedValues[] = $value@key}
+{/foreach}
+
+{* Формируем значения для селекта *}
+{$items = []}
+{if !$property->getValidateRuleOne('allowMany')}
+ {$items[] = [
+ 'value' => 0,
+ 'text' => '—'
+ ]}
+{/if}
+
+{foreach $property->getSelects() as $item}
+ {$items[] = [
+ 'value' => $item->getId(),
+ 'text' => $item->getValue()
+ ]}
+{/foreach}
+
+{component 'field' template='select'
+ name = "property[{$property->getId()}][]"
+ label = $property->getTitle()
+ note = $property->getDescription()
+ items = $items
+ isMultiple = $property->getValidateRuleOne('allowMany')
+ selectedValue = $selectedValues}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.tags.tpl b/application/frontend/components/property/input/property.tags.tpl
new file mode 100644
index 0000000..0d3422f
--- /dev/null
+++ b/application/frontend/components/property/input/property.tags.tpl
@@ -0,0 +1,10 @@
+{$value = $property->getValue()}
+
+{component 'field' template='text'
+ name = "property[{$property->getId()}]"
+ value = $value->getValueVarchar()
+ id = "property-value-tags-{$property->getId()}"
+ inputAttributes=[ "data-property-id" => $property->getId() ]
+ inputClasses="autocomplete-property-tags-sep"
+ note = $property->getDescription()
+ label = $property->getTitle()}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.text.tpl b/application/frontend/components/property/input/property.text.tpl
new file mode 100644
index 0000000..d87fbda
--- /dev/null
+++ b/application/frontend/components/property/input/property.text.tpl
@@ -0,0 +1,17 @@
+{if $property->getParam( 'use_html' )}
+ {component 'editor'
+ name = "property[{$property->getId()}]"
+ value = $property->getValue()->getValueForForm()
+ label = $property->getTitle()
+ escape = false
+ inputClasses = 'js-editor-default' }
+
+{else}
+ {component 'field' template='textarea'
+ name = "property[{$property->getId()}]"
+ value = $property->getValue()->getValueForForm()
+ rows = 10
+ escape = false
+ note = $property->getDescription()
+ label = $property->getTitle()}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.time.tpl b/application/frontend/components/property/input/property.time.tpl
new file mode 100644
index 0000000..4ee61b9
--- /dev/null
+++ b/application/frontend/components/property/input/property.time.tpl
@@ -0,0 +1,6 @@
+{component 'field.time'
+ name = "property[{$property->getId()}][date]"
+ inputClasses = "js-field-{$template}-default"
+ value = $property->getValue()->getValueForForm()
+ note = $property->getDescription()
+ label = $property->getTitle()}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.varchar.tpl b/application/frontend/components/property/input/property.varchar.tpl
new file mode 100644
index 0000000..9d8bcf2
--- /dev/null
+++ b/application/frontend/components/property/input/property.varchar.tpl
@@ -0,0 +1,5 @@
+{component 'field' template='text'
+ name = "property[{$property->getId()}]"
+ value = $property->getValue()->getValueForForm()
+ note = $property->getDescription()
+ label = $property->getTitle()}
\ No newline at end of file
diff --git a/application/frontend/components/property/input/property.video_link.tpl b/application/frontend/components/property/input/property.video_link.tpl
new file mode 100644
index 0000000..57fac83
--- /dev/null
+++ b/application/frontend/components/property/input/property.video_link.tpl
@@ -0,0 +1,13 @@
+{$value = $property->getValue()}
+
+{component 'field' template='text'
+ name = "property[{$property->getId()}]"
+ value = $value->getValueVarchar()
+ note = $property->getDescription()
+ label = $property->getTitle()}
+
+{component 'property' template='input.property.video-modal' value=$value}
+
+
+ {lang 'property.video.watch'}
+
\ No newline at end of file
diff --git a/application/frontend/components/property/output/item.tpl b/application/frontend/components/property/output/item.tpl
new file mode 100644
index 0000000..8b1e5ae
--- /dev/null
+++ b/application/frontend/components/property/output/item.tpl
@@ -0,0 +1,15 @@
+{component_define_params params=[ 'property' ]}
+
+{if $property}
+ {* Проверяем наличие кастомного шаблона item.[type].[target_type].tpl *}
+ {$template = $LS->Component_GetTemplatePath('property', "output/property.{$property->getType()}.{$property->getTargetType()}" )}
+
+ {if !$template}
+ {$template = $LS->Component_GetTemplatePath('property', "output/property.{$property->getType()}" )}
+ {if !$template}
+ {$template = $LS->Component_GetTemplatePath('property', "output/property.default" )}
+ {/if}
+ {/if}
+
+ {include "{$template}" property=$property}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/property/output/list.tpl b/application/frontend/components/property/output/list.tpl
new file mode 100644
index 0000000..c0b7e6c
--- /dev/null
+++ b/application/frontend/components/property/output/list.tpl
@@ -0,0 +1,9 @@
+{component_define_params params=[ 'properties' ]}
+
+{if $properties}
+
+ {foreach $properties as $property}
+ {component 'property' template='output.item' property=$property}
+ {/foreach}
+
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/property/output/property.default.tpl b/application/frontend/components/property/output/property.default.tpl
new file mode 100644
index 0000000..484ed2d
--- /dev/null
+++ b/application/frontend/components/property/output/property.default.tpl
@@ -0,0 +1,9 @@
+
+
+ {$property->getTitle()}
+
+
+
+ {$property->getValue()->getValueForDisplay()}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/property/output/property.file.tpl b/application/frontend/components/property/output/property.file.tpl
new file mode 100644
index 0000000..ba31253
--- /dev/null
+++ b/application/frontend/components/property/output/property.file.tpl
@@ -0,0 +1,21 @@
+{$value = $property->getValue()}
+{$valueType = $value->getValueTypeObject()}
+
+
+
+ {$property->getTitle()}
+
+
+ {if $value->getValueVarchar()}
+ {if $oUserCurrent || ! $property->getParam('access_only_auth')}
+
getValueVarchar()}/">{$value->getValueForDisplay()}
+ {if $valueType->getCountDownloads()}
+
{lang 'property.file.downloads'}: {$valueType->getCountDownloads()}
+ {/if}
+ {else}
+ {lang 'property.file.forbidden'}
+ {/if}
+ {else}
+ {lang 'property.file.empty'}
+ {/if}
+
\ No newline at end of file
diff --git a/application/frontend/components/property/output/property.image.tpl b/application/frontend/components/property/output/property.image.tpl
new file mode 100644
index 0000000..2bb2a9d
--- /dev/null
+++ b/application/frontend/components/property/output/property.image.tpl
@@ -0,0 +1,15 @@
+{$valueType = $property->getValue()->getValueTypeObject()}
+
+
+
+ {$property->getTitle()}
+
+
+ {if $valueType->getImageWebPath()}
+
+
+
+ {else}
+ {lang 'property.image.empty'}
+ {/if}
+
\ No newline at end of file
diff --git a/application/frontend/components/property/output/property.imageset.tpl b/application/frontend/components/property/output/property.imageset.tpl
new file mode 100644
index 0000000..e13219d
--- /dev/null
+++ b/application/frontend/components/property/output/property.imageset.tpl
@@ -0,0 +1,21 @@
+{$valueType = $property->getValue()->getValueTypeObject()}
+
+
+
+ {$property->getTitle()}
+
+
+ {$aMedia = $valueType->getMedia()}
+
+ {if $aMedia}
+
+ {foreach $aMedia as $oMedia}
+
+
+
+ {/foreach}
+
+ {else}
+ {lang 'property.image.empty'}
+ {/if}
+
\ No newline at end of file
diff --git a/application/frontend/components/report/README.md b/application/frontend/components/report/README.md
new file mode 100644
index 0000000..f70e563
--- /dev/null
+++ b/application/frontend/components/report/README.md
@@ -0,0 +1 @@
+# Компонент report
\ No newline at end of file
diff --git a/application/frontend/components/report/component.json b/application/frontend/components/report/component.json
new file mode 100644
index 0000000..79118a3
--- /dev/null
+++ b/application/frontend/components/report/component.json
@@ -0,0 +1,17 @@
+{
+ "name": "report",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-vendor": "*",
+ "ls-core": "*",
+ "ls-component": "*",
+ "css-reset": "*",
+ "modal": "*"
+ },
+ "templates": {
+ "report": "modal.report.tpl"
+ },
+ "scripts": {
+ "report": "js/report.js"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/report/js/report.js b/application/frontend/components/report/js/report.js
new file mode 100644
index 0000000..e57f467
--- /dev/null
+++ b/application/frontend/components/report/js/report.js
@@ -0,0 +1,76 @@
+/**
+ * Report
+ *
+ * @module ls/report
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsReport", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ params: {},
+
+ // Ссылки
+ urls: {
+ modal: null,
+ add: null
+ },
+
+ // Селекторы
+ selectors: {
+ form: 'form'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ this.option( 'params', $.extend( {}, this.option( 'params' ), ls.utils.getDataOptions( this.element, 'param' ) ) );
+
+ this._on({ click: this.showModal });
+ },
+
+ /**
+ * Показывает модальное окно с формой
+ */
+ showModal: function( event ) {
+ var _this = this, form;
+
+ ls.modal.load( this.option( 'urls.modal' ), this.option( 'params' ), {
+ aftershow: function ( e, modal ) {
+ form = modal.element.find( _this.option( 'selectors.form' ) );
+
+ // Отправка формы
+ form.on( 'submit', function ( event ) {
+ _this._submit( 'add', form, function( response ) {
+ modal.hide();
+ });
+
+ event.preventDefault();
+ });
+ },
+ afterhide: function () {
+ form.off();
+ form = null;
+ },
+ center: false
+ });
+
+ event.preventDefault();
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/report/modal.report.tpl b/application/frontend/components/report/modal.report.tpl
new file mode 100644
index 0000000..b14031f
--- /dev/null
+++ b/application/frontend/components/report/modal.report.tpl
@@ -0,0 +1,44 @@
+{**
+ * Жалоба на пользователя
+ *
+ * @param array $types
+ *}
+
+{component_define_params params=[ 'types' ]}
+
+{capture 'modal_content'}
+
+{/capture}
+
+{component 'modal'
+ title = {lang 'report.form.title'}
+ content = $smarty.capture.modal_content
+ classes = 'js-modal-default'
+ mods = 'report'
+ id = 'modal-complaint-user'
+ primaryButton = [
+ 'text' => {lang 'report.form.submit'},
+ 'form' => 'form-complaint-user'
+ ]}
\ No newline at end of file
diff --git a/application/frontend/components/search-ajax/README.md b/application/frontend/components/search-ajax/README.md
new file mode 100644
index 0000000..131f655
--- /dev/null
+++ b/application/frontend/components/search-ajax/README.md
@@ -0,0 +1 @@
+# Компонент search-ajax
\ No newline at end of file
diff --git a/application/frontend/components/search-ajax/component.json b/application/frontend/components/search-ajax/component.json
new file mode 100644
index 0000000..a3865c9
--- /dev/null
+++ b/application/frontend/components/search-ajax/component.json
@@ -0,0 +1,10 @@
+{
+ "name": "search-ajax",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-component": "*"
+ },
+ "scripts": {
+ "search-ajax": "js/search-ajax.js"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/search-ajax/js/search-ajax.js b/application/frontend/components/search-ajax/js/search-ajax.js
new file mode 100644
index 0000000..fffc39d
--- /dev/null
+++ b/application/frontend/components/search-ajax/js/search-ajax.js
@@ -0,0 +1,237 @@
+/**
+ * Аякс поиск
+ *
+ * @module ls/search-ajax
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsSearchAjax", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ search: null
+ },
+
+ // Селекторы
+ selectors: {
+ list: '.js-search-ajax-list',
+ more: '.js-search-ajax-more',
+ title: null
+ },
+
+ // Локализация
+ i18n: {
+ title: null
+ },
+
+ // Фильтры
+ filters : [],
+
+ // Парметры передаваемый при аякс запросе
+ params : {}
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ var _this = this;
+
+ // Иниц-ия фильтров
+ $.each( this.option( 'filters' ), function ( index, value ) {
+ _this._initFilter( value );
+ });
+
+
+ // Кнопка подгрузки
+ this.elements.more.lsMore({
+ urls: {
+ load: _this.option( 'urls.search' )
+ },
+ beforeload: function ( event, context ) {
+ $.extend( context.option( 'params' ), _this.option( 'params' ) );
+ }
+ });
+ },
+
+ /**
+ * Добавление фильтра
+ */
+ addFilter: function( filter ) {
+ this.option( 'filters' ).push( filter );
+ this._initFilter( filter );
+ },
+
+ /**
+ * Иниц-ия фильтра
+ */
+ _initFilter: function( filter ) {
+ var _this = this,
+ element = $( filter.selector );
+
+ switch ( filter.type ) {
+ // Текстовое поле
+ case 'text':
+ element.on( 'keyup', function () {
+ ls.timer.run( _this, _this.update, null, null, 300 );
+ });
+
+ break;
+ case 'radio':
+ case 'checkbox':
+ case 'select':
+ // TODO: multiselect
+ element.on( 'change', function () {
+ _this.update();
+ });
+
+ break;
+ case 'list':
+ case 'sort':
+ element.on( 'click', function ( event ) {
+ var el = $( this ),
+ els = el.closest( 'ul' ).find( 'li' ).not( el ),
+ value = el.data( 'value' ),
+ activeClass = filter.activeClass || ls.options.classes.states.active;
+
+ els.removeClass( activeClass );
+ el.addClass( activeClass );
+
+ if ( filter.type == 'sort' ) {
+ var order = el.attr( 'data-order' );
+
+ els.attr( 'data-order', 'asc' );
+ el.attr( 'data-order', el.attr( 'data-order' ) == 'asc' ? 'desc' : 'asc' );
+ }
+
+ _this.update();
+ event.preventDefault();
+ });
+
+ break;
+ default:
+ break;
+ }
+ },
+
+ updateFilter: function( filter ) {
+ var _this = this,
+ element = $( filter.selector ),
+ activeClass = filter.activeClass || ls.options.classes.states.active;
+
+ switch ( filter.type ) {
+ // Текстовое поле
+ case 'text':
+ element.each(function () {
+ _this.setParam( filter.name, $(this).val() );
+ _this.setParam( 'isPrefix', 0 );
+ });
+
+ break;
+ case 'radio':
+ case 'checkbox':
+ case 'select':
+ element.each(function () {
+ var value, el = $( this );
+
+ // Пропускаем неотмеченные радио инпуты
+ if ( filter.type == 'radio' && ! el.is( ':checked' ) ) return;
+
+ if ( filter.type == 'checkbox' ) {
+ value = el.is( ':checked' ) ? 1 : 0;
+ } else {
+ value = el.val();
+ }
+
+ _this.setParam( filter.name, value );
+ });
+
+ break;
+ case 'list':
+ case 'sort':
+ element.each(function () {
+ var el = $( this ).closest( 'ul' ).find( 'li.' + activeClass ),
+ value = el.data( 'value' );
+
+ if ( filter.type == 'sort' ) {
+ _this.setParam( 'order', el.attr( 'data-order' ) );
+ }
+
+ _this.setParam( filter.name, value );
+ });
+
+ break;
+ default:
+ break;
+ }
+ },
+
+ /**
+ * Установка параметра
+ */
+ setParam: function( name, value ) {
+ this.option( 'params.' + name, value );
+ // this.updateUrl();
+ },
+
+ /**
+ * Получение параметра
+ */
+ getParam: function( name ) {
+ return this.option( 'params.' + name );
+ },
+
+ /**
+ * Обновление поиска
+ */
+ update: function() {
+ for (var i = 0; i < this.option( 'filters' ).length; i++) {
+ this.updateFilter( this.option( 'filters' )[i] );
+ };
+
+ this._trigger( 'beforeupdate', null, this );
+
+ this._load( 'search', 'onUpdate' );
+ },
+
+ /**
+ *
+ */
+ onUpdate: function ( response ) {
+ this.elements.more[ response.hide ? 'hide' : 'show' ]();
+
+ if ( response.searchCount ) {
+ this.elements.list.show().html( $.trim( response.html ) );
+ } else {
+ this.elements.list.hide();
+ }
+
+ if ( this.option( 'i18n.title' ) && this.elements.title.length) {
+ this.elements.title.show().text( this._i18n( 'title', response.searchCount ) );
+ }
+
+ this._trigger( 'afterupdate', null, { context: this, response: response } );
+ },
+
+ /**
+ * Обновляет ссылку на основе параметров
+ */
+ updateUrl: function () {
+ window.history.pushState( {}, 'Search', window.location.origin + window.location.pathname + '?' + $.param( this.option( 'params' ) ) );
+ }
+ });
+})( jQuery );
diff --git a/application/frontend/components/search-form/README.md b/application/frontend/components/search-form/README.md
new file mode 100644
index 0000000..ca547cf
--- /dev/null
+++ b/application/frontend/components/search-form/README.md
@@ -0,0 +1 @@
+# Компонент search-form
\ No newline at end of file
diff --git a/application/frontend/components/search-form/component.json b/application/frontend/components/search-form/component.json
new file mode 100644
index 0000000..76a8ca5
--- /dev/null
+++ b/application/frontend/components/search-form/component.json
@@ -0,0 +1,14 @@
+{
+ "name": "search-form",
+ "version": "1.0.0",
+ "dependencies": {
+ "field": "*",
+ "button": "*"
+ },
+ "templates": {
+ "search-form": "search-form.tpl"
+ },
+ "styles": {
+ "search-form": "css/search-form.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/search-form/css/search-form.css b/application/frontend/components/search-form/css/search-form.css
new file mode 100644
index 0000000..0339322
--- /dev/null
+++ b/application/frontend/components/search-form/css/search-form.css
@@ -0,0 +1,47 @@
+/**
+ * Форма поиска
+ *
+ * @template forms/form.search.base.tpl
+ */
+.ls-search-form {
+ padding: 15px;
+ margin-bottom: 20px;
+ background: #f7f7f7;
+ position: relative;
+}
+.ls-search-form .ls-field {
+ margin-bottom: 0;
+}
+.ls-search-form-input[type="text"] {
+ padding-right: 30px;
+}
+
+.ls-button--icon.ls-search-form-submit {
+ border: none;
+ background: none;
+ position: absolute;
+ padding: 0;
+ height: auto;
+ top: 21px;
+ right: 22px;
+ cursor: pointer;
+ opacity: .7;
+ filter: alpha(opacity=70);
+}
+.ls-search-form-submit:hover {
+ opacity: 1;
+ filter: alpha(opacity=100);
+ background-color: transparent;
+}
+
+/**
+ * Light
+ */
+.ls-search-form--light {
+ background: none;
+ padding: 0;
+}
+.ls-search-form--light .ls-search-form-submit {
+ top: 6px;
+ right: 7px;
+}
\ No newline at end of file
diff --git a/application/frontend/components/search-form/search-form.tpl b/application/frontend/components/search-form/search-form.tpl
new file mode 100644
index 0000000..a4058e5
--- /dev/null
+++ b/application/frontend/components/search-form/search-form.tpl
@@ -0,0 +1,23 @@
+{**
+ * Форма поиска
+ *}
+
+{* Название компонента *}
+{$component = 'ls-search-form'}
+{component_define_params params=[ 'action', 'method', 'placeholder', 'placeholder', 'note', 'value', 'inputClasses', 'inputAttributes', 'inputName', 'noSubmitButton', 'mods', 'classes', 'attributes' ]}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/search-form/tests/visual/search_form.tpl b/application/frontend/components/search-form/tests/visual/search_form.tpl
new file mode 100644
index 0000000..6b0898f
--- /dev/null
+++ b/application/frontend/components/search-form/tests/visual/search_form.tpl
@@ -0,0 +1,30 @@
+{**
+ * Тестирование форм поиска
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ Component search-form
+{/block}
+
+{block 'layout_content'}
+ {function test_heading}
+ {$sText}
+ {/function}
+
+
+ {test_heading sText='Default'}
+
+ {component 'search-form'
+ name = 'text'
+ note = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Velit, libero.'}
+
+
+ {test_heading sText='Light'}
+
+ {component 'search-form'
+ name = 'text'
+ mods = 'light'
+ note = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Velit, libero.'}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/search/README.md b/application/frontend/components/search/README.md
new file mode 100644
index 0000000..9c89fe9
--- /dev/null
+++ b/application/frontend/components/search/README.md
@@ -0,0 +1 @@
+# Компонент search
\ No newline at end of file
diff --git a/application/frontend/components/search/component.json b/application/frontend/components/search/component.json
new file mode 100644
index 0000000..cc41727
--- /dev/null
+++ b/application/frontend/components/search/component.json
@@ -0,0 +1,13 @@
+{
+ "name": "search",
+ "version": "1.0.0",
+ "dependencies": {
+ "search-form": "*"
+ },
+ "templates": {
+ "main": "search-form.main.tpl"
+ },
+ "styles": {
+ "search": "css/search.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/search/css/search.css b/application/frontend/components/search/css/search.css
new file mode 100644
index 0000000..b908bc3
--- /dev/null
+++ b/application/frontend/components/search/css/search.css
@@ -0,0 +1,4 @@
+.searched-item {
+ background: #fff999;
+ border-bottom: 1px dotted #999;
+}
\ No newline at end of file
diff --git a/application/frontend/components/search/search-form.main.tpl b/application/frontend/components/search/search-form.main.tpl
new file mode 100644
index 0000000..43e724b
--- /dev/null
+++ b/application/frontend/components/search/search-form.main.tpl
@@ -0,0 +1,7 @@
+{**
+ * Форма основного поиска (по топикам и комментариям)
+ *}
+
+{component_define_params params=[ 'searchType', 'mods', 'classes', 'attributes' ]}
+
+{component 'search-form' name='main' action="{router page='search'}{$searchType|default:'topics'}" params=$params}
\ No newline at end of file
diff --git a/application/frontend/components/sort/README.md b/application/frontend/components/sort/README.md
new file mode 100644
index 0000000..23ba4b6
--- /dev/null
+++ b/application/frontend/components/sort/README.md
@@ -0,0 +1 @@
+# Компонент sort
\ No newline at end of file
diff --git a/application/frontend/components/sort/component.json b/application/frontend/components/sort/component.json
new file mode 100644
index 0000000..d717709
--- /dev/null
+++ b/application/frontend/components/sort/component.json
@@ -0,0 +1,14 @@
+{
+ "name": "sort",
+ "version": "1.0.0",
+ "dependencies": {
+ "dropdown": "*"
+ },
+ "templates": {
+ "ajax": "sort.ajax.tpl",
+ "sort": "sort.ajax.tpl"
+ },
+ "styles": {
+ "sort": "css/sort.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/sort/css/sort.css b/application/frontend/components/sort/css/sort.css
new file mode 100644
index 0000000..0419ada
--- /dev/null
+++ b/application/frontend/components/sort/css/sort.css
@@ -0,0 +1,24 @@
+/**
+ * Блок сортировки
+ *
+ * @template sort.tpl
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+.ls-sort {
+ margin-bottom: 15px;
+}
+.ls-sort--inline {
+ display: inline;
+ margin-right: 10px;
+}
+
+.ls-sort li.active[data-order=asc] a:after {
+ content: "↑";
+}
+.ls-sort li.active[data-order=desc] a:after {
+ content: "↓";
+}
\ No newline at end of file
diff --git a/application/frontend/components/sort/sort.ajax.tpl b/application/frontend/components/sort/sort.ajax.tpl
new file mode 100644
index 0000000..0465728
--- /dev/null
+++ b/application/frontend/components/sort/sort.ajax.tpl
@@ -0,0 +1,30 @@
+{**
+ * Блок сортировки
+ *
+ * @param array $items
+ * @param array $text
+ * @param string $label
+ * @param boolean $showLabel
+ *}
+
+{$component = 'ls-sort'}
+{component_define_params params=[ 'items', 'text', 'label', 'mods', 'classes', 'attributes' ]}
+
+{$classes = "{$classes} {$component}"}
+
+{foreach $items as $item}
+ {$items[ $item@key ][ 'attributes' ] = array_merge( $items[ $item@key ][ 'attributes' ]|default:[], [
+ 'data-name' => 'sort_by',
+ 'data-value' => $item[ 'name' ],
+ 'data-order' => $item[ 'order' ]|default:'desc'
+ ])}
+{/foreach}
+
+{component 'button' template='group' classes=$classes params=$params buttons=[
+ [ 'text' => $label|default:$aLang.sort.label, 'isDisabled' => true ],
+ {component 'dropdown'
+ text = $text|default:'...'
+ classes = 'js-dropdown-default'
+ attributes = [ 'data-lsdropdown-selectable' => 'true' ]
+ menu = $items}
+]}
\ No newline at end of file
diff --git a/application/frontend/components/sort/sort.timespan.tpl b/application/frontend/components/sort/sort.timespan.tpl
new file mode 100644
index 0000000..c68ed17
--- /dev/null
+++ b/application/frontend/components/sort/sort.timespan.tpl
@@ -0,0 +1,19 @@
+{**
+ * Выпадающее меню выбора временного периода (за 24 часа, за месяц и т.д.)
+ *}
+
+{component_define_params params=[ 'periodSelectCurrent' ]}
+
+{if $periodSelectCurrent}
+ {component 'dropdown'
+ classes = 'js-dropdown-default'
+ name = 'sort_by_date'
+ text = {lang "blog.menu.top_period_$periodSelectCurrent"}
+ menu = [
+ [ 'name' => '1', 'url' => "{$periodSelectRoot}?period=1", 'text' => {lang 'blog.menu.top_period_1'} ],
+ [ 'name' => '7', 'url' => "{$periodSelectRoot}?period=7", 'text' => {lang 'blog.menu.top_period_7'} ],
+ [ 'name' => '30', 'url' => "{$periodSelectRoot}?period=30", 'text' => {lang 'blog.menu.top_period_30'} ],
+ [ 'name' => 'all', 'url' => "{$periodSelectRoot}?period=all", 'text' => {lang 'blog.menu.top_period_all'} ]
+ ]
+ params = $params}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/subscribe/README.md b/application/frontend/components/subscribe/README.md
new file mode 100644
index 0000000..0f6390b
--- /dev/null
+++ b/application/frontend/components/subscribe/README.md
@@ -0,0 +1 @@
+# Компонент subscribe
\ No newline at end of file
diff --git a/application/frontend/components/subscribe/component.json b/application/frontend/components/subscribe/component.json
new file mode 100644
index 0000000..acac803
--- /dev/null
+++ b/application/frontend/components/subscribe/component.json
@@ -0,0 +1,10 @@
+{
+ "name": "subscribe",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-core": "*"
+ },
+ "scripts": {
+ "subscribe": "js/subscribe.js"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/subscribe/js/subscribe.js b/application/frontend/components/subscribe/js/subscribe.js
new file mode 100644
index 0000000..c2b22fc
--- /dev/null
+++ b/application/frontend/components/subscribe/js/subscribe.js
@@ -0,0 +1,32 @@
+/**
+ * Подписка
+ *
+ * @module ls/subscribe
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+var ls = ls || {};
+
+ls.subscribe = (function ($) {
+
+ /**
+ * Подписка/отписка
+ */
+ this.toggle = function(targetType, targetId, mail, value) {
+ var url = aRouter['subscribe']+'ajax-subscribe-toggle/';
+ var params = { target_type: targetType, target_id: targetId, mail: mail, value: value };
+
+ ls.hook.marker('toggleBefore');
+
+ ls.ajax.load( url, params, function( response ) {
+ ls.hook.run('ls_subscribe_toggle_after',[targetType, targetId, mail, value, response ]);
+ });
+
+ return false;
+ }
+
+ return this;
+}).call(ls.subscribe || {}, jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/tags-personal/README.md b/application/frontend/components/tags-personal/README.md
new file mode 100644
index 0000000..5d24314
--- /dev/null
+++ b/application/frontend/components/tags-personal/README.md
@@ -0,0 +1 @@
+# Компонент tags-favourite
\ No newline at end of file
diff --git a/application/frontend/components/tags-personal/component.json b/application/frontend/components/tags-personal/component.json
new file mode 100644
index 0000000..c653c21
--- /dev/null
+++ b/application/frontend/components/tags-personal/component.json
@@ -0,0 +1,20 @@
+{
+ "name": "tags-personal",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-component": "*",
+ "tag": "*",
+ "modal": "*"
+ },
+ "templates": {
+ "modal": "modal.tags_personal.tpl",
+ "cloud": "tags-cloud.tpl",
+ "tags-personal": "tags.tpl"
+ },
+ "scripts": {
+ "tags-personal": "js/tags-personal.js"
+ },
+ "styles": {
+ "tags-personal": "css/tags-personal.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/tags-personal/css/tags-personal.css b/application/frontend/components/tags-personal/css/tags-personal.css
new file mode 100644
index 0000000..0bc227f
--- /dev/null
+++ b/application/frontend/components/tags-personal/css/tags-personal.css
@@ -0,0 +1,18 @@
+.ls-tags-item.ls-tags-item--personal a {
+ color: #3CA023;
+ border-color: #B0DEA4;
+ background-color: #F3F9F2;
+}
+.ls-tags-item.ls-tags-item--personal a:hover {
+ color: #2C8217;
+ border-color: #8ABD7D;
+ background-color: #E4F1E1;
+}
+.ls-tags-item.ls-tags-personal-edit a {
+ border-color: transparent;
+ background: none;
+}
+.ls-tags-item.ls-tags-personal-edit a:hover {
+ border-color: transparent;
+ background: none;
+}
\ No newline at end of file
diff --git a/application/frontend/components/tags-personal/js/tags-personal.js b/application/frontend/components/tags-personal/js/tags-personal.js
new file mode 100644
index 0000000..e419ef6
--- /dev/null
+++ b/application/frontend/components/tags-personal/js/tags-personal.js
@@ -0,0 +1,196 @@
+/**
+ * Персональные теги
+ *
+ * @module ls/tags-favourite
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsTagsFavourite", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ save: null
+ },
+
+ // Селекторы внешних элементов
+ extSelectors: {
+ // Общий блок для всех виджетов с формой редактирвоания
+ editBlock: '#favourite-form-tags',
+ // Форма редактирования
+ form: '#js-favourite-form',
+ // Кнопка отправки формы
+ formSubmitButton: '.js-tags-form-submit',
+ // Поле со списком тегов
+ formTags: '.js-tags-form-input-list'
+ },
+
+ // Селекторы
+ selectors: {
+ // Блок с персональными тегами
+ tags: '.js-tags-personal-tags',
+ // Персональный тег
+ tag: '.js-tags-personal-tag',
+ // Кнопка редактирвоания
+ edit: '.js-tags-personal-edit'
+ },
+
+ // Ajax параметры
+ params : {
+ target_type: null
+ },
+
+ // HTML
+ html: {
+ // Персональный тег
+ tag: function ( tag ) {
+ return '' +
+ '' + tag.tag + ' ';
+ }
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ this.extElements = this._getElementsFromSelectors( this.options.extSelectors );
+
+ this._on( this.elements.edit, { click: '_onEditClick' });
+ this._on( this.extElements.form, { submit: '_onFormSubmit' });
+ },
+
+ /**
+ * Коллбэк вызываемый при клике по кнопке редактирования
+ */
+ _onEditClick: function( event ) {
+ this.editShow();
+ event.preventDefault();
+ },
+
+ /**
+ * Коллбэк вызываемый при сабмите формы
+ *
+ * @param {Object} event
+ */
+ _onFormSubmit: function( event ) {
+ // Для всех виджетов используется одна общая форма редактирования,
+ // поэтому убеждаемся что форма открыта именно для текущего виджета
+ if ( this.extElements.form.data( 'target_id' ) != this.option( 'params.target_id' ) ) return;
+
+ this._submit( 'save', this.extElements.form, '_onFormSubmitSuccess', {
+ submitButton: this.extElements.formSubmitButton
+ });
+
+ event.preventDefault();
+ },
+
+ /**
+ * Коллбэк вызываемый при успешной отправке формы
+ *
+ * @param {Object} response
+ */
+ _onFormSubmitSuccess: function( response ) {
+ this.editHide();
+ this.setPersonalTags( response.tags );
+ },
+
+ /**
+ * Получает персональные теги (jQuery элементы)
+ */
+ getPersonalTagsElements: function() {
+ return this.element.find( this.option( 'selectors.tag' ) );
+ },
+
+ /**
+ * Получает персональные теги
+ */
+ getPersonalTags: function() {
+ return this.getPersonalTagsElements().map(function ( index, tag ) {
+ return this.getTagInfo( $( tag ) );
+ }.bind( this ));
+ },
+
+ /**
+ * Получает информацию о теге (урл и имя)
+ *
+ * @param {jQuery} tagElement Тег
+ * @return {Object} Тег
+ */
+ getTagInfo: function( tagElement ) {
+ tagElement = tagElement.find( 'a' );
+
+ return {
+ tag: $.trim( tagElement.text() ),
+ url: tagElement.attr( 'href' )
+ };
+ },
+
+ /**
+ * Устанавливает персональные теги
+ *
+ * @param {Array} tags Список персональных тегов
+ */
+ setPersonalTags: function( tags ) {
+ this.removePersonalTags();
+ this.elements.edit.before( $.map( tags, this.option( 'html.tag' ) ) );
+ },
+
+ /**
+ * Удаляет персональные теги
+ */
+ removePersonalTags: function() {
+ this.getPersonalTagsElements().remove();
+ },
+
+ /**
+ * Отмечает виджет как (не)доступный для редактирования
+ *
+ * @param {Boolean} isEditable Доступен виджет для редактирования или нет
+ */
+ setEditable: function( isEditable ) {
+ if ( isEditable ) {
+ this.elements.edit.show();
+ } else {
+ this.removePersonalTags();
+ this.elements.edit.hide();
+ }
+ },
+
+ /**
+ * Показывает блок редактирования
+ */
+ editShow: function() {
+ this.extElements.form.data( 'target_id', this.option( 'params.target_id' ) );
+ this.extElements.formTags.val( this._tagsToString() );
+ this.extElements.editBlock.lsModal( 'show' );
+ },
+
+ /**
+ * Скрывает блок редактирования
+ */
+ editHide: function() {
+ this.extElements.editBlock.lsModal( 'hide' );
+ },
+
+ /**
+ * Возвращает персональные теги в виде строки
+ */
+ _tagsToString: function() {
+ return $.map( this.getPersonalTags(), function( tag ) { return tag.tag; } ).join( ', ' );
+ },
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/tags-personal/modal.tags_personal.tpl b/application/frontend/components/tags-personal/modal.tags_personal.tpl
new file mode 100644
index 0000000..3c130e2
--- /dev/null
+++ b/application/frontend/components/tags-personal/modal.tags_personal.tpl
@@ -0,0 +1,25 @@
+{**
+ * Добавление пользовательских тегов к топику
+ *}
+
+{capture 'modal_content'}
+
+{/capture}
+
+{component 'modal'
+ title = {lang 'tags_personal.title'}
+ content = $smarty.capture.modal_content
+ classes = 'js-modal-default'
+ mods = 'favourite-tags'
+ id = 'favourite-form-tags'
+ primaryButton = [
+ 'text' => {lang 'common.save'},
+ 'classes' => 'js-tags-form-submit',
+ 'form' => 'js-favourite-form'
+ ]}
\ No newline at end of file
diff --git a/application/frontend/components/tags-personal/tags-cloud.tpl b/application/frontend/components/tags-personal/tags-cloud.tpl
new file mode 100644
index 0000000..b41c224
--- /dev/null
+++ b/application/frontend/components/tags-personal/tags-cloud.tpl
@@ -0,0 +1,13 @@
+{**
+ * Избранные теги пользователя
+ *
+ * @param array $tags
+ * @param object $activeTag
+ *}
+
+{component_define_params params=[ 'activeTag', 'tags' ]}
+
+{component 'details'
+ classes = 'js-tags-favourite-cloud'
+ title = "{lang 'tags_personal.title'} {if $activeTag}({$activeTag}){/if}"
+ content = {component 'tags' template='cloud' tags=$tags active=$activeTag}}
\ No newline at end of file
diff --git a/application/frontend/components/tags-personal/tags.tpl b/application/frontend/components/tags-personal/tags.tpl
new file mode 100644
index 0000000..a999de6
--- /dev/null
+++ b/application/frontend/components/tags-personal/tags.tpl
@@ -0,0 +1,34 @@
+{**
+ * Список тегов
+ *}
+
+{extends 'component@tags.tags'}
+
+{block 'tags_options' append}
+ {component_define_params params=[ 'targetId', 'tagsPersonal', 'isEditable' ]}
+
+ {$attributes = array_merge( $attributes|default:[], [
+ 'data-param-target_id' => $targetId
+ ])}
+{/block}
+
+{block 'tags_list' append}
+ {* Персональные теги *}
+ {if $oUserCurrent}
+ {foreach $tagsPersonal as $tag}
+ {component 'tags' template='item'
+ text=$tag->getText()
+ url=$tag->getUrl()
+ classes="js-tags-personal-tag"
+ mods="personal"}
+ {/foreach}
+
+ {* Кнопка "Изменить теги" *}
+
+
+ {component 'icon' icon='edit'}
+ {lang 'tags_personal.edit'}
+
+
+ {/if}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/talk/README.md b/application/frontend/components/talk/README.md
new file mode 100644
index 0000000..86c26cb
--- /dev/null
+++ b/application/frontend/components/talk/README.md
@@ -0,0 +1 @@
+# Компонент talk
\ No newline at end of file
diff --git a/application/frontend/components/talk/add.tpl b/application/frontend/components/talk/add.tpl
new file mode 100644
index 0000000..90111ce
--- /dev/null
+++ b/application/frontend/components/talk/add.tpl
@@ -0,0 +1,53 @@
+{**
+ * Форма создания личного сообщения
+ *}
+
+{hook run='talk_add_begin'}
+
+
+
+{hook run='talk_add_end'}
\ No newline at end of file
diff --git a/application/frontend/components/talk/blacklist.tpl b/application/frontend/components/talk/blacklist.tpl
new file mode 100644
index 0000000..39b0c04
--- /dev/null
+++ b/application/frontend/components/talk/blacklist.tpl
@@ -0,0 +1,13 @@
+{**
+ * Черный список
+ *
+ * @param array $users
+ *}
+
+{component_define_params params=[ 'users' ]}
+
+{component 'user-list-add'
+ users = $users
+ title = $aLang.talk.blacklist.title
+ note = $aLang.talk.blacklist.note
+ classes = 'js-user-list-add-blacklist'}
diff --git a/application/frontend/components/talk/component.json b/application/frontend/components/talk/component.json
new file mode 100644
index 0000000..476b76d
--- /dev/null
+++ b/application/frontend/components/talk/component.json
@@ -0,0 +1,37 @@
+{
+ "name": "talk",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-component": "*",
+ "user-list-add": "*",
+ "details": "*",
+ "search-form": "*",
+ "article": "*",
+ "comment": "*",
+ "vote": "*",
+ "favourite": "*",
+ "button": "*",
+ "field": "*",
+ "alert": "*"
+ },
+ "templates": {
+ "add": "add.tpl",
+ "blacklist": "blacklist.tpl",
+ "list": "talk-list.tpl",
+ "message-root": "talk-message-root.tpl",
+ "search-form": "talk-search-form.tpl",
+ "talk": "talk.tpl",
+ "participants-item": "participants/participants-item.tpl",
+ "participants-list": "participants/participants-list.tpl",
+ "participants": "participants/participants.tpl"
+ },
+ "scripts": {
+ "users": "js/talk-users.js",
+ "talk-list": "js/talk-list.js"
+ },
+ "styles": {
+ "talk": "css/talk.css",
+ "talk-list": "css/talk-list.css",
+ "message-root": "css/message-root.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/talk/css/message-root.css b/application/frontend/components/talk/css/message-root.css
new file mode 100644
index 0000000..5a62834
--- /dev/null
+++ b/application/frontend/components/talk/css/message-root.css
@@ -0,0 +1,21 @@
+/**
+ * Первое сообщение в диалоге
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+.ls-talk-message-root,
+.ls-talk-message-root-info,
+.ls-talk-message-root-actionbar,
+.ls-talk-message-root-text {
+ margin-bottom: 15px;
+}
+
+.ls-talk-message-root-info-item {
+ display: inline-block;
+}
+.ls-talk-message-root-info-item--author {
+ margin-right: 15px;
+}
\ No newline at end of file
diff --git a/application/frontend/components/talk/css/talk-list.css b/application/frontend/components/talk/css/talk-list.css
new file mode 100644
index 0000000..4fa20b5
--- /dev/null
+++ b/application/frontend/components/talk/css/talk-list.css
@@ -0,0 +1,89 @@
+/**
+ * Список личных сообщений
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+/* Экшнбар */
+.ls-actionbar.talk-list-actionbar {
+ margin-bottom: 0;
+}
+
+/* Список */
+.talk-list.ls-table td {
+ vertical-align: top;
+}
+.talk-list.ls-table .cell-checkbox,
+.talk-list.ls-table .cell-favourite {
+ width: 14px;
+ line-height: 1em;
+ padding-left: 5px;
+ padding-right: 5px;
+}
+.talk-list.ls-table .cell-info {
+ width: 150px;
+}
+
+/* Сообщение */
+.talk-list-item:first-child td {
+ border-top: 1px solid #f1f1f1;
+}
+.talk-list-item {
+ background: #fff;
+}
+.talk-list-item.talk-unread {
+ background: #f7f7f7;
+}
+.talk-list-item.selected {
+ background: #FFC;
+}
+
+/* Информация об отправителе */
+.talk-list-item-info {
+ width: 150px;
+ position: relative;
+ padding-left: 75px;
+ min-height: 64px;
+}
+.talk-list-item-info-avatar {
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+.talk-list-item-info-date {
+ display: block;
+ font-size: 11px;
+ color: #aaa;
+}
+
+.talk-list-item-extra {
+ position: relative;
+ padding-right: 50px;
+}
+.talk-list-item-title {
+ font-size: 15px;
+ margin-bottom: 5px;
+}
+.talk-list-item-text {
+ color: #999;
+}
+.talk-list-item-count {
+ position: absolute;
+ top: 0;
+ right: 0;
+ background: #f0f0f0;
+ color: #aaa;
+ padding: 3px 7px;
+ border-radius: 3px;
+ font-size: 11px;
+}
+
+.talk-list-item.talk-unread .talk-list-item-count {
+ background: #2891D3;
+ color: #ADDBF8;
+}
+.talk-list-item.talk-unread .talk-list-item-count strong {
+ color: #fff;
+}
\ No newline at end of file
diff --git a/application/frontend/components/talk/css/talk.css b/application/frontend/components/talk/css/talk.css
new file mode 100644
index 0000000..10b72a8
--- /dev/null
+++ b/application/frontend/components/talk/css/talk.css
@@ -0,0 +1,32 @@
+/**
+ * Личные сообщения
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+.talk {
+ background: #fafafa;
+ padding: 20px;
+ margin-bottom: 0;
+}
+
+.ls-talk-participants-details {
+ margin-bottom: 30px;
+}
+
+/**
+ * Список участников диалога
+ */
+.message-users .user-list-small-item.inactive {
+ opacity: .5;
+ cursor: help;
+}
+.message-users .user-list-small-item.inactive .ls-talk-participants-item-inactivate,
+.message-users .user-list-small-item .ls-talk-participants-item-activate {
+ display: none;
+}
+.message-users .user-list-small-item.inactive .ls-talk-participants-item-activate {
+ display: inline-block;
+}
\ No newline at end of file
diff --git a/application/frontend/components/talk/js/talk-list.js b/application/frontend/components/talk/js/talk-list.js
new file mode 100644
index 0000000..a9377c6
--- /dev/null
+++ b/application/frontend/components/talk/js/talk-list.js
@@ -0,0 +1,73 @@
+/**
+ * Управление списком личных сообщений
+ *
+ * @module ls/talk-list
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsTalkList", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Селекторы
+ selectors: {
+ form: '#talk-form',
+ formAction: '#talk-form-action',
+ button: '.js-talk-form-button',
+ buttonMarkAsRead: '.js-talk-form-button[data-action=mark_as_read]',
+ buttonRemove: '.js-talk-form-button[data-action=remove]'
+ },
+ i18n: {
+ remove_confirm: '@common.remove_confirm'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+ var _this = this;
+
+ // Экшнбар
+ $('.js-talk-actionbar-select').lsActionbarItemSelect({
+ selectors: {
+ target_item: '.js-talk-list-item'
+ }
+ });
+
+ this.elements.buttonMarkAsRead.on('click', function (e) {
+ _this.setAction( $(this).data('action') );
+ });
+
+ this.elements.buttonRemove.lsConfirm({
+ message: this._i18n('remove_confirm'),
+ onconfirm: function () {
+ this.setAction( 'remove' );
+ }.bind(this)
+ })
+ },
+
+ /**
+ * Устанавливает текущее действие
+ *
+ * @param {String} action Действие
+ */
+ setAction: function( action ) {
+ if ( ! this.elements.form.find('input[type=checkbox]:checked').length ) return;
+
+ this.elements.formAction.val( action );
+ this.elements.form.submit();
+ }
+ });
+})(jQuery);
diff --git a/application/frontend/components/talk/js/talk-users.js b/application/frontend/components/talk/js/talk-users.js
new file mode 100644
index 0000000..617f9f9
--- /dev/null
+++ b/application/frontend/components/talk/js/talk-users.js
@@ -0,0 +1,89 @@
+/**
+ * Добавление / удаление пользователей из личных сообщений
+ *
+ * @module ls/talk/users
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsTalkUsers", $.livestreet.lsUserListAdd, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ urls: {
+ add: aRouter['talk'] + 'ajaxaddtalkuser/',
+ inactivate: aRouter['talk'] + 'ajaxdeletetalkuser/'
+ },
+ selectors: {
+ // Кнопка отключения пользователя от диалога
+ item_inactivate: '.js-message-users-user-inactivate',
+ // Кнопка повторного приглашения пользователя в диалог
+ item_activate: '.js-message-users-user-activate'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ var _this = this;
+
+ this._super();
+
+ // Отключение пользователя от диалога
+ this.elements.list.on('click' + this.eventNamespace, this.options.selectors.item_inactivate, function (e) {
+ _this.inactivate( $(this) );
+ e.preventDefault();
+ });
+
+ // Повторное приглашение пользователя в диалог
+ this.elements.list.on('click' + this.eventNamespace, this.options.selectors.item_activate, function (e) {
+ _this.add( [ $(this).data('user-id') ] );
+ e.preventDefault();
+ });
+ },
+
+ /**
+ * Активирует пользователя при его повторном добавлении
+ */
+ _onUserAdd: function ( user ) {
+ this.userActivate( user.user_id );
+ },
+
+ /**
+ * Повторное приглашение пользователя в диалог
+ */
+ inactivate: function ( button ) {
+ var userId = button.data( 'user-id' );
+
+ this._load( 'inactivate', { user_id: userId }, function( response ) {
+ this.userInactivate( userId );
+
+ this._trigger( "afterinactivate", null, { context: this, response: response } );
+ });
+ },
+
+ /**
+ * Активирует пользователя при его повторном добавлении
+ */
+ userActivate: function ( userId ) {
+ this._getUserById( userId ).removeClass( 'inactive' );
+ },
+
+ /**
+ * Отключения пользователя от диалога
+ */
+ userInactivate: function ( userId ) {
+ this._getUserById( userId ).addClass( 'inactive' );
+ },
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/talk/participants/participants-item.tpl b/application/frontend/components/talk/participants/participants-item.tpl
new file mode 100644
index 0000000..4a08696
--- /dev/null
+++ b/application/frontend/components/talk/participants/participants-item.tpl
@@ -0,0 +1,26 @@
+{**
+ *
+ *}
+
+{extends 'component@user-list-add.item'}
+
+{block 'user_list_add_item_options' append}
+ {component_define_params params=[ 'editable' ]}
+
+ {if $userContainer && $userContainer->getUserActive() != $TALK_USER_ACTIVE}
+ {$classes = "$classes inactive"}
+ {$attributes = [ 'title' => {lang 'talk.users.inactive'} ]}
+ {/if}
+{/block}
+
+{block 'user_list_add_item_actions'}
+ {if $editable|default:true && $user->getId() != $oUserCurrent->getId()}
+
+ {component 'icon' icon='minus'}
+
+
+
+ {component 'icon' icon='plus'}
+
+ {/if}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/talk/participants/participants-list.tpl b/application/frontend/components/talk/participants/participants-list.tpl
new file mode 100644
index 0000000..4f35489
--- /dev/null
+++ b/application/frontend/components/talk/participants/participants-list.tpl
@@ -0,0 +1,9 @@
+{**
+ *
+ *}
+
+{extends 'component@user-list-add.list'}
+
+{block 'user_list_add_item'}
+ {component 'talk' template='participants-item' user=$user showActions=true}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/talk/participants/participants.tpl b/application/frontend/components/talk/participants/participants.tpl
new file mode 100644
index 0000000..545c151
--- /dev/null
+++ b/application/frontend/components/talk/participants/participants.tpl
@@ -0,0 +1,17 @@
+{**
+ *
+ *}
+
+{extends 'Component@user-list-add.user-list-add'}
+
+{block 'user_list_add_list'}
+ {component_define_params params=[ 'users' ]}
+
+ {component 'talk' template='participants-list'
+ hideableEmptyAlert = true
+ users = $users
+ showActions = true
+ show = !! $users
+ classes = "js-$component-users"
+ itemClasses = "js-$component-user"}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/talk/talk-list.tpl b/application/frontend/components/talk/talk-list.tpl
new file mode 100644
index 0000000..a2a290e
--- /dev/null
+++ b/application/frontend/components/talk/talk-list.tpl
@@ -0,0 +1,137 @@
+{**
+ * Список диалогов
+ *
+ * @param array $talks
+ * @param boolean $selectable
+ * @param boolean $paging
+ *}
+
+{component_define_params params=[ 'talks', 'selectable' ]}
+
+
+ {if $talks}
+
+ {else}
+ {component 'blankslate' text=$aLang.talk.notices.empty}
+ {/if}
+
+ {component 'pagination' total=+$paging.iCountPage current=+$paging.iCurrentPage url="{$paging.sBaseUrl}/page__page__/{$paging.sGetParams}"}
+
\ No newline at end of file
diff --git a/application/frontend/components/talk/talk-message-root.tpl b/application/frontend/components/talk/talk-message-root.tpl
new file mode 100644
index 0000000..a62dfd8
--- /dev/null
+++ b/application/frontend/components/talk/talk-message-root.tpl
@@ -0,0 +1,43 @@
+{**
+ * Первое сообщение в диалоге
+ *}
+
+{$component = 'ls-talk-message-root'}
+{component_define_params params=[ 'talk', 'mods', 'classes', 'attributes' ]}
+
+
+
+ {* Заголовок *}
+
+ {$talk->getTitle()}
+
+
+ {* Информация *}
+
+ {* Автор *}
+
+ {component 'user' template='avatar' user=$talk->getUser() size='xxsmall' mods='inline'}
+
+
+
+
+ {date_format date=$talk->getDate() hours_back="12" minutes_back="60" now="60" day="day H:i" format="j F Y, H:i"}
+
+
+
+
+ {* Содержимое *}
+
+ {$talk->getText()}
+
+
+ {* Действия *}
+ {component 'actionbar' classes="{$component}-actionbar" items=[
+ [ 'buttons' => [
+ [ 'text' => {component 'favourite' classes="js-favourite-talk" target=$talk}, 'mods' => 'icon', 'classes' => 'js-talk-message-root-favourite' ]
+ ]],
+ [ 'buttons' => [
+ [ 'icon' => 'trash', 'url' => "{$talk->getUrlDelete()}?security_ls_key={$LIVESTREET_SECURITY_KEY}", 'text' => {lang 'common.remove'}, 'show' => $oUserCurrent->getId() == $talk->getUser()->getId() || $oUserCurrent->isAdministrator(), 'classes' => 'js-confirm-remove-default' ]
+ ]]
+ ]}
+
\ No newline at end of file
diff --git a/application/frontend/components/talk/talk-search-form.tpl b/application/frontend/components/talk/talk-search-form.tpl
new file mode 100644
index 0000000..2ffc56f
--- /dev/null
+++ b/application/frontend/components/talk/talk-search-form.tpl
@@ -0,0 +1,60 @@
+{**
+ * Поиск по личным сообщениям
+ *}
+
+{capture 'talk_search_form'}
+
+{/capture}
+
+{component 'details'
+ classes = 'js-talk-search-form'
+ title = {lang 'talk.search.title'}
+ content = $smarty.capture.talk_search_form}
\ No newline at end of file
diff --git a/application/frontend/components/talk/talk.tpl b/application/frontend/components/talk/talk.tpl
new file mode 100644
index 0000000..6a8e435
--- /dev/null
+++ b/application/frontend/components/talk/talk.tpl
@@ -0,0 +1,41 @@
+{**
+ * Диалог
+ *
+ * @param object $talk
+ * @param array $comments
+ * @param array $lastCommentId
+ *}
+
+{component_define_params params=[ 'talk', 'comments', 'lastCommentId' ]}
+
+{* Первое сообщение *}
+{component 'talk' template='message-root' talk=$talk}
+
+{* Участники личного сообщения *}
+{capture 'talk_message_root_participants'}
+ {component 'talk' template='participants'
+ users = $talk->getTalkUsers()
+ classes = 'message-users js-message-users'
+ attributes = [ 'data-param-target_id' => $talk->getId() ]
+ editable = $talk->getUserId() == $oUserCurrent->getId() || $oUserCurrent->isAdministrator()
+ excludeRemove = [ $oUserCurrent->getId() ]}
+{/capture}
+
+{component 'details'
+ classes = 'js-details-default ls-talk-participants-details'
+ title = "{lang 'talk.users.title'} ({count($talk->getTalkUsers())})"
+ content = $smarty.capture.talk_message_root_participants}
+
+{if $activeParticipantsCount || $comments}
+ {* Вывод комментариев к сообщению *}
+ {component 'comment' template='comments'
+ comments = $comments
+ classes = 'js-comments-talk'
+ attributes = [ 'id' => 'comments' ]
+ targetId = $talk->getId()
+ targetType = 'talk'
+ count = $talk->getCountComment()
+ dateReadLast = $talk->getTalkUser()->getDateLast()
+ lastCommentId = $lastCommentId
+ forbidText = $aLang.talk.notices.deleted}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/toolbar-scrollnav/README.md b/application/frontend/components/toolbar-scrollnav/README.md
new file mode 100644
index 0000000..8b49527
--- /dev/null
+++ b/application/frontend/components/toolbar-scrollnav/README.md
@@ -0,0 +1 @@
+# Компонент toolbar-scrollnav
\ No newline at end of file
diff --git a/application/frontend/components/toolbar-scrollnav/component.json b/application/frontend/components/toolbar-scrollnav/component.json
new file mode 100644
index 0000000..48bb62c
--- /dev/null
+++ b/application/frontend/components/toolbar-scrollnav/component.json
@@ -0,0 +1,16 @@
+{
+ "name": "toolbar-scrollnav",
+ "version": "1.0.0",
+ "dependencies": {
+ "toolbar": "*"
+ },
+ "templates": {
+ "toolbar.scrollnav": "toolbar.scrollnav.tpl"
+ },
+ "scripts": {
+ "toolbar.scrollnav": "js/toolbar.scrollnav.js"
+ },
+ "styles": {
+ "toolbar-scrollnav": "css/toolbar-scrollnav.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/toolbar-scrollnav/css/toolbar-scrollnav.css b/application/frontend/components/toolbar-scrollnav/css/toolbar-scrollnav.css
new file mode 100644
index 0000000..8bbc798
--- /dev/null
+++ b/application/frontend/components/toolbar-scrollnav/css/toolbar-scrollnav.css
@@ -0,0 +1,7 @@
+/**
+ * Кнопка навигации по топикам
+ */
+
+.ls-toolbar-item--topic a.ls-toolbar-topic-prev {
+ border-bottom: 1px solid #eee;
+}
\ No newline at end of file
diff --git a/application/frontend/components/toolbar-scrollnav/js/toolbar.scrollnav.js b/application/frontend/components/toolbar-scrollnav/js/toolbar.scrollnav.js
new file mode 100644
index 0000000..c0e159f
--- /dev/null
+++ b/application/frontend/components/toolbar-scrollnav/js/toolbar.scrollnav.js
@@ -0,0 +1,148 @@
+/**
+ * Навигация по топикам
+ *
+ * @module ls/toolbar/topics
+ * @dependencies Factory Widget, scrollTo, hotkeys, lsPagination
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ *
+ * TODO: Унифицировать
+ * TODO: Добавить коллбэки и хуки
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsToolbarTopics", {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Хоткеи
+ keys: {
+ // Комбинация клавиш для перехода к следующему объекту
+ next: 'ctrl+shift+down',
+
+ // Комбинация клавиш для перехода к предыдущему объекту
+ prev: 'ctrl+shift+up'
+ },
+
+ // Селекторы
+ selectors: {
+ // Кнопка прокрутки к следующему объекту
+ next: '.js-toolbar-topics-next',
+
+ // Кнопка прокрутки к предыдущему объекту
+ prev: '.js-toolbar-topics-prev',
+
+ // Объект
+ item: '.js-topic',
+
+ // Пагинация
+ pagination: '.js-pagination-topics'
+ },
+
+ // Продолжительность прокрутки, мс
+ duration: 500,
+
+ // Параметр в хэше урл указывающий к какому объекту прокручивать
+ // после загрузки страницы (first или last)
+ param: 'gotopic'
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ // Элементы
+ this.elements = {
+ next: this.element.find(this.options.selectors.next),
+ prev: this.element.find(this.options.selectors.prev),
+ pagination: $(this.options.selectors.pagination).eq(0),
+ items: $(this.options.selectors.item)
+ };
+
+ // Текущий объект
+ this.reset();
+
+ // Обработка параметров в хэше url'а
+ this._checkUrl();
+
+ //
+ // События
+ //
+
+ // Обработка нажатий по кнопкам след/пред
+ this._on( this.elements.next, { 'click': this.next } );
+ this._on( this.elements.prev, { 'click': this.prev } );
+
+ // Обработка хоткеев
+ this.document.bind( 'keydown' + this.eventNamespace, this.options.keys.next, this.next.bind(this) );
+ this.document.bind( 'keydown' + this.eventNamespace, this.options.keys.prev, this.prev.bind(this) );
+ },
+
+ /**
+ * Обработка параметров в хэше url'а
+ */
+ _checkUrl: function () {
+ // Проверяем наличие параметра options.param в хэше url'а
+ var goto = new RegExp( this.option( 'param' ) + '=(last|first)', 'i' ).exec( location.hash );
+
+ if ( goto ) {
+ // С помощью goto[1] получаем значение параметра options.param (first или last)
+ var item = this.elements.items[ goto[1] ]();
+
+ // Скроллим через небольшой промежуток времени,
+ // чтобы страница успела прогрузиться
+ setTimeout( this.scroll.bind(this, item), 500 );
+ }
+ },
+
+ /**
+ * Переход к объекту
+ *
+ * @param {String} name Название функции
+ */
+ _go: function ( name ) {
+ // Получаем объект к которому нужно перейти
+ var next = ! this.current ? this.elements.items.eq(0) : this.current[ name ]( this.options.selectors.item );
+
+ // Скроллим к след/пред объекту
+ // Если на текущей странице больше нет объектов, переходим на другую
+ next.length ? this.scroll( next ) : this.elements.pagination.lsPagination( name, true );
+ },
+
+ /**
+ * Переход к следующему объекту
+ */
+ next: function () {
+ this._go( 'next' );
+ },
+
+ /**
+ * Переход к предыдущему объекту
+ */
+ prev: function () {
+ this._go( 'prev' );
+ },
+
+ /**
+ * Скролл к текущему объекту
+ */
+ scroll: function ( item ) {
+ $.scrollTo( this.current = item, this.options.duration, { offset: 0 } );
+ },
+
+ /**
+ * Сброс текущего активного объекта
+ */
+ reset: function () {
+ this.current = null;
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/toolbar-scrollnav/toolbar.scrollnav.tpl b/application/frontend/components/toolbar-scrollnav/toolbar.scrollnav.tpl
new file mode 100644
index 0000000..b58a024
--- /dev/null
+++ b/application/frontend/components/toolbar-scrollnav/toolbar.scrollnav.tpl
@@ -0,0 +1,16 @@
+{**
+ * Тулбар
+ * Кнопка прокручивания к следующему/предыдущему топику
+ *}
+
+
+ {component 'toolbar.item'
+ icon='arrow-up'
+ classes='js-toolbar-topics-prev'
+ attributes=[ 'title' => {lang 'toolbar.topic_nav.prev'} ]}
+
+ {component 'toolbar.item'
+ icon='arrow-down'
+ classes='js-toolbar-topics-next'
+ attributes=[ 'title' => {lang 'toolbar.topic_nav.next'} ]}
+
\ No newline at end of file
diff --git a/application/frontend/components/toolbar-scrollup/README.md b/application/frontend/components/toolbar-scrollup/README.md
new file mode 100644
index 0000000..474c29c
--- /dev/null
+++ b/application/frontend/components/toolbar-scrollup/README.md
@@ -0,0 +1 @@
+# Компонент toolbar-scrollup
\ No newline at end of file
diff --git a/application/frontend/components/toolbar-scrollup/component.json b/application/frontend/components/toolbar-scrollup/component.json
new file mode 100644
index 0000000..5b3e714
--- /dev/null
+++ b/application/frontend/components/toolbar-scrollup/component.json
@@ -0,0 +1,16 @@
+{
+ "name": "toolbar-scrollup",
+ "version": "1.0.0",
+ "dependencies": {
+ "toolbar": "*"
+ },
+ "templates": {
+ "toolbar.scrollup": "toolbar.scrollup.tpl"
+ },
+ "scripts": {
+ "toolbar.scrollup": "js/toolbar.scrollup.js"
+ },
+ "styles": {
+ "toolbar-scrollup": "css/toolbar-scrollup.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/toolbar-scrollup/css/toolbar-scrollup.css b/application/frontend/components/toolbar-scrollup/css/toolbar-scrollup.css
new file mode 100644
index 0000000..12d14b5
--- /dev/null
+++ b/application/frontend/components/toolbar-scrollup/css/toolbar-scrollup.css
@@ -0,0 +1,15 @@
+/**
+ * Кнопка прокрутки вверх
+ */
+
+.ls-toolbar-item--scrollup {
+ display: none;
+}
+.ls-toolbar-item--scrollup i {
+ -webkit-transition: transform .3s;
+ transition: transform .3s;
+}
+.ls-toolbar-item--scrollup.active i {
+ -webkit-transform: rotate(180deg);
+ transform: rotate(180deg);
+}
\ No newline at end of file
diff --git a/application/frontend/components/toolbar-scrollup/js/toolbar.scrollup.js b/application/frontend/components/toolbar-scrollup/js/toolbar.scrollup.js
new file mode 100644
index 0000000..fad2917
--- /dev/null
+++ b/application/frontend/components/toolbar-scrollup/js/toolbar.scrollup.js
@@ -0,0 +1,89 @@
+/**
+ * Прокрутка вверх
+ *
+ * @module ls/toolbar/scrollup
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsToolbarScrollUp", {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Продолжительность прокрутки, мс
+ duration: 500
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._on({ click: 'onClick' });
+ this._on( this.window, { scroll: 'onScroll' } );
+ },
+
+ /**
+ * Показывает/скрывает кнопку прокрутки в зависимости от значения scrollTop
+ */
+ onScroll: function() {
+ if ( this.prev && this.isTop && this.window.scrollTop() > 0 ) {
+ this.element.removeClass( ls.options.classes.states.active );
+ this.isTop = false;
+ this.prev = null;
+ }
+
+ ! this.prev && this.element[ this.window.scrollTop() > this.window.height() / 2 ? 'fadeIn' : 'fadeOut' ]( 500 );
+ },
+
+ /**
+ * Обработка клика
+ */
+ onClick: function() {
+ // Не обрабатываем клики в процессе скролла
+ ! this.isScroll && this[ this.prev && this.isTop ? 'back' : 'up' ]();
+ },
+
+ /**
+ * Прокрутка вверх
+ */
+ up: function() {
+ this.prev = this.window.scrollTop();
+ this.isScroll = true;
+
+ $.scrollTo( 0, this.options.duration, {
+ onAfter: function () {
+ this.isTop = true;
+ this.isScroll = false;
+ this.element.addClass( ls.options.classes.states.active );
+ }.bind(this)
+ });
+ },
+
+ /**
+ * Прокрутка к предыдущей позиции
+ */
+ back: function() {
+ if ( ! this.prev ) return;
+
+ this.isTop = false;
+ this.isScroll = true;
+
+ $.scrollTo( this.prev, this.options.duration, {
+ onAfter: function () {
+ this.element.removeClass( ls.options.classes.states.active );
+ this.isScroll = false;
+ this.prev = null;
+ }.bind(this)
+ });
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/toolbar-scrollup/toolbar.scrollup.tpl b/application/frontend/components/toolbar-scrollup/toolbar.scrollup.tpl
new file mode 100644
index 0000000..c1bb585
--- /dev/null
+++ b/application/frontend/components/toolbar-scrollup/toolbar.scrollup.tpl
@@ -0,0 +1,10 @@
+{**
+ * Тулбар
+ * Кнопка прокрутки страницы вверх
+ *}
+
+{component 'toolbar.item'
+ icon='chevron-up'
+ classes='js-toolbar-scrollup'
+ mods='scrollup'
+ attributes=[ 'title' => {lang 'toolbar.scrollup.title'} ]}
\ No newline at end of file
diff --git a/application/frontend/components/topic/README.md b/application/frontend/components/topic/README.md
new file mode 100644
index 0000000..52f517e
--- /dev/null
+++ b/application/frontend/components/topic/README.md
@@ -0,0 +1 @@
+# Компонент topic
\ No newline at end of file
diff --git a/application/frontend/components/topic/blocks/block.topics-tags.tpl b/application/frontend/components/topic/blocks/block.topics-tags.tpl
new file mode 100644
index 0000000..7e651de
--- /dev/null
+++ b/application/frontend/components/topic/blocks/block.topics-tags.tpl
@@ -0,0 +1,23 @@
+{**
+ * Теги
+ *}
+
+{component_define_params params=[ 'tags', 'tagsUser' ]}
+
+{component 'block'
+ title = {lang 'tags.block_tags.title'}
+ classes = 'js-block-default'
+ footer = {component 'tags' template='search-form' mods='light'}
+ tabs = [
+ 'tabs' => [
+ [
+ 'text' => {lang 'tags.block_tags.nav.all'},
+ 'content' => {component 'tags' template='cloud' tags=$tags}
+ ],
+ [
+ 'text' => {lang 'tags.block_tags.nav.favourite'},
+ 'content' => {component 'tags' template='cloud' tags=$tagsUser},
+ 'is_enabled' => !! $oUserCurrent
+ ]
+ ]
+ ]}
\ No newline at end of file
diff --git a/application/frontend/components/topic/component.json b/application/frontend/components/topic/component.json
new file mode 100644
index 0000000..a157809
--- /dev/null
+++ b/application/frontend/components/topic/component.json
@@ -0,0 +1,27 @@
+{
+ "name": "topic",
+ "version": "1.0.0",
+ "dependencies": {
+ "article": "*",
+ "favourite": "*",
+ "tags-favourite": "*",
+ "vote": "*"
+ },
+ "templates": {
+ "topic": "topic.tpl",
+ "add": "topic-add.tpl",
+ "add-type": "topic-add-type.tpl",
+ "list": "topic-list.tpl",
+ "preview": "topic-preview.tpl",
+ "type": "topic-type.tpl",
+ "block.tags": "blocks/block.topics-tags.tpl"
+ },
+ "scripts": {
+ "add": "js/topic-add.js",
+ "favourite": "js/topic-favourite.js",
+ "topic": "js/topic.js"
+ },
+ "styles": {
+ "topic": "css/topic.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/topic/css/topic.css b/application/frontend/components/topic/css/topic.css
new file mode 100644
index 0000000..d32bb74
--- /dev/null
+++ b/application/frontend/components/topic/css/topic.css
@@ -0,0 +1,156 @@
+/**
+ * Топики
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+
+/**
+ * Основные стили
+ */
+.ls-topic {
+ overflow: hidden;
+ zoom: 1;
+ margin-bottom: 50px;
+}
+.ls-topic:last-child {
+ margin-bottom: 0;
+}
+
+
+/**
+ * Шапка
+ */
+.ls-topic-header {
+ margin-bottom: 25px;
+}
+.ls-topic-header .ls-topic-title {
+ font: 400 32px/1.3em "Open Sans", sans-serif;
+ margin: 0 0 15px;
+}
+.ls-topic-header .ls-topic-title a {
+ text-decoration: none;
+}
+.ls-topic-header .ls-topic-title a:visited {
+ color: #000;
+}
+.ls-topic-header .ls-topic-title i {
+ position: relative;
+ top: 8px;
+ cursor: help;
+}
+.ls-topic-header .ls-topic-info {
+ color: #777;
+ margin-bottom: 15px;
+ overflow: hidden;
+}
+.ls-topic-header .ls-topic-info-item {
+ float: left;
+ margin-right: 15px;
+}
+
+
+/**
+ * Содержимое топика
+ */
+.ls-topic-content {
+ margin-bottom: 20px;
+}
+.ls-topic-text.ls-text {
+ color: #333;
+ font-size: 15px;
+ line-height: 1.6em;
+}
+.ls-topic-cut {
+ margin-top: 10px;
+}
+
+
+/**
+ * Подвал
+ */
+.ls-topic-footer .ls-topic-info-item {
+ float: left;
+ padding: 8px;
+ margin-right: 20px;
+}
+.ls-topic-info a {
+ text-decoration: none;
+}
+.ls-topic-info a:hover {
+ text-decoration: underline;
+}
+
+/* Автор */
+.ls-topic-info-item.ls-topic-info-item--author {
+ padding: 0;
+}
+.ls-topic-info-item .ls-avatar .ls-avatar-image {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+}
+.ls-topic-info-item .ls-avatar .ls-avatar-name-link {
+ color: #333;
+}
+
+/* Ссылка на комментарии */
+.ls-topic-info-item.ls-topic-info-item--comments a span {
+ color: #777;
+}
+
+/* Голосование */
+.ls-topic-info-item.ls-topic-info-item--vote {
+ padding: 0;
+}
+
+.ls-topic-info-item.ls-topic-info-item--date--deferred {
+ color: #ff0000;
+}
+
+/**
+ * Responsive styles
+ */
+@media (max-width: 480px) {
+ .ls-topic-header .ls-topic-title {
+ font-size: 24px;
+ }
+}
+
+/**
+ * Превью (изображение)
+ */
+.ls-topic-preview-image {
+ padding: 10px;
+ margin-bottom: 20px;
+ text-align: center;
+ background: #f7f7f7;
+}
+.ls-topic-preview-image img {
+ max-width: 100%;
+ vertical-align: top;
+}
+
+/**
+ * Предпросмотр топика
+ */
+.ls-topic-preview {
+ margin-top: 30px;
+ border: 1px solid #eee;
+ display: none;
+}
+.ls-topic-preview-header {
+ padding: 20px 30px;
+ background: #fafafa;
+}
+.ls-topic-preview-title {
+ margin: 0;
+}
+.ls-topic-preview-body {
+ padding: 30px;
+}
+.ls-topic-preview-footer {
+ padding: 0 30px 30px;
+}
\ No newline at end of file
diff --git a/application/frontend/components/topic/js/topic-add.js b/application/frontend/components/topic/js/topic-add.js
new file mode 100644
index 0000000..73e62d3
--- /dev/null
+++ b/application/frontend/components/topic/js/topic-add.js
@@ -0,0 +1,153 @@
+/**
+ * Форма добавления топика
+ *
+ * @module ls/topic/add
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsTopicAdd", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Максимальное кол-во блогов которое можно выбрать
+ max_blog_count: 3,
+
+ // Ссылки
+ urls: {
+ add: aRouter[ 'content' ] + 'ajax/add/',
+ edit: aRouter[ 'content' ] + 'ajax/edit/',
+ preview: aRouter[ 'content' ] + 'ajax/preview/'
+ },
+
+ // Селекторы
+ selectors: {
+ preview: '#topic-text-preview',
+ preview_content: '#topic-text-preview .js-topic-preview-content',
+ image_preview: '.js-topic-add-field-image-preview',
+ blogs: '.js-topic-add-blogs',
+ buttons: {
+ preview: '.js-topic-preview-text-button',
+ preview_hide: '.js-topic-preview-text-hide-button',
+ draft: '.js-topic-draft-button'
+ }
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ var _this = this;
+
+ this.elements = {
+ preview: $( this.option( 'selectors.preview' ) ),
+ preview_content: $( this.option( 'selectors.preview_content' ) ),
+ image_preview: this.element.find( this.option( 'selectors.image_preview' ) ),
+ blogs: this.element.find( this.option( 'selectors.blogs' ) ),
+ buttons: {
+ preview: this.element.find( this.option( 'selectors.buttons.preview' ) ),
+ preview_hide: $( this.option( 'selectors.buttons.preview_hide' ) ),
+ draft: this.element.find( this.option( 'selectors.buttons.draft' ) ),
+ submit: this.element.find( this.option( 'selectors.buttons.submit' ) )
+ }
+ };
+
+ // Иниц-ия формы
+ this.element.lsContent({
+ urls: {
+ add: this.option( 'urls.add' ),
+ edit: this.option( 'urls.edit' )
+ },
+ beforesubmit: this._prepareParams.bind( this )
+ });
+
+ // Выбор блогов
+ this.elements.blogs.lsFieldAutocomplete({
+ max_selected_options: this.option( 'max_blog_count' ),
+ width: '100%'
+ });
+
+ // Установка правильной сортировки блогов
+ var chosenOrder = this.elements.blogs.data('chosenOrder');
+ if (chosenOrder && chosenOrder.length) {
+ this.elements.blogs.setSelectionOrder(chosenOrder);
+ }
+
+ // Превью (изображение)
+ this.elements.image_preview.lsFieldImageAjax({
+ urls: {
+ add: this.option( 'urls.add' ),
+ edit: this.option( 'urls.edit' )
+ }
+ });
+
+ // Добавление в черновик
+ this.elements.buttons.draft.on( 'click' + this.eventNamespace, this.saveAsDraft.bind( this ) );
+
+ // Превью текста
+ this.elements.buttons.preview.on( 'click' + this.eventNamespace, this.previewShow.bind( this ) );
+
+ // Закрытие превью текста
+ this.elements.buttons.preview_hide.on( 'click' + this.eventNamespace, this.previewHide.bind( this ) );
+ },
+
+ /**
+ * Добавление в черновик
+ */
+ saveAsDraft: function() {
+ this.element.lsContent( 'submit', { is_draft: 1 } );
+ },
+
+ /**
+ * Превью текста
+ */
+ previewShow: function() {
+ this._submit( 'preview', this.element, function( response ) {
+ if ( response.bStateError ) {
+ ls.msg.error( null, response.sMsg );
+ } else {
+ this.elements.preview.show();
+ this.elements.preview_content.html( response.sText );
+ }
+ }.bind( this ), {
+ submitButton: this.elements.buttons.preview
+ });
+ },
+
+ /**
+ * Закрытие превью текста
+ */
+ previewHide: function() {
+ this.elements.preview.hide();
+ this.elements.preview_content.empty();
+ },
+
+ /**
+ * Дополнительная обработка параметров перед отправкой формы
+ */
+ _prepareParams: function() {
+ // Корректируем сортировку выбранных блогов
+ if (this.elements.blogs.length) {
+ var orders = this.elements.blogs.getSelectionOrder();
+ if (!orders || !orders.length) {
+ orders = this.elements.blogs.val();
+ }
+ if (orders && orders.length) {
+ this.element.lsContent('option', 'params.topic[blogs_id_raw]', orders);
+ }
+ }
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/topic/js/topic-favourite.js b/application/frontend/components/topic/js/topic-favourite.js
new file mode 100644
index 0000000..917f650
--- /dev/null
+++ b/application/frontend/components/topic/js/topic-favourite.js
@@ -0,0 +1,34 @@
+/**
+ * Кнопка добавления топика в избранное
+ *
+ * @module ls/topic/favourite
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsTopicFavourite", $.livestreet.lsFavourite, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ urls: {
+ toggle: aRouter['ajax'] + 'favourite/topic/'
+ },
+ tags: null
+ },
+
+ /**
+ *
+ */
+ onToggleSuccess: function ( response ) {
+ this._super( response );
+
+ this.option( 'tags' ).lsTagsFavourite( 'setEditable', response.bState );
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/topic/js/topic.js b/application/frontend/components/topic/js/topic.js
new file mode 100644
index 0000000..fc15138
--- /dev/null
+++ b/application/frontend/components/topic/js/topic.js
@@ -0,0 +1,66 @@
+/**
+ * comment
+ *
+ * @module ls/topic
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsTopic", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ vote: aRouter.ajax + 'vote/topic/',
+ voteInfo: aRouter.ajax + 'vote/get/info/topic'
+ },
+
+ // Селекторы
+ selectors: {
+ vote: '.js-vote-topic',
+ favourite: '.js-favourite-topic',
+ tags: '.js-tags-favourite'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ // Избранное
+ this.elements.favourite.lsTopicFavourite({
+ tags: this.elements.tags
+ });
+
+ // Голосование за топик
+ this.elements.vote.lsVote({
+ urls: {
+ vote: this.option( 'urls.vote' ),
+ info: this.option( 'urls.voteInfo' )
+ }
+ });
+
+ // Теги
+ this.elements.tags.lsTagsFavourite({
+ urls: {
+ save: aRouter['ajax'] + 'favourite/save-tags/'
+ },
+ params: {
+ target_type: 'topic'
+ }
+ });
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/topic/topic-add-type.tpl b/application/frontend/components/topic/topic-add-type.tpl
new file mode 100644
index 0000000..deaea37
--- /dev/null
+++ b/application/frontend/components/topic/topic-add-type.tpl
@@ -0,0 +1,20 @@
+{**
+ * Подключение шаблона редактировани топика определенного типа
+ *
+ * @param object $topic
+ * @param boolean $isPreview
+ *}
+
+{component_define_params params=[ 'topic', 'type', 'blogs', 'blogId', 'skipBlogs', 'mods', 'classes', 'attributes' ]}
+
+{$typeCode = $type->getCode()}
+
+{if $LS->Topic_IsAllowTopicType($typeCode)}
+ {$template = $LS->Component_GetTemplatePath('topic', "topic-add-type-{$typeCode}")}
+
+ {if $template}
+ {component "topic.topic-add-type-$typeCode" params=$params}
+ {else}
+ {component 'topic.add' params=$params}
+ {/if}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/topic/topic-add.tpl b/application/frontend/components/topic/topic-add.tpl
new file mode 100644
index 0000000..1d70b2e
--- /dev/null
+++ b/application/frontend/components/topic/topic-add.tpl
@@ -0,0 +1,223 @@
+{**
+ * Базовая форма создания топика
+ *
+ * @param object $topic
+ * @param object $type
+ * @param array $blogs
+ * @param array $blogId
+ *}
+
+{component_define_params params=[ 'topic', 'type', 'skipBlogs', 'blogs', 'classes' ]}
+
+
+
+
+{* Блок с превью текста *}
+{component 'topic' template='preview'}
\ No newline at end of file
diff --git a/application/frontend/components/topic/topic-list.tpl b/application/frontend/components/topic/topic-list.tpl
new file mode 100644
index 0000000..eaab676
--- /dev/null
+++ b/application/frontend/components/topic/topic-list.tpl
@@ -0,0 +1,20 @@
+{**
+ * Список топиков
+ *
+ * @param array $topics
+ * @param array $paging
+ *}
+
+{component_define_params params=[ 'topics', 'paging' ]}
+
+{if $topics}
+ {add_block group='toolbar' name='component@toolbar-scrollnav.toolbar.scrollnav' show=count( $topics )}
+
+ {foreach $topics as $topic}
+ {component 'topic' template='type' topic=$topic isList=true}
+ {/foreach}
+
+ {component 'pagination' total=+$paging.iCountPage current=+$paging.iCurrentPage url="{$paging.sBaseUrl}/page__page__/{$paging.sGetParams}" classes='js-pagination-topics'}
+{else}
+ {component 'blankslate' text=$aLang.common.empty}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/topic/topic-preview.tpl b/application/frontend/components/topic/topic-preview.tpl
new file mode 100644
index 0000000..a35062b
--- /dev/null
+++ b/application/frontend/components/topic/topic-preview.tpl
@@ -0,0 +1,19 @@
+{**
+ * Предпросмотр топика
+ *
+ * @param object $topic
+ *}
+
+{$component = 'ls-topic-preview'}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/topic/topic-type.tpl b/application/frontend/components/topic/topic-type.tpl
new file mode 100644
index 0000000..99c51fc
--- /dev/null
+++ b/application/frontend/components/topic/topic-type.tpl
@@ -0,0 +1,20 @@
+{**
+ * Подключение шаблона топика определенного типа
+ *
+ * @param object $topic
+ * @param boolean $isPreview
+ *}
+
+{component_define_params params=[ 'topic', 'isPreview', 'isList', 'mods', 'classes', 'attributes' ]}
+
+{$type = $topic->getType()}
+
+{if $LS->Topic_IsAllowTopicType($type)}
+ {$template = $LS->Component_GetTemplatePath('topic', "topic-type-{$type}")}
+
+ {if $template}
+ {component "topic.topic-type-$type" params=$params}
+ {else}
+ {component 'topic' params=$params}
+ {/if}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/topic/topic.tpl b/application/frontend/components/topic/topic.tpl
new file mode 100644
index 0000000..011331a
--- /dev/null
+++ b/application/frontend/components/topic/topic.tpl
@@ -0,0 +1,220 @@
+{**
+ * Базовый шаблон топика
+ * Используется также для отображения превью топика
+ *
+ * @param object $topic
+ * @param boolean $isList
+ * @param boolean $isPreview
+ *}
+
+{$component = 'ls-topic'}
+{component_define_params params=[ 'type', 'topic', 'isPreview', 'isList', 'mods', 'classes', 'attributes' ]}
+
+{$user = $topic->getUser()}
+{$type = ($topic->getType()) ? $topic->getType() : $type}
+
+{if ! $isList}
+ {$mods = "{$mods} single"}
+{/if}
+
+{$classes = "{$classes} topic js-topic"}
+
+{block 'topic_options'}{/block}
+
+
+ {**
+ * Хидер
+ *}
+ {block 'topic_header'}
+
+ {/block}
+
+
+ {**
+ * Текст
+ *}
+ {block 'topic_body'}
+ {* Превью *}
+ {$previewImage = $topic->getPreviewImageWebPath(Config::Get('module.topic.default_preview_size'))}
+
+ {if $previewImage}
+
+
+
+ {/if}
+
+
+
+ {block 'topic_content_text'}
+ {if $isList and $topic->getTextShort()}
+ {$topic->getTextShort()}
+ {else}
+ {$topic->getText()}
+ {/if}
+ {/block}
+
+
+ {* Кат *}
+ {if $isList && $topic->getTextShort()}
+ {component 'button'
+ classes = "{$component}-cut"
+ url = "{$topic->getUrl()}#cut"
+ text = "{$topic->getCutText()|default:$aLang.topic.read_more}"}
+ {/if}
+
+
+ {* Дополнительные поля *}
+ {block 'topic_content_properties'}
+ {if ! $isList}
+ {component 'property' template='output.list' properties=$topic->property->getPropertyList()}
+ {/if}
+ {/block}
+
+ {* Опросы *}
+ {block 'topic_content_polls'}
+ {if ! $isList}
+ {component 'poll' template='list' polls=$topic->getPolls()}
+ {/if}
+ {/block}
+ {/block}
+
+
+ {**
+ * Футер
+ *}
+ {block 'topic_footer'}
+ {if ! $isList && $topic->getTypeObject()->getParam('allow_tags')}
+ {$favourite = $topic->getFavourite()}
+
+ {if ! $isPreview}
+ {component 'tags-personal'
+ classes = 'js-tags-favourite'
+ tags = $topic->getTagsObjects()
+ tagsPersonal = ( $favourite ) ? $favourite->getTagsObjects() : []
+ isEditable = ! $favourite
+ targetType = 'topic'
+ targetId = $topic->getId()}
+ {/if}
+ {/if}
+
+
+
+ {* Всплывающий блок появляющийся при нажатии на кнопку Поделиться *}
+ {if ! $isList && ! $isPreview}
+
+ {/if}
+ {/block} {* /topic_footer *}
+
diff --git a/application/frontend/components/user-list-add/README.md b/application/frontend/components/user-list-add/README.md
new file mode 100644
index 0000000..8a59a2a
--- /dev/null
+++ b/application/frontend/components/user-list-add/README.md
@@ -0,0 +1 @@
+# Компонент user-list-add
\ No newline at end of file
diff --git a/application/frontend/components/user-list-add/component.json b/application/frontend/components/user-list-add/component.json
new file mode 100644
index 0000000..e75ae1d
--- /dev/null
+++ b/application/frontend/components/user-list-add/component.json
@@ -0,0 +1,20 @@
+{
+ "name": "user-list-add",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-component": "*",
+ "user": "*",
+ "field": "*"
+ },
+ "templates": {
+ "item": "item.tpl",
+ "list": "list.tpl",
+ "user-list-add": "user-list-add.tpl"
+ },
+ "scripts": {
+ "user-list-add": "js/user-list-add.js"
+ },
+ "styles": {
+ "user-list-add": "css/user-list-add.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/user-list-add/css/user-list-add.css b/application/frontend/components/user-list-add/css/user-list-add.css
new file mode 100644
index 0000000..97a2c15
--- /dev/null
+++ b/application/frontend/components/user-list-add/css/user-list-add.css
@@ -0,0 +1,25 @@
+/**
+ * Список пользователей
+ */
+
+.user-list-add {
+ margin-bottom: 15px;
+}
+.user-list-add:last-child {
+ margin-bottom: 0;
+}
+.user-list-add-title {
+ font-size: 18px;
+ margin-bottom: 15px;
+}
+.user-list-add-form {
+ padding: 20px;
+ margin-bottom: 5px;
+ background: #f7f7f7;
+ border: 1px solid #eee;
+}
+.user-list-add-note {
+ margin-bottom: 20px;
+ color: #aaa;
+ font-size: 13px;
+}
\ No newline at end of file
diff --git a/application/frontend/components/user-list-add/item.tpl b/application/frontend/components/user-list-add/item.tpl
new file mode 100644
index 0000000..f75907b
--- /dev/null
+++ b/application/frontend/components/user-list-add/item.tpl
@@ -0,0 +1,24 @@
+{extends 'component@user.user-list-small-item'}
+
+{block 'user_list_small_item_options' append}
+ {component_define_params params=[ 'showActions', 'showRemove' ]}
+
+ {block 'user_list_add_item_options'}{/block}
+{/block}
+
+{block 'user_list_small_item_content' append}
+ {block 'user_list_add_item_content'}
+ {* Действия *}
+ {if $showActions}
+
+ {block 'user_list_add_item_actions'}
+ {if $showRemove|default:true}
+
+ {component 'icon' icon='remove'}
+
+ {/if}
+ {/block}
+
+ {/if}
+ {/block}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/user-list-add/js/user-list-add.js b/application/frontend/components/user-list-add/js/user-list-add.js
new file mode 100644
index 0000000..d54cbeb
--- /dev/null
+++ b/application/frontend/components/user-list-add/js/user-list-add.js
@@ -0,0 +1,176 @@
+/**
+ * Пополняемый список пользователей
+ *
+ * @module ls/user-list-add
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsUserListAdd", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ urls: {
+ add: null,
+ remove: null,
+ list: aRouter.ajax + 'modal-friend-list'
+ },
+ // Селекторы
+ selectors: {
+ // Блок со списком объектов
+ list: '.js-user-list-add-users',
+ // Объект
+ item: '.js-user-list-small-item',
+ // Кнопка удаления объекта
+ item_remove: '.js-user-list-add-user-remove',
+ // Сообщение о пустом списке
+ empty: '.js-user-list-small-empty',
+ // Форма добавления
+ form: '.js-user-list-add-form',
+ // Выбор пользователей
+ choose: '.js-user-list-add-choose'
+ },
+ // Анимация при скрытии объекта
+ hide: {
+ effect: 'slide',
+ duration: 200,
+ direction: 'left'
+ },
+ // Ajax параметры
+ params: {},
+ i18n: {
+ success_add: '@common.success.add'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ var _this = this;
+
+ this._super();
+
+ // Удаление пользователя из списка
+ this.elements.list.on('click' + this.eventNamespace, this.options.selectors.item_remove, function (e) {
+ _this.remove( $(this).data('user-id') );
+ e.preventDefault();
+ });
+
+ // Добавление пользователя в список
+ this.elements.form.on('submit' + this.eventNamespace, function (e) {
+ var items = _this.elements.choose.lsUserFieldChoose( 'getUsers' );
+
+ if ( items.length ) {
+ ls.utils.formLock( _this.elements.form );
+ _this.add( items );
+ }
+
+ e.preventDefault();
+ });
+
+ // Выбор пользователей
+ this.elements.choose.lsUserFieldChoose({
+ urls: {
+ modal: this.option( 'urls.list' )
+ }
+ });
+ },
+
+ /**
+ * Добавление объекта
+ */
+ add: function( users ) {
+ if ( ! users ) return;
+
+ this._load( 'add', { 'users': users }, '_onAdd' );
+ },
+
+ /**
+ * Коллбэк вызываемый при добавлении объекта
+ */
+ _onAdd: function ( response ) {
+ var users = this._getUsersAll();
+
+ // Составляем список добавляемых объектов
+ var itemsHtml = $.map( response.users, function ( item ) {
+ if ( item.bStateError ) {
+ ls.msg.error( null, item.sMsg );
+ } else {
+ ls.msg.notice( null, this._i18n( 'success_add' ) );
+
+ this._trigger( "afteruseradd", null, { context: this, item: item, response: response } );
+
+ this._onUserAdd( item );
+
+ // Не добавляем юзера если он уже есть в списке
+ return users.filter( '[data-user-id=' + item.user_id + ']' ).length ? null : item.html;
+ }
+ }.bind(this)).join('');
+
+ if ( itemsHtml ) {
+ this.elements.empty.hide();
+ this.elements.list.show().prepend( itemsHtml );
+ }
+
+ ls.utils.formUnlock( this.elements.form );
+ this.elements.choose.lsUserFieldChoose( 'empty' );
+
+ this._trigger( "afteradd", null, { context: this, response: response } );
+ },
+
+ _onUserAdd: function (item) {
+ return;
+ },
+
+ /**
+ * Удаление объекта
+ */
+ remove: function( userId ) {
+ if ( ! this.options.urls.remove ) return;
+
+ var _this = this;
+
+ this._load( 'remove', { user_id: userId }, function( response ) {
+ this._hide( this._getUserById( userId ), this.options.hide, function () {
+ $( this ).remove();
+
+ // Скрываем список если объектов в нем больше нет
+ if ( ! _this.elements.list.find( _this.options.selectors.item ).length ) {
+ _this.elements.list.hide();
+ _this.elements.empty.show();
+ }
+ });
+
+ this._trigger( "afterremove", null, { context: this, response: response } );
+ });
+ },
+
+ /**
+ * Получает пользователя по ID
+ *
+ * @private
+ * @param {Number} userId , ID объекта
+ * @return {jQuery} Объект
+ */
+ _getUserById: function( userId ) {
+ return this.elements.list.find( this.options.selectors.item + '[data-user-id=' + userId + ']' );
+ },
+
+ /**
+ * Получает всех пользователей
+ */
+ _getUsersAll: function() {
+ return this.elements.list.find( this.options.selectors.item );
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/user-list-add/list.tpl b/application/frontend/components/user-list-add/list.tpl
new file mode 100644
index 0000000..ae4fe99
--- /dev/null
+++ b/application/frontend/components/user-list-add/list.tpl
@@ -0,0 +1,17 @@
+{**
+ * Список пользователей
+ *
+ * @param object $users
+ * @param string $title
+ * @param boolean $hideableEmptyAlert
+ * @param boolean $show
+ * @param array $exclude
+ *}
+
+{extends 'component@user.user-list-small'}
+
+{block 'user_list_small_item'}
+ {block 'user_list_add_item'}
+ {component 'user-list-add' template='item' user=$user showActions=true}
+ {/block}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/user-list-add/user-list-add.tpl b/application/frontend/components/user-list-add/user-list-add.tpl
new file mode 100644
index 0000000..b56303b
--- /dev/null
+++ b/application/frontend/components/user-list-add/user-list-add.tpl
@@ -0,0 +1,53 @@
+{**
+ * Пополняемый список пользователей
+ *
+ * @param array $users
+ * @param string $title
+ * @param string $note
+ * @param boolean $editable
+ *
+ * @param string $classes
+ * @param array $attributes
+ * @param array $mods
+ *}
+
+{* Название компонента *}
+{$component = 'user-list-add'}
+{component_define_params params=[ 'title', 'note', 'editable', 'users', 'mods', 'classes', 'attributes' ]}
+
+{* Форма добавления *}
+
+ {* Заголовок *}
+ {if $title}
+
{$title}
+ {/if}
+
+ {* Описание *}
+ {if $note}
+
{$note}
+ {/if}
+
+ {* Форма добавления *}
+ {if $editable|default:true}
+
+ {/if}
+
+ {* Список пользователей *}
+ {* TODO: Изменить порядок вывода - сначало новые *}
+ {block 'user_list_add_list'}
+ {component 'user-list-add' template='list'
+ hideableEmptyAlert = true
+ users = $users
+ showActions = true
+ show = !! $users
+ classes = "js-$component-users"
+ itemClasses = "js-$component-user"}
+ {/block}
+
\ No newline at end of file
diff --git a/application/frontend/components/user/README.md b/application/frontend/components/user/README.md
new file mode 100644
index 0000000..cc5c619
--- /dev/null
+++ b/application/frontend/components/user/README.md
@@ -0,0 +1 @@
+# Компонент user
\ No newline at end of file
diff --git a/application/frontend/components/user/actions.tpl b/application/frontend/components/user/actions.tpl
new file mode 100644
index 0000000..917b724
--- /dev/null
+++ b/application/frontend/components/user/actions.tpl
@@ -0,0 +1,24 @@
+{**
+ * Список действий
+ *
+ * @param object $user
+ *}
+
+{component_define_params params=[ 'user' ]}
+
+{component 'nav'
+ hook = 'user_actions'
+ hookParams = [ user => $user ]
+ mods = 'stacked'
+ classes = 'profile-actions'
+ items = [
+ [ 'html' => {component 'user' template='friend-item' friendship=$user->getUserFriend() userTarget=$oUserProfile classes='js-user-friend'} ],
+ [ 'url' => "{router page='talk'}add/?talk_recepient_id={$user->getId()}", 'text' => {lang 'user.actions.send_message'} ],
+ [
+ 'url' => "#",
+ 'classes' => "js-user-follow {if $user->isFollow()}active{/if}",
+ 'attributes' => [ 'data-id' => $user->getId(), 'data-login' => $user->getLogin() ],
+ 'text' => {lang name="user.actions.{( $user->isFollow() ) ? 'unfollow' : 'follow'}"}
+ ],
+ [ 'url' => "#", 'text' => {lang 'user.actions.report'}, classes => 'js-user-report', 'attributes' => [ 'data-param-target_id' => $user->getId() ] ]
+ ]}
\ No newline at end of file
diff --git a/application/frontend/components/user/avatar/user-avatar-list.tpl b/application/frontend/components/user/avatar/user-avatar-list.tpl
new file mode 100644
index 0000000..20c60da
--- /dev/null
+++ b/application/frontend/components/user/avatar/user-avatar-list.tpl
@@ -0,0 +1,22 @@
+{**
+ * Список пользователей (аватары)
+ *
+ * @param array $users Список пользователей
+ * @param array $pagination Массив с параметрами пагинации
+ * @param array $emptyText
+ *}
+
+{component_define_params params=[ 'size' ]}
+
+{$items = []}
+
+{foreach $users as $user}
+ {* TODO: Костыль для блогов *}
+ {if $user->getUser()}{$user = $user->getUser()}{/if}
+
+ {$items[] = {component 'user' template='avatar' size=$smarty.local.size|default:'small' user=$user}}
+{/foreach}
+
+{component 'avatar' template='list' items=$items params=$params}
+
+{component 'pagination' total=+$pagination.iCountPage current=+$pagination.iCurrentPage url="{$pagination.sBaseUrl}/page__page__/"}
\ No newline at end of file
diff --git a/application/frontend/components/user/avatar/user-avatar.tpl b/application/frontend/components/user/avatar/user-avatar.tpl
new file mode 100644
index 0000000..7e01e4a
--- /dev/null
+++ b/application/frontend/components/user/avatar/user-avatar.tpl
@@ -0,0 +1,26 @@
+{**
+ * Блок с аватаркой и именем пользователя
+ *
+ * @param object $user
+ * @param string $classes
+ * @param array $attributes
+ * @param array $mods
+ *}
+
+{component_define_params params=[ 'user', 'size' ]}
+
+{$sizes = [
+ 'large' => 200,
+ 'default' => 100,
+ 'small' => 64,
+ 'xsmall' => 48,
+ 'xxsmall' => 24,
+ 'text' => 24
+]}
+
+{component 'avatar'
+ image = $user->getProfileAvatarPath( $sizes[ $size|default:'default' ] )
+ url = $user->getUserWebPath()
+ classes = 'user-item'
+ name = $user->getDisplayName()
+ params = $params}
\ No newline at end of file
diff --git a/application/frontend/components/user/blocks/block.user-actions.tpl b/application/frontend/components/user/blocks/block.user-actions.tpl
new file mode 100644
index 0000000..fe1837c
--- /dev/null
+++ b/application/frontend/components/user/blocks/block.user-actions.tpl
@@ -0,0 +1,9 @@
+{**
+ * Меню пользователя ("Добавить в друзья", "Написать письмо" и т.д.)
+ *}
+
+{if $oUserCurrent && $oUserCurrent->getId() != $oUserProfile->getId() }
+ {component 'block'
+ mods = 'nopadding transparent user-actions'
+ content = {component 'user' template='actions' user=$oUserProfile}}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/user/blocks/block.user-nav.tpl b/application/frontend/components/user/blocks/block.user-nav.tpl
new file mode 100644
index 0000000..54760d1
--- /dev/null
+++ b/application/frontend/components/user/blocks/block.user-nav.tpl
@@ -0,0 +1,5 @@
+{**
+ * Блок с навигацией по профилю пользователя
+ *}
+
+{component 'block' mods='nopadding transparent user-nav' content={component 'user' template='nav'}}
\ No newline at end of file
diff --git a/application/frontend/components/user/blocks/block.user-note.tpl b/application/frontend/components/user/blocks/block.user-note.tpl
new file mode 100644
index 0000000..c27c74a
--- /dev/null
+++ b/application/frontend/components/user/blocks/block.user-note.tpl
@@ -0,0 +1,9 @@
+{**
+ * Блок с заметкой о пользователе
+ *}
+
+{if $oUserCurrent && $oUserCurrent->getId() != $oUserProfile->getId() }
+ {component 'block'
+ mods = 'nopadding transparent user-note'
+ content = {component 'note' classes='js-user-note' note=$oUserProfile->getUserNote() targetId=$oUserProfile->getId()}}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/user/blocks/block.user-photo.tpl b/application/frontend/components/user/blocks/block.user-photo.tpl
new file mode 100644
index 0000000..6f7c9f4
--- /dev/null
+++ b/application/frontend/components/user/blocks/block.user-photo.tpl
@@ -0,0 +1,37 @@
+{**
+ * Блок с фотографией пользователя в профиле
+ *}
+
+{capture 'block_content'}
+ {$session = $oUserProfile->getSession()}
+
+ {* Статус онлайн\оффлайн *}
+ {if $session}
+ {if $oUserProfile->isOnline() && $smarty.now - strtotime($session->getDateLast()) < 60*5}
+ {$aLang.user.status.online}
+ {else}
+
+ {$date = {date_format date=$session->getDateLast() hours_back="12" minutes_back="60" day_back="8" now="60*5" day="day H:i" format="j F в G:i"}|lower}
+
+ {if $oUserProfile->getProfileSex() != 'woman'}
+ {lang 'user.status.was_online_male' date=$date}
+ {else}
+ {lang 'user.status.was_online_female' date=$date}
+ {/if}
+
+ {/if}
+ {/if}
+
+ {component 'photo'
+ classes = 'js-user-photo'
+ hasPhoto = $oUserProfile->getProfileFoto()
+ editable = $oUserProfile->isAllowEdit()
+ targetId = $oUserProfile->getId()
+ url = $oUserProfile->getUserWebPath()
+ photoPath = $oUserProfile->getProfileFotoPath()
+ photoAltText = $oUserProfile->getDisplayName()}
+{/capture}
+
+{component 'block'
+ mods = 'user-photo'
+ content = $smarty.capture.block_content}
\ No newline at end of file
diff --git a/application/frontend/components/user/blocks/block.users-search.tpl b/application/frontend/components/user/blocks/block.users-search.tpl
new file mode 100644
index 0000000..ec3b471
--- /dev/null
+++ b/application/frontend/components/user/blocks/block.users-search.tpl
@@ -0,0 +1,33 @@
+{**
+ * Статистика по пользователям
+ *}
+
+{capture 'block_content'}
+ {* Сейчас на сайте *}
+ {component 'field' template='checkbox'
+ name = 'is_online'
+ inputClasses = 'js-search-ajax-user-online'
+ checked = false
+ label = {lang 'user.search.form.is_online'}}
+
+ {* Пол *}
+ Пол
+
+ {component 'field' template='radio' inputClasses='js-search-ajax-user-sex' name='sex' value='' checked=true label={lang 'user.search.form.gender.any'}}
+ {component 'field' template='radio' inputClasses='js-search-ajax-user-sex' name='sex' value='man' label={lang 'user.search.form.gender.male'}}
+ {component 'field' template='radio' inputClasses='js-search-ajax-user-sex' name='sex' value='woman' label={lang 'user.search.form.gender.female'}}
+
+
+ {* Страна/город *}
+ {component 'field' template='geo'
+ classes = 'js-field-geo-default'
+ targetType = 'user'
+ countries = $countriesUsed
+ name = 'geo'
+ label = {lang name='user.settings.profile.fields.place.label'} }
+{/capture}
+
+{component 'block'
+ mods = 'users-search'
+ title = {lang 'user.search.title'}
+ content = $smarty.capture.block_content}
\ No newline at end of file
diff --git a/application/frontend/components/user/blocks/block.users-statistics.tpl b/application/frontend/components/user/blocks/block.users-statistics.tpl
new file mode 100644
index 0000000..4b37c4e
--- /dev/null
+++ b/application/frontend/components/user/blocks/block.users-statistics.tpl
@@ -0,0 +1,32 @@
+{**
+ * Статистика по пользователям
+ *}
+
+{capture 'user_block_stat'}
+
+ {* @hook Начало блока с информацией о пользователях *}
+ {hook run='user_block_stat_begin'}
+
+ {* Кол-во пользователей *}
+ {component 'info-list' list=[
+ [ 'label' => "{lang name='user.stats.all'}:", 'content' => $usersStat.count_all ],
+ [ 'label' => "{lang name='user.stats.active'}:", 'content' => $usersStat.count_active ],
+ [ 'label' => "{lang name='user.stats.not_active'}:", 'content' => $usersStat.count_inactive ]
+ ]}
+
+ {* Пол *}
+ {component 'info-list' list=[
+ [ 'label' => "{lang name='user.stats.men'}:", 'content' => $usersStat.count_sex_man ],
+ [ 'label' => "{lang name='user.stats.women'}:", 'content' => $usersStat.count_sex_woman ],
+ [ 'label' => "{lang name='user.stats.none'}:", 'content' => $usersStat.count_sex_other ]
+ ]}
+
+ {* @hook Конец блока с информацией о пользователях *}
+ {hook run='user_block_stat_end'}
+
+{/capture}
+
+{component 'block'
+ mods = 'info users-stats'
+ title = {lang 'user.stats.title'}
+ content = $smarty.capture.user_block_stat}
\ No newline at end of file
diff --git a/application/frontend/components/user/component.json b/application/frontend/components/user/component.json
new file mode 100644
index 0000000..5d9d93b
--- /dev/null
+++ b/application/frontend/components/user/component.json
@@ -0,0 +1,66 @@
+{
+ "name": "user",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-component": "*",
+ "article": "*",
+ "comment": "*",
+ "button": "*",
+ "field": "*",
+ "photo": "*",
+ "avatar": "*",
+ "alert": "*"
+ },
+ "templates": {
+ "actions": "actions.tpl",
+ "header": "header.tpl",
+ "friend-item": "friend-item.tpl",
+ "info-group": "info-group.tpl",
+ "info": "info.tpl",
+ "search-form": "search-form.users.tpl",
+ "stat": "stat.tpl",
+ "nav": "nav.user.tpl",
+ "choose": "user-field-choose.tpl",
+
+ "avatar": "avatar/user-avatar.tpl",
+ "avatar-list": "avatar/user-avatar-list.tpl",
+
+ "list": "list/user-list.tpl",
+ "list-item": "list/user-list-item.tpl",
+ "list-loop": "list/user-list-loop.tpl",
+
+ "list-small-item": "user-list-small-item.tpl",
+ "list-small": "user-list-small.tpl",
+
+ "block.actions": "blocks/block.user-actions.tpl",
+ "block.nav": "blocks/block.user-nav.tpl",
+ "block.note": "blocks/block.user-note.tpl",
+ "block.photo": "blocks/block.user-photo.tpl",
+ "block.users-search": "blocks/block.users-search.tpl",
+ "block.users-statistics": "blocks/block.users-statistics.tpl",
+
+ "modal.add-friend": "modals/modal.add-friend.tpl",
+ "modal.crop-avatar": "modals/modal.crop-avatar.tpl",
+ "modal.crop-photo": "modals/modal.crop-photo.tpl",
+ "modal.user-list": "modals/modal.user-list.tpl",
+
+ "settings/account": "settings/account.tpl",
+ "settings/invite": "settings/invite.tpl",
+ "settings/profile": "settings/profile.tpl",
+ "settings/tuning": "settings/tuning.tpl"
+ },
+ "scripts": {
+ "fields": "js/user-fields.js",
+ "follow": "js/user-follow.js",
+ "friend": "js/user-friend.js",
+ "choose": "js/user-field-choose.js",
+ "modal-list": "js/user-modal-list.js",
+ "user": "js/user.js"
+ },
+ "styles": {
+ "blocks": "css/user-blocks.css",
+ "header": "css/user-header.css",
+ "list-small": "css/user-list-small.css",
+ "user": "css/user.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/user/css/user-blocks.css b/application/frontend/components/user/css/user-blocks.css
new file mode 100644
index 0000000..38ffab9
--- /dev/null
+++ b/application/frontend/components/user/css/user-blocks.css
@@ -0,0 +1,82 @@
+/**
+ * Фото пользователя
+ *
+ * @modifier user
+ * @template blocks/block.userPhoto.tpl
+ */
+.ls-block--user-photo .ls-block-content {
+ padding: 0;
+ position: relative;
+}
+
+/* Статус (онлайн/оффлайн) */
+.user-status {
+ position: absolute;
+ top: 20px;
+ left: 0;
+ z-index: 1;
+ padding: 12px 15px;
+ font: 300 13px/1em 'Open Sans';
+}
+.user-status--online {
+ background: #b7bc1c;
+ background: rgba(183, 188, 28, .8);
+ color: #fff;
+}
+.user-status--offline {
+ background: #333;
+ background: rgba(0, 0, 0, .6);
+ color: #fff;
+}
+
+/**
+ * Блок управления на странице пользователя
+ *
+ * @modifier user-actions
+ * @template blocks/block.userActions.tpl
+ */
+.ls-block--user-actions ul { overflow: hidden; background: #fff; border: 1px solid #eee; border-radius: 5px; }
+.ls-block--user-actions ul li { margin-bottom: 0; }
+.ls-block--user-actions ul li > span,
+.ls-block--user-actions ul li a { display: block; padding: 10px 15px; color: #777; cursor: pointer; }
+.ls-block--user-actions ul li > span:hover,
+.ls-block--user-actions ul li a:hover { background: #fafafa; color: #333; }
+.ls-block--user-actions ul li a.followed { color: #f00; }
+
+/**
+ * Навигация по профилю пользователя
+ *
+ * @modifier user-nav
+ * @template blocks/block.userNav.tpl
+ */
+.ls-nav.ls-nav--stacked.user-nav {
+ overflow: hidden;
+ border: 1px solid #eee;
+ border-radius: 5px;
+}
+.ls-nav.ls-nav--stacked.user-nav > li {
+ margin-bottom: 0;
+ background-color: transparent;
+ border-bottom: 1px solid #f4f4f4;
+}
+.ls-nav.ls-nav--stacked.user-nav > li .ls-badge {
+ background: #aaa;
+}
+.ls-nav.ls-nav--stacked.user-nav > li a {
+ padding: 11px 13px;
+ display: block;
+ background: #fff;
+ color: #555;
+ cursor: pointer;
+}
+.ls-nav.ls-nav--stacked.user-nav > li a:hover {
+ background: #fafafa;
+ color: #333;
+}
+.ls-nav.ls-nav--stacked.user-nav > li.active a {
+ background: #2891D3;
+ color: #fff;
+}
+.ls-nav.ls-nav--stacked.user-nav > li.active .ls-badge {
+ background: #2379B0;
+}
\ No newline at end of file
diff --git a/application/frontend/components/user/css/user-header.css b/application/frontend/components/user/css/user-header.css
new file mode 100644
index 0000000..349c9ab
--- /dev/null
+++ b/application/frontend/components/user/css/user-header.css
@@ -0,0 +1,123 @@
+/**
+ * Шапка профиля
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+.user-profile {
+ position: relative;
+ padding: 30px;
+ margin: -40px -40px 30px -40px;
+ border-bottom: 1px solid #eee;
+}
+
+.user-profile-user {
+ padding-right: 100px;
+}
+
+/* Аватар */
+.user-profile-user-avatar {
+ float: left;
+ width: 70px;
+ height: 70px;
+ margin: 0 18px 0 0;
+ border-radius: 100px;
+}
+.user-profile--is-online .user-profile-user-avatar {
+ background: #b7bc1c;
+}
+
+/* Логин и имя */
+.user-profile-user-body {
+ float: left;
+ padding-top: 18px;
+}
+.user-profile--has-name .user-profile-user-body {
+ padding-top: 7px;
+}
+.user-profile-user-login {
+ margin-bottom: 1px;
+ font-size: 27px;
+ line-height: 1.3em;
+}
+.user-profile-user-login,
+.user-profile-user-login a {
+ color: #eee;
+ color: rgba(0, 0, 0, .9);
+}
+.user-profile-user-login a:hover {
+ color: #fff;
+ color: rgba(0, 0, 0, 1);
+}
+.user-profile-user-name {
+ color: #aaa;
+ color: rgba(0, 0, 0, .5);
+}
+.user-profile-user-login,
+.user-profile-user-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: "Open Sans", sans-serif;
+}
+
+/* Рейтинг */
+.user-profile-rating {
+ position: absolute;
+ top: 46px;
+ right: 40px;
+ text-transform: uppercase;
+ font-size: 11px;
+ text-align: right;
+ color: rgba(0, 0, 0, .8);
+}
+.user-profile-rating-label {
+ color: rgba(0, 0, 0, .4);
+}
+.user-profile-rating-value {
+ font: 300 24px/1em "Open Sans", sans-serif;
+}
+
+
+
+/**
+ * Responsive
+ */
+@media (max-width: 999px) {
+ .user-profile {
+ margin: -15px -15px 15px -15px;
+ }
+}
+@media (max-width: 480px) {
+ .user-profile {
+ text-align: center;
+ margin: -15px -15px 15px -15px;
+ }
+ .user-profile-user,
+ .user-profile-user-avatar,
+ .user-profile-user-body {
+ float: none;
+ }
+ .user-profile-user-body {
+ margin-bottom: 15px;
+ }
+ .user-profile-user {
+ padding-right: 0;
+ }
+ .user-profile-user-avatar {
+ width: 100px;
+ height: 100px;
+ margin-right: 0;
+ }
+ .user-profile-rating {
+ position: static;
+ text-align: inherit;
+ }
+ .user-profile-rating-value,
+ .user-profile-rating-label {
+ font-size: inherit;
+ display: inline;
+ }
+}
diff --git a/application/frontend/components/user/css/user-list-small.css b/application/frontend/components/user/css/user-list-small.css
new file mode 100644
index 0000000..7d20cab
--- /dev/null
+++ b/application/frontend/components/user/css/user-list-small.css
@@ -0,0 +1,14 @@
+/**
+ * Список пользователей
+ */
+.user-list-small { margin-bottom: 15px; max-height: 440px; overflow: auto; }
+.user-list-small:last-child { margin-bottom: 0; }
+
+.user-list-small-item { background: #fafafa; padding: 10px 55px 10px 10px; margin-bottom: 1px; position: relative; }
+.user-list-small-item:last-child { margin-bottom: 0; }
+.user-list-small-item input { vertical-align: middle; }
+.user-list-small-item.selected { background: #ffc; }
+
+.user-list-small-item-actions { position: absolute; top: 50%; right: 13px; margin-top: -8px; }
+.user-list-small-item-actions li { display: inline-block; cursor: pointer; opacity: .7; }
+.user-list-small-item-actions li:hover { opacity: 1; }
\ No newline at end of file
diff --git a/application/frontend/components/user/css/user.css b/application/frontend/components/user/css/user.css
new file mode 100644
index 0000000..dc800e4
--- /dev/null
+++ b/application/frontend/components/user/css/user.css
@@ -0,0 +1,31 @@
+/**
+ * Профиль пользователя
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+
+/* Заголовок */
+.profile-page-header {
+ font-size: 25px;
+ margin-bottom: 25px;
+ color: #555;
+}
+
+/* Блок с информацией */
+.user-info-group {
+ margin-bottom: 50px;
+}
+.user-info-group:last-child {
+ margin-bottom: 0;
+}
+.user-info-group-title {
+ font-size: 18px;
+}
+
+/* Список */
+.user-info-group-items .ls-info-list-item-label { width: 150px; }
+.user-info-group-items .ls-info-list-item { margin-bottom: 10px; }
+.user-info-group-items .ls-info-list-item-content { font-weight: normal; }
\ No newline at end of file
diff --git a/application/frontend/components/user/friend-item.tpl b/application/frontend/components/user/friend-item.tpl
new file mode 100644
index 0000000..d8dbad8
--- /dev/null
+++ b/application/frontend/components/user/friend-item.tpl
@@ -0,0 +1,54 @@
+{**
+ * Добавление / удаление из друзей
+ *}
+
+{$component = 'user-friend'}
+{component_define_params params=[ 'friendship', 'tag', 'userTarget', 'mods', 'classes', 'attributes' ]}
+
+{block 'user_friend_options'}
+ {$tag = $tag|default:'li'}
+
+ {if $friendship}
+ {$status = $friendship->getFriendStatus()}
+ {$userCurrentId = $oUserCurrent->getId()}
+ {$userToId = $friendship->getUserTo()}
+
+ {* Добавлен *}
+ {if $status == $USER_FRIEND_ACCEPT + $USER_FRIEND_OFFER || $status == $USER_FRIEND_ACCEPT + $USER_FRIEND_ACCEPT}
+ {$status = 'added'}
+
+ {* Ожидает подтверждения *}
+ {elseif ( $friendship->getStatusTo() == $USER_FRIEND_REJECT && $friendship->getStatusFrom() == $USER_FRIEND_OFFER && $userToId == $userCurrentId )
+ || ( $status == $USER_FRIEND_OFFER + $USER_FRIEND_NULL && $userCurrentId == $userToId )}
+ {$status = 'pending'}
+
+ {* Приглашение отклонено *}
+ {elseif $status == $USER_FRIEND_OFFER + $USER_FRIEND_REJECT && $userToId != $userCurrentId}
+ {$status = 'rejected'}
+
+ {* Приглашение отправлено *}
+ {elseif $status == $USER_FRIEND_OFFER + $USER_FRIEND_NULL && $userCurrentId == $friendship->getUserFrom()}
+ {$status = 'sent'}
+
+ {* Текущий пользователь удалил из друзей target пользователя, *}
+ {* но предложение target пользователя еще в силе *}
+ {else}
+ {$status = 'linked'}
+ {/if}
+
+ {* Добавить в друзья *}
+ {else}
+ {$status = 'notfriends'}
+ {/if}
+{/block}
+
+
+<{$tag} class="{$component} {cmods name=$component mods=$mods} {$classes}" {cattr list=$attributes} data-status="{$status}" data-target="{$userTarget->getId()}">
+ {block 'user_friend'}
+ {if in_array( $status, [ 'sent', 'rejected' ] )}
+ {lang name="user.friends.status.{$status}"}
+ {else}
+ {lang name="user.friends.status.{$status}"}
+ {/if}
+ {/block}
+{$tag}>
\ No newline at end of file
diff --git a/application/frontend/components/user/header.tpl b/application/frontend/components/user/header.tpl
new file mode 100644
index 0000000..125c6a7
--- /dev/null
+++ b/application/frontend/components/user/header.tpl
@@ -0,0 +1,53 @@
+{**
+ * Шапка профиля
+ *}
+
+{$component = 'user-profile'}
+{component_define_params params=[ 'user', 'mods', 'classes', 'attributes' ]}
+
+{if $user->getProfileName()}
+ {$mods = "{$mods} has-name"}
+{/if}
+
+{if $user->isOnline()}
+ {$mods = "{$mods} is-online"}
+{/if}
+
+
+ {* @hook Начало шапки с информацией о пользователе *}
+ {hook run='user_header_begin' user=$user}
+
+ {* Пользователь *}
+
+
+
+
+
+
+
+
+ {if $user->getProfileName()}
+
+ {$user->getProfileName()|escape}
+
+ {/if}
+
+
+
+ {* Рейтинг *}
+
+ {* @hook Рейтинг пользователя *}
+ {hookb run='user_rating' user=$user}
+
+
{lang 'vote.rating'}
+
{$user->getRating()}
+
+ {/hookb}
+
+ {* @hook Конец шапки с информацией о пользователе *}
+ {hook run='user_header_end' user=$user}
+
\ No newline at end of file
diff --git a/application/frontend/components/user/info-group.tpl b/application/frontend/components/user/info-group.tpl
new file mode 100644
index 0000000..efac9cf
--- /dev/null
+++ b/application/frontend/components/user/info-group.tpl
@@ -0,0 +1,40 @@
+{**
+ * Блок с информацией
+ *}
+
+{$component = 'user-info-group'}
+{component_define_params params=[ 'url', 'count', 'html', 'items', 'name', 'title', 'mods', 'classes', 'attributes' ]}
+
+{hook run="{$component}-{$name}-before"}
+
+{* Получаем пункты установленные плагинами *}
+{hook run="{$component}-{$name}-items" assign='itemsHook' items=$items array=true}
+{$items = ($itemsHook) ? $itemsHook : $items}
+
+{if $html || $items}
+
+ {if $title}
+
+ {if $url}
+ {$title}
+ {else}
+ {$title}
+ {/if}
+
+ {if $count}
+ {$count}
+ {/if}
+
+ {/if}
+
+
+ {if $html}
+ {$html}
+ {else}
+ {component 'info-list' list=$items classes='user-info-group-items'}
+ {/if}
+
+
+{/if}
+
+{hook run="{$component}-{$name}-after"}
\ No newline at end of file
diff --git a/application/frontend/components/user/info.tpl b/application/frontend/components/user/info.tpl
new file mode 100644
index 0000000..8b22e76
--- /dev/null
+++ b/application/frontend/components/user/info.tpl
@@ -0,0 +1,242 @@
+{**
+ * Информация о пользователе
+ *
+ * @param object $user
+ * @param array usersInvited
+ * @param object invitedByUser
+ * @param array blogsJoined
+ * @param array blogsModerate
+ * @param array blogsAdminister
+ * @param array blogsCreated
+ * @param array usersFriend
+ *}
+
+{component_define_params params=[ 'blogsAdminister', 'blogsCreated', 'blogsJoined', 'blogsModerate', 'friends', 'invitedByUser', 'user', 'usersInvited', 'mods', 'classes', 'attributes' ]}
+
+{$session = $user->getSession()}
+{$geoTarget = $user->getGeoTarget()}
+
+{* @hook Начало информации о пользователе *}
+{hook run='user_info_begin' user=$user}
+
+{**
+ * О себе
+ *}
+{if $user->getProfileAbout()}
+ {capture 'user_info_about'}
+
+ {$user->getProfileAbout()}
+
+ {/capture}
+
+ {component 'user' template='info-group' title={lang name='user.profile.about.title'} html=$smarty.capture.user_info_about}
+{/if}
+
+
+{**
+ * Личное
+ *}
+{$items = []}
+{$userfields = $user->getUserFieldValues(true, array(''))}
+
+{* Пол *}
+{if $user->getProfileSex() != 'other'}
+ {$items[] = [
+ 'label' => {lang name='user.profile.personal.gender'},
+ 'content' => "{if $user->getProfileSex() == 'man'}{lang name='user.profile.personal.gender_male'}{else}{lang name='user.profile.personal.gender_female'}{/if}"
+ ]}
+{/if}
+
+{* День рождения *}
+{if $user->getProfileBirthday()}
+ {$items[] = [
+ 'label' => {lang name='user.profile.personal.birthday'},
+ 'content' => {date_format date=$user->getProfileBirthday() format="j F Y" notz=true}
+ ]}
+{/if}
+
+{* Местоположение *}
+{if $geoTarget}
+ {capture 'info_private_geo'}
+
+ {if $geoTarget->getCountryId()}
+ {$user->getProfileCountry()|escape} {if $geoTarget->getCityId()},{/if}
+ {/if}
+
+ {if $geoTarget->getCityId()}
+ {$user->getProfileCity()|escape}
+ {/if}
+
+ {/capture}
+
+ {$items[] = [
+ 'label' => {lang name='user.profile.personal.place'},
+ 'content' => $smarty.capture.info_private_geo
+ ]}
+{/if}
+
+{component 'user' template='info-group' title={lang name='user.profile.personal.title'} items=$items}
+
+
+{**
+ * Контакты
+ *}
+{$items = []}
+{$userfields = $user->getUserFieldValues(true, array('contact'))}
+
+{foreach $userfields as $field}
+ {$items[] = [
+ 'label' => $field->getTitle()|escape,
+ 'content' => $field->getValue(true, true)
+ ]}
+{/foreach}
+
+{component 'user' template='info-group' name='contact' title={lang name='user.profile.contact'} items=$items}
+
+
+{**
+ * Соц. сети
+ *}
+{$items = []}
+{$userfields = $user->getUserFieldValues(true, array('social'))}
+
+{foreach $userfields as $field}
+ {$items[] = [
+ 'label' => $field->getTitle()|escape,
+ 'content' => $field->getValue(true, true)
+ ]}
+{/foreach}
+
+{component 'user' template='info-group' name='social-networks' title={lang name='user.profile.social_networks'} items=$items}
+
+
+
+{**
+ * Активность
+ *}
+{$items = []}
+
+{* Кто пригласил пользователя *}
+{if $invitedByUser}
+ {$items[] = [
+ 'label' => {lang name='user.profile.activity.invited_by'},
+ 'content' => "getUserWebPath()}\">{$invitedByUser->getDisplayName()} "
+ ]}
+{/if}
+
+{* Приглашенные пользователем *}
+{if $usersInvited}
+ {$users = ''}
+
+ {foreach $usersInvited as $userInvited}
+ {$users = $users|cat:"getUserWebPath()}\">{$userInvited->getDisplayName()} "}
+ {/foreach}
+
+ {$items[] = [
+ 'label' => {lang name='user.profile.activity.invited'},
+ 'content' => $users
+ ]}
+{/if}
+
+{* Блоги созданные пользователем *}
+{if $blogsCreated}
+ {$blogs = ''}
+
+ {foreach $blogsCreated as $blog}
+ {$blogs = $blogs|cat:"getUrlFull()}\">{$blog->getTitle()|escape} {if ! $blog@last}, {/if}"}
+ {/foreach}
+
+ {$items[] = [
+ 'label' => {lang name='user.profile.activity.blogs_created'},
+ 'content' => $blogs
+ ]}
+{/if}
+
+{* Блоги администрируемые пользователем *}
+{if $blogsAdminister}
+ {$blogs = ''}
+
+ {foreach $blogsAdminister as $blogUser}
+ {$blog = $blogUser->getBlog()}
+ {$blogs = $blogs|cat:"getUrlFull()}\">{$blog->getTitle()|escape} {if ! $blogUser@last}, {/if}"}
+ {/foreach}
+
+ {$items[] = [
+ 'label' => {lang name='user.profile.activity.blogs_admin'},
+ 'content' => $blogs
+ ]}
+{/if}
+
+{* Блоги модерируемые пользователем *}
+{if $blogsModerate}
+ {$blogs = ''}
+
+ {foreach $blogsModerate as $blogUser}
+ {$blog = $blogUser->getBlog()}
+ {$blogs = $blogs|cat:"getUrlFull()}\">{$blog->getTitle()|escape} {if ! $blogUser@last}, {/if}"}
+ {/foreach}
+
+ {$items[] = [
+ 'label' => {lang name='user.profile.activity.blogs_mod'},
+ 'content' => $blogs
+ ]}
+{/if}
+
+{* Блоги в которые вступил пользователь *}
+{if $blogsJoined}
+ {$blogs = ''}
+
+ {foreach $blogsJoined as $blogUser}
+ {$blog = $blogUser->getBlog()}
+ {$blogs = $blogs|cat:"getUrlFull()}\">{$blog->getTitle()|escape} {if ! $blogUser@last}, {/if}"}
+ {/foreach}
+
+ {$items[] = [
+ 'label' => {lang name='user.profile.activity.blogs_joined'},
+ 'content' => $blogs
+ ]}
+{/if}
+
+{* Дата регистрации *}
+{$items[] = [
+ 'label' => {lang name='user.date_registration'},
+ 'content' => {date_format date=$user->getDateRegister()}
+]}
+
+{* Дата последнего визита *}
+{if $session}
+ {$items[] = [
+ 'label' => {lang name='user.date_last_session'},
+ 'content' => {date_format date=$session->getDateLast()}
+ ]}
+{/if}
+
+{component 'user' template='info-group' name='activity' title={lang name='user.profile.activity.title'} items=$items}
+
+{**
+ * Друзья
+ *}
+{if $friends}
+ {capture 'user_info_friends'}
+ {component 'user' template='avatar-list' users=$friends}
+ {/capture}
+
+ {component 'user' template='info-group'
+ title = "getUserWebPath()}friends/\">{$aLang.user.friends.title} ({$iCountFriendsUser})"
+ html = $smarty.capture.user_info_friends}
+{/if}
+
+{**
+ * Стена
+ *}
+{capture 'user_info_wall'}
+ {insert name='block' block='wall' params=[
+ 'classes' => 'js-wall-default',
+ 'user_id' => $user->getId()
+ ]}
+{/capture}
+
+{component 'user' template='info-group' name='wall' title={lang name='wall.title'} html=$smarty.capture.user_info_wall}
+
+{* @hook Конец информации о пользователе *}
+{hook run='user_info_end' user=$user}
\ No newline at end of file
diff --git a/application/frontend/components/user/js/user-field-choose.js b/application/frontend/components/user/js/user-field-choose.js
new file mode 100644
index 0000000..7661327
--- /dev/null
+++ b/application/frontend/components/user/js/user-field-choose.js
@@ -0,0 +1,97 @@
+/**
+ * Выбор пользователей
+ *
+ * @module ls/user/field-choose
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsUserFieldChoose", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ modal: null,
+ autocomplete: aRouter.ajax + 'autocompleter/user/'
+ },
+ // Селекторы
+ selectors: {
+ // Список пользователей
+ users: '.js-user-field-choose-users',
+ // Выбор пользователей
+ button: '.js-user-field-choose-button'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ // Показывает модальное окно со списком пользователей
+ // и принимает от него список выбранных пользователей
+ this.elements.button.on( 'click', function (e) {
+ ls.userModalList.show( this.option( 'urls.modal' ), true, this.onModalListAdd.bind(this) );
+
+ e.preventDefault();
+ }.bind(this));
+
+ this.elements.users.lsFieldAutocomplete({
+ urls: {
+ load: this.option( 'urls.autocomplete' )
+ },
+ params: {
+ extended: true
+ }
+ });
+ },
+
+ /**
+ * Получает список выбранных пользователей
+ *
+ * @return {Array} Массив с выбранными пользователями
+ */
+ getUsers: function () {
+ return this.elements.users.val();
+ },
+
+ /**
+ * Очищает поле со списком пользователей
+ */
+ empty: function () {
+ this.elements.users.empty().trigger('chosen:updated');
+ },
+
+ /**
+ * Коллбэк вызываемый при отправке формы в мод. окне
+ *
+ * @param {Array} users Список выбранных пользователей
+ */
+ onModalListAdd: function (users) {
+ var currentUsers = this.elements.users.val();
+
+ $.each(users, function (index, user) {
+ if ($.inArray(user.id + "", currentUsers) !== -1) return;
+
+ $(' ')
+ .attr('value', user.id)
+ .prop('selected', true)
+ .html(user.login)
+ .appendTo(this.elements.users);
+ }.bind(this));
+
+ this.elements.users.trigger('chosen:updated');
+ },
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/user/js/user-fields.js b/application/frontend/components/user/js/user-fields.js
new file mode 100644
index 0000000..919af78
--- /dev/null
+++ b/application/frontend/components/user/js/user-fields.js
@@ -0,0 +1,130 @@
+/**
+ * User fields
+ *
+ * @module ls/user/fields
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsUserFields", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Селекторы
+ selectors: {
+ template: '#user-field-template',
+ list: '.js-user-field-list',
+ field: '.js-user-field-item',
+ field_remove: '.js-user-field-item-remove',
+ empty: '.js-user-fields-empty',
+ submit: '.js-user-fields-submit'
+ },
+ max: 3,
+ i18n: {
+ error_max_userfields: '@user.settings.profile.notices.error_max_userfields',
+ remove_confirm: '@common.remove_confirm'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ this.elements = {
+ template: $( this.option( 'selectors.template' ) ),
+ empty: this.element.find( this.option( 'selectors.empty' ) ),
+ list: this.element.find( this.option( 'selectors.list' ) ),
+ submit: this.element.find( this.option( 'selectors.submit' ) )
+ };
+
+ this.elements.submit.on( 'click' + this.eventNamespace, this.add.bind( this ) );
+ this.element.on( 'click' + this.eventNamespace, this.option( 'selectors.field_remove' ), this.remove.bind( this ) );
+ this.element.on( 'change' + this.eventNamespace, 'select', this.change.bind( this ) );
+ },
+
+ /**
+ * Добавление контакта
+ */
+ add: function( event ) {
+ var typeId, template = this.getTemplate();
+
+ template.find( 'option' ).each(function ( key, value ) {
+ var id = $( value ).val();
+
+ if ( this.getCountByTypeId( id ) < this.option( 'max' ) ) {
+ typeId = id;
+ return false;
+ }
+ }.bind( this ));
+
+ if ( typeId ) {
+ template.find( 'select' ).val( typeId );
+ this.elements.list.append( template );
+ } else {
+ template = null;
+ ls.msg.error( null, this._i18n( 'error_max_userfields', { count: this.option( 'max' ) } ) );
+ }
+
+ this.elements.empty.hide();
+ },
+
+ /**
+ * Удаление контакта
+ */
+ remove: function( event ) {
+ if ( ! confirm( this._i18n( 'remove_confirm' ) ) ) return;
+
+ $( event.target )
+ .off()
+ .closest( this.option( 'selectors.field' ) )
+ .remove();
+
+ if ( this.getCount() === 0 ) {
+ this.elements.empty.show();
+ }
+ },
+
+ /**
+ * Изменение типа
+ */
+ change: function( event ) {
+ if ( this.getCountByTypeId( $( event.target ).val() ) > this.option( 'max' ) ) {
+ ls.msg.error( null, this._i18n( 'error_max_userfields', { count: this.option( 'max' ) } ) );
+ }
+ },
+
+ /**
+ * Получает шаблон для вставки
+ */
+ getTemplate: function() {
+ return this.elements.template.clone().show();
+ },
+
+ /**
+ * Получает кол-во контактов
+ */
+ getCount: function() {
+ return this.elements.list.find( this.option( 'selectors.field' ) ).length;
+ },
+
+ /**
+ * Получает кол-во контактов определенного типа
+ */
+ getCountByTypeId: function( id ) {
+ return this.elements.list.find( 'select' ).filter(function () {
+ return $( this ).val() == id;
+ }).length;
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/user/js/user-follow.js b/application/frontend/components/user/js/user-follow.js
new file mode 100644
index 0000000..bbeaa8c
--- /dev/null
+++ b/application/frontend/components/user/js/user-follow.js
@@ -0,0 +1,93 @@
+/**
+ * Follow user
+ *
+ * @module ls/user/follow
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsUserFollow", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ // Подписаться
+ follow: null,
+
+ // Отписаться
+ unfollow: null
+ },
+ selectors: {
+ item: '> a'
+ },
+ classes: {
+ active: 'active'
+ },
+ params: {},
+ i18n: {
+ follow: '@user.actions.follow',
+ unfollow: '@user.actions.unfollow'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+ this._on({ click: 'onClick' });
+
+ if ( ! this.elements.item.length) {
+ this.elements.item = this.element;
+ }
+ },
+
+ /**
+ * Коллбэк вызываемый при клике на кнопку подписки
+ */
+ onClick: function( event ) {
+ this[ this._hasClass( 'active' ) ? 'unfollow' : 'follow' ]();
+ event.preventDefault();
+ },
+
+ /**
+ * Подписаться
+ */
+ follow: function() {
+ this._load( 'follow', { users: [ this.element.data('id') ] }, 'onFollow' );
+ },
+
+ /**
+ * Коллбэк вызываемый при подписке
+ */
+ onFollow: function( response ) {
+ this.elements.item.text( this._i18n('unfollow') );
+ this._addClass( 'active' );
+ },
+
+ /**
+ * Отписаться
+ */
+ unfollow: function() {
+ this._load( 'unfollow', { user_id: this.element.data('id') }, 'onUnfollow' );
+ },
+
+ /**
+ * Коллбэк вызываемый при отписке
+ */
+ onUnfollow: function( response ) {
+ this.elements.item.text( this._i18n('follow') );
+ this._removeClass( 'active' );
+ }
+ });
+})(jQuery);
diff --git a/application/frontend/components/user/js/user-friend.js b/application/frontend/components/user/js/user-friend.js
new file mode 100644
index 0000000..e2acf66
--- /dev/null
+++ b/application/frontend/components/user/js/user-friend.js
@@ -0,0 +1,156 @@
+/**
+ * Add friend
+ *
+ * @module ls/user/friend
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsUserFriend", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ // Добавить в друзья
+ add: null,
+
+ // Удалить из друзей
+ remove: null,
+
+ // Подтвердить
+ accept: null,
+
+ // Модальное окно с формой добавления
+ modal: null
+ },
+ selectors: {
+ form: '.js-user-friend-form',
+ text: '.js-user-friend-text'
+ },
+
+ params: {}
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ this.target = this.element.data( 'target' );
+
+ this._on({ click: 'onClick' });
+ },
+
+ /**
+ * Коллбэк вызываемый при клике на кнопку добавления в друзья
+ */
+ onClick: function( event ) {
+ var status = this.getStatus();
+
+ if ( status == 'notfriends' ) {
+ this.showForm();
+ } else if ( status == 'pending' ) {
+ this.accept();
+ } else if ( status == 'added' ) {
+ this.remove();
+ } else if ( status == 'linked' ) {
+ this.addLinked();
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ },
+
+ /**
+ * Получение статуса
+ */
+ getStatus: function() {
+ return this.element.attr( 'data-status' );
+ },
+
+ /**
+ * Установка статуса
+ */
+ setStatus: function( status ) {
+ var textElement = this.element.find( this.option( 'selectors.text' ) ),
+ textClass = this.option( 'selectors.text' ).slice( 1 );
+
+ if ( ~ [ 'sent', 'rejected' ].indexOf( status ) ) {
+ textElement.replaceWith( '' + ls.lang.get( 'user.friends.status.' + status ) + ' ' );
+ } else {
+ textElement.replaceWith( '' + ls.lang.get( 'user.friends.status.' + status ) + ' ' );
+ }
+
+ this.element.attr( 'data-status', status );
+ },
+
+ /**
+ * Показывает форму
+ */
+ showForm: function() {
+ var _this = this;
+
+ ls.modal.load( this.option( 'urls.modal' ), { target: this.target }, {
+ aftershow: function( e, modal ) {
+ var form = modal.element.find( _this.option( 'selectors.form' ) ),
+ textarea = form.find( _this.option( 'selectors.text' ) );
+
+ textarea.focus();
+
+ form.on( 'submit', function ( event ) {
+ var text = textarea.val();
+
+ ls.utils.formLock( form );
+
+ _this._load( 'add', { idUser: _this.target, userText: text }, function( response ) {
+ modal.hide();
+ _this.setStatus( 'sent' );
+ }, {
+ onResponse: function () {
+ ls.utils.formUnlock( form );
+ }
+ });
+
+ event.preventDefault();
+ }.bind(this))
+ }
+ });
+ },
+
+ /**
+ * Повторное подтверждение
+ */
+ addLinked: function() {
+ this.accept( 'add' );
+ },
+
+ /**
+ * Подтверждение
+ */
+ accept: function( url ) {
+ this._load( url || 'accept', { idUser: this.target }, function( response ) {
+ this.setStatus( 'added' );
+ });
+ },
+
+ /**
+ * Удаление из друзей
+ */
+ remove: function() {
+ this._load( 'remove', { idUser: this.target }, function( response ) {
+ this.setStatus( 'linked' );
+ });
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/user/js/user-modal-list.js b/application/frontend/components/user/js/user-modal-list.js
new file mode 100644
index 0000000..f89c403
--- /dev/null
+++ b/application/frontend/components/user/js/user-modal-list.js
@@ -0,0 +1,57 @@
+/**
+ * Модальное окно со списком пользователей
+ *
+ * @module ls/user/modal-list
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+var ls = ls || {};
+
+ls.userModalList = (function ($) {
+ "use strict";
+
+ /**
+ * Инициализация
+ */
+ var init = function(event, modal) {
+ modal.element.on('click', '.js-user-list-select-add', function (e) {
+ var checkboxes = $('.js-user-list-select').find('.js-user-list-small-checkbox:checked');
+
+ // Получаем логины для добавления
+ var users = $.map(checkboxes, function(element) {
+ return {
+ id: $(element).data('user-id'),
+ login: $(element).data('user-login')
+ }
+ });
+
+ if ( $.isFunction(modal.options.add) ) {
+ modal.options.add(users);
+ }
+
+ modal.hide();
+ });
+ };
+
+ /**
+ * Показывает окно
+ *
+ * @param {String} url
+ * @param {Boolean} isSelectable
+ * @param {Object} options
+ * @param {Object} params
+ */
+ this.show = function( url, isSelectable, onAdd, options, params ) {
+ ls.modal.load( url, {
+ selectable: isSelectable
+ }, {
+ aftershow: init.bind(this),
+ add: onAdd
+ });
+ }
+
+ return this;
+}).call(ls.user || {}, jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/user/js/user.js b/application/frontend/components/user/js/user.js
new file mode 100644
index 0000000..3102c18
--- /dev/null
+++ b/application/frontend/components/user/js/user.js
@@ -0,0 +1,21 @@
+/**
+ * Управление пользователями
+ *
+ * @module ls/user
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+var ls = ls || {};
+
+ls.user = (function ($) {
+ "use strict";
+
+ this.init = function () {
+ return;
+ };
+
+ return this;
+}).call(ls.user || {}, jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/user/list/user-list-item.tpl b/application/frontend/components/user/list/user-list-item.tpl
new file mode 100644
index 0000000..cd5bcef
--- /dev/null
+++ b/application/frontend/components/user/list/user-list-item.tpl
@@ -0,0 +1,40 @@
+{if $user->getUser()}
+ {$user = $user->getUser()}
+{/if}
+
+{* Заголовок *}
+{capture 'title'}
+ {$user->getDisplayName()}
+{/capture}
+
+{* Описание *}
+{capture 'content'}
+ {$session = $user->getSession()}
+ {$usernote = $user->getUserNote()}
+
+ {* Заметка *}
+ {if $usernote}
+ {component 'note' classes='js-user-note' note=$usernote targetId=$user->getId()}
+ {/if}
+
+ {* Информация *}
+ {if $session}
+ {$lastSessionDate = {date_format date=$session->getDateLast() hours_back="12" minutes_back="60" now="60" day="day H:i" format="j F Y, H:i"}}
+ {/if}
+
+ {component 'info-list' classes='object-list-item-info' list=[
+ [ 'label' => "{$aLang.user.date_last_session}:", 'content' => ( $session ) ? $lastSessionDate : '—' ],
+ [ 'label' => "{$aLang.user.date_registration}:", 'content' => {date_format date=$user->getDateRegister() hours_back="12" minutes_back="60" now="60" day="day H:i" format="j F Y, H:i"} ],
+ [ 'label' => "{$aLang.vote.rating}:", 'content' => $user->getRating() ]
+ ]}
+{/capture}
+
+
+{component 'item'
+ title=$smarty.capture.title
+ content=$smarty.capture.content
+ image=[
+ 'url' => $user->getUserWebPath(),
+ 'path' => $user->getProfileAvatarPath( 100 ),
+ 'alt' => $user->getLogin()
+ ]}
\ No newline at end of file
diff --git a/application/frontend/components/user/list/user-list-loop.tpl b/application/frontend/components/user/list/user-list-loop.tpl
new file mode 100644
index 0000000..6c21cf3
--- /dev/null
+++ b/application/frontend/components/user/list/user-list-loop.tpl
@@ -0,0 +1,11 @@
+{**
+ * Список пользователей
+ *
+ * @param array $users
+ *}
+
+{component_define_params params=[ 'users' ]}
+
+{foreach $users as $user}
+ {component 'user' template='list-item' user=$user}
+{/foreach}
\ No newline at end of file
diff --git a/application/frontend/components/user/list/user-list.tpl b/application/frontend/components/user/list/user-list.tpl
new file mode 100644
index 0000000..4db84b7
--- /dev/null
+++ b/application/frontend/components/user/list/user-list.tpl
@@ -0,0 +1,27 @@
+{**
+ * Список пользователей
+ *
+ * @param array $users
+ * @param array $pagination
+ * @param boolean $useMore
+ * @param boolean $hideMore
+ * @param string $textEmpty
+ *}
+
+{component_define_params params=[ 'users', 'pagination', 'users', 'useMore', 'hideMore', 'textEmpty' ]}
+
+{if $users}
+ {* Список пользователей *}
+ {component 'item.group' classes='js-more-users-container' items={component 'user' template='list-loop' users=$users}}
+
+ {* Кнопка подгрузки *}
+ {if $useMore}
+ {if ! $hideMore}
+ {component 'more' classes='js-more-search' target='.js-more-users-container' ajaxParams=[ 'next_page' => 2 ]}
+ {/if}
+ {else}
+ {component 'pagination' total=+$pagination.iCountPage current=+$pagination.iCurrentPage url="{$pagination.sBaseUrl}/page__page__/"}
+ {/if}
+{else}
+ {component 'blankslate' text=$textEmpty|default:{lang name='user.notices.empty'}}
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/user/modals/modal.add-friend.tpl b/application/frontend/components/user/modals/modal.add-friend.tpl
new file mode 100644
index 0000000..a588dd8
--- /dev/null
+++ b/application/frontend/components/user/modals/modal.add-friend.tpl
@@ -0,0 +1,26 @@
+{**
+ * Добавление в друзья
+ *}
+
+{capture 'modal_content'}
+ {* TODO: Form validation (front-end / back-end) *}
+
+{/capture}
+
+{component 'modal'
+ title = {lang 'user.friends.form.title'}
+ content = $smarty.capture.modal_content
+ classes = 'js-modal-default'
+ mods = 'user-add-friend'
+ id = 'modal-add-friend'
+ primaryButton = [
+ 'text' => {lang 'user.friends.form.fields.submit.text'},
+ 'form' => 'add_friend_form'
+ ]}
\ No newline at end of file
diff --git a/application/frontend/components/user/modals/modal.crop-avatar.tpl b/application/frontend/components/user/modals/modal.crop-avatar.tpl
new file mode 100644
index 0000000..daaa573
--- /dev/null
+++ b/application/frontend/components/user/modals/modal.crop-avatar.tpl
@@ -0,0 +1,11 @@
+{**
+ * Кроп фотографии
+ *}
+
+{extends 'Component@crop.crop'}
+
+{block 'modal_options' append}
+ {$title = {lang 'user.photo.crop_avatar.title'}}
+ {$desc = {lang 'user.photo.crop_avatar.desc'}}
+ {$usePreview = true}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/user/modals/modal.crop-photo.tpl b/application/frontend/components/user/modals/modal.crop-photo.tpl
new file mode 100644
index 0000000..86b7543
--- /dev/null
+++ b/application/frontend/components/user/modals/modal.crop-photo.tpl
@@ -0,0 +1,15 @@
+{**
+ * Кроп фотографии
+ *}
+
+{extends 'Component@crop.crop'}
+
+{block 'modal_options' append}
+ {$title = {lang 'user.photo.crop_photo.title'}}
+ {$desc = {lang 'user.photo.crop_photo.desc'}}
+ {$usePreview = false}
+{/block}
+
+{block 'modal_footer_begin'}
+ {component 'button' text={lang 'user.photo.crop_photo.submit'} classes='js-crop-submit' mods='primary'}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/components/user/modals/modal.user-list.tpl b/application/frontend/components/user/modals/modal.user-list.tpl
new file mode 100644
index 0000000..b693ec7
--- /dev/null
+++ b/application/frontend/components/user/modals/modal.user-list.tpl
@@ -0,0 +1,39 @@
+{**
+ * Список пользователей
+ *
+ * @param array $users
+ * @param boolean $selectable
+ *}
+
+{component_define_params params=[ 'users', 'selectable' ]}
+
+{capture 'modal_content'}
+ {* Экшнбар *}
+ {if $users && $selectable}
+ {component 'actionbar' template='item.select'
+ classes = 'js-user-list-modal-actionbar'
+ target = '.js-user-list-select .js-user-list-small-item'
+ assign = usersHtml}
+
+ {component 'actionbar' items=[[ 'buttons' => [ 'html' => $usersHtml ] ]]}
+ {/if}
+
+ {* Список *}
+ {component 'user' template='list-small'
+ users = $users
+ selectable = $selectable
+ showEmpty = true
+ classes = 'js-user-list-select'}
+{/capture}
+
+{component 'modal'
+ title = $title|default:$aLang.user.users|escape
+ content = $smarty.capture.modal_content
+ classes = 'js-modal-default'
+ mods = 'users-select'
+ id = 'modal-users-select'
+ primaryButton = ( $users && $selectable ) ? [
+ 'text' => {lang 'common.add'},
+ 'classes' => 'js-user-list-select-add',
+ 'form' => 'form-complaint-user'
+ ] : false}
\ No newline at end of file
diff --git a/application/frontend/components/user/nav.user.tpl b/application/frontend/components/user/nav.user.tpl
new file mode 100644
index 0000000..fff3e76
--- /dev/null
+++ b/application/frontend/components/user/nav.user.tpl
@@ -0,0 +1,14 @@
+{**
+ * Навигация на странице пользователя
+ *}
+
+{component 'nav' hook='user_profile' activeItem=$sMenuProfileItemSelect mods='stacked' classes='user-nav' hookParams=[ 'oUserProfile' => $oUserProfile ] items=[
+ [ 'name' => 'whois', 'text' => {lang name='user.profile.nav.info'}, 'url' => "{$oUserProfile->getUserWebPath()}" ],
+ [ 'name' => 'wall', 'text' => {lang name='user.profile.nav.wall'}, 'url' => "{$oUserProfile->getUserWebPath()}wall/", 'count' => $iCountWallUser ],
+ [ 'name' => 'created', 'text' => {lang name='user.profile.nav.publications'}, 'url' => "{$oUserProfile->getUserWebPath()}created/topics/", 'count' => $iCountCreated ],
+ [ 'name' => 'favourites', 'text' => {lang name='user.profile.nav.favourite'}, 'url' => "{$oUserProfile->getUserWebPath()}favourites/topics/", 'count' => $iCountFavourite ],
+ [ 'name' => 'friends', 'text' => {lang name='user.profile.nav.friends'}, 'url' => "{$oUserProfile->getUserWebPath()}friends/", 'count' => $iCountFriendsUser ],
+ [ 'name' => 'activity', 'text' => {lang name='user.profile.nav.activity'}, 'url' => "{$oUserProfile->getUserWebPath()}stream/" ],
+ [ 'name' => 'talk', 'text' => {lang name='user.profile.nav.messages'}, 'url' => "{router page='talk'}", 'count' => $iUserCurrentCountTalkNew, 'is_enabled' => $oUserCurrent && $oUserCurrent->getId() == $oUserProfile->getId() ],
+ [ 'name' => 'settings', 'text' => {lang name='user.profile.nav.settings'}, 'url' => "{router page='settings'}", 'is_enabled' => $oUserCurrent && $oUserCurrent->getId() == $oUserProfile->getId() ]
+]}
\ No newline at end of file
diff --git a/application/frontend/components/user/search-form.users.tpl b/application/frontend/components/user/search-form.users.tpl
new file mode 100644
index 0000000..7017f77
--- /dev/null
+++ b/application/frontend/components/user/search-form.users.tpl
@@ -0,0 +1,12 @@
+{**
+ * Форма поиска блогов
+ *}
+
+{component 'search-form'
+ name = 'blog'
+ method = 'post'
+ placeholder = $aLang.user.search.placeholder
+ classes = 'js-tag-search-form'
+ inputClasses = 'js-search-text-main'
+ inputName = 'user_login'
+ noSubmitButton = true}
\ No newline at end of file
diff --git a/application/frontend/components/user/settings/account.tpl b/application/frontend/components/user/settings/account.tpl
new file mode 100644
index 0000000..f267ecc
--- /dev/null
+++ b/application/frontend/components/user/settings/account.tpl
@@ -0,0 +1,62 @@
+{**
+ * Настройки аккаунта (емэйл, пароль)
+ *}
+
+{component_define_params params=[ 'user' ]}
+
+{hook run='settings_account_begin'}
+
+
+
+{hook run='settings_account_end'}
\ No newline at end of file
diff --git a/application/frontend/components/user/settings/invite.tpl b/application/frontend/components/user/settings/invite.tpl
new file mode 100644
index 0000000..1dc7a77
--- /dev/null
+++ b/application/frontend/components/user/settings/invite.tpl
@@ -0,0 +1,54 @@
+{**
+ * Управление инвайтами
+ *}
+
+
+ {lang name='user.settings.invites.note'}
+
+
+{* @hook Начало формы с настройками инвайтов *}
+{hook run='user_settings_invite_begin'}
+
+
+ {if Config::Get('general.reg.invite')}
+ {lang name='user.settings.invites.available'}:
+
+ {if $oUserCurrent->isAdministrator()}
+ {lang name='user.settings.invites.many'}
+ {else}
+ {$iCountInviteAvailable}
+ {/if}
+
+ {else}
+ {if $sReferralLink}
+ {lang name='user.settings.invites.referral_link'}:
+ {$sReferralLink|escape}
+ {/if}
+
+ {/if}
+
+
+ {lang name='user.settings.invites.used'}: {($iCountInviteUsed) ? $iCountInviteUsed : {lang name='user.settings.invites.used_empty'}}
+
+
+
+
+{* @hook Конец формы с настройками инвайтов *}
+{hook run='user_settings_invite_end'}
\ No newline at end of file
diff --git a/application/frontend/components/user/settings/profile.tpl b/application/frontend/components/user/settings/profile.tpl
new file mode 100644
index 0000000..6bfb40f
--- /dev/null
+++ b/application/frontend/components/user/settings/profile.tpl
@@ -0,0 +1,130 @@
+{**
+ * Настройки профиля
+ *}
+
+{component_define_params params=[ 'user' ]}
+
+{* @hook Начало формы с настройками профиля *}
+{hook run='user_settings_profile_begin'}
+
+{* Шаблон пользовательского поля (userfield) *}
+{function name=userfield}
+
+
+ {foreach $aUserFieldsContact as $fieldAll}
+ getId() == $field->getId()}selected{/if}>
+ {$fieldAll->getTitle()|escape}
+
+ {/foreach}
+
+
+
+ {component 'icon' icon='remove' classes='js-user-field-item-remove' attributes=[ title => {lang 'common.remove'} ]}
+
+{/function}
+
+{* Скрытое пользовательское поле для вставки через js *}
+{* Вынесено за пределы формы, чтобы не передавалось при отправке формы *}
+{call userfield field=false}
+
+
+
+
+{hook run='settings_profile_end'}
\ No newline at end of file
diff --git a/application/frontend/components/user/settings/tuning.tpl b/application/frontend/components/user/settings/tuning.tpl
new file mode 100644
index 0000000..6281a6a
--- /dev/null
+++ b/application/frontend/components/user/settings/tuning.tpl
@@ -0,0 +1,75 @@
+{**
+ * Настройка уведомлений
+ *}
+
+{* @hook Начало формы с настройками уведомлений *}
+{hook run='user_settings_tuning_begin'}
+
+
+
+{hook run='settings_tuning_end'}
\ No newline at end of file
diff --git a/application/frontend/components/user/user-field-choose.tpl b/application/frontend/components/user/user-field-choose.tpl
new file mode 100644
index 0000000..1596663
--- /dev/null
+++ b/application/frontend/components/user/user-field-choose.tpl
@@ -0,0 +1,27 @@
+{**
+ * Выбор пользователей
+ *
+ * @param string $lang_choose
+ *}
+
+{$component = 'user-field-choose'}
+{component_define_params params=[ 'name', 'label', 'lang_choose', 'mods', 'classes', 'attributes' ]}
+
+{$label = $label|default:{lang 'user.choose.label'}}
+{$lang_choose = $lang_choose|default:{lang 'user.choose.choose'}}
+
+{* Ссылка показывающая мод. окно со списком пользователей *}
+{capture 'user_field_choose'}
+
+ {$lang_choose}
+
+{/capture}
+
+{component 'field.autocomplete'
+ label = $label
+ name = $name
+ inputClasses = 'js-user-field-choose-users ls-hidden'
+ isMultiple = true
+ placeholder = " "
+ note = $smarty.capture.user_field_choose
+ params = $params}
\ No newline at end of file
diff --git a/application/frontend/components/user/user-list-small-item.tpl b/application/frontend/components/user/user-list-small-item.tpl
new file mode 100644
index 0000000..06647a1
--- /dev/null
+++ b/application/frontend/components/user/user-list-small-item.tpl
@@ -0,0 +1,31 @@
+{**
+ * Список пользователей с элементами управления / Пользователь
+ *
+ * @param object $user
+ * @param string $selectable
+ * @param string $showActions
+ * @param string $showRemove
+ *
+ * @param string $classes
+ * @param array $attributes
+ * @param array $mods
+ *}
+
+{$component = 'user-list-small-item'}
+{component_define_params params=[ 'selectable', 'user', 'mods', 'classes', 'attributes' ]}
+
+{block 'user_list_small_item_options'}
+ {$userId = $user->getId()}
+{/block}
+
+
+ {* Чекбокс *}
+ {if $selectable}
+
+ {/if}
+
+ {* Пользователь *}
+ {block 'user_list_small_item_content'}
+ {component 'user' template='avatar' size='xxsmall' mods='inline' user=$user}
+ {/block}
+
\ No newline at end of file
diff --git a/application/frontend/components/user/user-list-small.tpl b/application/frontend/components/user/user-list-small.tpl
new file mode 100644
index 0000000..a32aa58
--- /dev/null
+++ b/application/frontend/components/user/user-list-small.tpl
@@ -0,0 +1,50 @@
+{**
+ * Список пользователей с элементами управления
+ *
+ * @param object $users
+ * @param string $title
+ * @param boolean $hideableEmptyAlert
+ * @param boolean $show
+ * @param boolean $selectable
+ * @param array $exclude
+ * @param string $itemTemplate
+ *}
+
+{$component = 'user-list-small'}
+{component_define_params params=[ 'exclude', 'hideableEmptyAlert', 'selectable', 'show', 'title', 'users', 'mods', 'classes', 'attributes' ]}
+
+{* Заголовок *}
+{if $title}
+ {$title}
+{/if}
+
+{* Уведомление о пустом списке *}
+{if ! $users || $hideableEmptyAlert}
+ {component 'blankslate'
+ text = $aLang.common.empty
+ classes = 'js-user-list-small-empty'
+ visible = ! $users}
+{/if}
+
+{if $selectable}
+ {$mods = "$mods selectable"}
+{/if}
+
+{* Список пользователей *}
+{if $users || ! $show|default:true}
+
+ {foreach $users as $user}
+ {$userContainer = $user}
+
+ {if $user->getUser()}
+ {$user = $user->getUser()}
+ {/if}
+
+ {if ! $exclude || ! in_array( $user->getId(), $exclude )}
+ {block 'user_list_small_item'}
+ {component 'user' template='list-small-item' user=$user selectable=$selectable}
+ {/block}
+ {/if}
+ {/foreach}
+
+{/if}
\ No newline at end of file
diff --git a/application/frontend/components/userbar/README.md b/application/frontend/components/userbar/README.md
new file mode 100644
index 0000000..bc86c63
--- /dev/null
+++ b/application/frontend/components/userbar/README.md
@@ -0,0 +1 @@
+# Компонент userbar
\ No newline at end of file
diff --git a/application/frontend/components/userbar/component.json b/application/frontend/components/userbar/component.json
new file mode 100644
index 0000000..8af9ab9
--- /dev/null
+++ b/application/frontend/components/userbar/component.json
@@ -0,0 +1,14 @@
+{
+ "name": "userbar",
+ "version": "1.0.0",
+ "dependencies": {
+ "nav": "*",
+ "search-form": "*"
+ },
+ "templates": {
+ "userbar": "userbar.tpl"
+ },
+ "styles": {
+ "userbar": "css/userbar.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/userbar/css/userbar.css b/application/frontend/components/userbar/css/userbar.css
new file mode 100644
index 0000000..e0fef3f
--- /dev/null
+++ b/application/frontend/components/userbar/css/userbar.css
@@ -0,0 +1,101 @@
+/**
+ * Юзербар
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+.ls-userbar {
+ background: #fff;
+}
+.ls-userbar-inner {
+ margin: 0 auto;
+ padding: 0 50px;
+}
+
+/* Меню */
+.ls-userbar-nav {
+ float: left;
+}
+
+/* Форма поиска */
+.ls-userbar .ls-search-form {
+ float: right;
+ width: 200px;
+ position: relative;
+ margin: 13px 5px 0 5px;
+ padding: 0;
+}
+
+/* Лого */
+.ls-userbar-logo {
+ float: left;
+ font-size: 28px;
+ line-height: 54px;
+ margin: 0 25px 0 0;
+}
+
+/**
+ * Userbar
+ */
+.ls-nav--userbar {
+ float: left;
+}
+.ls-nav--userbar > .ls-nav-item > a {
+ color: #000;
+ padding: 20px 15px;
+}
+.ls-nav--userbar > .ls-nav-item > a:hover {
+ background: #fafafa;
+}
+
+/* Логин */
+.ls-nav--userbar > .ls-nav-item.ls-nav-item--userbar-username > a {
+ position: relative;
+ padding-left: 40px;
+ padding-right: 25px;
+ font-weight: bold;
+ color: #333;
+}
+.ls-nav--userbar > .ls-nav-item.ls-nav-item--userbar-username .avatar {
+ position: absolute;
+ top: 17px;
+ left: 8px;
+ border-radius: 100%;
+}
+
+/* Новые сообщения */
+.ls-nav--userbar > .ls-nav-item--has-counter > a {
+ background: #65CA34;
+ color: #fff;
+ font-weight: bold;
+}
+.ls-nav--userbar > .ls-nav-item--has-counter > a:hover {
+ background: #5EBD30;
+}
+.ls-nav--userbar > .ls-nav-item > a > .ls-badge {
+ background: #47B113;
+}
+
+/* Dropdown support */
+.ls-nav.ls-nav--userbar .ls-dropdown-toggle:after {
+ border-top-color: #000;
+}
+.ls-nav.ls-nav--userbar .ls-dropdown-toggle.open {
+ background: #08c;
+ color: #fff;
+}
+.ls-nav.ls-nav--userbar .ls-dropdown-toggle.open:after {
+ border-top-color: #fff;
+}
+
+
+/**
+ * Responsive styles
+ */
+@media (max-width: 360px) {
+ .ls-userbar-inner {
+ padding: 0;
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/userbar/userbar.tpl b/application/frontend/components/userbar/userbar.tpl
new file mode 100644
index 0000000..842a797
--- /dev/null
+++ b/application/frontend/components/userbar/userbar.tpl
@@ -0,0 +1,45 @@
+{**
+ * Юзербар
+ *}
+
+
+
+ {if ! Config::Get( 'view.layout_show_banner' )}
+
+ {/if}
+
+
+ {if $oUserCurrent}
+ {$createMenu = []}
+
+ {foreach $LS->Topic_GetTopicTypes() as $type}
+ {$createMenu[] = [ 'name' => $type->getCode(), 'text' => $type->getName(), 'url' => $type->getUrlForAdd() ]}
+ {/foreach}
+
+ {$createMenu[] = [ 'name' => 'blog', 'text' => {lang 'modal_create.items.blog'}, 'url' => {router page='blog/add'} ]}
+ {$createMenu[] = [ 'name' => 'talk', 'text' => {lang 'modal_create.items.talk'}, 'url' => {router page='talk/add'} ]}
+ {$createMenu[] = [ 'name' => 'drafts', 'text' => {lang 'topic.drafts'}, 'url' => "{router page='content/drafts'}", count => $iUserCurrentCountTopicDraft ]}
+
+ {$items = [
+ [
+ 'html' => {insert name='block' block='menu' params=[ 'name' => "user" ]}
+ ],
+ [ 'text' => $aLang.common.create, menu => [ hook => 'create', items => $createMenu ] ],
+ [ 'text' => $aLang.talk.title, 'url' => "{router page='talk'}", 'title' => $aLang.talk.new_messages, 'is_enabled' => $iUserCurrentCountTalkNew, 'count' => $iUserCurrentCountTalkNew ],
+ [ 'text' => $aLang.auth.logout, 'url' => "{router page='auth'}logout/?security_ls_key={$LIVESTREET_SECURITY_KEY}" ]
+ ]}
+ {else}
+ {$items = [
+ [ 'text' => $aLang.auth.login.title, 'classes' => 'js-modal-toggle-login', 'url' => {router page='auth/login'} ],
+ [ 'text' => $aLang.auth.registration.title, 'classes' => 'js-modal-toggle-registration', 'url' => {router page='auth/register'} ]
+ ]}
+ {/if}
+
+ {component 'nav' hook='userbar_nav' hookParams=[ user => $oUserCurrent ] activeItem=$sMenuHeadItemSelect mods='userbar' items=$items}
+
+
+ {component 'search' template='main' mods='light'}
+
+
diff --git a/application/frontend/components/vote/README.md b/application/frontend/components/vote/README.md
new file mode 100644
index 0000000..9b55708
--- /dev/null
+++ b/application/frontend/components/vote/README.md
@@ -0,0 +1,117 @@
+# Компонент vote
+
+Голосование
+
+
+## Зависимости
+
+* jquery
+* jquery.widget
+* tooltip
+* ls.utils
+* ls.ajax
+
+
+## Шаблоны
+
+### vote.tpl
+Основной шаблон с блоком голосования.
+
+| Опция | Тип | По умолчанию | Описание |
+| :------------ | :---------- | :----------------- | :------- |
+| `sHeading` | string | null | Заголовок |
+| `sClasses` | string | null | Дополнительные классы (указываются через пробел) |
+| `sMods` | string | null | Список классов-модификаторов (указываются через пробел) |
+| `sAttributes` | string | null | Атрибуты (указываются через пробел) |
+| `bShowRating` | boolean | true | Показывать рейтинг или нет, если false, то значение рейтинга заменяется на _"?"_ |
+| `bIsLocked` | boolean | false | Блокировка голосования, если true, то кнопки голосования не будут показываться |
+
+### vote.info.tpl
+Шаблон с информацией о голосовании выводимая в тултипе, который появляется при наведении на блок голосования.
+
+| Опция | Тип | По умолчанию | Описание |
+| :------------ | :---------- | :----------- | :------------------------- |
+| `oObject` | object | null | Объект с инфо-ей о голосовании |
+
+
+
+## Стили
+
+Список модификаторов основного блока
+
+| Мод-ор | Описание |
+| :---------------- | :------- |
+| `voted` | Пользователь проголосовал |
+| `not-voted` | Не проголосовал |
+| `voted-up` | Понравилось |
+| `voted-down` | Не понравилось |
+| `voted-zero` | Воздержался |
+| `count-positive` | Рейтинг больше нуля |
+| `count-negative` | Меньше нуля |
+| `count-zero` | Равен нулю |
+| `rating-hidden` | Рейтинг скрыт |
+| `large` | Большой блок голосования |
+| `small` | Маленький блок голосования |
+
+
+
+## Скрипты
+
+### vote.js
+
+Основные опции
+
+| Опция | Тип | По умолчанию | Описание |
+| :----------------- | :---------- | :---------------- | :------------------------- |
+| `params` | object | null | Параметры отправляемые при аякс запросе |
+| `tooltip_options` | object | null | Опции тултипа с информацией о голосовании |
+
+Ссылки
+
+| Опция | Тип | По умолчанию | Описание |
+| :------------ | :---------- | :----------- | :------------ |
+| `urls.vote` | string | null | Голосование |
+| `urls.info` | string | null | Информация о голосовании |
+
+Селекторы
+
+| Опция | Тип | По умолчанию | Описание |
+| :------------------- | :---------- | :---------------- | :------- |
+| `selectors.item` | string | '.js-vote-item' | Кнопки голосования |
+| `selectors.rating` | string | '.js-vote-rating' | Блок с рейтингом |
+
+Классы
+
+| Опция | Тип | По умолчанию | Описание |
+| :----------------------- | :---------- | :--------------------- | :------------------------- |
+| `classes.voted` | string | 'vote--voted' | Пользователь проголосовал |
+| `classes.not_voted` | string | 'vote--not-voted' | Не проголосовал |
+| `classes.voted_up` | string | 'vote--voted-up' | Понравилось |
+| `classes.voted_down` | string | 'vote--voted-down' | Не понравилось |
+| `classes.voted_zero` | string | 'vote--voted-zero' | Воздержался |
+| `classes.count_positive` | string | 'vote--count-positive' | Рейтинг больше нуля |
+| `classes.count_negative` | string | 'vote--count-negative' | Меньше нуля |
+| `classes.count_zero` | string | 'vote--count-zero' | Равен нулю |
+| `classes.rating_hidden` | string | 'vote--rating-hidden' | Рейтинг скрыт |
+
+
+## Использование
+
+Пример использования в плагине.
+
+_Шаблон с изображением_ **image.tpl**
+```smarty
+...
+{include 'components/vote/vote.tpl' sMods='small' sClasses='js-plugin-gallery-image-vote'}
+...
+```
+
+_Файл иниц-ии js плагина_ **init.js**
+```js
+$('.js-plugin-gallery-image-vote').vote({
+ urls: {
+ vote: aRouter['gallery'] + 'vote/image/',
+ info: aRouter['gallery'] + 'vote/info/'
+ }
+});
+```
diff --git a/application/frontend/components/vote/component.json b/application/frontend/components/vote/component.json
new file mode 100644
index 0000000..d69c3ca
--- /dev/null
+++ b/application/frontend/components/vote/component.json
@@ -0,0 +1,18 @@
+{
+ "name": "vote",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-component": "*",
+ "tooltip": "*"
+ },
+ "templates": {
+ "info": "vote.info.tpl",
+ "vote": "vote.tpl"
+ },
+ "scripts": {
+ "vote": "js/vote.js"
+ },
+ "styles": {
+ "vote": "css/vote.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/vote/css/vote-rtl.css b/application/frontend/components/vote/css/vote-rtl.css
new file mode 100644
index 0000000..1a2c310
--- /dev/null
+++ b/application/frontend/components/vote/css/vote-rtl.css
@@ -0,0 +1 @@
+.ls-vote--small .ls-vote-item, .ls-vote--small .ls-vote-rating { float: right; }
\ No newline at end of file
diff --git a/application/frontend/components/vote/css/vote.css b/application/frontend/components/vote/css/vote.css
new file mode 100644
index 0000000..6fb117d
--- /dev/null
+++ b/application/frontend/components/vote/css/vote.css
@@ -0,0 +1,88 @@
+/**
+ * Голосование
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+/* Body */
+.ls-vote-body { overflow: hidden; position: relative; padding-left: 40px; }
+
+/* Vote Item */
+.ls-vote-item { opacity: .3; filter: alpha(opacity=30); cursor: pointer; position: absolute; }
+.ls-vote-item:hover { opacity: .8; filter: alpha(opacity=80); }
+
+.ls-vote-item-up { top: 1px; left: 0; }
+.ls-vote-item-down { top: 1px; left: 20px; }
+
+.ls-vote-item i {
+}
+
+/* Rating */
+.ls-vote-rating { font-weight: bold; color: #aaa; font-size: 14px; }
+
+.ls-vote--count-positive .ls-vote-rating { color: #390; }
+.ls-vote--count-negative .ls-vote-rating { color: #f00; }
+
+/* Voted */
+.ls-vote--voted .ls-vote-body { padding-left: 20px; }
+
+.ls-vote--voted .ls-vote-item { left: 0; opacity: 1; filter: alpha(opacity=100); }
+.ls-vote--voted .ls-vote-item:hover { opacity: 1; filter: alpha(opacity=100); cursor: default; }
+
+.ls-vote--voted.ls-vote--voted-zero .ls-vote-item-down,
+.ls-vote--voted.ls-vote--voted-zero .ls-vote-item-up,
+
+.ls-vote--voted.ls-vote--voted-up .ls-vote-item-down,
+.ls-vote--voted.ls-vote--voted-up .ls-vote-item-abstain,
+
+.ls-vote--voted.ls-vote--voted-down .ls-vote-item-up,
+.ls-vote--voted.ls-vote--voted-down .ls-vote-item-abstain { display: none; }
+
+/* Locked */
+.ls-vote--locked .ls-vote-item { display: none; }
+
+
+/**
+ * Large (User, Blog)
+ */
+.ls-vote--large .ls-vote-heading {
+ text-transform: uppercase;
+ text-align: right;
+ font-size: 11px;
+ margin-bottom: 5px;
+ font-family: Arial;
+ color: #aaa;
+}
+.ls-vote--large .ls-vote-rating { font: 300 30px/1em 'Open Sans'; text-align: right; }
+.ls-vote--large .ls-vote-item-up,
+.ls-vote--large .ls-vote-item-down { top: 7px; }
+.ls-vote--large.ls-vote--count-positive .ls-vote-rating { color: #333; }
+
+
+/**
+ * Small (Topic)
+ */
+.ls-vote--small { border-radius: 3px; overflow: hidden; position: relative; }
+
+/* Body */
+.ls-vote--small .ls-vote-body { padding: 0; }
+
+/* Кнопки голосования */
+.ls-vote--small .ls-vote-item { opacity: 1; filter: alpha(opacity=100); position: static; }
+
+.ls-vote--small .ls-vote-item,
+.ls-vote--small .ls-vote-rating { float: left; background: #ac90df; padding: 8px 13px; }
+.ls-vote--small .ls-vote-item i { opacity: 1; }
+.ls-vote--small .ls-vote-item:hover { opacity: .9; filter: alpha(opacity=90); }
+
+/* Рейтинг */
+.ls-vote--small .ls-vote-rating { color: #fff; display: none; }
+.ls-vote--small.ls-vote--voted .ls-vote-rating { display: block; }
+
+.ls-vote--small.ls-vote--count-negative .ls-vote-item,
+.ls-vote--small.ls-vote--count-negative .ls-vote-rating { background: #da3a3a; }
+
+/* Заблокированное */
+.ls-vote--small.ls-vote--locked .ls-vote-rating { display: block; }
\ No newline at end of file
diff --git a/application/frontend/components/vote/js/vote.js b/application/frontend/components/vote/js/vote.js
new file mode 100644
index 0000000..3f82313
--- /dev/null
+++ b/application/frontend/components/vote/js/vote.js
@@ -0,0 +1,135 @@
+/**
+ * Голосование
+ *
+ * @module ls/vote
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsVote", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ // Голосование
+ vote: null,
+ // Информация о голосовании
+ info: null
+ },
+
+ // Селекторы
+ selectors: {
+ // Кнопки голосования
+ item: '.js-vote-item',
+ // Рейтинг
+ rating: '.js-vote-rating'
+ },
+
+ // Классы
+ classes : {
+ // Пользователь проголосовал
+ voted: 'ls-vote--voted',
+ // Не проголосовал
+ not_voted: 'ls-vote--not-voted',
+ // Понравилось
+ voted_up: 'ls-vote--voted-up',
+ // Не понравилось
+ voted_down: 'ls-vote--voted-down',
+ // Воздержался
+ voted_zero: 'ls-vote--voted-zero',
+
+ // Рейтинг больше нуля
+ count_positive: 'ls-vote--count-positive',
+ // Меньше нуля
+ count_negative: 'ls-vote--count-negative',
+ // Равен нулю
+ count_zero: 'ls-vote--count-zero',
+
+ // Рейтинг скрыт
+ rating_hidden: 'ls-vote--rating-hidden'
+ },
+ // Параметры отправляемые при каждом аякс запросе
+ params: {},
+ // Опции тултипа с информацией о голосовании
+ tooltip_options: {}
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ // Обработка кликов по кнопкам голосования
+ if ( ! this._hasClass( 'voted' ) ) {
+ this._on( this.elements.item, {
+ click: function ( event ) {
+ this.vote( $( event.currentTarget ).data( 'vote-value' ) );
+ event.preventDefault();
+ }
+ });
+ }
+
+ // Иниц-ия тултипа с информацией о голосовании
+ // Показываем инфо-ию только если рейтинг отображается
+ if ( ! this._hasClass( 'rating_hidden' ) ) {
+ this.info();
+ }
+ },
+
+ /**
+ * Голосование
+ *
+ * @param {Number} value Значение
+ */
+ vote: function( value ) {
+ this.option( 'params.value', value );
+
+ this._load( 'vote', function ( response ) {
+ response.iRating = parseFloat( response.iRating );
+
+ // Добавляем/удаляем классы
+ this._removeClass( 'count_negative count_positive count_zero rating_hidden not_voted' );
+ this._addClass( 'voted' );
+ this._addClass( value > 0 ? 'voted_up' : ( value < 0 ? 'voted_down' : 'voted_zero' ) );
+ this._addClass( response.iRating > 0 ? 'count_positive' : ( response.iRating < 0 ? 'count_negative' : 'count_zero' ) );
+
+ // Не обрабатываем клики после голосования
+ this._off( this.elements.item, 'click' );
+
+ // Удаляем подсказки и устанавливаем рейтинг
+ this.elements.item.removeAttr( 'title' );
+ this.elements.rating.text( response.iRating );
+
+ // Иниц-ия тултипа
+ this.info().lsTooltip( 'show' );
+ });
+ },
+
+ /**
+ * Иниц-ия тултипа с информацией о голосовании
+ *
+ * @return {jQuery}
+ */
+ info: function () {
+ if ( ! this.options.urls.info ) return $();
+
+ return this.element.lsTooltip($.extend({}, {
+ ajax: {
+ url: this.options.urls.info,
+ params: this.options.params
+ }
+ }, this.options.tooltip_options));
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/vote/vote.info.tpl b/application/frontend/components/vote/vote.info.tpl
new file mode 100644
index 0000000..8d026d1
--- /dev/null
+++ b/application/frontend/components/vote/vote.info.tpl
@@ -0,0 +1,17 @@
+{**
+ * Информация о голосовании
+ *
+ * @param object $target
+ *}
+
+{$component = 'ls-vote-info'}
+{component_define_params params=[ 'target' ]}
+
+
+ {component 'icon' icon='plus' mods='white'} {$target->getCountVoteUp()}
+ {component 'icon' icon='minus' mods='white'} {$target->getCountVoteDown()}
+ {component 'icon' icon='eye' mods='white'} {$target->getCountVoteAbstain()}
+ {component 'icon' icon='asterisk' mods='white'} {$target->getCountVote()}
+
+ {hook run='topic_show_vote_stats' topic=$target}
+
\ No newline at end of file
diff --git a/application/frontend/components/vote/vote.tpl b/application/frontend/components/vote/vote.tpl
new file mode 100644
index 0000000..e15a1c6
--- /dev/null
+++ b/application/frontend/components/vote/vote.tpl
@@ -0,0 +1,89 @@
+{**
+ * Голосование
+ *
+ * @param object $target Объект сущности
+ * @param boolean $showRating Показывать рейтинг или нет
+ * @param boolean $isLocked Блокировка голосования
+ * @param boolean $useAbstain
+ *}
+
+{* Название компонента *}
+{$component = 'ls-vote'}
+{component_define_params params=[ 'showRating', 'target', 'isLocked', 'useAbstain', 'mods', 'classes', 'attributes' ]}
+
+{* Установка дефолтных значений *}
+{$showRating = $showRating|default:true}
+
+{* Рейтинг *}
+{$rating = $target->getRating()}
+
+{* Получаем модификаторы *}
+{if $showRating}
+ {if $rating > 0}
+ {$mods = "$mods count-positive"}
+ {elseif $rating < 0}
+ {$mods = "$mods count-negative"}
+ {else}
+ {$mods = "$mods count-zero"}
+ {/if}
+{/if}
+
+{if $vote = $target->getVote()}
+ {$mods = "$mods voted"}
+
+ {if $vote->getDirection() > 0}
+ {$mods = "$mods voted-up"}
+ {elseif $vote->getDirection() < 0}
+ {$mods = "$mods voted-down"}
+ {else}
+ {$mods = "$mods voted-zero"}
+ {/if}
+{else}
+ {$mods = "$mods not-voted"}
+{/if}
+
+{if ! $oUserCurrent || $isLocked}
+ {$mods = "$mods locked"}
+{/if}
+
+{if ! $showRating}
+ {$mods = "$mods rating-hidden"}
+{/if}
+
+{* Дополнительный мод-ор для иконок *}
+{$iconMod = ( in_array( 'small', explode(' ', $mods) ) ) ? 'white' : ''}
+
+{block 'vote_options'}{/block}
+
+
+ {* Основной блок *}
+
+ {block 'vote_body'}
+ {* Рейтинг *}
+
+ {if $showRating}
+ {$rating}
+ {else}
+ ?
+ {/if}
+
+
+ {* Воздержаться *}
+ {if $useAbstain}
+
+ {component 'icon' icon='eye' mods=$iconMod}
+
+ {/if}
+
+ {* Нравится *}
+
+ {component 'icon' icon='plus' mods=$iconMod}
+
+
+ {* Не нравится *}
+
+ {component 'icon' icon='minus' mods=$iconMod}
+
+ {/block}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/wall/README.md b/application/frontend/components/wall/README.md
new file mode 100644
index 0000000..5247506
--- /dev/null
+++ b/application/frontend/components/wall/README.md
@@ -0,0 +1 @@
+# Компонент wall
\ No newline at end of file
diff --git a/application/frontend/components/wall/component.json b/application/frontend/components/wall/component.json
new file mode 100644
index 0000000..5ed70ba
--- /dev/null
+++ b/application/frontend/components/wall/component.json
@@ -0,0 +1,26 @@
+{
+ "name": "wall",
+ "version": "1.0.0",
+ "dependencies": {
+ "ls-component": "*",
+ "alert": "*",
+ "comment": "*",
+ "button": "*",
+ "field": "*"
+ },
+ "templates": {
+ "comments": "wall.comments.tpl",
+ "entry": "wall.entry.tpl",
+ "form": "wall.form.tpl",
+ "posts": "wall.posts.tpl",
+ "wall": "wall.tpl"
+ },
+ "scripts": {
+ "wall-entry": "js/wall-entry.js",
+ "wall-form": "js/wall-form.js",
+ "wall": "js/wall.js"
+ },
+ "styles": {
+ "wall": "css/wall.css"
+ }
+}
\ No newline at end of file
diff --git a/application/frontend/components/wall/css/wall.css b/application/frontend/components/wall/css/wall.css
new file mode 100644
index 0000000..c8b371b
--- /dev/null
+++ b/application/frontend/components/wall/css/wall.css
@@ -0,0 +1,47 @@
+/**
+ * Стена
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+
+/**
+ * Форма добавления сообщения на стену
+ */
+.wall-form { margin-bottom: 2px; padding: 15px; background: #fafafa; }
+.wall-form .ls-field { margin-bottom: 0; }
+.wall-form textarea { height: 30px; min-height: 30px; max-height: 30px; resize: none; }
+
+.wall-form-footer { display: none; }
+
+/* Open */
+.wall-form.open .ls-field { margin-bottom: 15px; }
+.wall-form.open textarea { height: 90px; max-height: 300px; resize: vertical; }
+.wall-form.open .wall-form-footer { display: block; }
+
+
+/**
+ * Сообщения
+ */
+.ls-comment.ls-comment--self.wall-comment { background: #fafafa; }
+
+.wall-comments { padding-left: 70px; }
+
+
+/**
+ * Кнопка подгрузки комментариев
+ */
+.ls-more.wall-more-comments {
+ border: none;
+ margin: 0 0 2px;
+ background-color: #f7f7f7;
+ color: #777;
+}
+.ls-more.wall-more-comments:hover {
+ background-color: #eee;
+}
+.ls-more.wall-more-comments.ls-more--locked {
+ background-color: #f7f7f7;
+}
\ No newline at end of file
diff --git a/application/frontend/components/wall/js/wall-entry.js b/application/frontend/components/wall/js/wall-entry.js
new file mode 100644
index 0000000..8d90889
--- /dev/null
+++ b/application/frontend/components/wall/js/wall-entry.js
@@ -0,0 +1,111 @@
+/**
+ * Wall entry
+ *
+ * @module ls/wall/entry
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsWallEntry", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ wall: null,
+
+ // Ссылки
+ urls: {
+ remove: null
+ },
+
+ // Селекторы
+ selectors: {
+ remove: '.js-comment-remove',
+ reply: '.js-comment-reply'
+ },
+
+ params: {}
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ // ID поста
+ this.id = this.element.data( 'id' );
+
+ // Тип записи (комментарий/пост)
+ this.type = this.element.data( 'type' );
+
+ // Форма добавления комментария к текущему посту
+ this.form = this.getType() === 'post' ? this.option( 'wall' ).lsWall( 'getFormById', this.id ) : null;
+
+ //
+ // События
+ //
+
+ // Удаление
+ this._on( this.elements.remove, {
+ click: function( event ) {
+ this.remove();
+ event.preventDefault();
+ }
+ });
+
+ // Показать/скрыть форму ответа
+ this._on( this.elements.reply, {
+ click: function( event ) {
+ this.formToggle();
+ event.preventDefault();
+ }
+ });
+ },
+
+ /**
+ * Показать/скрыть форму ответа
+ */
+ formToggle: function() {
+ this.form.lsWallForm( 'toggle' );
+ },
+
+ /**
+ * Возвращает тип записи (комментарий/пост)
+ *
+ * @return {String} Тип записи (комментарий/пост)
+ */
+ getType: function() {
+ return this.type;
+ },
+
+ /**
+ * Удаление
+ */
+ remove: function() {
+ this._load( 'remove', { user_id: this.option( 'wall' ).lsWall( 'getUserId' ), id: this.id }, 'onRemove' );
+ },
+
+ /**
+ * Коллбэк вызываемый после удаления
+ */
+ onRemove: function( response ) {
+ this.element.fadeOut( 'slow', function() {
+ this.element.remove();
+ this.option( 'wall' ).lsWall( 'checkEmpty' );
+ }.bind(this));
+
+ this.option( 'wall' ).lsWall( 'getCommentWrapperById', this.id ).fadeOut( 'slow', function () {
+ $( this ).remove();
+ });
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/wall/js/wall-form.js b/application/frontend/components/wall/js/wall-form.js
new file mode 100644
index 0000000..9dfec17
--- /dev/null
+++ b/application/frontend/components/wall/js/wall-form.js
@@ -0,0 +1,147 @@
+/**
+ * Wall form
+ *
+ * @module ls/wall/form
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsWallForm", {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ wall: null,
+
+ // Ссылки
+ urls: {
+ add: null
+ },
+
+ // Селекторы
+ selectors: {
+ text: '.js-wall-form-text',
+ button_submit: '.js-wall-form-submit'
+ }
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ var _this = this;
+
+ // Элементы
+ this.elements = {
+ text: this.element.find( this.option( 'selectors.text' ) ),
+ submit: this.element.find( this.option( 'selectors.submit' ) )
+ };
+
+ // ID поста
+ this.id = this.element.data( 'id' );
+
+ // Кнопка "Ответить" в посте
+ this.reply = this.option( 'wall' ).lsWall( 'getEntryById', this.id ).lsWallEntry( 'getElement', 'reply' );
+
+ // Отправка формы
+ this._on({ submit: this.submit });
+ this.elements.text.on( 'keydown' + this.eventNamespace, null, 'ctrl+return', this.submit.bind( this ) );
+
+ // Разворачивание формы
+ this._on( this.elements.text, { click: this.open } );
+
+ // Сворачиваем открытые формы
+ // при клике вне формы или кнопки Ответить
+ this.document.on( 'mouseup' + this.eventNamespace, function( e ) {
+ if ( e.which == 1 &&
+ this.isOpened() &&
+ ! this.element.is( e.target ) &&
+ ( ! this.reply || ( this.reply && ! this.reply.is( e.target ) ) ) &&
+ this.element.has( e.target ).length === 0 &&
+ ! this.elements.text.val() ) {
+
+ // Сворачиваем форму если у поста формы есть комментарии или если форма корневая
+ if ( this.option( 'wall' ).lsWall( 'getCommentsByPostId', this.id ).length || this.id === 0 ) {
+ this.close();
+ }
+ // Если у поста нет комментариев то скрываем форму
+ else {
+ this.hide();
+ }
+ }
+ }.bind( this ));
+ },
+
+ /**
+ * Отправка формы
+ */
+ submit: function( event ) {
+ var text = this.elements.text.val();
+
+ ls.utils.formLock( this.element );
+ this.option( 'wall' ).lsWall( 'add', this.id, text );
+
+ event.preventDefault();
+ },
+
+ /**
+ * Разворачивает форму
+ */
+ open: function() {
+ this.element.addClass( ls.options.classes.states.open );
+ },
+
+ /**
+ * Сворачивает форму
+ */
+ close: function() {
+ this.element.removeClass( ls.options.classes.states.open );
+ this.elements.text.val('');
+ },
+
+ /**
+ * Показать форму
+ */
+ show: function() {
+ this.element.show();
+ this.open();
+ this.elements.text.focus();
+ },
+
+ /**
+ * Скрыть форму
+ */
+ hide: function() {
+ this.element.hide();
+ },
+
+ /**
+ * Развернута форма или нет
+ */
+ isOpened: function() {
+ return this.element.hasClass( ls.options.classes.states.open );
+ },
+
+ /**
+ * Сворачивает/разворачивает форму
+ */
+ expandToggle: function() {
+ this[ this.isOpened() ? 'close' : 'open' ]();
+ },
+
+ /**
+ * Показывает/скрывает форму комментирования
+ */
+ toggle: function() {
+ this[ this.element.is( ':visible' ) ? 'hide' : 'show' ]();
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/wall/js/wall.js b/application/frontend/components/wall/js/wall.js
new file mode 100644
index 0000000..2642715
--- /dev/null
+++ b/application/frontend/components/wall/js/wall.js
@@ -0,0 +1,192 @@
+/**
+ * Стена пользователя
+ *
+ * @module ls/wall
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+(function($) {
+ "use strict";
+
+ $.widget( "livestreet.lsWall", $.livestreet.lsComponent, {
+ /**
+ * Дефолтные опции
+ */
+ options: {
+ // Ссылки
+ urls: {
+ add: null,
+ remove: null,
+ load: null,
+ load_comments: null
+ },
+
+ // Селекторы
+ selectors: {
+ entry: '.js-wall-entry',
+ comment: '.js-wall-comment',
+ post: '.js-wall-post',
+ form: '.js-wall-form',
+ more: '.js-wall-more',
+ more_comments: '.js-wall-more-comments',
+ comment_wrapper: '.js-wall-comment-wrapper',
+ container: '.js-wall-entry-container',
+ empty: '.js-wall-alert-empty'
+ },
+
+ params: {}
+ },
+
+ /**
+ * Конструктор
+ *
+ * @constructor
+ * @private
+ */
+ _create: function () {
+ this._super();
+
+ var _this = this;
+
+ this.userId = this.getUserId();
+
+ // Подгрузка новых постов
+ this.elements.more.lsMore({
+ urls: {
+ load: this.option( 'urls.load' )
+ },
+ proxy: [ 'last_id' ],
+ params: {
+ user_id: this.getUserId()
+ }
+ });
+
+ // Подгрузка комментариев
+ this.elements.more_comments.livequery( function () {
+ $( this ).lsMore({
+ urls: {
+ load: _this.option( 'urls.load_comments' )
+ },
+ append: false,
+ proxy: [ 'last_id' ],
+ params: {
+ user_id: _this.getUserId()
+ }
+ });
+ });
+
+ // Записи
+ this.elements.entry.livequery( function () {
+ $( this ).lsWallEntry({
+ wall: _this.element,
+ urls: {
+ remove: _this.option( 'urls.remove' )
+ }
+ })
+ });
+
+ // Формы
+ this.elements.form.livequery( function () {
+ $( this ).lsWallForm({
+ wall: _this.element
+ });
+ });
+ },
+
+ /**
+ * Добавление
+ *
+ * TODO: Оптимизировать
+ */
+ add: function( pid, text ) {
+ var form = this.getFormById( pid );
+
+ this._load( 'add', { user_id: this.getUserId(), pid: pid, text: text }, function( response ) {
+ if ( pid === 0 ) this.elements.empty.hide();
+
+ this.load( pid );
+ form.lsWallForm( 'close' );
+ }, {
+ onResponse: function () {
+ ls.utils.formUnlock( form );
+ }
+ });
+ },
+
+ /**
+ * Подгружает записи
+ *
+ * TODO: Оптимизировать
+ */
+ load: function( pid ) {
+ var container = this.element.find( this.options.selectors.container + '[data-id=' + pid + ']' ),
+ firstId = container.find( '>' + this.option( 'selectors.entry' ) + ':' + ( pid === 0 ? 'first' : 'last' ) ).data( 'id' ) || -1,
+ params = { user_id: this.getUserId(), first_id: firstId, target_id: pid };
+
+ this._load( pid === 0 ? 'load' : 'load_comments', params, function( response ) {
+ if ( response.count_loaded ) {
+ container[ pid === 0 ? 'prepend' : 'append' ]( response.html );
+ }
+ });
+ },
+
+ /**
+ * Получает посты
+ */
+ getPosts: function() {
+ return this.element.find( this.option( 'selectors.post' ) );
+ },
+
+ /**
+ * Получает комментарии по ID поста
+ */
+ getCommentsByPostId: function( pid ) {
+ return this.getCommentWrapperById( pid ).find( this.option( 'selectors.comment' ) );
+ },
+
+ /**
+ * Получает запись по ID
+ */
+ getEntryById: function( id ) {
+ return this.element.find( this.option( 'selectors.entry' ) + '[data-id=' + id + ']' ).eq( 0 );
+ },
+
+ /**
+ * Получает враппер комментариев по ID поста
+ */
+ getCommentWrapperById: function( id ) {
+ return this.element.find( this.option( 'selectors.comment_wrapper' ) + '[data-id=' + id + ']' ).eq( 0 );
+ },
+
+ /**
+ * Получает форму по ID поста
+ */
+ getFormById: function( id ) {
+ return this.element.find( this.option( 'selectors.form' ) + '[data-id=' + id + ']' ).eq( 0 );
+ },
+
+ /**
+ * Получает ID владельца стены
+ */
+ getUserId: function() {
+ return this.userId ? this.userId : this.userId = this.element.data( 'user-id' );
+ },
+
+ /**
+ * Получает развернутые формы
+ */
+ getOpenedForms: function() {
+ return this.element.find( this.option( 'selectors.form' ) + '.' + ls.options.classes.states.open );
+ },
+
+ /**
+ * Проверяет и если нужно показывает/скрывает сообщение о пустом списке
+ */
+ checkEmpty: function() {
+ this.elements.empty[ this.getPosts().length ? 'hide' : 'show' ]();
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/application/frontend/components/wall/wall.comments.tpl b/application/frontend/components/wall/wall.comments.tpl
new file mode 100644
index 0000000..d6a5d71
--- /dev/null
+++ b/application/frontend/components/wall/wall.comments.tpl
@@ -0,0 +1,11 @@
+{**
+ * Список комментариев к записи на стене
+ *
+ * @param array $comments Список комментариев
+ *}
+
+{component_define_params params=[ 'comments' ]}
+
+{foreach $comments as $comment}
+ {component 'wall' template='entry' entry=$comment showReply=false classes='wall-comment js-wall-comment' type='comment'}
+{/foreach}
\ No newline at end of file
diff --git a/application/frontend/components/wall/wall.entry.tpl b/application/frontend/components/wall/wall.entry.tpl
new file mode 100644
index 0000000..67f9031
--- /dev/null
+++ b/application/frontend/components/wall/wall.entry.tpl
@@ -0,0 +1,17 @@
+{**
+ * Стена / Запись (Пост / Комментарий)
+ *
+ * @param object $entry Комментарий
+ * @param boolean $showReply Показывать или нет кнопку комментирования
+ * @param string $classes Классы
+ *}
+
+{component_define_params params=[ 'entry', 'type', 'showReply', 'classes' ]}
+
+{component 'comment'
+ hookPrefix = 'wall_entry'
+ comment = $entry
+ showReply = $showReply
+ useScroll = false
+ attributes = [ 'data-type' => $type, 'data-user-id' => $entry->getUser()->getId() ]
+ classes = "wall-comment js-wall-entry {$classes}"}
\ No newline at end of file
diff --git a/application/frontend/components/wall/wall.form.tpl b/application/frontend/components/wall/wall.form.tpl
new file mode 100644
index 0000000..dcdc38e
--- /dev/null
+++ b/application/frontend/components/wall/wall.form.tpl
@@ -0,0 +1,25 @@
+{**
+ * Стена / Форма добавления записи
+ *
+ * @param integer $id ID родительского поста
+ * @param boolean $display Отображать форму или нет
+ * @param string $placeholder Плейсхолдер
+ *}
+
+{component_define_params params=[ 'classes', 'id', 'display', 'placeholder' ]}
+
+
\ No newline at end of file
diff --git a/application/frontend/components/wall/wall.posts.tpl b/application/frontend/components/wall/wall.posts.tpl
new file mode 100644
index 0000000..5ce0eb9
--- /dev/null
+++ b/application/frontend/components/wall/wall.posts.tpl
@@ -0,0 +1,42 @@
+{**
+ * Список постов на стене
+ *
+ * @param array $posts Список постов
+ *}
+
+{component_define_params params=[ 'posts' ]}
+
+{foreach $posts as $post}
+ {$comments = $post->getLastReplyWall()}
+ {$postId = $post->getId()}
+
+ {* Запись *}
+ {component 'wall' template='entry' entry=$post showReply=!$comments classes='wall-post js-wall-post' type='post'}
+
+ {* Комментарии *}
+
+{/foreach}
\ No newline at end of file
diff --git a/application/frontend/components/wall/wall.tpl b/application/frontend/components/wall/wall.tpl
new file mode 100644
index 0000000..d5034aa
--- /dev/null
+++ b/application/frontend/components/wall/wall.tpl
@@ -0,0 +1,48 @@
+{**
+ * Стена
+ *
+ * @param array $posts Посты
+ * @param array $count Общее кол-во постов на стене
+ * @param array $lastId ID последнего загруженного поста
+ * @param array $classes Доп-ые классы
+ * @param array $mods Модификаторы
+ * @param array $attributes Атрибуты
+ *}
+
+{* Название компонента *}
+{$component = 'wall'}
+{component_define_params params=[ 'count', 'posts', 'lastId', 'mods', 'classes', 'attributes' ]}
+
+{$loadedCount = count($posts)}
+{$moreCount = $count - $loadedCount}
+
+{* Стена *}
+
+ {* Форма добавления записи *}
+ {if $oUserCurrent}
+ {component 'wall' template='form'}
+ {else}
+ {component 'alert' text=$aLang.wall.alerts.unregistered mods='info' classes='ls-mt-15'}
+ {/if}
+
+ {* Список записей *}
+
+ {component 'wall' template='posts' posts=$posts}
+
+
+ {* Уведомление о пустом списке *}
+ {if $oUserCurrent || ( ! $oUserCurrent && ! $loadedCount )}
+ {component 'blankslate' text=$aLang.common.empty classes='ls-mt-15 js-wall-alert-empty' attributes=[ 'id' => 'wall-empty' ] visible=!$loadedCount}
+ {/if}
+
+ {* Кнопка подгрузки записей *}
+ {if $moreCount}
+ {component 'more'
+ classes = 'js-wall-more'
+ count = $moreCount
+ target = '.js-wall-entry-container[data-id=0]'
+ ajaxParams = [
+ 'last_id' => $lastId
+ ]}
+ {/if}
+
\ No newline at end of file
diff --git a/application/frontend/i18n/en.php b/application/frontend/i18n/en.php
new file mode 100644
index 0000000..9dbf0eb
--- /dev/null
+++ b/application/frontend/i18n/en.php
@@ -0,0 +1,1809 @@
+ array(
+ 'up' => 'Like',
+ 'down' => 'Dislike',
+ 'abstain' => 'Skip voting, check the rating',
+ 'count' => 'Voted',
+ 'rating' => 'Rating',
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'success' => 'Thank you for your vote',
+ 'success_abstain' => 'You have skipped the voting to check the rating',
+ 'error_time' => 'Votes are not accepted anymore!',
+ 'error_already_voted' => 'You have already been voted!',
+ 'error_acl' => 'Not enough rating to vote!',
+ 'error_auth' => 'You have to be logged in to vote',
+ 'error_self' => 'You can not vote for yourself',
+ ),
+ ),
+ /**
+ * Избранное
+ */
+ 'favourite' => array(
+ 'favourite' => 'Favourite',
+ 'add' => 'Add to favourite',
+ 'remove' => 'Delete from favourite',
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'add_success' => 'Added to favourites',
+ 'remove_success' => 'Removed from favourites',
+ 'already_added' => 'Already in favourites!',
+ 'already_removed' => 'Already removed from favourites!',
+ ),
+ ),
+ /**
+ * Поиск
+ */
+ 'search' => array(
+ 'search' => 'Search',
+ 'find' => 'Find',
+ 'result' => array(
+ 'topics' => 'Topics',
+ 'comments' => 'Comments',
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'empty' => 'Nothing found',
+ 'query_incorrect' => 'Minimum search length is 3 symbols',
+ ),
+ ),
+ /**
+ * Сортировка
+ */
+ 'sort' => array(
+ 'label' => 'Sort',
+ 'by_login' => 'by login',
+ 'by_name' => 'by name',
+ 'by_title' => 'by title',
+ 'by_date' => 'by date',
+ 'by_date_registration' => 'by reg date',
+ 'by_rating' => 'by rating',
+ ),
+ /**
+ * Заметка пользователя
+ */
+ 'user_note' => array(
+ 'add' => 'Add note',
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'target_error' => 'Not able to add a note to the selected user', // TODO: Remove?
+ ),
+ ),
+ /**
+ * Блог
+ */
+ 'blog' => array(
+ 'blog' => 'Blog',
+ 'blogs' => 'Blogs',
+ 'readers_declension' => 'reader;reader;readers',
+ 'administrators' => 'Administrators',
+ 'moderators' => 'Moderators',
+ 'owner' => 'Owner',
+ 'create_blog' => 'Create a blog',
+ 'can_add' => 'You can create a new blog!',
+ 'cant_add' => 'Your rating must be %%rating%% in order to create a blog.',
+ 'private' => 'Private blog',
+ 'personal_prefix' => 'Blog belongs to...',
+ 'personal_description' => 'This is your personal blog.',
+ 'topics_total' => 'Total topics',
+ 'date_created' => 'Creation date',
+ 'rating_limit' => 'Rating limit',
+ 'rss' => 'RSS',
+ // Действия
+ 'actions' => array(
+ 'write' => 'Add to blog',
+ 'join' => '___blog.join.join___',
+ 'leave' => '___blog.join.leave___',
+ 'rss' => 'Subscribe to RSS',
+ 'edit' => '___common.edit___',
+ 'remove' => '___common.remove___',
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'private' => 'You are not authorized to access this private blog.',
+ 'banned' => 'You are banned in this blog',
+ 'empty' => 'No blogs',
+ ),
+ /**
+ * Поиск
+ */
+ 'search' => array(
+ 'placeholder' => 'Search by name',
+ 'result_title' => '%%count%% blogs found;%%count%% blogs found;%%count%% blogs found',
+ 'form' => array(
+ 'type' => array(
+ 'any' => 'Any',
+ 'public' => 'Public',
+ 'private' => 'Private'
+ ),
+ 'relation' => array(
+ 'all' => 'All',
+ 'my' => 'My',
+ 'joined' => 'Subscribed'
+ )
+ )
+ ),
+ /**
+ * Приглашения
+ */
+ 'invite' => array(
+ 'invite_users' => 'Invite users',
+ 'repeat' => 'Repeat',
+ 'empty' => 'No invited users',
+ // Письмо с приглашением
+ 'email' => array(
+ 'title' => "Invitation to read '%%blog_title%%' blog",
+ 'text' => "User %%login%% is inviting you to access a private blog '%%blog_title%%'.Accept - Reject "
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'add' => 'Invitation sent to %%login%%',
+ 'add_self' => 'You can not invite yourself',
+ 'already_invited' => 'Ivitation was already sent to %%login%%',
+ 'already_joined' => 'User %%login%% has already joined the blog',
+ 'remove' => 'Invitation for user %%login%% removed',
+ 'reject' => 'User %%login%% has rejected an invitation',
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'already_joined' => 'You are already a member of this blog',
+ 'accepted' => 'Invitation accepted',
+ 'rejected' => 'Invitation rejected',
+ )
+ ),
+ /**
+ * Страница добавления/редактирования блога
+ */
+ 'add' => array(
+ 'title' => 'Create a new blog',
+ // Поля
+ 'fields' => array(
+ 'title' => array(
+ 'label' => 'Blog title',
+ 'note' => 'Blog title should make sense. It should be easy to understand what is the blog about.',
+ 'error' => 'Blog title must be withing 2 to 200 charachters long',
+ 'error_unique' => 'Blog with this name already exists',
+ ),
+ 'url' => array(
+ 'label' => 'Blogs URL',
+ 'note' => 'Blogs URL may contain only english letters, digits and dash. All spaces will be replaced with underscore. Ideally URL should match the blogs title. This parameter is not editable after the blog is created.',
+ 'error' => 'URL must be 2 to 50 chars long. Only English letters, Digits, symbols "-" and "_" are allowed.',
+ 'error_badword' => 'URL must be defferent from:',
+ 'error_unique' => 'This URL is already exists',
+ ),
+ 'category' => array(
+ 'label' => 'Blogs category',
+ 'note' => 'A category can be assigned to the blog. This helps to implement a structure to the web site.',
+ 'error' => 'Category not found',
+ 'error_only_children' => 'Only child category is allowed (Category must not have a sub-category)',
+ ),
+ 'type' => array(
+ 'label' => 'Blogs type',
+ 'note_open' => 'Public - anyone can join, all topics are visible.',
+ 'note_close' => 'Private - must be invited by the blog administrators. Topics are only visible to approved users.',
+ 'value_open' => 'Public',
+ 'value_close' => 'Private',
+ 'error' => 'Unknown blog type',
+ ),
+ 'description' => array(
+ 'label' => 'Blogs description',
+ 'error' => 'Description must be from 10 to 3000 charachters long',
+ ),
+ 'rating' => array(
+ 'label' => 'Rating limit',
+ 'note' => 'Required rating in order to write blog posts',
+ 'error' => 'Rating limit must be a number',
+ ),
+ 'avatar' => array(
+ 'label' => 'Avatar',
+ 'error' => 'Was not able to load an avatar',
+ ),
+ 'skip_index' => array(
+ 'label' => 'Do not post topics on a main page',
+ 'note' => 'No topics from this blog will be allowed on a main page',
+ ),
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'acl' => 'You are not good enough yet to create a blog', // TODO: Remove?
+ )
+ ),
+ /**
+ * Удаление блога
+ */
+ 'remove' => array(
+ 'title' => 'Delete blog',
+ 'remove_topics' => 'Delete topics',
+ 'move_to' => 'Move topics to the blog',
+ // Сообщения
+ 'alerts' => array(
+ 'success' => 'Blog was successfully removed',
+ 'not_empty' => 'You are not allowed to delete the blog with posts. All posts must be removed first.',
+ 'move_error' => 'Was not able to move topics',
+ 'move_personal_error' => 'Forbidden to move topics to the private blogs', // TODO: Remove?
+ )
+ ),
+ /**
+ * Управление блогом
+ */
+ 'admin' => array(
+ 'title' => 'Blog edit',
+ 'role_administrator' => 'Administrator',
+ 'role_moderator' => 'Moderator',
+ 'role_reader' => 'Reader',
+ 'role_banned' => 'Banned',
+ // Навигация
+ 'nav' => array(
+ 'profile' => 'Profile',
+ 'users' => 'Users',
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'empty' => 'No blog readers', // TODO: Remove?
+ 'submit_success' => 'Permissions saved', // TODO: Remove?
+ )
+ ),
+ /**
+ * Голосование
+ */
+ 'vote' => array(
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'error_close' => 'You are not able to vote for a private blog',
+ ),
+ ),
+ /**
+ * Вступить / покинуть блог
+ */
+ 'join' => array(
+ 'join' => 'Join',
+ 'leave' => 'Leave',
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'join_success' => 'You have joined the blog',
+ 'leave_success' => 'You have left the blog',
+ 'error_invite' => 'You must be invited in order to join the private blog!', // Remove?
+ 'error_self' => 'Can not join. You are already an owner!', // Remove?
+ ),
+ ),
+ /**
+ * Категории
+ */
+ 'categories' => array(
+ 'category' => 'Category',
+ 'categories' => 'Categories',
+ 'empty' => 'No blogs in this category',
+ ),
+ /**
+ * Список пользователей
+ */
+ 'users' => array(
+ 'readers' => 'Readers',
+ 'readers_all' => 'All blog readers',
+ 'readers_total' => 'Total readers',
+ 'empty' => 'No readers',
+ ),
+ /**
+ * Сортировка
+ */
+ 'sort' => array(
+ 'by_users' => 'by readers amount',
+ 'by_topics' => 'by topics amount',
+ ),
+ /**
+ * Меню со списокм топиков
+ */
+ 'menu' => array(
+ 'all' => 'All',
+ 'all_good' => 'Most Popular',
+ 'all_discussed' => 'Hot',
+ 'all_top' => 'TOP',
+ 'all_new' => 'New',
+ 'all_list' => 'All Blogs',
+ 'top_period_1' => '24 hrs',
+ 'top_period_7' => '7 days',
+ 'top_period_30' => '30 days',
+ 'top_period_all' => 'All the time',
+ ),
+ /**
+ * Блоки
+ */
+ 'blocks' => array(
+ 'info' => array(
+ 'title' => 'Blog description',
+ ),
+ 'navigator' => array(
+ 'title' => 'Blogs navigation',
+ 'submit' => 'View',
+ 'category' => '___blog.categories.category___',
+ 'blog' => '___blog.blog___',
+ 'empty' => '___blog.categories.empty___',
+ ),
+ 'blogs' => array(
+ 'title' => 'Blogs',
+ 'nav' => array(
+ 'top' => 'Top',
+ 'joined' => 'Joined',
+ 'self' => 'Mine',
+ ),
+ 'item' => array(
+ 'rating' => '___vote.rating___',
+ 'private' => '___blog.private___',
+ ),
+ 'joined_empty' => '___common.empty___', // TODO: Remove?
+ 'self_empty' => '___common.empty___', // TODO: Remove?
+ ),
+ 'search' => array(
+ 'title' => 'Blogs search',
+ 'categories' => array(
+ 'title' => '___blog.categories.categories___',
+ 'all' => 'All',
+ ),
+ 'type' => array(
+ 'title' => 'Blog type',
+ ),
+ 'relation' => array(
+ 'title' => 'Ownership',
+ ),
+ ),
+ ),
+ 'types' => array(
+ 'personal' => 'Personal blogs',
+ 'open' => 'Public blogs',
+ 'close' => 'Private blogs',
+ ),
+ ),
+ /**
+ * Личные сообщения
+ */
+ 'talk' => array(
+ 'title' => 'Messages',
+ 'participants' => '%%count%% user;%%count%% users;%%count%% users',
+ 'new_messages' => 'You have new messages',
+ 'send_message' => 'Send a message',
+ // Меню
+ 'nav' => array(
+ 'inbox' => 'Messages',
+ 'new' => 'Only new',
+ 'add' => 'New message',
+ 'favourites' => 'Favourites',
+ 'blacklist' => 'Add to blacklist'
+ ),
+ // Форма добавления
+ 'add' => array(
+ 'title' => 'New message',
+ 'choose_friends' => 'Select recipients from the friendlist',
+ // Поля
+ 'fields' => array(
+ 'users' => array(
+ 'label' => 'To'
+ ),
+ 'title' => array(
+ 'label' => 'Title',
+ ),
+ 'text' => array(
+ 'label' => 'Message',
+ ),
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'users_error' => 'At least one recepient must be selected',
+ 'users_error_not_found' => 'Recepient not found', // TODO: Move to common
+ 'users_error_many' => 'Too many recepients',
+ 'title_error' => 'Title must be within 2 to 200 charachters',
+ 'text_error' => 'Message must be within 2 to 3000 charachters',
+ )
+ ),
+ // Сообщение
+ 'message' => array(
+ // Сообщения
+ 'notices' => array(
+ 'error_text' => 'Message must be within 2 to 3000 charachters',
+ )
+ ),
+ // Экшнбар
+ 'actionbar' => array(
+ 'read' => 'Read',
+ 'unread' => 'Unread',
+ 'mark_as_read' => 'Mark as read',
+ ),
+ // Форма поиска
+ 'search' => array(
+ 'title' => 'Search by messages',
+ // Поля
+ 'fields' => array(
+ 'sender' => array(
+ 'label' => 'Sender',
+ 'note' => 'Confirm senders login'
+ ),
+ 'receiver' => array(
+ 'label' => 'Recepient',
+ 'note' => 'Confirm recepients login'
+ ),
+ 'keyword' => array(
+ 'label' => 'Search the title',
+ ),
+ 'keyword_text' => array(
+ 'label' => 'Search the text',
+ ),
+ 'start' => array(
+ 'label' => 'Date limit',
+ 'placeholder' => 'From'
+ ),
+ 'end' => array(
+ 'placeholder' => 'To'
+ ),
+ 'favourite' => array(
+ 'label' => 'Search only in favourites'
+ ),
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'error' => 'Search error',
+ 'error_date_format' => 'Wrong date format',
+ 'result_count' => 'Found: %%count%% messages',
+ 'result_empty' => 'No messages found'
+ )
+ ),
+ // Черный список
+ 'blacklist' => array(
+ 'title' => 'Black list',
+ 'note' => 'Users to stop receiveing messages from',
+ // Сообщения
+ 'notices' => array(
+ 'blocked' => 'User %%login%% does not accept messages from you',
+ 'user_not_found' => 'User %%login%% is not in your black list',
+ ),
+ ),
+ // Список участников разговора
+ 'users' => array(
+ 'title' => 'Conference user list',
+ 'inactive' => 'User is inactive',
+ // Сообщения
+ 'notices' => array(
+ 'user_not_found' => 'User %%login%% is inactive',
+ 'deleted' => 'User %%login%% has deleted this conversation',
+ )
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'time_limit' => 'You are not allowed to send messages so often',
+ 'empty' => 'No messages',
+ 'deleted' => 'Sender has deleted the conversation',
+ 'not_found' => 'Conversation not found'
+ ),
+ ),
+ /**
+ * Опросы
+ */
+ 'poll' => array(
+ 'polls' => 'Polls',
+ 'vote' => 'Vote',
+ 'abstain' => 'Skip',
+ 'only_auth' => 'Only authorized users are allowed to vote',
+ // Результат
+ 'result' => array(
+ 'voted_total' => 'Voted',
+ 'abstained_total' => 'Skipped',
+ 'sort' => 'Sorting Enable/Disable',
+ ),
+ // Форма добавления
+ 'form' => array(
+ 'title' => array(
+ 'add' => 'Add poll',
+ 'edit' => 'Edit poll',
+ ),
+ 'answers_title' => 'Answers',
+ // Поля
+ 'fields' => array(
+ 'title' => 'Question',
+ 'is_guest_allow' => 'Guests allowed',
+ 'is_guest_check_ip' => 'Only one vote per IP is allowed',
+ 'type' => array(
+ 'label' => 'User can select',
+ 'label_one' => 'Only one answer',
+ 'label_many' => 'Several answers'
+ ),
+ ),
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'error_answers_max' => 'Maximum allowed answers %%count%%',
+ 'error_not_allow_vote' => 'Not allowed to vote in this poll',
+ 'error_not_allow_remove' => 'This poll may not be removed',
+ 'error_already_vote' => 'You`ve already been voted',
+ 'error_no_answers' => 'An option should be selected',
+ 'error_answers_max_wrong' => 'Maximum answers should be more than one',
+ 'error_answers_count' => 'You must select more than one answer',
+ 'error_answer_remove' => 'Unable to remove an option as someone already used it',
+ 'error_target_type' => 'Wrong answer type',
+ 'error_target_tmp' => 'Timestamp is already in use',
+ ),
+ ),
+ /**
+ * Комментарии
+ */
+ 'comments' => array(
+ 'comments_declension' => '%%count%% comment;%%count%% comments;%%count%% comments',
+ 'no_comments' => 'No comments',
+ 'count_new' => 'New comments',
+ 'update' => 'Refresh comments',
+ 'title' => 'Comments',
+ 'subscribe' => 'Subscribe',
+ 'unsubscribe' => 'Unsubscribe',
+ // Комментарий
+ 'comment' => array(
+ 'deleted' => 'Comment was deleted',
+ 'restore' => 'Restore',
+ 'reply' => 'Reply',
+ 'scroll_to_parent' => 'Reply on',
+ 'scroll_to_child' => 'Back to reply',
+ 'target_author' => 'Author',
+ 'url' => 'Comment link',
+ 'edit_info' => 'Comment edited',
+ ),
+ // Сворачивание
+ 'folding' => array(
+ 'fold' => 'Collapse',
+ 'unfold' => 'Expand',
+ 'fold_all' => 'Collapse all',
+ 'unfold_all' => 'Expand all',
+ ),
+ // Форма добавления
+ 'form' => array(
+ 'title' => 'Leave a comment',
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'success_restore' => 'Comment restored',
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'unregistered' => 'Only registered users are allowed to comment'
+ ),
+ ),
+ /**
+ * Пополняемый список пользователей
+ */
+ 'user_list_add' => array(
+ // Форма добавления
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'add' => array(
+ 'label' => '___user.users___',
+ ),
+ ),
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'success_add' => 'Successfuly added user %%login%%',
+ 'error_already_added' => 'User %%login%% is already in the list',
+ 'error_self' => 'Not able to add yourself',
+ ),
+ ),
+ /**
+ * Мэйлы
+ */
+ 'emails' => array(
+ 'common' => array(
+ 'comment_text' => 'Comment body',
+ 'regards' => 'Sincerely, site admins',
+ ),
+ // Приглашение в закрытый блог
+ 'blog_invite_new' => array(
+ 'subject' => 'You have been invited to join the blog',
+ 'text' =>
+ 'User %%user_name%%
+ is inviting you to join the blog %%blog_name%% .
+
+ View the invite
+
+ Do not forget to authorize!',
+ ),
+ // Оповещение о новом комментарии в топике
+ 'comment_new' => array(
+ 'subject' => 'New comment',
+ 'text' =>
+ 'User %%user_name%%
+ just added a new comment to the topic %%topic_name%% ,
+ follow this link to read it
+
+ %%comment_text%%
+ %%unsubscribe%%',
+ 'unsubscribe' => 'STOP receiving new comments from this topic (Unsubscribe) '
+ ),
+ // Оповещение об ответе на комментарий
+ 'comment_reply' => array(
+ 'subject' => 'You have got a reply',
+ 'text' =>
+ 'User %%user_name%%
+ just replied on your comment in the topic %%topic_name%% ,
+ follow this link to read it
+
+ %%comment_text%%'
+ ),
+ // Приглашение на сайт
+ 'invite' => array(
+ 'subject' => 'You are invited',
+ 'text' =>
+ 'User %%user_name%%
+ just sent you invite for registration on %%website_name%%
+
+ Go to %%ref_link%% link to register.'
+ ),
+ // Повторная активация
+ 'reactivation' => array(
+ 'subject' => 'Reactivation request',
+ 'text' =>
+ 'You have requested another reactivation on %%website_name%%
+
+ Account activation link:
+
+ %%activation_url%% '
+ ),
+ // Регистрация
+ 'registration' => array(
+ 'subject' => 'Registration',
+ 'text' =>
+ 'You have been registered on %%website_name%%
+
+ Username: %%user_name%% '
+ ),
+ // Подтверждение регистрации
+ 'registration_activate' => array(
+ 'subject' => 'Registration confirmation',
+ 'text' =>
+ 'You have been registered on %%website_name%%
+
+ Your username: %%user_name%%
+
+ To complete registration you need to follow the activation link:
+ %%activation_url%% '
+ ),
+ // Смена пароля
+ 'reminder_code' => array(
+ 'subject' => 'Password recovery',
+ 'text' =>
+ 'If you would like to change your password on %%website_name%% , please follow the link:
+ %%recover_url%% '
+ ),
+ // Новый пароль
+ 'reminder_password' => array(
+ 'subject' => 'New password',
+ 'text' =>
+ 'Your new password: %%password%% '
+ ),
+ // Оповещение о новом сообщении в диалоге
+ 'talk_comment_new' => array(
+ 'subject' => 'You have got a new message comment',
+ 'text' =>
+ 'User %%user_name%%
+ just replied on %%talk_name%% ,
+ follow this link to read the message
+
+ %%message_text%%
+
+ Do not forget to authorize!'
+ ),
+ // Оповещение о новом сообщении
+ 'talk_new' => array(
+ 'subject' => 'You`ve got a new message',
+ 'text' =>
+ 'You`ve got a new message from %%user_name%% ,
+ it may be read at here
+
+ Message title: %%talk_name%%
+ %%talk_text%%
+
+ Do not forget to authorize!'
+ ),
+ // Оповещение о новом топике
+ 'topic_new' => array(
+ 'subject' => 'New post in blog',
+ 'text' =>
+ 'User %%user_name%%
+ sumitted a new post — %%topic_name%%
+ in the blog %%blog_name%% '
+ ),
+ // Смена почты
+ 'user_changemail' => array(
+ 'subject' => 'E-mail change confirmation',
+ 'text' =>
+ 'You`ve sent an e-mail change request for the user %%user_name%%
+ on %%website_name%% .
+
+ Old e-mail: %%mail_old%%
+ New e-mail: %%mail_new%%
+
+ To confirm change, please follow the link :
+ %%change_url%% '
+ ),
+ // Жалоба
+ 'user_complaint' => array(
+ 'subject' => 'User complaint',
+ 'text' =>
+ 'User %%user_name%%
+ complaint on %%user_target_url%% .
+
+ Reason: %%complaint_title%%
+ %%complaint_text%%',
+ 'more' => 'Details'
+ ),
+ // Заявка в друзья
+ 'user_friend_new' => array(
+ 'subject' => 'You`ve been added as a frined',
+ 'text' =>
+ 'User %%user_name%%
+
+ %%text%%
+
+ Read a request
+
+ Do not forget to authorize!'
+ ),
+ // Новое сообщение на стене
+ 'wall_new' => array(
+ 'subject' => 'New message on your wall',
+ 'text' =>
+ 'User %%user_name%%
+ had added a message on your wall
+
+ Message:
+ %%message_text%%'
+ ),
+ // Ответ на сообщение на стене
+ 'wall_reply' => array(
+ 'subject' => 'Reply to your wall message',
+ 'text' =>
+ 'User %%user_name%%
+ has replied on the wall
+
+ Your message:
+ %%message_parent_text%%
+
+ Users reply:
+ %%message_text%% '
+ )
+ ),
+ /**
+ * Стена
+ */
+ 'wall' => array(
+ 'title' => 'Wall',
+ // Форма
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'text' => array(
+ 'placeholder' => 'Write on the wall',
+ 'placeholder_reply' => 'Reply...',
+ ),
+ ),
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'error_add_pid' => 'Impossible to reply to this message',
+ 'error_add_time_limit' => 'You`re not allowed to write so often'
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'unregistered' => 'Only registered and authorized users are allowed to write on the wall'
+ ),
+ ),
+ /**
+ * Авторизация
+ */
+ 'auth' => array(
+ 'authorization' => 'Authorization',
+ 'logout' => 'Logout',
+ // Вход
+ 'login' => array(
+ 'title' => 'Login',
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'login' => array(
+ 'label' => 'Username or E-mail'
+ ),
+ 'remember' => array(
+ 'label' => 'Remember me'
+ ),
+ 'submit' => array(
+ 'text' => 'Login'
+ )
+ )
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'error_login' => 'Wrong username (e-mail) and/or passwod.',
+ 'error_not_activated' => 'Your account requires activation. Re-send activation link '
+ ),
+ ),
+ // Повторный запрос активации
+ 'reactivation' => array(
+ 'title' => 'Re-send activation',
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'mail' => array(
+ 'label' => 'Your e-mail'
+ ),
+ 'submit' => array(
+ 'text' => 'Get the activation link'
+ )
+ )
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'success' => 'Activation link was sent to your e-mail address.',
+ )
+ ),
+ // Сброс пароля
+ 'reset' => array(
+ 'title' => 'Password recovery',
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'mail' => array(
+ 'label' => 'Your e-mail'
+ ),
+ 'submit' => array(
+ 'text' => 'Get the password change link'
+ )
+ )
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'success_send_password' => 'New password was sent to your e-mail',
+ 'success_send_link' => 'Password change link was sent to your e-mail',
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'error_bad_code' => 'Wrong password recovery code.',
+ )
+ ),
+ // Регистрация по приглашению
+ 'invite' => array(
+ 'title' => 'Registration by invitation',
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'code' => array(
+ 'label' => 'Invitation code'
+ ),
+ 'submit' => array(
+ 'text' => 'Confirm code'
+ )
+ ),
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'error_code' => 'Incorrect invitation code',
+ )
+ ),
+ // Регистрация
+ 'registration' => array(
+ 'title' => 'Registration',
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'password_confirm' => array(
+ 'label' => 'Confirm password'
+ ),
+ 'submit' => array(
+ 'text' => 'Register'
+ )
+ )
+ ),
+ 'confirm' => array(
+ 'title' => 'Account activation',
+ 'text' => 'You`re almost done! An account has to be activated. Details were sent to the provided e-mail address.'
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'already_registered' => 'You`re already registered and the account is activated!',
+ 'success' => 'Congrats with successful registration',
+ 'success_activate' => 'Congrats! Your account is activated now.',
+ 'error_login' => 'Unaccepted username. Must be 3 to 30 characters long.',
+ 'error_login_used' => 'This username is already taken.',
+ 'error_mail_used' => 'This e-mail is already in use.',
+ 'error_reactivate' => 'Your account is already active',
+ 'error_code' => 'Wrong activation code!',
+ 'error_password_equal' => 'Passwords do not match',
+ ),
+ ),
+ // Общие лэйблы
+ 'labels' => array(
+ 'login' => 'Username',
+ 'password' => 'Password',
+ 'captcha' => 'Input captcha',
+ 'captcha_field' => 'Captcha',
+ ),
+ // Общие всплывающие сообщения
+ 'notices' => array(
+ 'error_bad_email' => 'User with the provided e-mail is not found',
+ ),
+ ),
+ /**
+ * Активность
+ */
+ 'activity' => array(
+ 'title' => 'Activity',
+ // Навигация
+ 'nav' => array(
+ 'all' => 'All',
+ 'personal' => 'Personal'
+ ),
+ // Настройки
+ 'settings' => array(
+ 'title' => 'Event settings',
+ 'note' => 'Select events to track',
+ 'options' => array(
+ 'add_wall' => 'New wall message',
+ 'add_topic' => 'New post',
+ 'add_comment' => 'New comment',
+ 'add_blog' => 'New blog',
+ 'vote_topic' => 'Vote for a post',
+ 'vote_comment_topic' => 'Vote for a comment',
+ 'vote_blog' => 'Vote for a blog',
+ 'vote_user' => 'Vote for a user',
+ 'add_friend' => 'Add to friends',
+ 'join_blog' => 'Join the blog',
+ )
+ ),
+ // Пользователи
+ 'users' => array(
+ 'title' => 'Users',
+ 'note' => 'Select users to track their activity',
+ ),
+ 'events' => array(
+ 'add_wall_male' => 'added message to the %%user%%`s wall ',
+ 'add_wall_female' => 'added message to the %%user%%`s wall ',
+ 'add_wall_self_male' => 'added message on his wall ',
+ 'add_wall_self_female' => 'added message on her wall ',
+ 'add_topic_male' => 'added new post %%topic%%',
+ 'add_topic_female' => 'added new post %%topic%%',
+ 'add_comment_male' => 'commented in %%topic%%',
+ 'add_comment_female' => 'commented in %%topic%%',
+ 'add_blog_male' => 'added new blog %%blog%%',
+ 'add_blog_female' => 'added new blog %%blog%%',
+ 'vote_topic_male' => 'voted for post %%topic%%',
+ 'vote_topic_female' => 'voted for post %%topic%%',
+ 'vote_comment_topic_male' => 'voted for %%topic%% comment',
+ 'vote_comment_topic_female' => 'voted for %%topic%% comment',
+ 'vote_blog_male' => 'voted for a blog %%blog%%',
+ 'vote_blog_female' => 'voted for a blog %%blog%%',
+ 'vote_user_male' => 'voted for a user %%user%%',
+ 'vote_user_female' => 'voted for a user %%user%%',
+ 'join_blog_male' => 'joined the blog %%blog%%',
+ 'join_blog_female' => 'joined the blog %%blog%%',
+ 'add_friend_male' => 'added user %%user%% to friends',
+ 'add_friend_female' => 'added user %%user%% to friends'
+ ),
+ // Блок с последними событиями
+ 'block_recent' => array(
+ 'title' => '___activity.title___',
+ 'topics' => 'Posts',
+ 'topics_empty' => '___common.empty___',
+ 'comments' => 'Comments',
+ 'comments_empty' => '___common.empty___',
+ 'feed' => 'RSS',
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'error_already_subscribed' => 'You`re already subscribed on this user',
+ )
+ ),
+ /**
+ * Лента
+ */
+ 'feed' => array(
+ 'title' => 'News feed',
+ // Блоги
+ 'blogs' => array(
+ 'title' => 'Blogs',
+ 'note' => 'Select blogs you would like to follow',
+ 'empty' => 'You didn`t select any blogs to follow'
+ ),
+ // Пользователи
+ 'users' => array(
+ 'title' => 'Users',
+ 'note' => 'Add users you would like to follow'
+ )
+ ),
+ /**
+ * Топик
+ */
+ 'topic' => array(
+ 'topics' => 'Posts',
+ 'topic_plural' => 'post;posts;posts',
+ 'drafts' => 'Draft',
+ 'read_more' => 'Read further',
+ 'author' => 'Author',
+ 'tags' => '___tags.tags___',
+ 'share' => 'Share',
+ 'is_draft' => 'Post is in drafts',
+ // Навигация
+ 'nav' => array(
+ 'drafts' => 'Drafts', // TODO: Remove duplication
+ 'published' => 'Published'
+ ),
+ 'content_type' => array(
+ 'states' => array(
+ 'active' => 'Active',
+ 'not_active' => 'Inactive',
+ 'wrong' => 'Unknown status',
+ ),
+ 'notices' => array(
+ 'error_code' => 'Type with the same code already exists',
+ ),
+ ),
+ // Форма добавления
+ 'add' => array(
+ 'title' => array(
+ 'add' => 'Add new post',
+ 'edit' => 'Edit post',
+ ),
+ // Поля
+ 'fields' => array(
+ 'blog' => array(
+ 'label' => 'Select the blogs',
+ 'placeholder' => 'Select the blogs',
+ 'note' => 'You have to join the blog in order to submit new posts in it.',
+ 'option_personal' => 'My personal blog',
+ ),
+ 'title' => array(
+ 'label' => 'Title'
+ ),
+ 'slug' => array(
+ 'label' => 'URL',
+ 'note' => 'Optional. May be automaticaly created or you can choose one.'
+ ),
+ 'text' => array(
+ 'label' => 'Content'
+ ),
+ 'tags' => array(
+ 'label' => '___tags.tags___',
+ 'note' => 'Tags must be comma-separated: tiger, maine coon, google'
+ ),
+ 'forbid_comments' => array(
+ 'label' => 'Block comments',
+ 'note' => 'If selected, no comments will be allowed in this post'
+ ),
+ 'publish_index' => array(
+ 'label' => 'Force to main page',
+ 'note' => 'If selected, post will be forced to the main page (available for admins only)'
+ ),
+ 'skip_index' => array(
+ 'label' => 'Force to SKIP the main page',
+ 'note' => 'If selected, post will be forced to SKIP the main page (available for admins only)'
+ ),
+ ),
+ // Кнопки
+ 'button' => array(
+ 'publish' => 'Submit',
+ 'update' => 'Save changes',
+ 'save_as_draft' => 'Save as a draft',
+ 'mark_as_draft' => 'Move to drafts',
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'error_blog_not_found' => 'Selected blog does not exist',
+ 'error_blog_max_count' => 'Maximum amount of blogs reached: %%count%%',
+ 'error_blog_not_allowed' => 'You`re not allowed to post in this blog',
+ 'error_text_unique' => 'You`ve already created a post with the same content',
+ 'error_type' => 'Wrong post type', // TODO: Remove?
+ 'error_slug' => 'Post URL must be provided',
+ 'error_favourite_draft' => 'Drafts are not allowed to favourites',
+ 'time_limit' => 'You`re not allowed to create posts so often',
+ 'rating_limit' => 'Not enough rating to create a post',
+ 'update_complete' => 'Updated successfully',
+ 'create_complete' => 'Created successfully',
+ )
+ ),
+ // Комментарии
+ 'comments' => array(
+ // Сообщения
+ 'notices' => array(
+ 'error_text' => 'Comment must be 2 to 3000 characters long, all tags should be valid',
+ 'acl' => 'You don`t have enough rating to submit a comment',
+ 'limit' => 'You`re not allowed to submit comments so often',
+ 'not_allowed' => 'No comments are allowed by the author',
+ 'spam' => 'Stop! Spam!',
+ )
+ ),
+ // Блоки
+ 'blocks' => array(
+ 'tip' => array(
+ 'title' => 'Advice',
+ 'text' => 'Tag <cut> is used to shorten long posts , by hiding a part of the content .',
+ )
+ )
+ ),
+ /**
+ * Пользователь
+ * !user
+ */
+ 'user' => array(
+ 'user' => 'User',
+ 'users' => 'Users',
+ 'rating' => '___vote.rating___',
+ 'date_last_session' => 'Last visit',
+ 'date_registration' => 'Registration date',
+ // Действия
+ 'actions' => array(
+ 'send_message' => '___talk.send_message___',
+ 'follow' => 'Subscribe',
+ 'unfollow' => 'Unsubscribe',
+ 'report' => '___report.report___',
+ ),
+ // Действия
+ 'choose' => array(
+ 'label' => '___user.users___',
+ 'choose' => 'Select from friend list',
+ ),
+ // Пол
+ 'gender' => array(
+ 'gender' => 'Gender',
+ 'male' => 'Male',
+ 'female' => 'Female',
+ 'men' => 'Men',
+ 'women' => 'Women',
+ 'none' => 'Not provided'
+ ),
+ // Статус
+ 'status' => array(
+ 'online' => 'Online',
+ 'offline' => 'Offline',
+ 'was_online_male' => 'Was online %%date%%',
+ 'was_online_female' => 'Was online %%date%%'
+ ),
+ // Жалоба
+ 'report' => array(
+ 'types' => array(
+ 'spam' => 'Spam',
+ 'obscene' => 'Obscene',
+ 'other' => 'Other'
+ )
+ ),
+ // Друзья
+ 'friends' => array(
+ 'title' => 'Friends',
+ 'add' => 'Add to friends',
+ 'remove' => 'Remove from friends',
+ 'rejected' => 'Request rejected',
+ 'sent' => 'Request sent',
+ // Статусы
+ 'status' => array(
+ 'notfriends' => '___user.friends.add___',
+ 'added' => '___user.friends.remove___',
+ 'pending' => '___user.friends.status.notfriends___',
+ 'rejected' => '___user.friends.rejected___',
+ 'sent' => '___user.friends.sent___',
+ 'linked' => '___user.friends.status.notfriends___',
+ ),
+ // Форма добавления в друзья
+ 'form' => array(
+ 'title' => '___user.friends.add___',
+ 'fields' => array(
+ 'text' => array(
+ 'label' => 'Name',
+ ),
+ 'submit' => array(
+ 'text' => '___common.send___',
+ )
+ ),
+ ),
+ // Сообщения
+ 'messages' => array(
+ 'offer' => array(
+ 'title' => 'User %%login%% is inviting you to be friends',
+ 'text' => "User %%login%% is inviting you to be friends. %%user_text%%Accept - Reject ",
+ ),
+ 'accept' => array(
+ 'title' => 'Your request is accepted',
+ 'text' => 'User %%login%% has accepted you as a friend',
+ ),
+ 'reject' => array(
+ 'title' => 'Your request is rejected',
+ 'text' => 'User %%login%% has rejected your friendship request',
+ ),
+ 'deleted' => array(
+ 'title' => 'You`ve been removed from friends',
+ 'text' => '%%login%% is not your friend anymore',
+ ),
+ ),
+ 'notices' => array(
+ 'add_success' => 'You`ve got a new friends',
+ 'remove_success' => 'Not your friend anymore',
+ 'not_found' => 'Friend not found!', // TODO: Remove?
+ 'already_exist' => 'This user is already your friend',
+ 'rejected' => 'This user has rejected your friendship',
+ 'time_limit' => 'Too many requests, please try again later',
+ 'offer_not_found' => 'Request not found', // TODO: Remove?
+ 'offer_already_done' => 'Request already processed',
+ )
+ ),
+ // Поиск
+ 'search' => array(
+ 'title' => 'Search by users',
+ 'placeholder' => 'Search by username',
+ 'result_title' => 'Found %%count%% user;Found %%count%% users;Found %%count%% users',
+ 'form' => array(
+ 'is_online' => 'Users online',
+ 'gender' => array(
+ 'any' => 'Any',
+ 'male' => 'Male',
+ 'female' => 'Female'
+ )
+ )
+ ),
+ // Публикации
+ 'publications' => array(
+ 'title' => 'Posts',
+ // Меню
+ 'nav' => array(
+ 'topics' => '___topic.topics___',
+ 'comments' => '___comments.title___',
+ 'notes' => 'Notes'
+ ),
+ ),
+ // Избранное
+ 'favourites' => array(
+ 'title' => '___favourite.favourite___',
+ // Меню
+ 'nav' => array(
+ 'topics' => '___topic.topics___',
+ 'comments' => '___comments.title___'
+ ),
+ ),
+ // Профиль
+ 'profile' => array(
+ 'title' => 'Profile',
+ 'social_networks' => 'Social networks',
+ 'contact' => 'Contacts',
+ // Меню
+ 'nav' => array(
+ 'info' => '___user.profile.title___',
+ 'wall' => '___wall.title___',
+ 'publications' => '___user.publications.title___',
+ 'favourite' => '___favourite.favourite___',
+ 'friends' => '___user.friends.title___',
+ 'activity' => '___activity.title___',
+ 'messages' => '___talk.title___',
+ 'settings' => 'Settings',
+ ),
+ 'about' => array(
+ 'title' => 'About'
+ ),
+ 'personal' => array(
+ 'title' => 'Personal',
+ 'birthday' => 'Birthday',
+ 'place' => 'Location',
+ 'gender' => '___user.gender.gender___',
+ 'gender_male' => '___user.gender.male___',
+ 'gender_female' => '___user.gender.female___',
+ ),
+ 'activity' => array(
+ 'title' => '___activity.title___',
+ 'blogs_joined' => 'Joined blogs',
+ 'blogs_created' => 'Created blogs',
+ 'blogs_admin' => 'Is an admin of',
+ 'blogs_mod' => 'Is a moderator of',
+ 'invited_by' => 'Invited by',
+ 'invited' => 'Invited',
+ )
+ ),
+ // Статистика
+ 'stats' => array(
+ 'title' => 'Stats',
+ 'all' => 'Total users',
+ 'active' => 'Active',
+ 'not_active' => 'Not active',
+ 'men' => '___user.gender.men___',
+ 'women' => '___user.gender.women___',
+ 'none' => '___user.gender.none___'
+ ),
+ // Настройки
+ 'settings' => array(
+ 'title' => 'Settings',
+ // Меню
+ 'nav' => array(
+ 'profile' => '___user.profile.title___',
+ 'account' => 'Account',
+ 'tuning' => 'Site settings',
+ 'invites' => 'Invites',
+ ),
+ // Настройки профиля
+ 'profile' => array(
+ 'generic' => 'Basic information',
+ 'contact' => '___user.profile.contact___',
+ 'fields' => array(
+ 'name' => array(
+ 'label' => 'Name',
+ ),
+ 'sex' => array(
+ 'label' => '___user.gender.gender___',
+ ),
+ 'birthday' => array(
+ 'label' => '___user.profile.personal.birthday___',
+ ),
+ 'place' => array(
+ 'label' => '___user.profile.personal.place___',
+ ),
+ 'about' => array(
+ 'label' => '___user.profile.about.title___',
+ ),
+ ),
+ 'notices' => array(
+ 'error_max_userfields' => 'You can`t add over %%count%% contact details'
+ ),
+ ),
+ // Настройки аккаунта
+ 'account' => array(
+ 'account' => 'Account settings',
+ 'password' => 'Password',
+ 'password_note' => 'Leave fields blank if you are not going to change the passwords.',
+ 'fields' => array(
+ 'email' => array(
+ 'note' => 'Your real e-mail. Activation code will be sent there',
+ 'notices' => array(
+ 'error_used' => 'This email is already in use',
+ 'change_from_notice' => 'A confirmation was sent to your OLD email address',
+ 'change_to_notice' => 'Thank you! A confirmation request was sent to your NEW email address .',
+ 'change_ok' => 'Your email is changed to %%mail%% ',
+ )
+ ),
+ 'password' => array(
+ 'label' => '___auth.labels.password___',
+ 'notices' => array(
+ 'error' => 'Wrong password',
+ )
+ ),
+ 'password_new' => array(
+ 'label' => 'New password',
+ 'notices' => array(
+ 'error' => 'Password must be at least 5 characters long',
+ )
+ ),
+ 'password_confirm' => array(
+ 'label' => '___auth.registration.form.fields.password_confirm.label___',
+ 'notices' => array(
+ 'error' => 'Passwords do not match',
+ )
+ ),
+ ),
+ ),
+ // Настройки сайта
+ 'tuning' => array(
+ 'email_notices' => 'E-mail notices',
+ 'general' => 'General settings',
+ 'fields' => array(
+ 'new_topic' => 'New blog post',
+ 'new_comment' => 'New post comment',
+ 'new_talk' => 'New private message',
+ 'reply_comment' => 'New comment reply',
+ 'new_friend' => 'New friendship request',
+ 'timezone' => array(
+ 'label' => 'Time zone'
+ ),
+ )
+ ),
+ // Инвайты
+ 'invites' => array(
+ 'note' => 'You may invite your friends to join the community. To do this, just input their e-mails and submit.',
+ 'available' => 'Invites available',
+ 'available_no' => 'You don`t have any invites available',
+ 'used' => 'Users invited',
+ 'used_empty' => 'no',
+ 'referral_link' => 'Your personal ref URL',
+ 'many' => 'a lot',
+ 'fields' => array(
+ 'email' => array(
+ 'label' => 'Invite user',
+ 'note' => 'This e-mail will be used to send invitation to',
+ ),
+ 'submit' => array(
+ 'text' => 'Send invitation',
+ ),
+ ),
+ 'notices' => array(
+ 'success' => 'Invitation sent'
+ )
+ ),
+ ),
+ 'photo' => array(
+ 'crop_avatar' => array(
+ 'title' => 'Avatar selection',
+ 'desc' => 'Select square area for avatar.',
+ ),
+ 'crop_photo' => array(
+ 'title' => 'Your photo',
+ 'desc' => 'Square zone should be selected to display in your profile.',
+ 'submit' => 'Save and continue',
+ ),
+ 'actions' => array(
+ 'change_photo' => 'Change photo',
+ 'upload_photo' => 'Upload photo',
+ 'change_avatar' => 'Change avatar',
+ 'remove' => '___common.remove___'
+ )
+ ),
+ // Блоки
+ 'blocks' => array(
+ 'cities' => array(
+ 'title' => 'Cities'
+ ),
+ 'countries' => array(
+ 'title' => 'Countries'
+ )
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'empty' => '___common.empty___',
+ 'not_found' => 'User %%login%% is not found',
+ 'not_found_by_id' => 'User #%%id%% not found'
+ ),
+ ),
+ /**
+ * Поля
+ */
+ 'field' => array(
+ 'email' => array(
+ 'label' => 'E-mail',
+ 'notices' => array(
+ 'error' => 'Invalid e-mail',
+ ),
+ ),
+ 'geo' => array(
+ 'select_country' => 'Select country',
+ 'select_region' => 'Select region',
+ 'select_city' => 'Select city',
+ ),
+ 'upload_area' => array(
+ 'label' => 'Drag files here or click to pick the files',
+ ),
+ 'category' => array(
+ 'label' => 'Category'
+ ),
+ ),
+ /**
+ * Категории
+ */
+ 'category' => array(
+ 'notices' => array(
+ 'validate_require' => 'Must pick a category',
+ 'validate_count' => 'Amount of categories must be within %%min%% to %%max%%',
+ 'validate_children' => 'Only child categories are allowed for selection',
+ 'validate_recursion' => 'Category can not be recursive',
+ 'validate_parent' => 'Wrong parent category',
+ 'validate_wrong' => 'Wrong category',
+ ),
+ ),
+ /**
+ * Кастомные поля
+ */
+ 'property' => array(
+ 'video' => array(
+ 'preview' => 'Video preview',
+ 'watch' => 'Watch'
+ ),
+ 'image' => array(
+ 'empty' => 'No image'
+ ),
+ 'imageset' => array(
+ 'label' => 'Photoset',
+ 'modalTitle' => 'Choose file'
+ ),
+ 'file' => array(
+ 'forbidden' => 'You must be authorized to access the file',
+ 'downloads' => 'Downloads',
+ 'empty' => 'No file'
+ ),
+ 'notices' => array(
+ 'validate_type' => 'Wrong type',
+ 'validate_code' => 'Code must be unique',
+ 'validate_value_date_future' => 'date may not be from the future',
+ 'validate_value_date_past' => 'date may not be from the past',
+ 'validate_value_file_empty' => 'Pick a file',
+ 'validate_value_file_upload' => 'An error occured during file upload',
+ 'validate_value_file_size_max' => 'Max file size (%%size%% Kb) limit reached',
+ 'validate_value_file_type' => 'Unaccepted file type. Accepted types are %%types%%',
+ 'validate_value_image_wrong' => 'File is not an image',
+ 'validate_value_image_width_max' => 'Accepted Max image width is %%size%%px',
+ 'validate_value_image_height_max' => 'Accepted Max image height is %%size%%px',
+ 'validate_value_select_max' => 'You can select up to %%count%% elements',
+ 'validate_value_select_min' => 'You should select more than %%count%% elements',
+ 'validate_value_select_wrong' => 'Check the right elements are selected',
+ 'validate_value_select_only_one' => 'Only one element is permitted',
+ 'validate_value_video_wrong' => 'Fix the video link: YouTube, Vimeo',
+ 'validate_value_wrong' => 'Form "%%field%%": ',
+ 'validate_value_wrong_base' => 'wrong value',
+ 'create_error' => 'An error occured while adding new form field',
+ ),
+ ),
+ /**
+ * Админка
+ */
+ 'admin' => array(
+ 'title' => 'Admin panel',
+ 'items' => array(
+ 'plugins' => '___admin.plugins.title___',
+ ),
+ 'install_plugin_admin' => 'Install advanced admin-panel',
+ // Страница администрирования плагинов
+ 'plugins' => array(
+ 'title' => 'Manage plugins',
+ 'plugin' => array(
+ 'author' => 'Author',
+ 'version' => 'Version',
+ 'url' => 'Web-site',
+ 'activate' => 'Activate',
+ 'deactivate' => 'Deactivate',
+ 'settings' => 'Settings',
+ 'remove' => '___common.remove___',
+ 'apply_update' => 'Apply update',
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'unknown_action' => 'Unknown action selected',
+ 'action_ok' => 'Successfully complete',
+ 'activation_overlap' => 'Plugins conflict. Resource %%resource%% is reassigned to %%delegate%% by %%plugin%%.',
+ 'activation_overlap_inherit' => 'Plugins conflict. Resource %%resource%% is used as a child for %%plugin%%.',
+ 'activation_file_not_found' => 'Plugin file not found',
+ 'activation_file_write_error' => 'Missing written permissions for the plugin',
+ 'activation_version_error' => 'LiveStreet core %%version%%+ is required for the plugin',
+ 'activation_requires_error' => 'Plugin dependency is required: %%plugin%% ',
+ 'activation_already_error' => 'Plugin is already activated',
+ 'deactivation_already_error' => 'Plugin is not activated',
+ 'deactivation_requires_error' => 'Another plugin depends on this one, first disable it: %%plugin%% ',
+ )
+ ),
+ ),
+ /**
+ * Жалобы
+ */
+ 'report' => array(
+ 'report' => 'Report',
+ 'form' => array(
+ 'title' => '___report.report___',
+ 'fields' => array(
+ 'type' => array(
+ 'label' => 'Reason'
+ ),
+ 'text' => array(
+ 'label' => 'Description'
+ )
+ ),
+ 'submit' => '___common.send___'
+ ),
+ 'notices' => array(
+ 'target_error' => 'Wrong object id', // TODO: Remove?
+ 'error_type' => 'Wrong report type', // TODO: Remove?
+ 'success' => 'Report is sent',
+ )
+ ),
+ /**
+ * Загрузка изображений
+ */
+ 'media' => array(
+ 'title' => 'Media files upload',
+ 'error' => array(
+ 'upload' => 'Was not able to upload the file',
+ 'not_image' => 'File is not an image',
+ 'too_large' => 'Exceeded maximum file size limit: %%size%%Kb',
+ 'incorrect_type' => 'Wrong file type',
+ 'max_count_files' => 'Maximum amount of files reached',
+ 'need_choose_items' => 'Need to choose elements',
+ ),
+ 'nav' => array(
+ 'insert' => 'Upload',
+ 'photoset' => 'Make a photoset',
+ 'url' => 'Image URL',
+ 'preview' => 'Preview',
+ ),
+ 'image_align' => array(
+ 'title' => 'Align',
+ 'no' => 'none',
+ 'left' => 'Left',
+ 'right' => 'Right',
+ 'center' => 'Center',
+ ),
+ 'insert' => array(
+ 'submit' => 'Paste',
+ 'settings' => array(
+ 'title' => 'Paste options',
+ 'fields' => array(
+ 'size' => array(
+ 'label' => 'Size',
+ 'original' => 'Original'
+ ),
+ )
+ ),
+ ),
+ 'photoset' => array(
+ 'submit' => 'Create a photoset',
+ 'settings' => array(
+ 'title' => 'Photoset options',
+ 'fields' => array(
+ 'use_thumbs' => array(
+ 'label' => 'Show preview feed'
+ ),
+ 'show_caption' => array(
+ 'label' => 'Show photo descriptions'
+ )
+ )
+ ),
+ ),
+ 'url' => array(
+ 'fields' => array(
+ 'url' => array(
+ 'label' => 'Link',
+ ),
+ 'title' => array(
+ 'label' => 'Description',
+ ),
+ ),
+ 'submit_insert' => 'Paste a URL',
+ 'submit_upload' => 'Upload and paste'
+ ),
+ ),
+ /**
+ * Теги
+ */
+ 'tags' => array(
+ 'tags' => 'Tags',
+ 'tag' => 'Tag',
+ 'search' => array(
+ 'title' => 'Tags search',
+ 'label' => '___tags.search.title___',
+ ),
+ 'block_tags' => array(
+ 'nav' => array(
+ 'all' => 'All tags',
+ // Теги избранных топиков
+ 'favourite' => 'My tags',
+ ),
+ 'title' => '___tags.tags___',
+ 'empty' => '___common.empty___',
+ ),
+ ),
+ /**
+ * Персональные теги
+ */
+ 'tags_personal' => array(
+ 'title' => 'Favourites tags',
+ 'edit' => 'change own tags',
+ ),
+ /**
+ * Toolbar
+ */
+ 'toolbar' => array(
+ 'scrollup' => array(
+ 'title' => 'Up',
+ ),
+ 'topic_nav' => array(
+ 'next' => 'Next post',
+ 'prev' => 'Previous post',
+ )
+ ),
+ /**
+ * Создание
+ */
+ 'modal_create' => array(
+ 'title' => 'Create',
+ 'items' => array(
+ 'blog' => 'Blog',
+ 'talk' => 'Message',
+ )
+ ),
+ /**
+ * Обрезка изображения
+ */
+ 'crop' => array(
+ 'title' => 'Image crop'
+ ),
+ /**
+ * Экшнбар
+ */
+ 'actionbar' => array(
+ 'select' => array(
+ 'title' => 'Select',
+ 'menu' => array(
+ 'all' => 'All',
+ 'deselect' => 'Deselect',
+ 'invert' => 'Invert selection',
+ ),
+ ),
+ ),
+ /**
+ * Управление правами (RBAC)
+ */
+ 'rbac' => array(
+ 'permission' => array(
+ 'create_blog' => array(
+ 'title' => 'Blog creation',
+ 'error' => 'You have no permission to create a blog',
+ ),
+ 'vote_blog' => array(
+ 'title' => 'Blog vote',
+ 'error' => 'You have no permission to vote for a blog',
+ ),
+ 'create_comment_favourite' => array(
+ 'title' => 'Add to favourites',
+ 'error' => 'You have no permissions to add comment to favourites',
+ ),
+ 'vote_comment' => array(
+ 'title' => 'Comments vote',
+ 'error' => 'You have no permissions to vote for a comment',
+ ),
+ 'create_invite' => array(
+ 'title' => 'Send an invite',
+ 'error' => 'You have no permissions to send an invite',
+ ),
+ 'create_talk' => array(
+ 'title' => 'Send a private message',
+ 'error' => 'You have no permissions to send a private message',
+ ),
+ 'create_talk_comment' => array(
+ 'title' => 'Comment private message',
+ 'error' => 'Not allowed to comment a private message',
+ ),
+ 'vote_user' => array(
+ 'title' => 'Vote for a user',
+ 'error' => 'You`re not allowed to vote for a user',
+ ),
+ 'create_topic' => array(
+ 'title' => 'Create post',
+ 'error' => 'Not enough permissions to create a post',
+ ),
+ 'create_topic_comment' => array(
+ 'title' => 'Post comments',
+ 'error' => 'Not enough permissions to comment a post'
+ ),
+ 'remove_topic' => array(
+ 'title' => 'Delete topic',
+ 'error' => 'Not enough permissions to delete a post',
+ ),
+ 'vote_topic' => array(
+ 'title' => 'Post vote',
+ 'error' => 'Not enough permissions to vote for a post',
+ ),
+ ),
+ 'notices' => array(
+ 'validate_group_code' => 'Code must be unique',
+ 'validate_group_wrong' => 'Wrong group',
+ 'validate_permission_code' => 'Code must be unique',
+ 'validate_role_code' => 'Code must be unique',
+ 'validate_role_recursive' => 'Recursive roles are forbidden today',
+ 'validate_role_wrong' => 'Wrong role',
+ 'error_not_allow' => 'You`re not allowed to "%%permission%%"',
+ ),
+ ),
+);
\ No newline at end of file
diff --git a/application/frontend/i18n/modules/.gitignore b/application/frontend/i18n/modules/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/application/frontend/i18n/ru.php b/application/frontend/i18n/ru.php
new file mode 100644
index 0000000..1db985a
--- /dev/null
+++ b/application/frontend/i18n/ru.php
@@ -0,0 +1,1822 @@
+ array(
+ 'up' => 'Нравится',
+ 'down' => 'Не нравится',
+ 'abstain' => 'Воздержаться от голосования и посмотреть рейтинг',
+ 'count' => 'Всего проголосовало',
+ 'rating' => 'Рейтинг',
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'success' => 'Ваш голос учтен',
+ 'success_abstain' => 'Вы воздержались для просмотра рейтинга',
+ 'error_time' => 'Срок голосования истёк!',
+ 'error_already_voted' => 'Вы уже голосовали!',
+ 'error_acl' => 'У вас не хватает рейтинга для голосования!',
+ 'error_auth' => 'Для голосования необходимо авторизоваться',
+ 'error_self' => 'Вы не можете голосовать за свое',
+ ),
+ ),
+ /**
+ * Избранное
+ */
+ 'favourite' => array(
+ 'favourite' => 'Избранное',
+ 'add' => 'Добавить в избранное',
+ 'remove' => 'Удалить из избранного',
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'add_success' => 'Добавлено в избранное',
+ 'remove_success' => 'Удалено из избранного',
+ 'already_added' => 'Уже добавлено в избранное!',
+ 'already_removed' => 'Уже удалено из избранного!',
+ ),
+ ),
+ /**
+ * Поиск
+ */
+ 'search' => array(
+ 'search' => 'Поиск',
+ 'find' => 'Найти',
+ 'result' => array(
+ 'topics' => 'Топики',
+ 'comments' => 'Комментарии',
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'empty' => 'Поиск не дал результатов',
+ 'query_incorrect' => 'Поисковый запрос должен быть от 3-х символов',
+ ),
+ ),
+ /**
+ * Сортировка
+ */
+ 'sort' => array(
+ 'label' => 'Сортировать',
+ 'by_login' => 'по логину',
+ 'by_name' => 'по имени',
+ 'by_title' => 'по названию',
+ 'by_date' => 'по дате',
+ 'by_date_registration' => 'по дате регистрации',
+ 'by_rating' => 'по рейтингу',
+ ),
+ /**
+ * Заметка пользователя
+ */
+ 'user_note' => array(
+ 'add' => 'Написать заметку',
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'target_error' => 'Неверный пользователь для заметки', // TODO: Remove?
+ ),
+ ),
+ /**
+ * Блог
+ */
+ 'blog' => array(
+ 'blog' => 'Блог',
+ 'blogs' => 'Блоги',
+ 'readers_declension' => 'читатель;читателя;читателей',
+ 'administrators' => 'Администраторы',
+ 'moderators' => 'Модераторы',
+ 'owner' => 'Создатель',
+ 'create_blog' => 'Создать блог',
+ 'can_add' => 'Вы можете создать свой блог!',
+ 'cant_add' => 'Для возможности создавать блоги, ваш рейтинг должен быть больше %%rating%%.',
+ 'private' => 'Закрытый блог',
+ 'personal_prefix' => 'Блог им.',
+ 'personal_description' => 'Это ваш персональный блог.',
+ 'topics_total' => 'Топиков',
+ 'date_created' => 'Дата создания',
+ 'rating_limit' => 'Ограничение на постинг',
+ 'rss' => 'RSS',
+ // Действия
+ 'actions' => array(
+ 'write' => 'Написать в блог',
+ 'join' => '___blog.join.join___',
+ 'leave' => '___blog.join.leave___',
+ 'rss' => 'Подписаться через RSS',
+ 'edit' => '___common.edit___',
+ 'remove' => '___common.remove___',
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'private' => 'Это закрытый блог, у вас нет прав на просмотр контента',
+ 'banned' => 'Вы забанены в этом блоге',
+ 'empty' => 'Список блогов пуст',
+ ),
+ /**
+ * Поиск
+ */
+ 'search' => array(
+ 'placeholder' => 'Поиск по названию',
+ 'result_title' => 'Найден %%count%% блог;Найдено %%count%% блога;Найдено %%count%% блогов',
+ 'form' => array(
+ 'type' => array(
+ 'any' => 'Любой',
+ 'public' => 'Открытый',
+ 'private' => 'Закрытый'
+ ),
+ 'relation' => array(
+ 'all' => 'Все',
+ 'my' => 'Мои',
+ 'joined' => 'Читаю'
+ )
+ )
+ ),
+ /**
+ * Приглашения
+ */
+ 'invite' => array(
+ 'invite_users' => 'Пригласить пользователей',
+ 'repeat' => 'Повторить',
+ 'empty' => 'Нет приглашенных пользователей',
+ // Письмо с приглашением
+ 'email' => array(
+ 'title' => "Приглашение стать читателем блога '%%blog_title%%'",
+ 'text' => "Пользователь %%login%% приглашает вас стать читателем закрытого блога '%%blog_title%%'.Принять - Отклонить "
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'add' => 'Пользователю %%login%% отправлено приглашение',
+ 'add_self' => 'Нельзя отправить инвайт самому себе',
+ 'already_invited' => 'Пользователю %%login%% уже отправлен инвайт',
+ 'already_joined' => 'Пользователь %%login%% уже состоит в блоге',
+ 'remove' => 'Приглашение для пользователя %%login%% удалено',
+ 'reject' => 'Пользователь %%login%% отклонил инвайт',
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'already_joined' => 'Вы уже являетесь пользователем этого блога',
+ 'accepted' => 'Приглашение принято',
+ 'rejected' => 'Приглашение отклонено',
+ )
+ ),
+ /**
+ * Страница добавления/редактирования блога
+ */
+ 'add' => array(
+ 'title' => 'Создание нового блога',
+ // Поля
+ 'fields' => array(
+ 'title' => array(
+ 'label' => 'Название блога',
+ 'note' => 'Название блога должно быть наполнено смыслом, чтобы можно было понять, о чем будет блог.',
+ 'error' => 'Название блога должно быть от 2 до 200 символов',
+ 'error_unique' => 'Блог с таким названием уже существует',
+ ),
+ 'url' => array(
+ 'label' => 'URL блога',
+ 'note' => 'URL блога, по которому он будет доступен. Может содержать только буквы латинского алфавита, цифры, дефис; пробелы будут заменены на "_". По смыслу URL должен совпадать с названием блога, после его создания редактирование этого параметра будет недоступно',
+ 'error' => 'URL блога должен быть от 2 до 50 символов и только на латинице + цифры и знаки "-", "_"',
+ 'error_badword' => 'URL блога должен отличаться от:',
+ 'error_unique' => 'Блог с таким URL уже существует',
+ ),
+ 'category' => array(
+ 'label' => 'Категория блога',
+ 'note' => 'Блогу можно назначить категорию, что позволяет более глубоко структурировать сайт',
+ 'error' => 'Не удалось найти категорию блога',
+ 'error_only_children' => 'Можно выбрать только конечную категорию (без дочерних)',
+ ),
+ 'type' => array(
+ 'label' => 'Тип блога',
+ 'note_open' => 'Открытый — к этому блогу может присоединиться любой желающий, топики видны всем',
+ 'note_close' => 'Закрытый — присоединиться можно только по приглашению администрации блога, топики видят только подписчики',
+ 'value_open' => 'Открытый',
+ 'value_close' => 'Закрытый',
+ 'error' => 'Неизвестный тип блога',
+ ),
+ 'description' => array(
+ 'label' => 'Описание блога',
+ 'error' => 'Текст описания блога должен быть от 10 до 3000 символов',
+ ),
+ 'rating' => array(
+ 'label' => 'Ограничение по рейтингу',
+ 'note' => 'Рейтинг, который необходим пользователю, чтобы написать в этот блог',
+ 'error' => 'Значение ограничения рейтинга должно быть числом',
+ ),
+ 'avatar' => array(
+ 'label' => 'Аватар',
+ 'error' => 'Не удалось загрузить аватар',
+ ),
+ 'skip_index' => array(
+ 'label' => 'Не выводить топики на главную',
+ 'note' => 'Все топики из этого блога не смогут попадать на главную страницу',
+ ),
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'acl' => 'Вы еще не достаточно окрепли, чтобы создавать свой блог', // TODO: Remove?
+ )
+ ),
+ /**
+ * Удаление блога
+ */
+ 'remove' => array(
+ 'title' => 'Удаление блога',
+ 'remove_topics' => 'Удалить топики',
+ 'move_to' => 'Переместить топики в блог',
+ // Сообщения
+ 'alerts' => array(
+ 'success' => 'Блог успешно удален',
+ 'not_empty' => 'Вы не можете удалить блок с записями. Предварительно удалите из блога все записи.',
+ 'move_error' => 'Не удалось переместить топики из удаляемого блога',
+ 'move_personal_error' => 'Нельзя перемещать топики в персональный блог', // TODO: Remove?
+ )
+ ),
+ /**
+ * Управление блогом
+ */
+ 'admin' => array(
+ 'title' => 'Редактирование блога',
+ 'role_administrator' => 'Администратор',
+ 'role_moderator' => 'Модератор',
+ 'role_reader' => 'Читатель',
+ 'role_banned' => 'Забаненный',
+ // Навигация
+ 'nav' => array(
+ 'profile' => 'Профиль',
+ 'users' => 'Пользователи',
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'empty' => 'В блоге никто не состоит', // TODO: Remove?
+ 'submit_success' => 'Права сохранены', // TODO: Remove?
+ )
+ ),
+ /**
+ * Голосование
+ */
+ 'vote' => array(
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'error_close' => 'Вы не можете голосовать за закрытый блог',
+ ),
+ ),
+ /**
+ * Вступить / покинуть блог
+ */
+ 'join' => array(
+ 'join' => 'Вступить',
+ 'leave' => 'Покинуть',
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'join_success' => 'Вы вступили в блог',
+ 'leave_success' => 'Вы покинули блог',
+ 'error_invite' => 'Присоединиться к этому блогу можно только по приглашению!', // Remove?
+ 'error_self' => 'Зачем вы хотите вступить в этот блог? Вы и так его хозяин!', // Remove?
+ ),
+ ),
+ /**
+ * Категории
+ */
+ 'categories' => array(
+ 'category' => 'Категория',
+ 'categories' => 'Категории',
+ 'empty' => 'В данной категории нет блогов',
+ ),
+ /**
+ * Список пользователей
+ */
+ 'users' => array(
+ 'readers' => 'Читатели',
+ 'readers_all' => 'Все читатели блога',
+ 'readers_total' => 'Читателей',
+ 'empty' => 'Нет читателей',
+ ),
+ /**
+ * Сортировка
+ */
+ 'sort' => array(
+ 'by_users' => 'по кол-ву читателей',
+ 'by_topics' => 'по кол-ву топиков',
+ ),
+ /**
+ * Меню со списокм топиков
+ */
+ 'menu' => array(
+ 'all' => 'Все',
+ 'all_good' => 'Интересные',
+ 'all_discussed' => 'Обсуждаемые',
+ 'all_top' => 'TOP',
+ 'all_new' => 'Новые',
+ 'all_list' => 'Все блоги',
+ 'top_period_1' => 'За 24 часа',
+ 'top_period_7' => 'За 7 дней',
+ 'top_period_30' => 'За 30 дней',
+ 'top_period_all' => 'За все время',
+ ),
+ /**
+ * Блоки
+ */
+ 'blocks' => array(
+ 'info' => array(
+ 'title' => 'Описание блога',
+ ),
+ 'navigator' => array(
+ 'title' => 'Навигация по блогам',
+ 'submit' => 'Смотреть',
+ 'category' => '___blog.categories.category___',
+ 'blog' => '___blog.blog___',
+ 'empty' => '___blog.categories.empty___',
+ ),
+ 'blogs' => array(
+ 'title' => 'Блоги',
+ 'nav' => array(
+ 'top' => 'Топ',
+ 'joined' => 'Подключенные',
+ 'self' => 'Мои',
+ ),
+ 'item' => array(
+ 'rating' => '___vote.rating___',
+ 'private' => '___blog.private___',
+ ),
+ 'joined_empty' => '___common.empty___', // TODO: Remove?
+ 'self_empty' => '___common.empty___', // TODO: Remove?
+ ),
+ 'search' => array(
+ 'title' => 'Поиск по блогам',
+ 'categories' => array(
+ 'title' => '___blog.categories.categories___',
+ 'all' => 'Все',
+ ),
+ 'type' => array(
+ 'title' => 'Тип блога',
+ ),
+ 'relation' => array(
+ 'title' => 'Принадлежность',
+ ),
+ ),
+ ),
+ 'types' => array(
+ 'personal' => 'Персональные блоги',
+ 'open' => 'Открытые блоги',
+ 'close' => 'Закрытые блоги',
+ ),
+ ),
+ /**
+ * Личные сообщения
+ */
+ 'talk' => array(
+ 'title' => 'Сообщения',
+ 'participants' => '%%count%% участник;%%count%% участника;%%count%% участников',
+ 'new_messages' => 'У вас есть новые сообщения',
+ 'send_message' => 'Отправить сообщение',
+ // Меню
+ 'nav' => array(
+ 'inbox' => 'Сообщения',
+ 'new' => 'Только новые',
+ 'add' => 'Новое письмо',
+ 'favourites' => 'Избранное',
+ 'blacklist' => 'Блокировать'
+ ),
+ // Форма добавления
+ 'add' => array(
+ 'title' => 'Новое письмо',
+ 'choose_friends' => 'Выбрать получателей из списка друзей',
+ // Поля
+ 'fields' => array(
+ 'users' => array(
+ 'label' => 'Кому'
+ ),
+ 'title' => array(
+ 'label' => 'Заголовок',
+ ),
+ 'text' => array(
+ 'label' => 'Сообщение',
+ ),
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'users_error' => 'Необходимо указать, кому вы хотите отправить сообщение',
+ 'users_error_not_found' => 'У нас нет пользователя с логином', // TODO: Move to common
+ 'users_error_many' => 'Слишком много адресатов',
+ 'title_error' => 'Заголовок сообщения должен быть от 2 до 200 символов',
+ 'text_error' => 'Текст сообщения должен быть от 2 до 3000 символов',
+ )
+ ),
+ // Сообщение
+ 'message' => array(
+ // Сообщения
+ 'notices' => array(
+ 'error_text' => 'Текст сообщения должен быть от 2 до 3000 символов',
+ )
+ ),
+ // Экшнбар
+ 'actionbar' => array(
+ 'read' => 'Прочитанные',
+ 'unread' => 'Не прочитанные',
+ 'mark_as_read' => 'Отметить как прочитанное',
+ ),
+ // Форма поиска
+ 'search' => array(
+ 'title' => 'Поиск по сообщениям',
+ // Поля
+ 'fields' => array(
+ 'sender' => array(
+ 'label' => 'Отправитель',
+ 'note' => 'Укажите логин отправителя'
+ ),
+ 'receiver' => array(
+ 'label' => 'Получатель',
+ 'note' => 'Укажите логин получателя'
+ ),
+ 'keyword' => array(
+ 'label' => 'Искать в заголовке',
+ ),
+ 'keyword_text' => array(
+ 'label' => 'Искать в тексте',
+ ),
+ 'start' => array(
+ 'label' => 'Ограничения по дате',
+ 'placeholder' => 'С числа'
+ ),
+ 'end' => array(
+ 'placeholder' => 'По число'
+ ),
+ 'favourite' => array(
+ 'label' => 'Искать только в избранном'
+ ),
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'error' => 'При поиске произошла ошибка',
+ 'error_date_format' => 'Указан неверный формат даты',
+ 'result_count' => 'Найдено писем: %%count%%',
+ 'result_empty' => 'По вашим критериям писем не найдено'
+ )
+ ),
+ // Черный список
+ 'blacklist' => array(
+ 'title' => 'Черный список',
+ 'note' => 'Добавьте пользователей от которых вы не хотите получать сообщения',
+ // Сообщения
+ 'notices' => array(
+ 'blocked' => 'Пользователь %%login%% не принимает от вас писем',
+ 'user_not_found' => 'Пользователя %%login%% нет в вашем black list`е',
+ ),
+ ),
+ // Список участников разговора
+ 'users' => array(
+ 'title' => 'Участники разговора',
+ 'inactive' => 'Пользователь не участвует в разговоре',
+ // Сообщения
+ 'notices' => array(
+ 'user_not_found' => 'Пользователь %%login%% не участвует в разговоре',
+ 'deleted' => 'Участник %%login%% удалил этот разговор',
+ )
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'time_limit' => 'Вам нельзя отправлять сообщения слишком часто',
+ 'empty' => 'Нет писем',
+ 'deleted' => 'Отправитель удалил сообщение',
+ 'not_found' => 'Разговор не найден'
+ ),
+ ),
+ /**
+ * Опросы
+ */
+ 'poll' => array(
+ 'polls' => 'Опросы',
+ 'vote' => 'Голосовать',
+ 'abstain' => 'Воздержаться',
+ 'only_auth' => 'Голосование доступно только авторизованным пользователям',
+ // Результат
+ 'result' => array(
+ 'voted_total' => 'Проголосовало',
+ 'abstained_total' => 'Воздержалось',
+ 'sort' => 'Включить\выключить сортировку',
+ ),
+ // Форма добавления
+ 'form' => array(
+ 'title' => array(
+ 'add' => 'Добавление опроса',
+ 'edit' => 'Редактирование опроса',
+ ),
+ 'answers_title' => 'Варианты ответов',
+ // Поля
+ 'fields' => array(
+ 'title' => 'Вопрос',
+ 'is_guest_allow' => 'Разрешить голосовать гостям',
+ 'is_guest_check_ip' => 'Только одно голосование гостей с одного IP адреса',
+ 'type' => array(
+ 'label' => 'Пользователь может выбрать',
+ 'label_one' => 'Один вариант',
+ 'label_many' => 'Несколько вариантов'
+ ),
+ ),
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'error_answers_max' => 'Максимально возможное число вариантов ответа %%count%%',
+ 'error_not_allow_vote' => 'В этом опросе уже нельзя голосовать',
+ 'error_not_allow_remove' => 'Этот опрос уже нельзя удалить',
+ 'error_already_vote' => 'Вы уже голосовали',
+ 'error_no_answers' => 'Необходимо выбрать вариант',
+ 'error_answers_max_wrong' => 'Максимальное количество вариантов ответа должно быть больше одного',
+ 'error_answers_count' => 'Необходимо заполнить больше одного варианта ответов',
+ 'error_answer_remove' => 'Нельзя удалить вариант ответа, за который уже голосовали',
+ 'error_target_type' => 'Неверный тип объекта',
+ 'error_target_tmp' => 'Временный идентификатор уже занят',
+ ),
+ ),
+ /**
+ * Комментарии
+ */
+ 'comments' => array(
+ 'comments_declension' => '%%count%% комментарий;%%count%% комментария;%%count%% комментариев',
+ 'comments_short' => 'комментарий;комментария;комментариев',
+ 'no_comments' => 'Нет комментариев',
+ 'count_new' => 'Число новых комментариев',
+ 'update' => 'Обновить комментарии',
+ 'title' => 'Комментарии',
+ 'subscribe' => 'Подписаться',
+ 'unsubscribe' => 'Отписаться',
+ // Комментарий
+ 'comment' => array(
+ 'deleted' => 'Комментарий был удален',
+ 'restore' => 'Восстановить',
+ 'reply' => 'Ответить',
+ 'scroll_to_parent' => 'Ответ на',
+ 'scroll_to_child' => 'Обратно к ответу',
+ 'target_author' => 'Автор',
+ 'url' => 'Ссылка на комментарий',
+ 'edit_info' => 'Комментарий отредактирован',
+ ),
+ // Сворачивание
+ 'folding' => array(
+ 'fold' => 'Свернуть',
+ 'unfold' => 'Развернуть',
+ 'fold_all' => 'Свернуть все',
+ 'unfold_all' => 'Развернуть все',
+ ),
+ // Форма добавления
+ 'form' => array(
+ 'title' => 'Оставить комментарий',
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'success_restore' => 'Комментарий восстановлен',
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'unregistered' => 'Только зарегистрированные и авторизованные пользователи могут оставлять комментарии'
+ ),
+ ),
+ /**
+ * Пополняемый список пользователей
+ */
+ 'user_list_add' => array(
+ // Форма добавления
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'add' => array(
+ 'label' => '___user.users___',
+ ),
+ ),
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'success_add' => 'Пользователь %%login%% успешно добавлен',
+ 'error_already_added' => 'Пользователь %%login%% уже есть в списке',
+ 'error_self' => 'Нельзя добавлять себя',
+ ),
+ ),
+ /**
+ * Мэйлы
+ */
+ 'emails' => array(
+ 'common' => array(
+ 'comment_text' => 'Текст комментария',
+ 'regards' => 'С уважением, администрация сайта',
+ ),
+ // Приглашение в закрытый блог
+ 'blog_invite_new' => array(
+ 'subject' => 'Вас пригласили вступить в блог',
+ 'text' =>
+ 'Пользователь %%user_name%%
+ приглашает вас вступить в блог %%blog_name%% .
+
+ Посмотреть приглашение
+
+ Не забудьте предварительно авторизоваться!',
+ ),
+ // Оповещение о новом комментарии в топике
+ 'comment_new' => array(
+ 'subject' => 'Новый комментарий к топику',
+ 'text' =>
+ 'Пользователь %%user_name%%
+ оставил новый комментарий к топику %%topic_name%% ,
+ прочитать его можно перейдя по этой ссылке
+
+ %%comment_text%%
+ %%unsubscribe%%',
+ 'unsubscribe' => 'Отписаться от новых комментариев к этому топику '
+ ),
+ // Оповещение об ответе на комментарий
+ 'comment_reply' => array(
+ 'subject' => 'Вам ответили на ваш комментарий',
+ 'text' =>
+ 'Пользователь %%user_name%%
+ ответил на ваш комментарий в топике %%topic_name%% ,
+ прочитать его можно перейдя по этой ссылке
+
+ %%comment_text%%'
+ ),
+ // Приглашение на сайт
+ 'invite' => array(
+ 'subject' => 'Приглашение на регистрацию',
+ 'text' =>
+ 'Пользователь %%user_name%%
+ пригласил вас зарегистрироваться на сайте %%website_name%%
+
+ Для регистрации пройдите по ссылке: %%ref_link%% .'
+ ),
+ // Повторная активация
+ 'reactivation' => array(
+ 'subject' => 'Повторный запрос активации',
+ 'text' =>
+ 'Вы запросили повторную активацию на сайте %%website_name%%
+
+ Ссылка на активацию аккаунта:
+
+ %%activation_url%% '
+ ),
+ // Регистрация
+ 'registration' => array(
+ 'subject' => 'Регистрация',
+ 'text' =>
+ 'Вы зарегистрировались на сайте %%website_name%%
+
+ Ваши регистрационные данные:
+
+ Логин: %%user_name%% '
+ ),
+ // Подтверждение регистрации
+ 'registration_activate' => array(
+ 'subject' => 'Регистрация',
+ 'text' =>
+ 'Вы зарегистрировались на сайте %%website_name%%
+
+ Ваши регистрационные данные:
+
+ Логин: %%user_name%%
+
+ Для завершения регистрации вам необходимо активировать аккаунт пройдя по ссылке:
+ %%activation_url%% '
+ ),
+ // Смена пароля
+ 'reminder_code' => array(
+ 'subject' => 'Восстановление пароля',
+ 'text' =>
+ 'Если вы хотите сменить себе пароль на сайте %%website_name%% , то перейдите по ссылке ниже:
+ %%recover_url%% '
+ ),
+ // Новый пароль
+ 'reminder_password' => array(
+ 'subject' => 'Новый пароль',
+ 'text' =>
+ 'Вам присвоен новый пароль: %%password%% '
+ ),
+ // Оповещение о новом сообщении в диалоге
+ 'talk_comment_new' => array(
+ 'subject' => 'У вас новый комментарий к письму',
+ 'text' =>
+ 'Пользователь %%user_name%%
+ оставил новый комментарий к письму %%talk_name%% ,
+ прочитать его можно перейдя по этой ссылке
+
+ %%message_text%%
+
+ Не забудьте предварительно авторизоваться!'
+ ),
+ // Оповещение о новом сообщении
+ 'talk_new' => array(
+ 'subject' => 'У вас новое письмо',
+ 'text' =>
+ 'Вам пришло новое письмо от пользователя %%user_name%% ,
+ прочитать его можно перейдя по этой ссылке
+
+ Тема письма: %%talk_name%%
+ %%talk_text%%
+
+ Не забудьте предварительно авторизоваться!'
+ ),
+ // Оповещение о новом топике
+ 'topic_new' => array(
+ 'subject' => 'Новый топик в блоге',
+ 'text' =>
+ 'Пользователь %%user_name%%
+ опубликовал в блоге %%blog_name%% ,
+ новый топик — %%topic_name%% '
+ ),
+ // Смена почты
+ 'user_changemail' => array(
+ 'subject' => 'Подтверждение смены емайла',
+ 'text' =>
+ 'Вами отправлен запрос на смену e-mail адреса пользователя %%user_name%%
+ на сайте %%website_name%% .
+
+ Старый e-mail: %%mail_old%%
+ Новый e-mail: %%mail_new%%
+
+ Для подтверждения смены емайла пройдите по ссылке:
+ %%change_url%% '
+ ),
+ // Жалоба
+ 'user_complaint' => array(
+ 'subject' => 'Жалоба на пользователя',
+ 'text' =>
+ 'Пользователь %%user_name%%
+ пожаловался на пользователя %%user_target_url%% .
+
+ Причина: %%complaint_title%%
+ %%complaint_text%%',
+ 'more' => 'Подробнее'
+ ),
+ // Заявка в друзья
+ 'user_friend_new' => array(
+ 'subject' => 'Вас добавили в друзья',
+ 'text' =>
+ 'Пользователь %%user_name%%
+
+ %%text%%
+
+ Посмотреть заявку
+
+ Не забудьте предварительно авторизоваться!'
+ ),
+ // Новое сообщение на стене
+ 'wall_new' => array(
+ 'subject' => 'Новое сообщение на вашей стене',
+ 'text' =>
+ 'Пользователь %%user_name%%
+ оставил сообщение на вашей стене
+
+ Текст сообщения:
+ %%message_text%%'
+ ),
+ // Ответ на сообщение на стене
+ 'wall_reply' => array(
+ 'subject' => 'Ответ на ваше сообщение на стене',
+ 'text' =>
+ 'Пользователь %%user_name%%
+ ответил на ваше сообщение на стене
+
+ Ваше сообщение:
+ %%message_parent_text%%
+
+ Текст ответа:
+ %%message_text%% '
+ )
+ ),
+ /**
+ * Стена
+ */
+ 'wall' => array(
+ 'title' => 'Стена',
+ // Форма
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'text' => array(
+ 'placeholder' => 'Написать на стене',
+ 'placeholder_reply' => 'Ответить...',
+ ),
+ ),
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'error_add_pid' => 'На данное сообщение невозможно ответить',
+ 'error_add_time_limit' => 'Вам нельзя слишком часто писать на стене'
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'unregistered' => 'Только зарегистрированные и авторизованные пользователи могут оставлять записи на стене'
+ ),
+ ),
+ /**
+ * Авторизация
+ */
+ 'auth' => array(
+ 'authorization' => 'Авторизация',
+ 'logout' => 'Выйти',
+ // Вход
+ 'login' => array(
+ 'title' => 'Войти',
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'login' => array(
+ 'label' => 'Логин или эл. почта'
+ ),
+ 'remember' => array(
+ 'label' => 'Запомнить меня'
+ ),
+ 'submit' => array(
+ 'text' => 'Войти'
+ )
+ )
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'error_login' => 'Неправильно указан логин (e-mail) или пароль!',
+ 'error_not_activated' => 'Вы не активировали вашу учетную запись. Повторный запрос активации '
+ ),
+ ),
+ // Повторный запрос активации
+ 'reactivation' => array(
+ 'title' => 'Повторный запрос активации',
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'mail' => array(
+ 'label' => 'Ваш e-mail'
+ ),
+ 'submit' => array(
+ 'text' => 'Получить ссылку на активацию'
+ )
+ )
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'success' => 'Ссылка для активации отправлена на ваш адрес электронной почты',
+ )
+ ),
+ // Сброс пароля
+ 'reset' => array(
+ 'title' => 'Восстановление пароля',
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'mail' => array(
+ 'label' => 'Ваш e-mail'
+ ),
+ 'submit' => array(
+ 'text' => 'Получить ссылку на изменение пароля'
+ )
+ )
+ ),
+ // Всплывающие сообщения
+ 'notices' => array(
+ 'success_send_password' => 'Новый пароль отправлен на ваш адрес электронной почты',
+ 'success_send_link' => 'Ссылка для восстановления пароля отправлена на ваш адрес электронной почты',
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'error_bad_code' => 'Неверный код на восстановление пароля.',
+ )
+ ),
+ // Регистрация по приглашению
+ 'invite' => array(
+ 'title' => 'Регистрация по приглашению',
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'code' => array(
+ 'label' => 'Код приглашения'
+ ),
+ 'submit' => array(
+ 'text' => 'Проверить код'
+ )
+ ),
+ ),
+ // Сообщения
+ 'alerts' => array(
+ 'error_code' => 'Неверный код приглашения',
+ )
+ ),
+ // Регистрация
+ 'registration' => array(
+ 'title' => 'Регистрация',
+ 'form' => array(
+ // Поля
+ 'fields' => array(
+ 'password_confirm' => array(
+ 'label' => 'Повторите пароль'
+ ),
+ 'submit' => array(
+ 'text' => 'Зарегистрироваться'
+ )
+ )
+ ),
+ 'confirm' => array(
+ 'title' => 'Активация аккаунта',
+ 'text' => 'Вы почти зарегистрировались, осталось только активировать аккаунт. Инструкции по активации отправлены по электронной почте на адрес, указанный при регистрации.'
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'already_registered' => 'Вы уже зарегистрированы у нас и даже авторизованы!',
+ 'success' => 'Поздравляем! Регистрация прошла успешно',
+ 'success_activate' => 'Поздравляем! Ваш аккаунт успешно активирован.',
+ 'error_login' => 'Неверный логин, допустим от 3 до 30 символов',
+ 'error_login_used' => 'Этот логин уже занят',
+ 'error_mail_used' => 'Этот e-mail уже используется',
+ 'error_reactivate' => 'Ваш аккаунт уже активирован',
+ 'error_code' => 'Неверный код активации!',
+ 'error_password_equal' => 'Пароли не совпадают',
+ ),
+ ),
+ // Общие лэйблы
+ 'labels' => array(
+ 'login' => 'Логин',
+ 'password' => 'Пароль',
+ 'captcha' => 'Введите цифры и буквы',
+ 'captcha_field' => 'Каптча',
+ ),
+ // Общие всплывающие сообщения
+ 'notices' => array(
+ 'error_bad_email' => 'Пользователь с таким e-mail не найден',
+ ),
+ ),
+ /**
+ * Активность
+ */
+ 'activity' => array(
+ 'title' => 'Активность',
+ // Навигация
+ 'nav' => array(
+ 'all' => 'Вся',
+ 'personal' => 'Персональная'
+ ),
+ // Настройки
+ 'settings' => array(
+ 'title' => 'Настройка событий',
+ 'note' => 'Выберите действия которые будут отслеживаться',
+ 'options' => array(
+ 'add_wall' => 'Добавление записи на стену',
+ 'add_topic' => 'Добавление топика',
+ 'add_comment' => 'Добавление комментария',
+ 'add_blog' => 'Добавление блога',
+ 'vote_topic' => 'Голосование за топик',
+ 'vote_comment_topic' => 'Голосование за комментарий к топику',
+ 'vote_blog' => 'Голосование за блог',
+ 'vote_user' => 'Голосование за пользователя',
+ 'add_friend' => 'Добавление в друзья',
+ 'join_blog' => 'Вступление в блог',
+ )
+ ),
+ // Пользователи
+ 'users' => array(
+ 'title' => 'Пользователи',
+ 'note' => 'Добавьте людей за активностью которых вы хотели бы следить',
+ ),
+ 'events' => array(
+ 'add_wall_male' => 'добавил запись на стену %%user%%',
+ 'add_wall_female' => 'добавила запись на стену %%user%%',
+ 'add_wall_self_male' => 'добавил запись себе на стену ',
+ 'add_wall_self_female' => 'добавила запись себе на стену ',
+ 'add_topic_male' => 'добавил новый топик %%topic%%',
+ 'add_topic_female' => 'добавила новый топик %%topic%%',
+ 'add_comment_male' => 'прокомментировал топик %%topic%%',
+ 'add_comment_female' => 'прокомментировала топик %%topic%%',
+ 'add_blog_male' => 'добавил новый блог %%blog%%',
+ 'add_blog_female' => 'добавила новый блог %%blog%%',
+ 'vote_topic_male' => 'оценил топик %%topic%%',
+ 'vote_topic_female' => 'оценила топик %%topic%%',
+ 'vote_comment_topic_male' => 'оценил комментарий к топику %%topic%%',
+ 'vote_comment_topic_female' => 'оценила комментарий к топику %%topic%%',
+ 'vote_blog_male' => 'оценил блог %%blog%%',
+ 'vote_blog_female' => 'оценила блог %%blog%%',
+ 'vote_user_male' => 'оценил пользователя %%user%%',
+ 'vote_user_female' => 'оценила пользователя %%user%%',
+ 'join_blog_male' => 'вступил в блог %%blog%%',
+ 'join_blog_female' => 'вступила в блог %%blog%%',
+ 'add_friend_male' => 'добавил в друзья пользователя %%user%%',
+ 'add_friend_female' => 'добавила в друзья пользователя %%user%%'
+ ),
+ // Блок с последними событиями
+ 'block_recent' => array(
+ 'title' => '___activity.title___',
+ 'topics' => 'Топики',
+ 'topics_empty' => '___common.empty___',
+ 'comments' => 'Комментарии',
+ 'comments_empty' => '___common.empty___',
+ 'feed' => 'RSS',
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'error_already_subscribed' => 'Вы уже подписаны на этого пользователя',
+ )
+ ),
+ /**
+ * Лента
+ */
+ 'feed' => array(
+ 'title' => 'Лента',
+ // Блоги
+ 'blogs' => array(
+ 'title' => 'Блоги',
+ 'note' => 'Выберите блоги которые вы хотели бы читать',
+ 'empty' => 'Вы не вступили ни в один блог'
+ ),
+ // Пользователи
+ 'users' => array(
+ 'title' => 'Пользователи',
+ 'note' => 'Добавьте людей, топики которых вы хотели бы читать'
+ )
+ ),
+ /**
+ * Топик
+ */
+ 'topic' => array(
+ 'topics' => 'Топики',
+ 'topic_plural' => 'топик;топика;топиков',
+ 'drafts' => 'Черновики',
+ 'deferred' => 'Отложенные',
+ 'is_deferred' => 'Это отложенная публикация',
+ 'read_more' => 'Читать дальше',
+ 'author' => 'Автор топика',
+ 'tags' => '___tags.tags___',
+ 'share' => 'Поделиться',
+ 'is_draft' => 'Топик находится в черновиках',
+ // Навигация
+ 'nav' => array(
+ 'drafts' => 'Черновики', // TODO: Remove duplication
+ 'published' => 'Опубликованные'
+ ),
+ 'content_type' => array(
+ 'states' => array(
+ 'active' => 'активен',
+ 'not_active' => 'не активен',
+ 'wrong' => 'неизвестный статус',
+ ),
+ 'notices' => array(
+ 'error_code' => 'Тип с таким кодом уже существует',
+ ),
+ ),
+ // Форма добавления
+ 'add' => array(
+ 'title' => array(
+ 'add' => 'Создание топика',
+ 'edit' => 'Редактирование топика',
+ ),
+ // Поля
+ 'fields' => array(
+ 'blog' => array(
+ 'label' => 'В какой блог публикуем?',
+ 'placeholder' => 'Выберите блоги для публикации',
+ 'note' => 'Для того чтобы написать в определенный блог, вы должны, для начала, вступить в него.',
+ 'option_personal' => 'Мой персональный блог',
+ ),
+ 'title' => array(
+ 'label' => 'Заголовок'
+ ),
+ 'slug' => array(
+ 'label' => 'URL',
+ 'note' => 'Формируется автоматически из названия топика, но вы можете задать свое значение'
+ ),
+ 'text' => array(
+ 'label' => 'Текст'
+ ),
+ 'tags' => array(
+ 'label' => '___tags.tags___',
+ 'note' => 'Теги нужно разделять запятой. Например: google, вконтакте, кирпич'
+ ),
+ 'forbid_comments' => array(
+ 'label' => 'Запретить комментировать',
+ 'note' => 'Если отметить эту галку, то нельзя будет оставлять комментарии к топику'
+ ),
+ 'publish_index' => array(
+ 'label' => 'Принудительно вывести на главную',
+ 'note' => 'Если отметить эту галку, то топик сразу попадёт на главную страницу (опция доступна только администраторам)'
+ ),
+ 'skip_index' => array(
+ 'label' => 'Принудительно пропустить вывод на главную',
+ 'note' => 'Если отметить эту галку, то топик никогда не будет выведен на главную страницу (опция доступна только администраторам)'
+ ),
+ 'publish_date' => array(
+ 'label' => 'Дата отложенной публикации',
+ 'label_date' => 'Дата',
+ 'label_time' => 'Время',
+ ),
+ ),
+ // Кнопки
+ 'button' => array(
+ 'publish' => 'Опубликовать',
+ 'update' => 'Сохранить изменения',
+ 'save_as_draft' => 'Сохранить в черновиках',
+ 'mark_as_draft' => 'Перенести в черновики',
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'error_blog_not_found' => 'Выбранный вами блог не существует',
+ 'error_blog_max_count' => 'Превышено максимальное число блогов: %%count%%',
+ 'error_blog_not_allowed' => 'Вы не можете писать в этот блог',
+ 'error_text_unique' => 'Вы уже писали топик с таким содержанием',
+ 'error_type' => 'Неверный тип топика', // TODO: Remove?
+ 'error_slug' => 'Необходимо указать URL топика',
+ 'error_favourite_draft' => 'Топик из черновиков нельзя добавить в избранное',
+ 'error_publish_date' => 'Необходимо указать корректную дату публикации в будущем',
+ 'time_limit' => 'Вам нельзя создавать топики слишком часто',
+ 'rating_limit' => 'Вам не хватает рейтинга для создания топика',
+ 'update_complete' => 'Обновление прошло успешно',
+ 'create_complete' => 'Добавление прошло успешно',
+ )
+ ),
+ // Комментарии
+ 'comments' => array(
+ // Сообщения
+ 'notices' => array(
+ 'error_text' => 'Текст комментария должен быть от 2 до 3000 символов и не содержать неразрешенных тегов',
+ 'acl' => 'Ваш рейтинг слишком мал для написания комментариев',
+ 'limit' => 'Вам нельзя писать комментарии слишком часто',
+ 'not_allowed' => 'Автор топика запретил добавлять комментарии',
+ 'spam' => 'Стоп! Спам!',
+ )
+ ),
+ // Блоки
+ 'blocks' => array(
+ 'tip' => array(
+ 'title' => 'Совет',
+ 'text' => 'Тег <cut> сокращает длинные записи , скрывая их целиком или частично под ссылкой («читать дальше»). Скрытая часть не видна в блоге, но доступна в полной записи на странице топика.',
+ )
+ )
+ ),
+ /**
+ * Пользователь
+ * !user
+ */
+ 'user' => array(
+ 'user' => 'Пользователь',
+ 'users' => 'Пользователи',
+ 'rating' => '___vote.rating___',
+ 'date_last_session' => 'Последний визит',
+ 'date_registration' => 'Дата регистрации',
+ // Действия
+ 'actions' => array(
+ 'send_message' => '___talk.send_message___',
+ 'follow' => 'Подписаться',
+ 'unfollow' => 'Отписаться',
+ 'report' => '___report.report___',
+ ),
+ // Действия
+ 'choose' => array(
+ 'label' => '___user.users___',
+ 'choose' => 'Выбрать из списка друзей',
+ ),
+ // Пол
+ 'gender' => array(
+ 'gender' => 'Пол',
+ 'male' => 'Мужской',
+ 'female' => 'Женский',
+ 'men' => 'Мужчины',
+ 'women' => 'Женщины',
+ 'none' => 'Пол не указан'
+ ),
+ // Статус
+ 'status' => array(
+ 'online' => 'Онлайн',
+ 'offline' => 'Оффлайн',
+ 'was_online_male' => 'Заходил %%date%%',
+ 'was_online_female' => 'Заходила %%date%%'
+ ),
+ // Жалоба
+ 'report' => array(
+ 'types' => array(
+ 'spam' => 'Спам',
+ 'obscene' => 'Непристойное поведение',
+ 'other' => 'Другое'
+ )
+ ),
+ // Друзья
+ 'friends' => array(
+ 'title' => 'Друзья',
+ 'add' => 'Добавить в друзья',
+ 'remove' => 'Удалить из друзей',
+ 'rejected' => 'Заявка отклонена',
+ 'sent' => 'Заявка отправлена',
+ // Статусы
+ 'status' => array(
+ 'notfriends' => '___user.friends.add___',
+ 'added' => '___user.friends.remove___',
+ 'pending' => '___user.friends.status.notfriends___',
+ 'rejected' => '___user.friends.rejected___',
+ 'sent' => '___user.friends.sent___',
+ 'linked' => '___user.friends.status.notfriends___',
+ ),
+ // Форма добавления в друзья
+ 'form' => array(
+ 'title' => '___user.friends.add___',
+ 'fields' => array(
+ 'text' => array(
+ 'label' => 'Представьтесь',
+ ),
+ 'submit' => array(
+ 'text' => '___common.send___',
+ )
+ ),
+ ),
+ // Сообщения
+ 'messages' => array(
+ 'offer' => array(
+ 'title' => 'Пользователь %%login%% приглашает вас дружить',
+ 'text' => "Пользователь %%login%% желает добавить вас в друзья. %%user_text%%Принять - Отклонить ",
+ ),
+ 'accept' => array(
+ 'title' => 'Ваша заявка одобрена',
+ 'text' => 'Пользователь %%login%% согласился с вами дружить',
+ ),
+ 'reject' => array(
+ 'title' => 'Ваша заявка отклонена',
+ 'text' => 'Пользователь %%login%% отказался с вами дружить',
+ ),
+ 'deleted' => array(
+ 'title' => 'Вас удалили из друзей',
+ 'text' => 'У вас больше нет друга %%login%%',
+ ),
+ ),
+ 'notices' => array(
+ 'add_success' => 'У вас появился новый друг',
+ 'remove_success' => 'У вас больше нет этого друга',
+ 'not_found' => 'Друг не найден!', // TODO: Remove?
+ 'already_exist' => 'Пользователь уже является вашим другом',
+ 'rejected' => 'Этот пользователь отказался с вами дружить',
+ 'time_limit' => 'Вы слишком часто отправляете личные сообщения, попробуйте добавить в друзья позже',
+ 'offer_not_found' => 'Заявка не найдена', // TODO: Remove?
+ 'offer_already_done' => 'Заявка уже обработана',
+ )
+ ),
+ // Поиск
+ 'search' => array(
+ 'title' => 'Поиск по пользователям',
+ 'placeholder' => 'Поиск по логину',
+ 'result_title' => 'Найден %%count%% пользователь;Найдено %%count%% пользователя;Найдено %%count%% пользователей',
+ 'form' => array(
+ 'is_online' => 'Сейчас на сайте',
+ 'gender' => array(
+ 'any' => 'Любой',
+ 'male' => 'Мужской',
+ 'female' => 'Женский'
+ )
+ )
+ ),
+ // Публикации
+ 'publications' => array(
+ 'title' => 'Публикации',
+ // Меню
+ 'nav' => array(
+ 'topics' => '___topic.topics___',
+ 'comments' => '___comments.title___',
+ 'notes' => 'Заметки'
+ ),
+ ),
+ // Избранное
+ 'favourites' => array(
+ 'title' => '___favourite.favourite___',
+ // Меню
+ 'nav' => array(
+ 'topics' => '___topic.topics___',
+ 'comments' => '___comments.title___'
+ ),
+ ),
+ // Профиль
+ 'profile' => array(
+ 'title' => 'Профиль',
+ 'social_networks' => 'Социальные сети',
+ 'contact' => 'Контакты',
+ // Меню
+ 'nav' => array(
+ 'info' => '___user.profile.title___',
+ 'wall' => '___wall.title___',
+ 'publications' => '___user.publications.title___',
+ 'favourite' => '___favourite.favourite___',
+ 'friends' => '___user.friends.title___',
+ 'activity' => '___activity.title___',
+ 'messages' => '___talk.title___',
+ 'settings' => 'Настройки',
+ ),
+ 'about' => array(
+ 'title' => 'О себе'
+ ),
+ 'personal' => array(
+ 'title' => 'Личное',
+ 'birthday' => 'Дата рождения',
+ 'place' => 'Местоположение',
+ 'gender' => '___user.gender.gender___',
+ 'gender_male' => '___user.gender.male___',
+ 'gender_female' => '___user.gender.female___',
+ ),
+ 'activity' => array(
+ 'title' => '___activity.title___',
+ 'blogs_joined' => 'Состоит в блогах',
+ 'blogs_created' => 'Создал блоги',
+ 'blogs_admin' => 'Администрирует',
+ 'blogs_mod' => 'Модерирует',
+ 'invited_by' => 'Приглашен',
+ 'invited' => 'Приглашенные',
+ )
+ ),
+ // Статистика
+ 'stats' => array(
+ 'title' => 'Статистика',
+ 'all' => 'Всего пользователей',
+ 'active' => 'Активные',
+ 'not_active' => 'Заблудившиеся',
+ 'men' => '___user.gender.men___',
+ 'women' => '___user.gender.women___',
+ 'none' => '___user.gender.none___'
+ ),
+ // Настройки
+ 'settings' => array(
+ 'title' => 'Настройки',
+ // Меню
+ 'nav' => array(
+ 'profile' => '___user.profile.title___',
+ 'account' => 'Аккаунт',
+ 'tuning' => 'Настройки сайта',
+ 'invites' => 'Инвайты',
+ ),
+ // Настройки профиля
+ 'profile' => array(
+ 'generic' => 'Основная информация',
+ 'contact' => '___user.profile.contact___',
+ 'fields' => array(
+ 'name' => array(
+ 'label' => 'Имя',
+ ),
+ 'sex' => array(
+ 'label' => '___user.gender.gender___',
+ ),
+ 'birthday' => array(
+ 'label' => '___user.profile.personal.birthday___',
+ ),
+ 'place' => array(
+ 'label' => '___user.profile.personal.place___',
+ ),
+ 'about' => array(
+ 'label' => '___user.profile.about.title___',
+ ),
+ ),
+ 'notices' => array(
+ 'error_max_userfields' => 'Нельзя добавить больше %%count%% одинаковых контактов'
+ ),
+ ),
+ // Настройки аккаунта
+ 'account' => array(
+ 'account' => 'Настройки аккаунта',
+ 'password' => 'Пароль',
+ 'password_note' => 'Оставьте поля пустыми если не хотите изменять пароль.',
+ 'fields' => array(
+ 'email' => array(
+ 'note' => 'Ваш реальный почтовый адрес, на него будут приходить уведомления',
+ 'notices' => array(
+ 'error_used' => 'Этот емайл уже занят',
+ 'change_from_notice' => 'На вашу старую почту отправлено подтверждение для смены емайла',
+ 'change_to_notice' => 'Спасибо! На ваш новый емайл адрес отправлено подтверждение для смены старого емайла.',
+ 'change_ok' => 'Ваш емайл изменен на %%mail%% ',
+ )
+ ),
+ 'password' => array(
+ 'label' => '___auth.labels.password___',
+ 'notices' => array(
+ 'error' => 'Неверный текущий пароль',
+ )
+ ),
+ 'password_new' => array(
+ 'label' => 'Новый пароль',
+ 'notices' => array(
+ 'error' => 'Неверный пароль, допустим от 5 символов',
+ )
+ ),
+ 'password_confirm' => array(
+ 'label' => '___auth.registration.form.fields.password_confirm.label___',
+ 'notices' => array(
+ 'error' => 'Пароли не совпадают',
+ )
+ ),
+ ),
+ ),
+ // Настройки сайта
+ 'tuning' => array(
+ 'email_notices' => 'Уведомления на e-mail',
+ 'general' => 'Общие настройки',
+ 'fields' => array(
+ 'new_topic' => 'При новом топике в блоге',
+ 'new_comment' => 'При новом комментарии в топике',
+ 'new_talk' => 'При новом личном сообщении',
+ 'reply_comment' => 'При ответе на комментарий',
+ 'new_friend' => 'При добавлении вас в друзья',
+ 'timezone' => array(
+ 'label' => 'Часовой пояс'
+ ),
+ )
+ ),
+ // Инвайты
+ 'invites' => array(
+ 'note' => 'Вы можете пригласить на сайт своих друзей и знакомых, для этого просто укажите их e-mail и нажмите кнопку.',
+ 'available' => 'Доступно приглашений',
+ 'available_no' => 'У вас пока нет доступных инвайтов',
+ 'used' => 'Приглашено пользователей',
+ 'used_empty' => 'нет',
+ 'referral_link' => 'Ваша персональная реферальная ссылка',
+ 'many' => 'много',
+ 'fields' => array(
+ 'email' => array(
+ 'label' => 'Пригласить пользователя',
+ 'note' => 'На этот e-mail будет выслано приглашение для регистрации',
+ ),
+ 'submit' => array(
+ 'text' => 'Отправить приглашение',
+ ),
+ ),
+ 'notices' => array(
+ 'success' => 'Приглашение отправлено'
+ )
+ ),
+ ),
+ 'photo' => array(
+ 'crop_avatar' => array(
+ 'title' => 'Выбор аватары',
+ 'desc' => 'Выберите квадратную область для аватарки.',
+ ),
+ 'crop_photo' => array(
+ 'title' => 'Ваша фотография',
+ 'desc' => 'Необходимо выбрать область для фотографии, которая будет отбражаться в вашем профиле.',
+ 'submit' => 'Сохранить и продолжить',
+ ),
+ 'actions' => array(
+ 'change_photo' => 'Изменить фотографию',
+ 'upload_photo' => 'Загрузить фотографию',
+ 'change_avatar' => 'Изменить аватар',
+ 'remove' => '___common.remove___'
+ )
+ ),
+ // Блоки
+ 'blocks' => array(
+ 'cities' => array(
+ 'title' => 'Города'
+ ),
+ 'countries' => array(
+ 'title' => 'Страны'
+ )
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'empty' => '___common.empty___',
+ 'not_found' => 'Пользователь %%login%% не найден',
+ 'not_found_by_id' => 'Пользователь #%%id%% не найден'
+ ),
+ ),
+ /**
+ * Поля
+ */
+ 'field' => array(
+ 'email' => array(
+ 'label' => 'E-mail',
+ 'notices' => array(
+ 'error' => 'Неверный формат e-mail',
+ ),
+ ),
+ 'geo' => array(
+ 'select_country' => 'Выберите страну',
+ 'select_region' => 'Укажите регион',
+ 'select_city' => 'Укажите город',
+ ),
+ 'upload_area' => array(
+ 'label' => 'Перетащите сюда файлы или кликните по этому тексту',
+ ),
+ 'category' => array(
+ 'label' => 'Категория'
+ ),
+ ),
+ /**
+ * Категории
+ */
+ 'category' => array(
+ 'notices' => array(
+ 'validate_require' => 'Необходимо выбрать категорию',
+ 'validate_count' => 'Количество категорий должно быть от %%min%% до %%max%%',
+ 'validate_children' => 'Для выбора доступны только конечные категории',
+ 'validate_recursion' => 'Попытка вложить категорию в саму себя',
+ 'validate_parent' => 'Неверная родительская категория',
+ 'validate_wrong' => 'Неверная категория',
+ ),
+ ),
+ /**
+ * Кастомные поля
+ */
+ 'property' => array(
+ 'video' => array(
+ 'preview' => 'Предпросмотр видео',
+ 'watch' => 'Смотреть'
+ ),
+ 'image' => array(
+ 'empty' => 'Изображения нет'
+ ),
+ 'imageset' => array(
+ 'label' => 'Фотосет',
+ 'modalTitle' => 'Выбор фото'
+ ),
+ 'file' => array(
+ 'forbidden' => 'Для доступа к файлу необходимо авторизоваться',
+ 'downloads' => 'Загрузок',
+ 'empty' => 'Файла нет'
+ ),
+ 'notices' => array(
+ 'validate_type' => 'Неверный тип поля',
+ 'validate_code' => 'Код поля должен быть уникальным',
+ 'validate_value_date_future' => 'дата не может быть в будущем',
+ 'validate_value_date_past' => 'дата не может быть в прошлом',
+ 'validate_value_file_empty' => 'Необходимо выбрать файл',
+ 'validate_value_file_upload' => 'При загрузке файла возникла ошибка',
+ 'validate_value_file_size_max' => 'Превышен размер файла, максимальный %%size%% Kb',
+ 'validate_value_file_type' => 'Неверный тип файла, допустимы %%types%%',
+ 'validate_value_image_wrong' => 'Файл не является изображением',
+ 'validate_value_image_width_max' => 'Максимальная допустимая ширина изображения %%size%%px',
+ 'validate_value_image_height_max' => 'Максимальная допустимая высота изображения %%size%%px',
+ 'validate_value_select_max' => 'Максимально можно выбрать только %%count%% элемента',
+ 'validate_value_select_min' => 'Минимально можно выбрать только %%count%% элемента',
+ 'validate_value_select_wrong' => 'Проверьте корректность выбранных элементов',
+ 'validate_value_select_only_one' => 'Можно выбрать только один элемент',
+ 'validate_value_video_wrong' => 'Необходимо указать корректную ссылку на видео: YouTube, Vimeo',
+ 'validate_value_wrong' => 'Поле "%%field%%": ',
+ 'validate_value_wrong_base' => 'неверное значение',
+ 'create_error' => 'Возникла ошибка при добавлении поля',
+ ),
+ ),
+ /**
+ * Админка
+ */
+ 'admin' => array(
+ 'title' => 'Админка',
+ 'items' => array(
+ 'plugins' => '___admin.plugins.title___',
+ ),
+ 'install_plugin_admin' => 'Установить расширенную админ-панель',
+ // Страница администрирования плагинов
+ 'plugins' => array(
+ 'title' => 'Управление плагинами',
+ 'plugin' => array(
+ 'author' => 'Автор',
+ 'version' => 'Версия',
+ 'url' => 'Сайт',
+ 'activate' => 'Активировать',
+ 'deactivate' => 'Деактивировать',
+ 'settings' => 'Настройки',
+ 'remove' => '___common.remove___',
+ 'apply_update' => 'Применить обновление',
+ ),
+ // Сообщения
+ 'notices' => array(
+ 'unknown_action' => 'Указано неизвестное действие',
+ 'action_ok' => 'Успешно выполнено',
+ 'activation_overlap' => 'Конфликт с активированным плагином. Ресурс %%resource%% переопределен на %%delegate%% плагином %%plugin%%.',
+ 'activation_overlap_inherit' => 'Конфликт с активированным плагином. Ресурс %%resource%% используется как наследник в плагине %%plugin%%.',
+ 'activation_file_not_found' => 'Файл плагина не найден',
+ 'activation_file_write_error' => 'Файл плагина не доступен для записи',
+ 'activation_version_error' => 'Для работы плагина необходимо ядро LiveStreet версии не ниже %%version%%',
+ 'activation_requires_error' => 'Для работы плагина необходим активированный плагин %%plugin%% ',
+ 'activation_already_error' => 'Плагин уже активирован',
+ 'deactivation_already_error' => 'Плагин не активирован',
+ 'deactivation_requires_error' => 'От плагина зависит другой плагин, сначала отключите его - %%plugin%% ',
+ )
+ ),
+ ),
+ /**
+ * Жалобы
+ */
+ 'report' => array(
+ 'report' => 'Пожаловаться',
+ 'form' => array(
+ 'title' => '___report.report___',
+ 'fields' => array(
+ 'type' => array(
+ 'label' => 'Причина'
+ ),
+ 'text' => array(
+ 'label' => 'Текст жалобы'
+ )
+ ),
+ 'submit' => '___common.send___'
+ ),
+ 'notices' => array(
+ 'target_error' => 'Неверный id объекта', // TODO: Remove?
+ 'error_type' => 'Неверный тип жалобы', // TODO: Remove?
+ 'success' => 'Ваша жалоба отправлена администрации',
+ )
+ ),
+ /**
+ * Загрузка изображений
+ */
+ 'media' => array(
+ 'title' => 'Загрузка медиа-файлов',
+ 'error' => array(
+ 'upload' => 'Не удалось загрузить файл',
+ 'not_image' => 'Файл не является изображением',
+ 'too_large' => 'Превышен максимальный размер файла: %%size%%Кб',
+ 'incorrect_type' => 'Неверный тип файла',
+ 'max_count_files' => 'Превышено максимальное число файлов',
+ 'need_choose_items' => 'Необходимо выбрать элементы',
+ ),
+ 'nav' => array(
+ 'insert' => 'Вставить',
+ 'photoset' => 'Создать фотосет',
+ 'url' => 'Вставить по ссылке',
+ 'preview' => 'Превью',
+ ),
+ 'image_align' => array(
+ 'title' => 'Выравнивание',
+ 'no' => 'Нет',
+ 'left' => 'Слева',
+ 'right' => 'Справа',
+ 'center' => 'По центру',
+ ),
+ 'insert' => array(
+ 'submit' => 'Вставить',
+ 'settings' => array(
+ 'title' => 'Опции вставки',
+ 'fields' => array(
+ 'size' => array(
+ 'label' => 'Размер',
+ 'original' => 'Оригинал'
+ ),
+ )
+ ),
+ ),
+ 'photoset' => array(
+ 'submit' => 'Создать фотосет',
+ 'settings' => array(
+ 'title' => 'Опции фотосета',
+ 'fields' => array(
+ 'use_thumbs' => array(
+ 'label' => 'Показывать ленту с превьюшками'
+ ),
+ 'show_caption' => array(
+ 'label' => 'Показывать описания фотографий'
+ )
+ )
+ ),
+ ),
+ 'url' => array(
+ 'fields' => array(
+ 'url' => array(
+ 'label' => 'Ссылка',
+ ),
+ 'title' => array(
+ 'label' => 'Описание',
+ ),
+ ),
+ 'submit_insert' => 'Вставить как ссылку',
+ 'submit_upload' => 'Загрузить и вставить'
+ ),
+ ),
+ /**
+ * Теги
+ */
+ 'tags' => array(
+ 'tags' => 'Теги',
+ 'tag' => 'Тег',
+ 'search' => array(
+ 'title' => 'Поиск по тегам',
+ 'label' => '___tags.search.title___',
+ ),
+ 'block_tags' => array(
+ 'nav' => array(
+ 'all' => 'Все теги',
+ // Теги избранных топиков
+ 'favourite' => 'Мои теги',
+ ),
+ 'title' => '___tags.tags___',
+ 'empty' => '___common.empty___',
+ ),
+ ),
+ /**
+ * Персональные теги
+ */
+ 'tags_personal' => array(
+ 'title' => 'Теги избранного',
+ 'edit' => 'изменить свои теги',
+ ),
+ /**
+ * Toolbar
+ */
+ 'toolbar' => array(
+ 'scrollup' => array(
+ 'title' => 'Вверх',
+ ),
+ 'topic_nav' => array(
+ 'next' => 'Следующий топик',
+ 'prev' => 'Предыдущий топик',
+ )
+ ),
+ /**
+ * Создание
+ */
+ 'modal_create' => array(
+ 'title' => 'Создать',
+ 'items' => array(
+ 'blog' => 'Блог',
+ 'talk' => 'Сообщение',
+ )
+ ),
+ /**
+ * Обрезка изображения
+ */
+ 'crop' => array(
+ 'title' => 'Обрезка изображения'
+ ),
+ /**
+ * Экшнбар
+ */
+ 'actionbar' => array(
+ 'select' => array(
+ 'title' => 'Выбрать',
+ 'menu' => array(
+ 'all' => 'Все',
+ 'deselect' => 'Убрать выделение',
+ 'invert' => 'Инвертировать',
+ ),
+ ),
+ ),
+ /**
+ * Управление правами (RBAC)
+ */
+ 'rbac' => array(
+ 'permission' => array(
+ 'create_blog' => array(
+ 'title' => 'Создание блога',
+ 'error' => 'У вас нет прав на создание блога',
+ ),
+ 'vote_blog' => array(
+ 'title' => 'Голосование за блог',
+ 'error' => 'У вас нет прав на голосование за блог',
+ ),
+ 'create_comment_favourite' => array(
+ 'title' => 'Добавление комментария в избранное',
+ 'error' => 'У вас нет прав на добавление в избранное',
+ ),
+ 'vote_comment' => array(
+ 'title' => 'Голосования за комментарии',
+ 'error' => 'У вас нет прав на голосование за комментарии',
+ ),
+ 'create_invite' => array(
+ 'title' => 'Создание инвайта',
+ 'error' => 'У вас нет прав на создание инвайта',
+ ),
+ 'create_talk' => array(
+ 'title' => 'Отправка личного сообщения',
+ 'error' => 'У вас нет прав на отправку личного сообщения',
+ ),
+ 'create_talk_comment' => array(
+ 'title' => 'Комментирование личных сообщений',
+ 'error' => 'У вас нет прав на комментирование личных сообщений',
+ ),
+ 'vote_user' => array(
+ 'title' => 'Голосование за пользователя',
+ 'error' => 'У вас нет прав на голосование за пользователей',
+ ),
+ 'create_topic' => array(
+ 'title' => 'Создание топика',
+ 'error' => 'У вас нет прав на создание топиков',
+ ),
+ 'create_topic_comment' => array(
+ 'title' => 'Комментирование топиков',
+ 'error' => 'У вас нет прав на комментирование топиков',
+ ),
+ 'remove_topic' => array(
+ 'title' => 'Удаление топиков',
+ 'error' => 'У вас нет прав на удаление топиков',
+ ),
+ 'vote_topic' => array(
+ 'title' => 'Голосование за топик',
+ 'error' => 'У вас нет прав на голосования за топики',
+ ),
+ ),
+ 'notices' => array(
+ 'validate_group_code' => 'Код должен быть уникальным',
+ 'validate_group_wrong' => 'Неверная группа',
+ 'validate_permission_code' => 'Код должен быть уникальным',
+ 'validate_role_code' => 'Код должен быть уникальным',
+ 'validate_role_recursive' => 'Попытка вложить роль в саму себя',
+ 'validate_role_wrong' => 'Неверная роль',
+ 'error_not_allow' => 'У вас нет прав на "%%permission%%"',
+ ),
+ ),
+);
diff --git a/application/frontend/skin/ifhub/.editorconfig b/application/frontend/skin/ifhub/.editorconfig
new file mode 100644
index 0000000..1cdab37
--- /dev/null
+++ b/application/frontend/skin/ifhub/.editorconfig
@@ -0,0 +1,13 @@
+# EditorConfig is awesome: http://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
diff --git a/application/frontend/skin/ifhub/actions/ActionAdmin/index.tpl b/application/frontend/skin/ifhub/actions/ActionAdmin/index.tpl
new file mode 100644
index 0000000..3e92e26
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionAdmin/index.tpl
@@ -0,0 +1,21 @@
+{**
+ * Админка
+ *
+ * @param boolean $availableAdminPlugin
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {lang 'admin.title'}
+{/block}
+
+{block 'layout_content'}
+ {component 'nav'
+ hook = 'admin'
+ mods = 'stacked pills'
+ items = [
+ [ 'name' => 'user', 'url' => "{router page='admin/plugins'}?plugin=admin&action=activate&security_ls_key={$LIVESTREET_SECURITY_KEY}", 'text' => {lang 'admin.install_plugin_admin'}, is_enabled => $availableAdminPlugin ],
+ [ 'name' => 'plugins', 'url' => "{router page='admin'}plugins/", 'text' => {lang 'admin.items.plugins'} ]
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionAdmin/plugins.tpl b/application/frontend/skin/ifhub/actions/ActionAdmin/plugins.tpl
new file mode 100644
index 0000000..4142e34
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionAdmin/plugins.tpl
@@ -0,0 +1,15 @@
+{**
+ * Плагины
+ *
+ * @param array $plugins Список плагинов
+ *}
+
+{extends 'layouts/layout.admin.tpl'}
+
+{block 'layout_admin_page_title'}
+ {lang 'admin.items.plugins'}
+{/block}
+
+{block 'layout_content'}
+ {component 'admin' template='plugins' plugins=$plugins}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionArchive/archive.tpl b/application/frontend/skin/ifhub/actions/ActionArchive/archive.tpl
new file mode 100644
index 0000000..b741705
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionArchive/archive.tpl
@@ -0,0 +1,20 @@
+{**
+ * Страница вывода правил
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ Архив IFWiki
+{/block}
+
+{block 'layout_content'}
+Файлы названы в формате [день].[месяц].[год]
, дампы делаются раз в две недели. Архиватор 7-zip.
+Дампы скачиваются напрямую с сервера IFWiki. XML-дампы готовы для импорта в любую свежую копию MediaWiki.
+Напоминаем, что содержимое IFWiki доступно по лицензии Attribution-Noncommercial 3.0 Unported (если не указано иное).
+
+{foreach $files as $file}
+ {$file}
+{/foreach}
+
+{/block}
diff --git a/application/frontend/skin/ifhub/actions/ActionArchive/index.tpl b/application/frontend/skin/ifhub/actions/ActionArchive/index.tpl
new file mode 100644
index 0000000..316bd4b
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionArchive/index.tpl
@@ -0,0 +1,18 @@
+{**
+ * Страница вывода правил
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ Архивы
+{/block}
+
+{block 'layout_content'}
+А здесь будет список архивов.
+На данный момент на сайте хранятся два архива:
+
+{/block}
diff --git a/application/frontend/skin/ifhub/actions/ActionAuth/activate.tpl b/application/frontend/skin/ifhub/actions/ActionAuth/activate.tpl
new file mode 100644
index 0000000..5178548
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionAuth/activate.tpl
@@ -0,0 +1,13 @@
+{**
+ * Уведомление об успешной регистрации
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.auth.registration.notices.success_activate}
+{/block}
+
+{block 'layout_content'}
+ {$aLang.common.site_go_main}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionAuth/confirm.tpl b/application/frontend/skin/ifhub/actions/ActionAuth/confirm.tpl
new file mode 100644
index 0000000..b05de2c
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionAuth/confirm.tpl
@@ -0,0 +1,15 @@
+{**
+ * Просьба перейти по ссылке отправленной на емэйл для активации аккаунта
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.auth.registration.confirm.title}
+{/block}
+
+{block 'layout_content'}
+ {$aLang.auth.registration.confirm.text}
+
+ {$aLang.common.site_go_main}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionAuth/invite.tpl b/application/frontend/skin/ifhub/actions/ActionAuth/invite.tpl
new file mode 100644
index 0000000..4caba47
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionAuth/invite.tpl
@@ -0,0 +1,13 @@
+{**
+ * Регистрация через инвайт
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.auth.invite.title}
+{/block}
+
+{block 'layout_content'}
+ {component 'auth' template='invite'}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionAuth/login.tpl b/application/frontend/skin/ifhub/actions/ActionAuth/login.tpl
new file mode 100644
index 0000000..85b933b
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionAuth/login.tpl
@@ -0,0 +1,13 @@
+{**
+ * Страница входа
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.auth.login.title}
+{/block}
+
+{block 'layout_content'}
+ {component 'auth' template='login' showExtra=true}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionAuth/reactivation.tpl b/application/frontend/skin/ifhub/actions/ActionAuth/reactivation.tpl
new file mode 100644
index 0000000..8e4deda
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionAuth/reactivation.tpl
@@ -0,0 +1,13 @@
+{**
+ * Форма запроса повторной активации аккаунта
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.auth.reactivation.title}
+{/block}
+
+{block 'layout_content'}
+ {component 'auth' template='reactivation'}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionAuth/register.tpl b/application/frontend/skin/ifhub/actions/ActionAuth/register.tpl
new file mode 100644
index 0000000..b24b55a
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionAuth/register.tpl
@@ -0,0 +1,13 @@
+{**
+ * Регистрация
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.auth.registration.title}
+{/block}
+
+{block 'layout_content'}
+ {component 'auth' template='registration'}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionAuth/reset.tpl b/application/frontend/skin/ifhub/actions/ActionAuth/reset.tpl
new file mode 100644
index 0000000..94c3680
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionAuth/reset.tpl
@@ -0,0 +1,13 @@
+{**
+ * Форма восстановления пароля
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.auth.reset.title}
+{/block}
+
+{block 'layout_content'}
+ {component 'auth' template='reset'}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionAuth/reset_confirm.tpl b/application/frontend/skin/ifhub/actions/ActionAuth/reset_confirm.tpl
new file mode 100644
index 0000000..dd10f91
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionAuth/reset_confirm.tpl
@@ -0,0 +1,14 @@
+{**
+ * Восстановление пароля.
+ * Пароль отправлен на емэйл пользователя.
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.auth.reset.title}
+{/block}
+
+{block 'layout_content'}
+ {$aLang.auth.reset.notices.success_send_password}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionBlog/add.tpl b/application/frontend/skin/ifhub/actions/ActionBlog/add.tpl
new file mode 100644
index 0000000..05bca29
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionBlog/add.tpl
@@ -0,0 +1,20 @@
+{**
+ * Создание блога
+ *
+ * @param array $blogCategories Список категорий блогов
+ * @param object $blogEdit Блог, передается в случае если блог редактируется
+ *}
+
+{extends 'layouts/layout.blog.edit.tpl'}
+
+{block 'layout_page_title'}
+ {if $sEvent == 'add'}
+ {$aLang.blog.add.title}
+ {else}
+ {$aLang.blog.admin.title}: {$blogEdit->getTitle()|escape}
+ {/if}
+{/block}
+
+{block 'layout_content'}
+ {component 'blog' template='add' blog=$blogEdit}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionBlog/admin.tpl b/application/frontend/skin/ifhub/actions/ActionBlog/admin.tpl
new file mode 100644
index 0000000..a2a9091
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionBlog/admin.tpl
@@ -0,0 +1,17 @@
+{**
+ * Управление пользователями блога
+ *
+ * @param object $blogEdit Блог
+ * @param array $blogUsers Список пользователей блога
+ * @param array $blogUsersInvited Список приглашенных пользователей, передается в случае если блог закрытый
+ *}
+
+{extends 'layouts/layout.blog.edit.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.blog.admin.title}: {$blogEdit->getTitle()|escape}
+{/block}
+
+{block 'layout_content'}
+ {component 'blog' template='admin' users=$blogUsers pagination=$paging}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionBlog/blog.tpl b/application/frontend/skin/ifhub/actions/ActionBlog/blog.tpl
new file mode 100644
index 0000000..32ff372
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionBlog/blog.tpl
@@ -0,0 +1,34 @@
+{**
+ * Блог
+ *
+ * @param object $blog Блог
+ * @param boolean $isPrivateBlog Закрытый блог или нет
+ * @param array $topics Список топиков
+ * @param array $paging Пагинация
+ * @param string $periodSelectCurrent
+ * @param string $periodSelectRoot
+ * @param array $blogUsers Читатели блога
+ * @param array $blogModerators Модераторы блога
+ * @param array $blogAdministrators Администраторы блога
+ * @param integer $countBlogUsers Кол-во читателей
+ * @param integer $countBlogModerators Кол-во модераторов
+ * @param integer $countBlogAdministrators Кол-во администраторов
+ *}
+
+{extends 'layouts/layout.topics.tpl'}
+
+{block 'layout_content_header'}
+ {component 'blog' blog=$blog blogs=$blogs}
+
+ {$smarty.block.parent}
+
+ {* Сообщение для забаненного пользователя *}
+ {if $blogUserCurrent and $blogUserCurrent->getIsBanned()}
+ {component 'alert' text=$aLang.blog.alerts.banned mods='error'}
+ {/if}
+
+ {* Список топиков *}
+ {if $isPrivateBlog}
+ {component 'alert' text=$aLang.blog.alerts.private mods='error'}
+ {/if}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionBlog/index.tpl b/application/frontend/skin/ifhub/actions/ActionBlog/index.tpl
new file mode 100644
index 0000000..17f9bd0
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionBlog/index.tpl
@@ -0,0 +1,10 @@
+{**
+ * Список топиков
+ *
+ * @param array $topics
+ * @param array $paging
+ * @param string $periodSelectCurrent
+ * @param string $periodSelectRoot
+ *}
+
+{extends 'layouts/layout.topics.tpl'}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionBlog/topic.tpl b/application/frontend/skin/ifhub/actions/ActionBlog/topic.tpl
new file mode 100644
index 0000000..893a00a
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionBlog/topic.tpl
@@ -0,0 +1,42 @@
+{**
+ * Топик
+ *
+ * @param object $topic
+ * @param array $comments
+ * @param integer $lastCommentId
+ * @param array $pagingComments
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_content'}
+ {* Топик *}
+ {component 'topic' template='topic-type' topic=$topic}
+
+ {* Комментарии *}
+ {component 'comment' template='comments'
+ comments = $comments
+ count = $topic->getCountComment()
+ classes = 'js-topic-comments'
+ attributes = [ 'id' => 'comments' ]
+ targetId = $topic->getId()
+ targetType = 'topic'
+ authorId = $topic->getUserId()
+ authorText = $aLang.topic.author
+ dateReadLast = $topic->getDateRead()
+ forbidAdd = $topic->getForbidComment()
+ forbidText = $aLang.topic.comments.notices.not_allowed
+ useSubscribe = true
+ isSubscribed = $topic->getSubscribeNewComment() && $topic->getSubscribeNewComment()->getStatus()
+ lastCommentId = $lastCommentId
+ pagination = [
+ total => +$pagingComments.iCountPage,
+ current => +$pagingComments.iCurrentPage,
+ url => "{$pagingComments.sGetParams}{($pagingComments.sGetParams) ? '&' : '?'}cmtpage=__page__"
+ ]
+ commentParams = [
+ useVote => true,
+ useEdit => true,
+ useFavourite => true
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionBlog/users.tpl b/application/frontend/skin/ifhub/actions/ActionBlog/users.tpl
new file mode 100644
index 0000000..1c5c74c
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionBlog/users.tpl
@@ -0,0 +1,19 @@
+{**
+ * Список пользователей которые подключены к блогу
+ *
+ * @param object $blog
+ * @param array $blogUsers
+ * @param integer $countBlogUsers
+ * @param array $paging
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.blog.users.readers_all} ({$countBlogUsers}):
+ {$blog->getTitle()|escape}
+{/block}
+
+{block 'layout_content'}
+ {component 'user' template='list' users=$blogUsers pagination=$paging}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionBlogs/index.tpl b/application/frontend/skin/ifhub/actions/ActionBlogs/index.tpl
new file mode 100644
index 0000000..ba8eae0
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionBlogs/index.tpl
@@ -0,0 +1,38 @@
+{**
+ * Список блогов
+ *
+ * @param array $blogs
+ * @param integer $searchCount
+ * @param array $paging
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_options' append}
+ {$sMenuHeadItemSelect = 'blogs'}
+{/block}
+
+{block 'layout_page_title'}
+ {$aLang.blog.blogs}
+{/block}
+
+{block 'layout_content'}
+ {component 'blog' template='search-form'}
+
+ {* Сортировка *}
+ {component 'sort' template='ajax'
+ classes = 'js-search-sort js-search-sort-menu'
+ text = $aLang.blog.sort.by_users
+ items = [
+ [ name => 'blog_count_user', text => $aLang.blog.sort.by_users ],
+ [ name => 'blog_count_topic', text => $aLang.blog.sort.by_topics ],
+ [ name => 'blog_title', text => $aLang.sort.by_title ]
+ ]}
+
+ {* Список блогов *}
+
+
+
+ {component 'blog' template='list' blogs=$blogs useMore=true}
+
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionContent/add.tpl b/application/frontend/skin/ifhub/actions/ActionContent/add.tpl
new file mode 100644
index 0000000..9b2c791
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionContent/add.tpl
@@ -0,0 +1,18 @@
+{**
+ * Создание/редактирование топика
+ *
+ * @parama object $topicEdit
+ * @parama string $topicType
+ * @parama array $blogsAllow
+ * @parama integer $blogId
+ *}
+
+{extends 'layouts/layout.content.form.tpl'}
+
+{block 'layout_options' append}
+ {$layoutShowSidebar = false}
+{/block}
+
+{block 'layout_content'}
+ {component 'topic.add-type' topic=$topicEdit type=$topicType blogs=$blogsAllow blogId=$blogId skipBlogs=$skipBlogs}
+{/block}
diff --git a/application/frontend/skin/ifhub/actions/ActionContent/drafts.tpl b/application/frontend/skin/ifhub/actions/ActionContent/drafts.tpl
new file mode 100644
index 0000000..99c6701
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionContent/drafts.tpl
@@ -0,0 +1,16 @@
+{**
+ * Черновики
+ *
+ * @parama array $topics
+ * @parama array $paging
+ *}
+
+{extends 'layouts/layout.content.form.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.topic.add.title.add}
+{/block}
+
+{block 'layout_content'}
+ {component 'topic.list' topics=$topics paging=$paging}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionContent/edit.tpl b/application/frontend/skin/ifhub/actions/ActionContent/edit.tpl
new file mode 100644
index 0000000..9b2c791
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionContent/edit.tpl
@@ -0,0 +1,18 @@
+{**
+ * Создание/редактирование топика
+ *
+ * @parama object $topicEdit
+ * @parama string $topicType
+ * @parama array $blogsAllow
+ * @parama integer $blogId
+ *}
+
+{extends 'layouts/layout.content.form.tpl'}
+
+{block 'layout_options' append}
+ {$layoutShowSidebar = false}
+{/block}
+
+{block 'layout_content'}
+ {component 'topic.add-type' topic=$topicEdit type=$topicType blogs=$blogsAllow blogId=$blogId skipBlogs=$skipBlogs}
+{/block}
diff --git a/application/frontend/skin/ifhub/actions/ActionDonate/index.tpl b/application/frontend/skin/ifhub/actions/ActionDonate/index.tpl
new file mode 100644
index 0000000..cd3cbbf
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionDonate/index.tpl
@@ -0,0 +1,38 @@
+{**
+ * Страница вывода правил
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ Фонд поддержки IFHub.club
+{/block}
+
+{block 'layout_content'}
+IFHub живёт уже четвёртый год, и ему во многом помогают читатели. В том числе, в оплате хостинга и домена.
+
+Здесь идёт сбор денег на 2019 год. Цель сборов - 7150р. до января 2020г.
+
+На сегодня собрано 0 рублей ≈ 0%
+
+Реквизиты для перечисления
+
+
+
+ Отдельные пожертвования публично не освещаются1 . Переводы через Яндекс.Деньги анонимны; через PayPal отправителя видит только админ (как получатель). Если нужно подтверждение получения — пишите админу.
+
+1 : Интересующиеся могут посмотреть исходники этой страницы на Github, которые обновляются вручную.
+
+А как вообще будет развиваться Ифхаб?
+
+Судьба LiveStreet под большим вопросом, поэтому потихоньку переписываем движок.
+Накопилась уже небольшая гора задач и хотелок,
+которые не позволяет решить архитектура LS.
+Свои идеи можно предлагать в основном трекере или на Github.
+
+Спонтанные мелкие улучшения объявляются через соцсети и в рубрике еженедельных новостей.
+{/block}
diff --git a/application/frontend/skin/ifhub/actions/ActionError/index.tpl b/application/frontend/skin/ifhub/actions/ActionError/index.tpl
new file mode 100644
index 0000000..8e4fd21
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionError/index.tpl
@@ -0,0 +1,23 @@
+{**
+ * Страница вывода ошибок
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_options' append}
+ {$layoutShowSystemMessages = false}
+{/block}
+
+{block 'layout_page_title'}
+ {if $aMsgError[0].title}
+ {$aLang.common.error.error}: {$aMsgError[0].title}
+ {/if}
+{/block}
+
+{block 'layout_content'}
+ {$aMsgError[0].msg}
+
+ {$aLang.common.site_history_back} ,
+ {$aLang.common.site_go_main}
+
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionIndex/index.tpl b/application/frontend/skin/ifhub/actions/ActionIndex/index.tpl
new file mode 100644
index 0000000..69afceb
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionIndex/index.tpl
@@ -0,0 +1,8 @@
+{**
+ * Главная
+ *
+ * @parama array $topics
+ * @parama array $paging
+ *}
+
+{extends 'layouts/layout.index.tpl'}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionPeople/index.tpl b/application/frontend/skin/ifhub/actions/ActionPeople/index.tpl
new file mode 100644
index 0000000..d0c9d1d
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionPeople/index.tpl
@@ -0,0 +1,36 @@
+{**
+ * Список всех пользователей
+ *
+ * @param array $users
+ * @param integer $searchCount
+ * @param array $countriesUsed
+ * @param array $paging
+ * @param array $usersStat
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.user.users}
+{/block}
+
+{block 'layout_content'}
+ {component 'user' template='search-form'}
+
+ {* Сортировка *}
+ {component 'sort' template='ajax'
+ classes = 'js-search-sort js-search-sort-menu'
+ text = $aLang.sort.by_rating
+ items = [
+ [ name => 'user_rating', text => $aLang.sort.by_rating, order => 'asc' ],
+ [ name => 'user_login', text => $aLang.sort.by_login ],
+ [ name => 'user_date_register', text => $aLang.sort.by_date_registration ]
+ ]}
+
+ {* Список пользователей *}
+
+
+
+ {component 'user' template='list' users=$users useMore=true}
+
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionProfile/activity.tpl b/application/frontend/skin/ifhub/actions/ActionProfile/activity.tpl
new file mode 100644
index 0000000..bedea80
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionProfile/activity.tpl
@@ -0,0 +1,20 @@
+{**
+ * Активность пользователя
+ *
+ * @param array $activityEvents
+ * @param integer $activityEventsAllCount
+ *}
+
+{extends 'layouts/layout.user.tpl'}
+
+{block 'layout_user_page_title'}
+ {lang name='activity.title'}
+{/block}
+
+{block 'layout_content' append}
+ {component 'activity'
+ events = $activityEvents
+ count = $activityEventsAllCount
+ targetId = $oUserProfile->getId()
+ classes = 'js-activity--user'}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionProfile/created.comments.tpl b/application/frontend/skin/ifhub/actions/ActionProfile/created.comments.tpl
new file mode 100644
index 0000000..8f25603
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionProfile/created.comments.tpl
@@ -0,0 +1,16 @@
+{**
+ * Список комментариев созданных пользователем
+ *
+ * @param array $comments
+ * @param array $paging
+ *}
+
+{extends 'layouts/layout.user.created.tpl'}
+
+{block 'layout_user_page_title'}
+ {lang 'user.publications.title'}
+{/block}
+
+{block 'layout_content' append}
+ {component 'comment.list' comments=$comments paging=$paging classes='js-topic-comments-list'}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionProfile/created.notes.tpl b/application/frontend/skin/ifhub/actions/ActionProfile/created.notes.tpl
new file mode 100644
index 0000000..1ceaf12
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionProfile/created.notes.tpl
@@ -0,0 +1,16 @@
+{**
+ * Список заметок созданных пользователем
+ *
+ * @param array $notesUsers
+ * @param array $paging
+ *}
+
+{extends 'layouts/layout.user.created.tpl'}
+
+{block 'layout_user_page_title'}
+ {lang 'user.publications.title'}
+{/block}
+
+{block 'layout_content' append}
+ {component 'user.list' users=$notesUsers pagination=$paging}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionProfile/created.topics.tpl b/application/frontend/skin/ifhub/actions/ActionProfile/created.topics.tpl
new file mode 100644
index 0000000..f8a7eaf
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionProfile/created.topics.tpl
@@ -0,0 +1,16 @@
+{**
+ * Список топиков созданных пользователем
+ *
+ * @param array $topics
+ * @param array $paging
+ *}
+
+{extends 'layouts/layout.user.created.tpl'}
+
+{block 'layout_user_page_title'}
+ {lang 'user.publications.title'}
+{/block}
+
+{block 'layout_content' append}
+ {component 'topic.list' topics=$topics paging=$paging}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionProfile/favourite.comments.tpl b/application/frontend/skin/ifhub/actions/ActionProfile/favourite.comments.tpl
new file mode 100644
index 0000000..34091b9
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionProfile/favourite.comments.tpl
@@ -0,0 +1,16 @@
+{**
+ * Избранные комментарии пользователя
+ *
+ * @param array $comments
+ * @param array $paging
+ *}
+
+{extends 'layouts/layout.user.favourite.tpl'}
+
+{block 'layout_user_page_title'}
+ {lang 'user.favourites.title'}
+{/block}
+
+{block 'layout_content' append}
+ {component 'comment.list' comments=$comments paging=$paging classes='js-topic-comments-list'}
+{/block}
diff --git a/application/frontend/skin/ifhub/actions/ActionProfile/favourite.topics.tpl b/application/frontend/skin/ifhub/actions/ActionProfile/favourite.topics.tpl
new file mode 100644
index 0000000..4c0333e
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionProfile/favourite.topics.tpl
@@ -0,0 +1,25 @@
+{**
+ * Избранные топики пользователя
+ *
+ * @param array $topics
+ * @param array $paging
+ * @param array $activeFavouriteTag
+ *}
+
+{extends 'layouts/layout.user.favourite.tpl'}
+
+{block 'layout_user_page_title'}
+ {lang 'user.favourites.title'}
+{/block}
+
+{block 'layout_content' append}
+ {* Блок с тегами избранного *}
+ {if $oUserCurrent && $oUserCurrent->getId() == $oUserProfile->getId()}
+ {insert name='block' block='tagsPersonalTopic' params=[
+ 'user' => $oUserProfile,
+ 'activeTag' => $activeFavouriteTag
+ ]}
+ {/if}
+
+ {component 'topic.list' topics=$topics paging=$paging}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionProfile/friends.tpl b/application/frontend/skin/ifhub/actions/ActionProfile/friends.tpl
new file mode 100644
index 0000000..6c5b336
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionProfile/friends.tpl
@@ -0,0 +1,16 @@
+{**
+ * Список друзей
+ *
+ * @param array $friends
+ * @param array $paging
+ *}
+
+{extends 'layouts/layout.user.tpl'}
+
+{block 'layout_user_page_title'}
+ {lang name='user.friends.title'}
+{/block}
+
+{block 'layout_content' append}
+ {component 'user' template='list' users=$friends pagination=$paging}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionProfile/info.tpl b/application/frontend/skin/ifhub/actions/ActionProfile/info.tpl
new file mode 100644
index 0000000..eda5208
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionProfile/info.tpl
@@ -0,0 +1,25 @@
+{**
+ * Профиль пользователя с информацией о нем
+ *
+ * @param array usersInvited
+ * @param object invitedByUser
+ * @param array blogsJoined
+ * @param array blogsModerate
+ * @param array blogsAdminister
+ * @param array blogsCreated
+ * @param array usersFriend
+ *}
+
+{extends 'layouts/layout.user.tpl'}
+
+{block 'layout_content' append}
+ {component 'user' template='info'
+ user = $oUserProfile
+ friends = $userFriends
+ usersInvited = $usersInvited
+ invitedByUser = $invitedByUser
+ blogsJoined = $blogsJoined
+ blogsAdminister = $blogsAdminister
+ blogsModerate = $blogsModerate
+ blogsCreated = $blogsCreated}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionProfile/wall.tpl b/application/frontend/skin/ifhub/actions/ActionProfile/wall.tpl
new file mode 100644
index 0000000..06404e8
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionProfile/wall.tpl
@@ -0,0 +1,16 @@
+{**
+ * Стена
+ *}
+
+{extends 'layouts/layout.user.tpl'}
+
+{block 'layout_user_page_title'}
+ {lang name='wall.title'}
+{/block}
+
+{block 'layout_content' append}
+ {insert name='block' block='wall' params=[
+ 'classes' => 'js-wall-default',
+ 'user_id' => $oUserProfile->getId()
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionRss/index.tpl b/application/frontend/skin/ifhub/actions/ActionRss/index.tpl
new file mode 100644
index 0000000..0e2f94d
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionRss/index.tpl
@@ -0,0 +1,27 @@
+
+
+
+ {$aChannel.title}
+ {$aChannel.link}
+
+
+ {$aChannel.language}
+ {$aChannel.managingEditor} ({Router::GetPath('/')})
+ {$aChannel.managingEditor} ({Router::GetPath('/')})
+ {Router::GetPath('/')}
+ {$aChannel.generator}
+
+ {foreach $aItems as $item}
+ -
+
{$item.title|escape:'html'}
+ {$item.guid}
+ {$item.link}
+ {$item.author}
+
+ {date_format date=$item.pubDate format="r"}
+ {$item.category|replace:',':'
+ '}
+
+ {/foreach}
+
+
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionRules/index.tpl b/application/frontend/skin/ifhub/actions/ActionRules/index.tpl
new file mode 100644
index 0000000..c896c2b
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionRules/index.tpl
@@ -0,0 +1,65 @@
+{**
+ * Страница вывода правил
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ Правила IFHub.club
+{/block}
+
+{block 'layout_content'}
+Правила Ифхаба просты для запоминания:
+
+
+ Будьте добры.
+ Будьте вежливы.
+ Мы собрались для обсуждения текста, игр и текстовых игр.
+
+
+Вот и всё. А теперь объясним эту философию.
+
+Наше уютное сообщество может оставаться уютным только тогда, когда все уважают
+друг друга.
+Это значит, что вы не должны сводить разговор на личности, материться и оскорблять
+других людей.
+
+
+
+Будьте открыты для любых мнений, пусть и непопулярных.
+Мы не ищем Правды, а обсуждаем проблемы; это площадка для дискуссий.
+Каждый раз, когда вы что-то пишете, подумайте, не может ли это кого-то расстроить.
+
+
+
+Эти правила очень жёсткие, а защищают их злые модераторы добра и вежливости.
+
+
+
+Любое неуважительное высказывание о религии, сексуальных предпочтениях,
+творческих предпочтениях (например, выборе платформ)
+будет наказываться модераторами, которые оберегают наш уют.
+
+
+
+Это сообщество экспериментаторов и маргиналов.
+Эксперименты наших посетителей не приносят прибыли и не имеют причин. Они есть.
+
+„Это игра?“ — Да, если автор так утверждает.
+„Это творчество?“ — Несомненно.
+„Это может существовать?“ — Это уже существует.
+
+
+
+Тем не менее, ваше мнение тоже важно, и вы можете его выразить голосованием.
+Под каждой статьёй и возле каждого комментария есть голосование: вы можете поставить + или -.
+Подумайте хорошо, это нельзя будет изменить.
+
+Как использовать плюс и минус: рекомендация
+
+ Плюс - это не голос «я согласен с этим топиком». Плюс - это голос за то, чтобы таких топиков было больше. За то, что вы хотите обсуждать эту тему. Плюс - это голос за то, чтобы этот топик появился на главной странице.
+
+
+Минус, опять же, — это не голос «я не согласен с этим топиком». Если вы не согласны, оставьте комментарий, почему. Минус означает «я не хочу поддерживать такие темы». Кроме того, он отдаляет или убирает топик с главной страницы.
+
+{/block}
diff --git a/application/frontend/skin/ifhub/actions/ActionSearch/index.tpl b/application/frontend/skin/ifhub/actions/ActionSearch/index.tpl
new file mode 100644
index 0000000..8a1efc0
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionSearch/index.tpl
@@ -0,0 +1,44 @@
+{**
+ * Страница с формой поиска
+ *
+ * @param array resultItems
+ * @param array paging
+ * @param array searchType
+ * @param array query
+ * @param array typeCounts
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {$aLang.search.search}
+{/block}
+
+{block 'layout_content_header' prepend}
+ {component 'search.main' searchType=$searchType}
+{/block}
+
+{block 'layout_options' append}
+ {$layoutNav = [[
+ name => 'search',
+ activeItem => $searchType,
+ items => [
+ [ 'name' => 'topics', 'url' => "{router page='search/topics'}?q={$_aRequest.q}", 'text' => $aLang.search.result.topics, 'count' => $typeCounts.topics ],
+ [ 'name' => 'comments', 'url' => "{router page='search/comments'}?q={$_aRequest.q}", 'text' => $aLang.search.result.comments, 'count' => $typeCounts.comments ]
+ ]
+ ]]}
+{/block}
+
+{block 'layout_content'}
+ {if $resultItems}
+ {if $searchType == 'topics'}
+ {component 'topic' template='list' topics=$resultItems paging=$paging}
+ {elseif $searchType == 'comments'}
+ {component 'comment' template='list' comments=$resultItems paging=$paging}
+ {else}
+ {hook run='search_result' type=$searchType}
+ {/if}
+ {elseif $_aRequest.q}
+ {component 'blankslate' text=$aLang.search.alerts.empty}
+ {/if}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionSearch/opensearch.tpl b/application/frontend/skin/ifhub/actions/ActionSearch/opensearch.tpl
new file mode 100644
index 0000000..588f4f1
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionSearch/opensearch.tpl
@@ -0,0 +1,18 @@
+
+ {Config::Get('view.name')}
+ {$sHtmlTitle}
+ {Config::Get('sys.mail.from_email')}
+
+ {$sHtmlDescription}
+ {Config::Get('path.skin.assets.web')}/images/favicons/opensearch.png
+ {Config::Get('path.skin.assets.web')}/images/favicons/favicon.ico
+ {Config::Get('view.name')} ({Router::GetPath('/')})
+
+ © «{Config::Get('view.name')}»
+
+ open
+ false
+ ru-ru
+ UTF-8
+ UTF-8
+
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionSettings/account.change_email_confirm.tpl b/application/frontend/skin/ifhub/actions/ActionSettings/account.change_email_confirm.tpl
new file mode 100644
index 0000000..66c1b1d
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionSettings/account.change_email_confirm.tpl
@@ -0,0 +1,14 @@
+{**
+ * Уведомления о смене емэйла
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_options' append}
+ {$layoutShowSystemMessages = false}
+ {$layoutShowSidebar = false}
+{/block}
+
+{block 'layout_content'}
+ {$sText}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionSettings/account.tpl b/application/frontend/skin/ifhub/actions/ActionSettings/account.tpl
new file mode 100644
index 0000000..80418c0
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionSettings/account.tpl
@@ -0,0 +1,9 @@
+{**
+ * Настройки аккаунта (емэйл, пароль)
+ *}
+
+{extends 'layouts/layout.user.settings.tpl'}
+
+{block 'layout_content' append}
+ {component 'user' template='settings/account' user=$oUserCurrent}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionSettings/invite.tpl b/application/frontend/skin/ifhub/actions/ActionSettings/invite.tpl
new file mode 100644
index 0000000..1196bf5
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionSettings/invite.tpl
@@ -0,0 +1,9 @@
+{**
+ * Управление инвайтами
+ *}
+
+{extends 'layouts/layout.user.settings.tpl'}
+
+{block 'layout_content' append}
+ {component 'user' template='settings/invite' user=$oUserCurrent}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionSettings/profile.tpl b/application/frontend/skin/ifhub/actions/ActionSettings/profile.tpl
new file mode 100644
index 0000000..c28304a
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionSettings/profile.tpl
@@ -0,0 +1,9 @@
+{**
+ * Основные настройки профиля
+ *}
+
+{extends 'layouts/layout.user.settings.tpl'}
+
+{block 'layout_content' append}
+ {component 'user' template='settings/profile' user=$oUserCurrent}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionSettings/tuning.tpl b/application/frontend/skin/ifhub/actions/ActionSettings/tuning.tpl
new file mode 100644
index 0000000..28024a2
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionSettings/tuning.tpl
@@ -0,0 +1,9 @@
+{**
+ * Настройка уведомлений
+ *}
+
+{extends 'layouts/layout.user.settings.tpl'}
+
+{block 'layout_content' append}
+ {component 'user' template='settings/tuning' user=$oUserCurrent}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionStream/all.tpl b/application/frontend/skin/ifhub/actions/ActionStream/all.tpl
new file mode 100644
index 0000000..9f7ead9
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionStream/all.tpl
@@ -0,0 +1,12 @@
+{**
+ * Вся активность
+ *
+ * @param array $activityEvents
+ * @param integer $activityEventsAllCount
+ *}
+
+{extends 'layouts/layout.activity.tpl'}
+
+{block 'layout_content'}
+ {component 'activity' events=$activityEvents count=$activityEventsAllCount classes='js-activity--all'}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionStream/personal.tpl b/application/frontend/skin/ifhub/actions/ActionStream/personal.tpl
new file mode 100644
index 0000000..0a47ed6
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionStream/personal.tpl
@@ -0,0 +1,12 @@
+{**
+ * Настраиваемая, персональная страница активности
+ *
+ * @param array $activityEvents
+ * @param integer $activityEventsAllCount
+ *}
+
+{extends 'layouts/layout.activity.tpl'}
+
+{block 'layout_content'}
+ {component 'activity' events=$activityEvents count=$activityEventsAllCount classes='js-activity--personal'}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionTag/index.tpl b/application/frontend/skin/ifhub/actions/ActionTag/index.tpl
new file mode 100644
index 0000000..4bfbbe2
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionTag/index.tpl
@@ -0,0 +1,18 @@
+{**
+ * Поиск по тегам
+ *
+ * @param array $topics
+ * @param array $paging
+ * @param string $tag
+ *}
+
+{extends 'layouts/layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {lang 'tags.search.title'}
+{/block}
+
+{block 'layout_content'}
+ {component 'tags' template='search-form'}
+ {component 'topic' template='list' topics=$topics paging=$paging}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionTalk/add.tpl b/application/frontend/skin/ifhub/actions/ActionTalk/add.tpl
new file mode 100644
index 0000000..43feb6e
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionTalk/add.tpl
@@ -0,0 +1,9 @@
+{**
+ * Создание личного сообщения
+ *}
+
+{extends 'layouts/layout.user.messages.tpl'}
+
+{block 'layout_content'}
+ {component 'talk' template='add'}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionTalk/blacklist.tpl b/application/frontend/skin/ifhub/actions/ActionTalk/blacklist.tpl
new file mode 100644
index 0000000..f0e800a
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionTalk/blacklist.tpl
@@ -0,0 +1,11 @@
+{**
+ * Черный список
+ *
+ * @param array $talkBlacklistUsers
+ *}
+
+{extends 'layouts/layout.user.messages.tpl'}
+
+{block 'layout_content'}
+ {component 'talk' template='blacklist' users=$talkBlacklistUsers}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionTalk/favourites.tpl b/application/frontend/skin/ifhub/actions/ActionTalk/favourites.tpl
new file mode 100644
index 0000000..7e5b781
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionTalk/favourites.tpl
@@ -0,0 +1,12 @@
+{**
+ * Список избранных сообщений
+ *
+ * @param array $talks
+ * @param array $paging
+ *}
+
+{extends 'layouts/layout.user.messages.tpl'}
+
+{block 'layout_content'}
+ {component 'talk.list' talks=$talks paging=$paging}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionTalk/inbox.tpl b/application/frontend/skin/ifhub/actions/ActionTalk/inbox.tpl
new file mode 100644
index 0000000..d5fe7d0
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionTalk/inbox.tpl
@@ -0,0 +1,13 @@
+{**
+ * Список сообщений
+ *
+ * @param array $talks
+ * @param array $paging
+ *}
+
+{extends 'layouts/layout.user.messages.tpl'}
+
+{block 'layout_content'}
+ {component 'talk' template='search-form'}
+ {component 'talk' template='list' talks=$talks paging=$paging selectable=true}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionTalk/talk.tpl b/application/frontend/skin/ifhub/actions/ActionTalk/talk.tpl
new file mode 100644
index 0000000..29a5242
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionTalk/talk.tpl
@@ -0,0 +1,17 @@
+{**
+ * Диалог
+ *
+ * @param object $talk
+ * @param array $comments
+ * @param integer $lastCommentId
+ *}
+
+{extends 'layouts/layout.user.messages.tpl'}
+
+{block 'layout_content'}
+ {component 'talk'
+ talk = $talk
+ comments = $comments
+ lastCommentId = $lastCommentId
+ activeParticipantsCount = $activeParticipantsCount}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/actions/ActionUserfeed/list.tpl b/application/frontend/skin/ifhub/actions/ActionUserfeed/list.tpl
new file mode 100644
index 0000000..05550fb
--- /dev/null
+++ b/application/frontend/skin/ifhub/actions/ActionUserfeed/list.tpl
@@ -0,0 +1,8 @@
+{**
+ * Лента пользователя
+ *
+ * @param array $topics
+ * @param array $paging
+ *}
+
+{extends 'layouts/layout.index.tpl'}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/assets/css/colors.css b/application/frontend/skin/ifhub/assets/css/colors.css
new file mode 100644
index 0000000..858cfbf
--- /dev/null
+++ b/application/frontend/skin/ifhub/assets/css/colors.css
@@ -0,0 +1,48 @@
+.ls-user::before {
+ background: url(/application/frontend/skin/ifhub/assets/images/usericon_28.png) no-repeat !important;
+ background-size: 100% 100% !important;
+ width: 18px !important;
+ height: 18px !important;
+ color: transparent;
+ content: "__" !important;
+}
+.ls-block-header > .ls-block-title > a {
+ color: #fff !important;
+}
+.ls-nav-item--userbar-username img.avatar {
+ display: none;
+}
+ul.classic {
+ list-style: inside;
+ list-style-type: circle;
+}
+ol.classic {
+ list-style: inside;
+ list-style-type: decimal;
+}
+
+.ls-nav--userbar > .ls-nav-item > a{
+ color: #ddd !important;
+ padding: 15px 20px !important;
+}
+.ls-nav--userbar > .ls-nav-item > a:hover{
+ color: #ddd !important;
+ background: #333 !important;
+}
+.ls-nav--userbar > .ls-nav-item > a > img {
+ display: none;
+}
+/**
+ * Главный поиск в шапке сайта - показывается по клику
+ **/
+.main-search {
+ display: none;
+}
+.no-js .main-search {
+ display: block;
+}
+
+/** Визуально выделить край комментария **/
+.ls-comment {
+ border-left: 1px solid #aaa;
+}
diff --git a/application/frontend/skin/ifhub/assets/css/layout.css b/application/frontend/skin/ifhub/assets/css/layout.css
new file mode 100644
index 0000000..e861c71
--- /dev/null
+++ b/application/frontend/skin/ifhub/assets/css/layout.css
@@ -0,0 +1,260 @@
+/**
+ * Сетка
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+
+/**
+ * Сетка
+ */
+.layout-container {
+ margin: 0 auto;
+ padding: 0 50px;
+}
+.layout-content {
+ background: #fff;
+ padding: 40px !important;
+}
+.layout-nav .ls-nav.ls-nav--main {
+ margin: 0 auto;
+}
+.layout-footer {
+ padding: 20px 0 50px;
+ color: #777;
+ position: relative;
+}
+.layout-container.layout-no-sidebar .layout-content {
+ width: 100%;
+ margin-right: 0;
+}
+.layout-sidebar {
+ padding-left: 10px !important;
+}
+.layout-sidebar .ls-block {
+ margin-bottom: 10px;
+}
+
+/* Responsive */
+@media (max-width: 999px) {
+ .layout-content { width: 100% !important; margin-bottom: 30px; padding: 15px !important; }
+ .layout-sidebar { width: 100% !important; margin-bottom: 30px; padding: 0; }
+
+ .ls-toolbar {
+ display: none;
+ }
+}
+@media (max-width: 480px) {
+ .layout-container {
+ padding: 0;
+ }
+}
+
+/**
+ * Главное меню
+ */
+.layout-nav {
+ padding: 0 20px;
+ margin-bottom: 10px;
+ background: #222;
+}
+
+@media (max-width: 480px) {
+ .layout-nav {
+ padding: 0;
+ margin-bottom: 0;
+ }
+}
+
+
+/**
+ * Шапка сайта
+ */
+.ls-jumbotron {
+ background-image: url(../images/header.jpg);
+}
+
+/**
+ * Для статей
+ */
+
+.incut {
+ margin: 0.5em auto;
+ padding: 1em 2em;
+ border: .08em solid rgba(0,0,0,.1);
+ color: rgba(0,0,0,.75);
+ background-color: #f7f7f7;
+ box-shadow: inset 0 0 .12em rgba(0,0,0,.1);
+ max-width: 600px;
+}
+.aside {
+ margin: 1em;
+ padding: 1em 2em;
+ border: .08em solid rgba(0,0,0,.1);
+ color: rgba(0,0,0,.75);
+ background-color: #f7f7f7;
+ box-shadow: inset 0 0 .12em rgba(0,0,0,.1);
+ float: right;
+}
+
+/**
+ * Делаем минус незаметнее, разделяем кнопки визуально
+ */
+.ls-topic {
+ content-visibility: auto;
+}
+.ls-topic-footer .ls-vote .ls-vote-body .ls-vote-item {
+ padding: 0.5em 1em;
+ margin-left: 0.2em;
+}
+.ls-topic-footer .ls-vote .ls-vote-body .ls-vote-item-down {
+ background: #eee;
+}
+
+@media (max-width: 800px) {
+ .markItUp .markItUpContainer .markItUpEditor {
+ font-size: 11pt;
+ }
+}
+@media (min-width: 801px) {
+ .markItUp .markItUpContainer .markItUpEditor {
+ font-size: 12pt;
+ }
+}
+.markItUp .markItUpContainer .markItUpEditor {
+ line-height: 1.6;
+ font-family: "Fira Code","Anonymous Pro","PT Mono","IBM Plex Mono", "Noto Mono","Courier New",Courier,"Lucida Sans Typewriter","Lucida Typewriter",monospace;
+}
+
+summary {
+ font-weight: bold;
+ margin: -.5em -.5em 0;
+ padding: .5em;
+}
+
+/** Спойлер
+ * Modified by fedorov mich // 2014 // bSpoiler LS 1.0
+ **/
+.spoiler-title {
+ color: #6da3bd;
+ border-bottom: 1px dashed;
+ font-weight: normal;
+ cursor: pointer;
+}
+.spoiler-title:hover {
+ color: #4d7285;
+}
+.spoiler-body {
+ display: none;
+ padding: 10px;
+ border: 1px solid #eee;
+ background: #f9f9f9;
+ margin-top: 10px;
+ overflow: hidden;
+}
+.spoiler-title:before {
+ float: left;
+ content: " ";
+ width: 16px;
+ height: 16px;
+ margin-top: 2px;
+ display: block;
+ border: 0px solid red;
+ background: url(../images/spoiler.icon.png) no-repeat left top;
+}
+.spoiler-title.open:before {
+ background: url(../images/spoiler.icon.png) no-repeat left bottom;
+}
+
+
+.newspoiler-title {
+ color: #6da3bd;
+ text-decoration: underline 1px dashed;
+ font-weight: normal;
+ cursor: pointer;
+}
+.newspoiler-title:hover {
+ color: #4d7285;
+}
+.newspoiler[open] .newspoiler-title {
+ text-decoration: none;
+ margin-bottom: .5em;
+}
+.newspoiler {
+ padding: 10px;
+}
+.newspoiler[open] {
+ border: 1px solid #eee;
+ background: #f9f9f9;
+}
+.newspoiler-title:before {
+ float: left;
+ content: " ";
+ width: 16px;
+ height: 16px;
+ margin-top: 2px;
+ display: block;
+ border: 0px solid red;
+ background: url(../images/spoiler.icon.png) no-repeat left top;
+}
+.newspoiler[open] .newspoiler-title:before {
+ background: url(../images/spoiler.icon.png) no-repeat left bottom;
+}
+
+/* https://github.com/stationer/DetailsShim - заглушка для старых браузеров */
+details.details_shim_closed,
+details.details_shim_open {display: block;}
+details.details_shim_closed > * {display: none;}
+details.details_shim_closed > summary,
+details.details_shim_open > summary {display: block;}
+details.details_shim_closed > summary:before {display: inline-block; content: "\25b6"; padding: 0 0.1em; margin-right: 0.4em; font-size: 0.9em;}
+details.details_shim_open > summary:before {display: inline-block; content: "\25bc"; padding: 0; margin-right: 0.35em;}
+.hidden {
+ display: none;
+}
+.ls-block--primary {
+ border: none;
+}
+.ls-block .ls-tab-list {
+ border-bottom: none;
+}
+.ls-tab.active {
+ -moz-box-shadow: inset 0 0 4px #0c3151;
+ -webkit-box-shadow: inset 0 0 4px #0c3151;
+ box-shadow: inset 0 0 4px #0c3151;
+}
+.ls-activity-block-recent-info {
+ width: 5em;
+ flex-grow: 0;
+ flex-shrink: 0;
+}
+/**
+ * Делаем кнопку комментов чуть больше, чтобы легче её нажимать
+ */
+.ls-activity-block-recent-info .ls-activity-block-recent-comments {
+ padding: 5px;
+}
+.shortinfo {
+ width: auto;
+ flex-grow: 1;
+ flex-shrink: 1;
+}
+.ls-item-group .ls-item {
+ border-bottom: 0;
+ padding-bottom: 7px;
+ padding-top: 7px;
+}
+.ls-block--activity-recent .ls-tab-pane .ls-item-body {
+ width: 100%;
+ display: flex;
+}
+.ls-tab-pane .ls-item-description {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+}
+.ls-block--activity-recent .ls-block-header {
+ display: none;
+}
diff --git a/application/frontend/skin/ifhub/assets/css/print.css b/application/frontend/skin/ifhub/assets/css/print.css
new file mode 100644
index 0000000..12864f2
--- /dev/null
+++ b/application/frontend/skin/ifhub/assets/css/print.css
@@ -0,0 +1,37 @@
+/**
+ * Стили для печати
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+@media print {
+ @page { margin: 0.5cm; }
+
+ * { background: transparent !important; color: black !important; text-shadow: none !important; filter:none !important; -ms-filter: none !important; }
+
+ body, .text { font-size: 14pt; }
+
+ a, a:visited { text-decoration: underline; }
+
+ pre, blockquote { border: 1px solid #999; page-break-inside: avoid; }
+ thead { display: table-header-group; }
+ tr, img { page-break-inside: avoid; }
+ img { max-width: 100% !important; }
+
+ h1 { font-size: 30pt !important; }
+ h4 { font-size: 26pt !important; }
+ h5 { font-size: 23pt !important; }
+ h6 { font-size: 20pt !important; }
+ p, h3, h4, h5 { orphans: 3; widows: 3; }
+ h3, h4, h5 { page-break-after: avoid; }
+
+ #header, #userbar, #nav, #sidebar, #footer, #comments, .toolbar, .nav-filter-wrapper,
+ .topic-footer, .stat-performance, .actions, .reply-header { display: none !important; }
+
+ #wrapper { -webkit-box-shadow: none; box-shadow: none; border: 0; padding: 0; }
+ #content { width: 100%; margin: 0; }
+
+ .topic { margin-bottom: 100px; }
+}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_100x100crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_100x100crop.png
new file mode 100644
index 0000000..3822864
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_100x100crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_24x24crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_24x24crop.png
new file mode 100644
index 0000000..e5709df
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_24x24crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_48x48crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_48x48crop.png
new file mode 100644
index 0000000..a82b923
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_48x48crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_500x500crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_500x500crop.png
new file mode 100644
index 0000000..d27a121
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_500x500crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_64x64crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_64x64crop.png
new file mode 100644
index 0000000..9b62093
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_blog_64x64crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_female_100x100crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_female_100x100crop.png
new file mode 100644
index 0000000..894626e
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_female_100x100crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_female_24x24crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_female_24x24crop.png
new file mode 100644
index 0000000..2a3d759
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_female_24x24crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_female_48x48crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_female_48x48crop.png
new file mode 100644
index 0000000..d2f8f16
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_female_48x48crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_female_64x64crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_female_64x64crop.png
new file mode 100644
index 0000000..4eb41a3
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_female_64x64crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_male_100x100crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_male_100x100crop.png
new file mode 100644
index 0000000..1e38ddb
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_male_100x100crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_male_24x24crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_male_24x24crop.png
new file mode 100644
index 0000000..b85148b
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_male_24x24crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_male_48x48crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_male_48x48crop.png
new file mode 100644
index 0000000..546715d
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_male_48x48crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/avatar_male_64x64crop.png b/application/frontend/skin/ifhub/assets/images/avatars/avatar_male_64x64crop.png
new file mode 100644
index 0000000..f141d8b
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/avatar_male_64x64crop.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/user_photo_female.png b/application/frontend/skin/ifhub/assets/images/avatars/user_photo_female.png
new file mode 100644
index 0000000..34775e7
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/user_photo_female.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/avatars/user_photo_male.png b/application/frontend/skin/ifhub/assets/images/avatars/user_photo_male.png
new file mode 100644
index 0000000..3626b19
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/avatars/user_photo_male.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/favicons/favicon.ico b/application/frontend/skin/ifhub/assets/images/favicons/favicon.ico
new file mode 100644
index 0000000..726d8a0
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/favicons/favicon.ico differ
diff --git a/application/frontend/skin/ifhub/assets/images/favicons/opensearch.png b/application/frontend/skin/ifhub/assets/images/favicons/opensearch.png
new file mode 100644
index 0000000..1914715
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/favicons/opensearch.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/kril.png b/application/frontend/skin/ifhub/assets/images/kril.png
new file mode 100644
index 0000000..59b46dd
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/kril.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/logo.png b/application/frontend/skin/ifhub/assets/images/logo.png
new file mode 100644
index 0000000..4aee57d
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/logo.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/spoiler.icon.png b/application/frontend/skin/ifhub/assets/images/spoiler.icon.png
new file mode 100644
index 0000000..0a77069
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/spoiler.icon.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/touchicon.png b/application/frontend/skin/ifhub/assets/images/touchicon.png
new file mode 100644
index 0000000..89d39f2
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/touchicon.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/touchicon_120.png b/application/frontend/skin/ifhub/assets/images/touchicon_120.png
new file mode 100644
index 0000000..ed5e7a2
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/touchicon_120.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/touchicon_152.png b/application/frontend/skin/ifhub/assets/images/touchicon_152.png
new file mode 100644
index 0000000..57e4bbf
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/touchicon_152.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/usericon.png b/application/frontend/skin/ifhub/assets/images/usericon.png
new file mode 100644
index 0000000..1b8ecc9
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/usericon.png differ
diff --git a/application/frontend/skin/ifhub/assets/images/usericon_28.png b/application/frontend/skin/ifhub/assets/images/usericon_28.png
new file mode 100644
index 0000000..34a6919
Binary files /dev/null and b/application/frontend/skin/ifhub/assets/images/usericon_28.png differ
diff --git a/application/frontend/skin/ifhub/assets/js/ifhub.js b/application/frontend/skin/ifhub/assets/js/ifhub.js
new file mode 100644
index 0000000..c4bdf5b
--- /dev/null
+++ b/application/frontend/skin/ifhub/assets/js/ifhub.js
@@ -0,0 +1,144 @@
+/**
+ * Инициализации модулей
+ *
+ * @license GNU General Public License, version 2
+ * @author Alexander Yakovlev
+ */
+
+jQuery(document).ready(function($){
+ $(".search-icon").on('click', function(){
+ $(".main-search").toggle()
+ });
+ $('.spoiler-title').on('click', function(){
+ $(this).toggleClass('open');
+ $(this).parent().children('div.spoiler-body').toggle('normal');
+ return false;
+ });
+});
+
+/* --- https://github.com/stationer/DetailsShim/blob/master/details-shim.js --- */
+/**
+ * Enable proper operation of tags in unsupportive browsers
+ *
+ * @param Details details element to shim
+ * @returns {boolean} false on error
+ */
+function details_shim(Details) {
+ // For backward compatibility, if no DOM Element is sent, call init()
+ if (!Details || !('nodeType' in Details) || !('tagName' in Details)) {
+ return details_shim.init();
+ }
+
+ var Summary;
+ // If we were passed a details tag, find its summary tag
+ if ('details' == Details.tagName.toLowerCase()) {
+ // Assume first found summary tag is the corresponding summary tag
+ Summary = Details.getElementsByTagName('summary')[0];
+
+ // If we were passed a summary tag, find its details tag
+ } else if (!!Details.parentNode
+ && 'summary' == Details.tagName.toLowerCase()
+ ) {
+ Summary = Details;
+ Details = Summary.parentNode;
+ } else {
+ // An invalid parameter was passed for Details
+ return false;
+ }
+
+ // If the details tag is natively supported or already shimmed
+ if ('boolean' == typeof Details.open) {
+ // If native, remove custom classes
+ if (!Details.getAttribute('data-open')) {
+ Details.className = Details.className
+ .replace(/\bdetails_shim_open\b|\bdetails_shim_closed\b/g, ' ');
+ }
+ return false;
+ }
+
+ // Set initial class according to `open` attribute
+ var state = Details.outerHTML
+ // OR older firefox doesn't have .outerHTML
+ || new XMLSerializer().serializeToString(Details);
+ state = state.substring(0, state.indexOf('>'));
+ // Read: There is an open attribute, and it's not explicitly empty
+ state = (-1 != state.indexOf('open') && -1 == state.indexOf('open=""'))
+ ? 'open'
+ : 'closed'
+ ;
+ Details.setAttribute('data-open', state);
+ Details.className += ' details_shim_' + state;
+
+ // Add onclick handler to toggle visibility class
+ Summary.addEventListener
+ ? Summary.addEventListener('click', function() { details_shim.toggle(Details); })
+ : Summary.attachEvent && Summary.attachEvent('onclick', function() { details_shim.toggle(Details); })
+ ;
+
+ Object.defineProperty(Details, 'open', {
+ get: function() {
+ return 'open' == this.getAttribute('data-open');
+ },
+ set: function(state) {
+ details_shim.toggle(this, state);
+ }
+ });
+
+ // wrap text nodes in span to expose them to css
+ for (var j = 0; j < Details.childNodes.length; j++) {
+ if (Details.childNodes[j].nodeType == 3
+ && /[^\s]/.test(Details.childNodes[j].data)
+ ) {
+ var span = document.createElement('span');
+ var text = Details.childNodes[j];
+ Details.insertBefore(span, text);
+ Details.removeChild(text);
+ span.appendChild(text);
+ }
+ }
+} // details_shim()
+
+/**
+ * Toggle the open state of specified tag
+ * @param Details The tag to toggle
+ * @param state Optional override state
+ */
+details_shim.toggle = function(Details, state) {
+ // If state was not passed, seek current state
+ if ('undefined' === typeof state) {
+ // new state
+ state = Details.getAttribute('data-open') == 'open'
+ ? 'closed'
+ : 'open'
+ ;
+ } else {
+ // Sanitize the input, expect boolean, force string
+ // Expecting boolean means even 'closed' will result in an open
+ // This is the behavior of the natively supportive browsers
+ state = !!state ? 'open' : 'closed';
+ }
+
+ Details.setAttribute('data-open', state);
+ // replace previous open/close class
+ Details.className = Details.className
+ .replace(/\bdetails_shim_open\b|\bdetails_shim_closed\b/g, ' ')
+ + ' details_shim_' + state;
+};
+
+/**
+ * Run details_shim() on each details tag
+ */
+details_shim.init = function() {
+ // Because must include a ,
+ // collecting tags collects *valid* tags
+ var Summaries = document.getElementsByTagName('summary');
+ for (var i = 0; i < Summaries.length; i++) {
+ details_shim(Summaries[i]);
+ }
+};
+
+// Run details_shim.init() when the page loads
+window.addEventListener
+ ? window.addEventListener('load', details_shim.init, false)
+ : window.attachEvent && window.attachEvent('onload', details_shim.init)
+;
diff --git a/application/frontend/skin/ifhub/assets/js/init.js b/application/frontend/skin/ifhub/assets/js/init.js
new file mode 100644
index 0000000..965967d
--- /dev/null
+++ b/application/frontend/skin/ifhub/assets/js/init.js
@@ -0,0 +1,686 @@
+/**
+ * Инициализации модулей
+ *
+ * @license GNU General Public License, version 2
+ * @copyright 2013 OOO "ЛС-СОФТ" {@link http://livestreetcms.com}
+ * @author Denis Shakhov
+ */
+
+// google quicklink, apache license
+!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):e.quicklink=n()}(this,function(){var e={};function n(e){return new Promise(function(n,t){var r=new XMLHttpRequest;r.open("GET",e,r.withCredentials=!0),r.onload=function(){200===r.status?n():t()},r.send()})}var t,r,i=(t="prefetch",((r=document.createElement("link")).relList||{}).supports&&r.relList.supports(t)?function(e){return new Promise(function(n,t){var r=document.createElement("link");r.rel="prefetch",r.href=e,r.onload=n,r.onerror=t,document.head.appendChild(r)})}:n);function o(t,r,o){if(!(e[t]||(o=navigator.connection)&&((o.effectiveType||"").includes("2g")||o.saveData)))return(r?function(e){return null==self.fetch?n(e):fetch(e,{credentials:"include"})}:i)(t).then(function(){e[t]=!0})}var u=u||function(e){var n=Date.now();return setTimeout(function(){e({didTimeout:!1,timeRemaining:function(){return Math.max(0,50-(Date.now()-n))}})},1)},c=new Set,f=new IntersectionObserver(function(e){e.forEach(function(e){if(e.isIntersecting){var n=e.target.href;c.has(n)&&a(n)}})});function a(e){c.delete(e),o(new URL(e,location.href).toString(),f.priority)}return function(e){e=Object.assign({timeout:2e3,priority:!1,timeoutFn:u,el:document},e),f.priority=e.priority;var n=e.origins||[location.hostname],t=e.ignores||[];e.timeoutFn(function(){e.urls?e.urls.forEach(a):Array.from(e.el.querySelectorAll("a"),function(e){f.observe(e),n.length&&!n.includes(e.hostname)||function e(n,t){return Array.isArray(t)?t.some(function(t){return e(n,t)}):(t.test||t).call(t,n.href,n)}(e,t)||c.add(e.href)})},{timeout:e.timeout})}});
+
+jQuery(document).ready(function($){
+ quicklink({
+ ignores: [
+ /\/profile\//,
+ /\/blogs?\/?/,
+ /\/content\/?/,
+ /\/talk\/?/,
+ /\/settings\/?/,
+ /\/admin\/?/,
+ /\/auth\/?/,
+ /\/people\/?/,
+ /\/tag\/?/,
+ /\/rss\/?/,
+ /\/ajax\/?/
+ ]
+ });
+
+ // Хук начала инициализации javascript-составляющих шаблона
+ ls.hook.run('ls_template_init_start',[],window);
+
+ $('html').removeClass('no-js');
+
+ /**
+ * Иниц-ия модулей ядра
+ */
+ ls.init({
+ production: false
+ });
+
+ ls.dev.init();
+
+ /**
+ * IE
+ */
+ if ( $( 'html' ).hasClass( 'oldie' ) ) {
+ // Эмуляция placeholder'ов в IE
+ $( 'input[type=text], textarea' ).placeholder();
+ }
+
+ /**
+ * Form validate
+ * Валидатор нужно иниц-ть до иниц-ии аякс форм, чтобы избежать валидации аякс-полей после сабмита формы
+ */
+ $('.js-form-validate').parsley();
+
+
+ /**
+ * Userbar
+ */
+ $('.ls-userbar .ls-nav--root > .ls-nav-item--has-children').lsDropdown({
+ selectors: {
+ toggle: '> .ls-nav-item-link',
+ text: '> .ls-nav-item-link > .ls-nav-item-text',
+ menu: '> .ls-nav--sub'
+ }
+ });
+
+
+ /**
+ * Навигация по контенту
+ */
+ $('.ls-nav--root.ls-nav--pills > .ls-nav-item--has-children').lsDropdown({
+ selectors: {
+ toggle: '> .ls-nav-item-link',
+ text: '> .ls-nav-item-link > .ls-nav-item-text',
+ menu: '> .ls-nav--sub'
+ },
+ selectable: true
+ });
+
+
+ /**
+ * Подтверждение удаления
+ */
+ $('.js-confirm-remove-default').livequery(function () {
+ $(this).lsConfirm({
+ message: ls.lang.get('common.remove_confirm')
+ });
+ });
+
+
+ /**
+ * Notification
+ */
+ ls.notification.init();
+
+
+ /**
+ * Actionbar
+ */
+ $('.js-user-list-modal-actionbar').livequery(function () {
+ $( this ).lsActionbarItemSelect({
+ selectors: {
+ target_item: '.js-user-list-select .js-user-list-small-item'
+ }
+ });
+ });
+
+
+ /**
+ * Modals
+ */
+ $('.js-modal-default').lsModal();
+ $('.js-modal-toggle-default').lsModalToggle();
+
+
+ /**
+ * Details
+ */
+ $('.js-details-default').lsDetails();
+
+
+ /**
+ * Dropdowns
+ */
+ $('.js-dropdown-default').livequery(function () {
+ $(this).lsDropdown();
+ });
+
+
+ /**
+ * Fields
+ */
+ $('.js-field-geo-default').lsFieldGeo({
+ urls: {
+ regions: aRouter.ajax + 'geo/get/regions/',
+ cities: aRouter.ajax + 'geo/get/cities/'
+ }
+ });
+
+ $('.js-field-date-default').livequery(function () {
+ $(this).lsDate({
+ language: LANGUAGE
+ });
+ });
+
+ $('.js-field-time-default').livequery(function () {
+ $(this).lsTime();
+ });
+
+ $('[data-type=captcha]').livequery(function () {
+ $(this).lsCaptcha();
+ });
+
+ $('[data-type=recaptcha]').livequery(function () {
+ $(this).lsReCaptcha({
+ key: ls.registry.get('recaptcha.site_key')
+ });
+ });
+
+ /**
+ * Alerts
+ */
+ $('.js-alert').lsAlert();
+
+
+ /**
+ * Tooltips
+ */
+ $('.js-tooltip').lsTooltip();
+
+ $('.js-popover-default').lsTooltip({
+ useAttrTitle: false,
+ trigger: 'click',
+ classes: 'tooltip-light'
+ });
+
+ if (ls.registry.get('block_stream_show_tip')) {
+ $('.js-title-comment, .js-title-topic').livequery(function () {
+ $(this).lsTooltip({
+ position: {
+ my: "right center",
+ at: "left left"
+ },
+ show: {
+ delay: 1500
+ }
+ });
+ });
+ }
+
+
+ /**
+ * Autocomplete
+ */
+ $( '.autocomplete-tags' ).lsAutocomplete({
+ multiple: false,
+ urls: {
+ load: aRouter.ajax + 'autocompleter/tag/'
+ }
+ });
+
+ $( '.autocomplete-tags-sep' ).lsAutocomplete({
+ multiple: true,
+ urls: {
+ load: aRouter.ajax + 'autocompleter/tag/'
+ }
+ });
+
+ $( '.autocomplete-users' ).lsAutocomplete({
+ multiple: false,
+ urls: {
+ load: aRouter.ajax + 'autocompleter/user/'
+ }
+ });
+
+ $( '.autocomplete-users-sep' ).lsAutocomplete({
+ multiple: true,
+ urls: {
+ load: aRouter.ajax + 'autocompleter/user/'
+ }
+ });
+
+ $('.autocomplete-property-tags').each(function(k,v){
+ $(v).lsAutocomplete({
+ multiple: false,
+ urls: {
+ load: aRouter.ajax + 'property/tags/autocompleter/'
+ },
+ params: {
+ property_id: $(v).data('propertyId')
+ }
+ });
+ });
+
+ $('.autocomplete-property-tags-sep').each(function(k,v){
+ $(v).lsAutocomplete({
+ multiple: true,
+ urls: {
+ load: aRouter.ajax + 'property/tags/autocompleter/'
+ },
+ params: {
+ property_id: $(v).data('propertyId')
+ }
+ });
+ });
+
+ /**
+ * Code highlight
+ */
+ $( 'pre code' ).lsHighlighter();
+
+
+ /**
+ * Blocks
+ */
+ $( '.js-block-default' ).lsBlock();
+
+
+ /**
+ * Активность
+ */
+ $('.js-activity--all').lsActivity({ urls: { more: aRouter.stream + 'get_more_all' } });
+ $('.js-activity--user').lsActivity({ urls: { more: aRouter.stream + 'get_more_user' } });
+ $('.js-activity--personal').lsActivity({
+ urls: {
+ more: aRouter.stream + 'get_more_personal'
+ },
+ create: function() {
+ // Настройки активности
+ $('.js-activity-settings').lsActivitySettings({
+ urls: {
+ toggle_type: aRouter.stream + 'switchEventType'
+ }
+ });
+
+ // Добавление пользователей в персональную активность
+ $('.js-activity-users').lsUserListAdd({
+ urls: {
+ add: aRouter.stream + 'ajaxadduser',
+ remove: aRouter.stream + 'ajaxremoveuser',
+ list: aRouter.ajax + 'modal-friend-list'
+ }
+ });
+ }
+ });
+
+
+ /**
+ * Лента
+ */
+ // Блоги
+ $('.js-feed-blogs').lsFeedBlogs({
+ urls: {
+ subscribe: aRouter.feed + 'subscribe',
+ unsubscribe: aRouter.feed + 'unsubscribe'
+ }
+ });
+
+ // Добавление пользователей в свою ленту
+ $('.js-feed-users').lsUserListAdd({
+ urls: {
+ add: aRouter.feed + 'ajaxadduser',
+ remove: aRouter.feed + 'unsubscribe',
+ list: aRouter.ajax + 'modal-friend-list'
+ }
+ });
+
+
+ /**
+ * Auth
+ */
+ ls.auth.init();
+
+ // Поиск
+ $( '.js-search-ajax-users' ).lsSearchAjax({
+ urls: {
+ search: aRouter.people + 'ajax-search/'
+ },
+ i18n: {
+ title: ls.lang.get( 'user.search.result_title' )
+ },
+ selectors: {
+ list: '.js-more-users-container',
+ more: '.js-more-search',
+ title: '@.js-user-list-search-title'
+ },
+ filters : [
+ {
+ type: 'text',
+ name: 'sText',
+ selector: '.js-search-text-main'
+ },
+ {
+ type: 'radio',
+ name: 'sex',
+ selector: '.js-search-ajax-user-sex'
+ },
+ {
+ type: 'checkbox',
+ name: 'is_online',
+ selector: '.js-search-ajax-user-online'
+ },
+ {
+ type: 'sort',
+ name: 'sort_by',
+ selector: '.js-search-sort-menu li'
+ },
+ {
+ type: 'select',
+ name: 'country',
+ selector: '.js-field-geo-country'
+ },
+ {
+ type: 'select',
+ name: 'region',
+ selector: '.js-field-geo-region'
+ },
+ {
+ type: 'select',
+ name: 'city',
+ selector: '.js-field-geo-city'
+ }
+ ],
+ afterupdate: function ( event, data ) {
+ data.context.getElement( 'more' ).lsMore( 'option', 'params.next_page', 2 );
+ }
+ });
+
+ // Добавление пользователя в свою активность
+ $('.js-user-follow').lsUserFollow({
+ urls: {
+ follow: aRouter['stream'] + 'ajaxadduser/',
+ unfollow: aRouter['stream'] + 'ajaxremoveuser/'
+ }
+ });
+
+ // Добавление пользователя в друзья
+ $('.js-user-friend').lsUserFriend({
+ urls: {
+ add: aRouter.profile + 'ajaxfriendadd/',
+ remove: aRouter.profile + 'ajaxfrienddelete/',
+ accept: aRouter.profile + 'ajaxfriendaccept/',
+ modal: aRouter.profile + 'ajax-modal-add-friend'
+ }
+ });
+
+ // Жалоба
+ $('.js-user-report').lsReport({
+ urls: {
+ modal: aRouter.profile + 'ajax-modal-complaint',
+ add: aRouter.profile + 'ajax-complaint-add'
+ }
+ });
+
+ // Управление кастомными полями
+ $( '.js-user-fields' ).lsUserFields();
+
+ // Фото пользователя
+ $( '.js-user-photo' ).lsPhoto({
+ urls: {
+ upload: aRouter.settings + 'ajax-upload-photo',
+ remove: aRouter.settings + 'ajax-remove-photo',
+ crop_photo: aRouter.settings + 'ajax-modal-crop-photo',
+ crop_avatar: aRouter.settings + 'ajax-modal-crop-avatar',
+ save_photo: aRouter.settings + 'ajax-crop-photo',
+ save_avatar: aRouter.settings + 'ajax-change-avatar',
+ cancel_photo: aRouter.settings + 'ajax-crop-cancel-photo'
+ },
+ changeavatar: function ( event, _this, avatars ) {
+ $( '.js-user-profile-avatar, .js-wall-entry[data-user-id=' + _this.option( 'params.target_id' ) + '] .ls-comment-avatar img' ).attr( 'src', avatars[ '64crop' ] + '?' + Math.random() );
+ $( '.nav-item--userbar-username img' ).attr( 'src', avatars[ '24crop' ] + '?' + Math.random() );
+ }
+ });
+
+ /**
+ * Talk
+ */
+
+ $('.js-talk-list').lsTalkList();
+
+ // Выбор получателей в форме добавления
+ $('.js-talk-add-user-choose').lsUserFieldChoose({
+ urls: {
+ modal: aRouter.ajax + 'modal-friend-list'
+ }
+ });
+
+ // Форма поиска
+ $('.js-talk-search-form').lsDetails();
+
+ // Добавление диалога в избранное
+ $('.js-favourite-talk').lsFavourite({
+ urls: {
+ toggle: aRouter['ajax'] + 'favourite/talk/'
+ }
+ });
+
+ // Добавление в избранное на странице диалога
+ $('.js-talk-message-root-favourite').on('click', function (event) {
+ if (event.target === event.currentTarget) {
+ $(this).find('.js-favourite-talk').lsFavourite('toggle');
+ }
+ });
+
+ // Комментарии
+ $('.js-comments-talk').lsComments({
+ urls: {
+ add: aRouter['talk'] + 'ajaxaddcomment/',
+ load: aRouter['talk'] + 'ajaxresponsecomment/'
+ }
+ });
+
+ // Управление участниками личного сообщения
+ $('.js-message-users').lsTalkUsers();
+
+ // Черный список
+ $('.js-user-list-add-blacklist').lsUserListAdd({
+ urls: {
+ add: aRouter['talk'] + 'ajaxaddtoblacklist/',
+ remove: aRouter['talk'] + 'ajaxdeletefromblacklist/',
+ list: aRouter.ajax + 'modal-friend-list'
+ }
+ });
+
+
+ /**
+ * Poll
+ */
+ $('.js-poll').lsPoll();
+ $('.js-poll-manage').lsPollManage({
+ max: ls.registry.get('poll_max_answers')
+ });
+
+
+ /**
+ * User Note
+ */
+ $('.js-user-note').livequery(function () {
+ $(this).lsNote({
+ urls: {
+ save: aRouter['profile'] + 'ajax-note-save/',
+ remove: aRouter['profile'] + 'ajax-note-remove/'
+ }
+ });
+ });
+
+
+ /**
+ * Editor
+ */
+ $( '.js-editor-default' ).lsEditor();
+
+
+ /**
+ * Blog
+ */
+
+ // Форма добавления блога
+ $('.js-blog-add').lsBlogAdd();
+
+ // Приглашение пользователей в блог
+ $('.js-user-list-add-blog-invite').lsBlogInvites();
+
+ // Вступить/покинуть блог (список блогов)
+ $( '.js-blog-join' ).livequery(function() {
+ $( this ).lsBlogJoin({
+ urls: {
+ toggle: aRouter.blog + 'ajaxblogjoin'
+ },
+ classes: {
+ loading: ls.options.classes.states.loading
+ }
+ });
+ });
+
+ // Вступить/покинуть блог (страница блога)
+ $( '.js-blog-profile-join' ).lsBlogJoin({
+ urls: {
+ toggle: aRouter.blog + 'ajaxblogjoin'
+ },
+ selectors: {
+ text: 'a'
+ },
+ classes: {
+ active: 'active'
+ }
+ });
+
+ // Поиск
+ $( '.js-search-ajax-blog' ).lsSearchAjax({
+ urls: {
+ search: aRouter.blogs + 'ajax-search/'
+ },
+ i18n: {
+ title: ls.lang.get( 'blog.search.result_title' )
+ },
+ selectors: {
+ list: '.js-more-blogs-container',
+ more: '.js-more-search',
+ title: '@.js-blog-list-search-title'
+ },
+ filters : [
+ {
+ type: 'text',
+ name: 'sText',
+ selector: '.js-search-text-main'
+ },
+ {
+ type: 'radio',
+ name: 'type',
+ selector: '.js-search-ajax-blog-type'
+ },
+ {
+ type: 'radio',
+ name: 'relation',
+ selector: '.js-search-ajax-blog-relation'
+ },
+ {
+ type: 'list',
+ name: 'category',
+ selector: '#js-search-ajax-blog-category li'
+ },
+ {
+ type: 'sort',
+ name: 'sort_by',
+ selector: '.js-search-sort-menu li'
+ }
+ ],
+ afterupdate: function ( event, data ) {
+ data.context.getElement( 'more' ).lsMore( 'option', 'params.next_page', 2 );
+ }
+ });
+
+ // Аватар блога
+ $( '.js-blog-avatar' ).lsPhoto({
+ urls: {
+ upload: aRouter.blog + 'ajax/upload-avatar',
+ remove: aRouter.blog + 'ajax/remove-avatar',
+ crop_photo: aRouter.blog + 'ajax/modal-crop-avatar',
+ save_photo: aRouter.blog + 'ajax/crop-avatar',
+ cancel_photo: aRouter.blog + 'ajax/crop-cancel-avatar'
+ },
+ use_avatar: false,
+ crop_photo: {
+ minSize: [ 100, 100 ],
+ usePreview: true
+ }
+ });
+
+
+ /**
+ * Topic
+ */
+ $( '.js-topic' ).lsTopic();
+
+ // Форма добавления
+ $( '#topic-add-form' ).lsTopicAdd({
+ max_blog_count: ls.registry.get('topic_max_blog_count')
+ });
+
+ // Пагинация
+ $('.js-pagination-topics').lsPagination({
+ hash: {
+ next: 'goTopic=first',
+ prev: 'goTopic=last'
+ }
+ });
+
+ // Комментарии
+ $('.js-topic-comments, .js-topic-comments-list').lsComments({
+ urls: {
+ add: aRouter['blog'] + 'ajaxaddcomment/',
+ load: aRouter['blog'] + 'ajaxresponsecomment/'
+ },
+ show_form: ls.registry.get('comment_show_form')
+ });
+
+ // Кнопка обновления комментариев
+ // TODO: Fix init
+ $('.js-comments-toolbar').lsCommentsToolbar({
+ comments: $('.js-topic-comments, .js-comments-talk')
+ });
+
+
+ /**
+ * Теги
+ */
+
+ // Облако тегов избранного
+ $('.js-tags-favourite-cloud').lsDetails();
+
+ // Поиск по тегам
+ $('.js-tag-search-form').submit(function() {
+ var val = $(this).find('.js-tag-search').val();
+
+ if ( val ) {
+ window.location = aRouter['tag'] + encodeURIComponent( val ) + '/';
+ }
+
+ return false;
+ });
+
+
+ /**
+ * Стена
+ */
+ $('.js-wall-default').lsWall({
+ urls: {
+ add: aRouter.ajax + 'wall/add/',
+ remove: aRouter.ajax + 'wall/remove/',
+ load: aRouter.ajax + 'wall/load/',
+ load_comments: aRouter.ajax + 'wall/load-comments/'
+ }
+ });
+
+
+ /**
+ * Лайтбокс
+ */
+ $('a.js-lbx').lsLightbox({ width:"100%", height:"100%" });
+
+
+ /**
+ * Toolbar
+ */
+ $('.js-toolbar-default').lsToolbar({
+ target: '.layout-wrapper',
+ offsetX: 10
+ });
+ $('.js-toolbar-scrollup').lsToolbarScrollUp();
+ $('.js-toolbar-topics').lsToolbarTopics();
+
+
+ /**
+ * Fotorama
+ */
+ $( '.fotorama' ).livequery(function() {
+ $( this ).lsSlider();
+ });
+
+ // Хук конца инициализации javascript-составляющих шаблона
+ ls.hook.run('ls_template_init_end',[],window);
+});
+
diff --git a/application/frontend/skin/ifhub/components/.gitkeep b/application/frontend/skin/ifhub/components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/application/frontend/skin/ifhub/components/activity/blocks/block.activity-recent.tpl b/application/frontend/skin/ifhub/components/activity/blocks/block.activity-recent.tpl
new file mode 100644
index 0000000..22e9bf7
--- /dev/null
+++ b/application/frontend/skin/ifhub/components/activity/blocks/block.activity-recent.tpl
@@ -0,0 +1,26 @@
+{**
+ * Последняя активность
+ *}
+
+{component_define_params params=[ 'content' ]}
+
+{* Подвал *}
+{capture 'block_footer'}
+ RSS главной страницы
+ RSS всех топиков
+ RSS комментариев
+{/capture}
+
+{component 'block'
+ mods = 'primary activity-recent'
+ classes = 'js-block-default'
+ title = {lang 'activity.block_recent.title'}
+ titleUrl = {router 'stream'}
+ footer = $smarty.capture.block_footer
+ tabs = [
+ 'classes' => 'js-tabs-block js-activity-block-recent-tabs',
+ 'tabs' => [
+ [ 'text' => 'Новые топики', 'url' => "{router page='ajax'}stream/topic", 'list' => $content ],
+ [ 'text' => 'Свежие комментарии', 'url' => "{router page='ajax'}stream/comment" ]
+ ]
+ ]}
diff --git a/application/frontend/skin/ifhub/components/activity/blocks/recent-comment.tpl b/application/frontend/skin/ifhub/components/activity/blocks/recent-comment.tpl
new file mode 100644
index 0000000..11bf3f9
--- /dev/null
+++ b/application/frontend/skin/ifhub/components/activity/blocks/recent-comment.tpl
@@ -0,0 +1,29 @@
+{component_define_params params=[ 'user', 'topic', 'date', 'commentN' ]}
+
+{capture 'item_content'}
+
+ {date_format date=$date hours_back="12" minutes_back="60" now="60" day="day H:i" format="j.m.Y"}
+
+
+
+
+
+
+
+{/capture}
+
+{component 'item'
+ element = 'li'
+ desc = $smarty.capture.item_content
+}
diff --git a/application/frontend/skin/ifhub/components/activity/blocks/recent-comments.tpl b/application/frontend/skin/ifhub/components/activity/blocks/recent-comments.tpl
new file mode 100644
index 0000000..c19a9e2
--- /dev/null
+++ b/application/frontend/skin/ifhub/components/activity/blocks/recent-comments.tpl
@@ -0,0 +1,22 @@
+{**
+ * Последняя активность
+ * Топики отсортированные по времени последнего комментария
+ *}
+
+{component_define_params params=[ 'comments' ]}
+
+{capture 'items'}
+ {foreach $comments as $comment}
+ {$topic = $comment->getTarget()}
+
+ {component 'activity' template='blocks/recent-comment'
+ user = $comment->getUser()
+ topic = $topic
+ commentN = $comment->getID()
+ date = $comment->getDate()}
+ {foreachelse}
+ {component 'blankslate' text={lang 'common.empty'} mods='no-background'}
+ {/foreach}
+{/capture}
+
+{component 'item' template='group' items=$smarty.capture.items}
diff --git a/application/frontend/skin/ifhub/components/activity/blocks/recent-topic.tpl b/application/frontend/skin/ifhub/components/activity/blocks/recent-topic.tpl
new file mode 100644
index 0000000..22cb639
--- /dev/null
+++ b/application/frontend/skin/ifhub/components/activity/blocks/recent-topic.tpl
@@ -0,0 +1,28 @@
+{component_define_params params=[ 'user', 'topic', 'date', 'commentN' ]}
+
+{capture 'item_content'}
+
+ {date_format date=$date hours_back="12" minutes_back="60" now="60" day="day H:i" format="j.m.Y"}
+
+
+
+
+
+
+
+{/capture}
+
+{component 'item'
+ element = 'li'
+ desc = $smarty.capture.item_content
+}
diff --git a/application/frontend/skin/ifhub/components/activity/blocks/recent-topics.tpl b/application/frontend/skin/ifhub/components/activity/blocks/recent-topics.tpl
new file mode 100644
index 0000000..f3d40d4
--- /dev/null
+++ b/application/frontend/skin/ifhub/components/activity/blocks/recent-topics.tpl
@@ -0,0 +1,19 @@
+{**
+ * Последняя активность
+ * Последние топики
+ *}
+
+{component_define_params params=[ 'topics' ]}
+
+{capture 'items'}
+ {foreach $topics as $topic}
+ {component 'activity' template='blocks/recent-topic'
+ user = $topic->getUser()
+ topic = $topic
+ date = $topic->getDatePublish()}
+ {foreachelse}
+ {component 'blankslate' text={lang 'common.empty'} mods='no-background'}
+ {/foreach}
+{/capture}
+
+{component 'item' template='group' items=$smarty.capture.items}
diff --git a/application/frontend/skin/ifhub/components/activity/event.tpl b/application/frontend/skin/ifhub/components/activity/event.tpl
new file mode 100644
index 0000000..9c9db22
--- /dev/null
+++ b/application/frontend/skin/ifhub/components/activity/event.tpl
@@ -0,0 +1,91 @@
+{**
+ * Событие
+ *
+ * @param object $event
+ *}
+
+{$component = 'activity-event'}
+{component_define_params params=[ 'event' ]}
+
+{$type = $event->getEventType()}
+{$target = $event->getTarget()}
+{$user = $event->getUser()}
+{$gender = ( $user->getProfileSex() == 'woman' ) ? 'female' : 'male'}
+
+{**
+ * Вывод текста
+ *
+ * @param $text Текст
+ *}
+{function activity_event_text text=''}
+ {if trim($text)}
+ {$text}
+ {/if}
+{/function}
+
+
+{* Событие *}
+{capture 'event_content'}
+ {* Дата *}
+
+ {date_format date=$event->getDateAdded() hours_back="12" minutes_back="60" now="60" day="day H:i" format="j F Y, H:i"}
+
+
+ {* Логин *}
+ {$user->getDisplayName()}
+
+ {* Текст события *}
+ {if $type == 'add_topic'}
+ {* Добавлен топик *}
+ {lang "activity.events.{$type}_{$gender}" topic="getUrl()}\">{$target->getTitle()|escape} "}
+ {elseif $type == 'add_comment'}
+ {* Добавлен комментарий *}
+ {lang "activity.events.{$type}_{$gender}" topic="getTarget()->getUrl()}#comment{$target->getId()}\">{$target->getTarget()->getTitle()|escape} "}
+ {elseif $type == 'add_blog'}
+ {* Создан блог *}
+ {lang "activity.events.{$type}_{$gender}" blog="getUrlFull()}\">{$target->getTitle()|escape} "}
+ {elseif $type == 'vote_blog'}
+ {* Проголосовали за блог *}
+ {lang "activity.events.{$type}_{$gender}" blog="getUrlFull()}\">{$target->getTitle()|escape} "}
+ {elseif $type == 'vote_topic'}
+ {* Проголосовали за топик *}
+ {lang "activity.events.{$type}_{$gender}" topic="getUrl()}\">{$target->getTitle()|escape} "}
+ {elseif $type == 'vote_comment_topic'}
+ {* Проголосовали за комментарий *}
+ {lang "activity.events.{$type}_{$gender}" topic="getTarget()->getUrl()}#comment{$target->getId()}\">{$target->getTarget()->getTitle()|escape} "}
+ {elseif $type == 'vote_user'}
+ {* Проголосовали за пользователя *}
+ {lang "activity.events.{$type}_{$gender}" user="getUserWebPath()}\">{$target->getDisplayName()} "}
+ {elseif $type == 'join_blog'}
+ {* Вступили в блог *}
+ {lang "activity.events.{$type}_{$gender}" blog="getUrlFull()}\">{$target->getTitle()|escape} "}
+ {elseif $type == 'add_friend'}
+ {* Добавили в друзья *}
+ {lang "activity.events.{$type}_{$gender}" user="getUserWebPath()}\">{$target->getDisplayName()} "}
+ {elseif $type == 'add_wall'}
+ {* Написали на стене *}
+ {if $target->getWallUser()->getId() == $user->getId()}
+ {lang "activity.events.{$type}_self_{$gender}" url=$target->getUrlWall()}
+ {else}
+ {lang "activity.events.{$type}_{$gender}" url=$target->getUrlWall() user=$target->getWallUser()->getDisplayName()}
+ {/if}
+
+ {activity_event_text text=$target->getText()}
+ {else}
+ {hook run="activity_event_`$type`" event=$event}
+ {/if}
+{/capture}
+
+{component 'item'
+ element='li'
+ classes="{$component} {cmods name=$component mods=$type} js-activity-event"
+ mods='image-rounded'
+ desc=$smarty.capture.event_content
+ image=[
+ 'url' => $user->getUserWebPath(),
+ 'path' => $user->getProfileAvatarPath(48),
+ 'alt' => $user->getDisplayName()
+ ]}
diff --git a/application/frontend/skin/ifhub/components/editor/editor.markup.help.tpl b/application/frontend/skin/ifhub/components/editor/editor.markup.help.tpl
new file mode 100644
index 0000000..49db595
--- /dev/null
+++ b/application/frontend/skin/ifhub/components/editor/editor.markup.help.tpl
@@ -0,0 +1,92 @@
+{**
+ * Справка по разметке редактора
+ *}
+
+{$component = 'editor-help'}
+{component_define_params params=[ 'targetId' ]}
+
+{function editor_help_item}
+{strip}
+ {foreach $items as $item}
+
+ {foreach $item['tags'] as $tag}
+
+
+ {$tag['text']}
+
+
+ {/foreach}
+
+ {$item['def']}
+
+ {/foreach}
+{/strip}
+{/function}
+
+
+
+
+
+
+
{$aLang.editor.markup.help.special}
+
+
+ {editor_help_item items=[
+ [ 'tags' => [ [ 'text' => '<cut>' ] ], 'def' => $aLang.editor.markup.help.special_cut ],
+ [ 'tags' => [ [ 'text' => "<cut name=\"{$aLang.editor.markup.help.special_cut_name_example_name}\">" ] ], 'def' => $aLang.editor.markup.help.special_cut_name ],
+ [ 'tags' => [ [ 'text' => "<video>http://...</video>", 'insert' => '<video></video>' ] ], 'def' => $aLang.editor.markup.help.special_video ],
+ [ 'tags' => [ [ 'text' => "<ls user=\"{$aLang.editor.markup.help.special_ls_user_example_user}\" />", 'insert' => '<ls user="" />' ] ], 'def' => $aLang.editor.markup.help.special_ls_user ],
+ [ 'tags' => [ [ 'text' => "<incut></incut>", 'insert' => '<incut></incut>' ] ], 'def' => 'Вставка блока-врезки посередине текста'],
+ [ 'tags' => [ [ 'text' => "<aside></aside>", 'insert' => '<aside></aside>' ] ], 'def' => 'Вставка блока-врезки справа от текста' ],
+ [ 'tags' => [ [ 'text' => "<spoiler title=\"Заголовок\">Скрытый текст</spoiler>", 'insert' => '<spoiler title=""></spoiler>' ] ], 'def' => 'Спойлер с особым заголовком' ]
+ ]}
+
+
+
{$aLang.editor.markup.help.standart}
+
+
+
+ {editor_help_item items=[
+ [ 'tags' => [
+ [ 'text' => '<h4></h4>' ],
+ [ 'text' => '<h5></h5>' ],
+ [ 'text' => '<h6></h6>' ]
+ ], 'def' => $aLang.editor.markup.help.standart_h ],
+ [ 'tags' => [ [ 'text' => "<img src=\"\" />" ] ], 'def' => $aLang.editor.markup.help.standart_img ],
+ [ 'tags' => [
+ [ 'text' => "<a href=\"http://...\">{$aLang.editor.markup.help.standart_a_example_href}</a>", 'insert' => '<a href=""></a>"' ]
+ ], 'def' => $aLang.editor.markup.help.standart_a ],
+ [ 'tags' => [ [ 'text' => "<b></b>" ] ], 'def' => $aLang.editor.markup.help.standart_b ],
+ [ 'tags' => [ [ 'text' => "<i></i>" ] ], 'def' => $aLang.editor.markup.help.standart_i ],
+ [ 'tags' => [ [ 'text' => "<s></s>" ] ], 'def' => $aLang.editor.markup.help.standart_s ],
+ [ 'tags' => [ [ 'text' => "<u></u>" ] ], 'def' => $aLang.editor.markup.help.standart_u ],
+ [ 'tags' => [ [ 'text' => "<abbr title=\"Расшифровка\">Сокращение</abbr>", 'insert' => '<abbr></abbr>' ] ], 'def' => 'Расшифровка сокращений' ]
+ ]}
+
+
+
+ {editor_help_item items=[
+ [ 'tags' => [ [ 'text' => "<hr />" ] ], 'def' => $aLang.editor.markup.help.standart_hr ],
+ [ 'tags' => [ [ 'text' => "<blockquote></blockquote>" ] ], 'def' => $aLang.editor.markup.help.standart_blockquote ],
+ [ 'tags' => [
+ [ 'text' => '<table></table>' ],
+ [ 'text' => '<th></th>' ],
+ [ 'text' => '<td></td>' ],
+ [ 'text' => '<tr></tr>' ]
+ ], 'def' => $aLang.editor.markup.help.standart_table ],
+ [ 'tags' => [
+ [ 'text' => '<ul></ul>' ],
+ [ 'text' => '<li></li>' ]
+ ], 'def' => $aLang.editor.markup.help.standart_ul ],
+ [ 'tags' => [
+ [ 'text' => '<ol></ol>' ],
+ [ 'text' => '<li></li>' ]
+ ], 'def' => $aLang.editor.markup.help.standart_ol ],
+ [ 'tags' => [ [ 'text' => "<iframe allowfullscreen=\"true\"></iframe>", 'insert' => '<iframe></iframe>' ] ], 'def' => 'Вставка игр (только с разрешённых сайтов: Itch.io, GameJolt.net, Philome.la)' ]
+ ]}
+
+
+
+
diff --git a/application/frontend/skin/ifhub/components/email/email.tpl b/application/frontend/skin/ifhub/components/email/email.tpl
new file mode 100644
index 0000000..aeb004b
--- /dev/null
+++ b/application/frontend/skin/ifhub/components/email/email.tpl
@@ -0,0 +1,111 @@
+{**
+ * Базовый шаблона e-mail'а
+ *}
+
+{$backgroundColor = 'F4F4F4'} {* Цвет фона *}
+
+{$containerBorderColor = 'D0D6E8'} {* Цвет границ основного контейнера *}
+
+{$headerBackgroundColor = '222222'} {* Цвет фона шапки *}
+{$headerTitleColor = 'FFFFFF'} {* Цвет заголовка в шапке *}
+{$headerDescriptionColor = 'dddddd'} {* Цвет описания в шапке *}
+
+{$contentBackgroundColor = 'FFFFFF'} {* Цвет фона содержимого письма *}
+{$contentTitleColor = '000000'} {* Цвет заголовка *}
+{$contentTextColor = '4f4f4f'} {* Цвет текста *}
+
+{$footerBackgroundColor = 'fafafa'} {* Цвет фона футера *}
+{$footerTextColor = '949fa3'} {* Цвет текста в футере *}
+{$footerLinkColor = '949fa3'} {* Цвет ссылки в футере *}
+
+{* Путь до папки с изображенями *}
+{$imagesDir = "{$LS->Component_GetWebPath('email')}/images"}
+
+{component_define_params params=[ 'title', 'content' ]}
+
+{* Фон *}
+
+
+
+
+
+
+ {* Основной контейнер *}
+
+ {* Шапка *}
+
+
+
+
+
+
+ {* Контент *}
+
+
+
+
+
+
+ {* Заголовок *}
+ {if $sTitle}
+
+
+ {$title}
+
+
+
+ {/if}
+
+ {* Текст *}
+
+
+ {block 'content'}{/block}
+ {$content}
+
+
+
+
+
+
+
+ {* Подвал *}
+
+
+
+
+
+
+
+
+
diff --git a/application/frontend/skin/ifhub/components/search/search-form.main.tpl b/application/frontend/skin/ifhub/components/search/search-form.main.tpl
new file mode 100644
index 0000000..1632cc4
--- /dev/null
+++ b/application/frontend/skin/ifhub/components/search/search-form.main.tpl
@@ -0,0 +1,9 @@
+{**
+ * Форма основного поиска (по топикам и комментариям)
+ *}
+
+{component_define_params params=[ 'searchType', 'mods', 'classes', 'attributes' ]}
+
+
+{component 'search-form' name='main' action="{router page='search'}{$searchType|default:'topics'}" params=$params}
+
diff --git a/application/frontend/skin/ifhub/emails/email.blog_invite_new.tpl b/application/frontend/skin/ifhub/emails/email.blog_invite_new.tpl
new file mode 100644
index 0000000..28f14e5
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.blog_invite_new.tpl
@@ -0,0 +1,15 @@
+{**
+ * Приглашение в закрытый блог
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.blog_invite_new.text' params=[
+ 'user_url' => $oUserFrom->getUserWebPath(),
+ 'user_name' => $oUserFrom->getDisplayName(),
+ 'blog_url' => $oBlog->getUrlFull(),
+ 'blog_name' => $oBlog->getTitle()|escape,
+ 'invite_url' => $sPath
+ ]}
+{/block}
diff --git a/application/frontend/skin/ifhub/emails/email.comment_new.tpl b/application/frontend/skin/ifhub/emails/email.comment_new.tpl
new file mode 100644
index 0000000..8fdb1a4
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.comment_new.tpl
@@ -0,0 +1,16 @@
+{**
+ * Оповещение о новом комментарии в топике
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.comment_new.text' params=[
+ 'user_url' => $oUserComment->getUserWebPath(),
+ 'user_name' => $oUserComment->getDisplayName(),
+ 'topic_name' => $oTopic->getTitle()|escape,
+ 'comment_url' => "{if Config::Get('module.comment.nested_per_page')}{router page='comments'}{else}{$oTopic->getUrl()}#comment{/if}{$oComment->getId()}",
+ 'comment_text' => "{if Config::Get('sys.mail.include_comment')}{lang name='emails.common.comment_text'}:{$oComment->getText()} {/if}",
+ 'unsubscribe' => "{if $sSubscribeKey} {lang name='emails.comment_new.unsubscribe' unsubscribe_url="{router page='subscribe'}unsubscribe/{$sSubscribeKey}/"}{/if}"
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.comment_reply.tpl b/application/frontend/skin/ifhub/emails/email.comment_reply.tpl
new file mode 100644
index 0000000..69d8e4c
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.comment_reply.tpl
@@ -0,0 +1,16 @@
+{**
+ * Оповещение об ответе на комментарий
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.comment_reply.text' params=[
+ 'user_url' => $oUserComment->getUserWebPath(),
+ 'user_name' => $oUserComment->getDisplayName(),
+ 'topic_name' => $oTopic->getTitle()|escape,
+ 'comment_url' => "{if Config::Get('module.comment.nested_per_page')}{router page='comments'}{else}{$oTopic->getUrl()}#comment{/if}{$oComment->getId()}",
+ 'comment_text' => "{if Config::Get('sys.mail.include_comment')}{lang name='emails.common.comment_text'}:{$oComment->getText()} {/if}"
+ ]}
+{/block}
+
diff --git a/application/frontend/skin/ifhub/emails/email.invite.tpl b/application/frontend/skin/ifhub/emails/email.invite.tpl
new file mode 100644
index 0000000..9d49076
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.invite.tpl
@@ -0,0 +1,17 @@
+{**
+ * Приглашение на сайт
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.invite.text' params=[
+ 'user_url' => $oUserFrom->getUserWebPath(),
+ 'user_name' => $oUserFrom->getDisplayName(),
+ 'website_url' => Router::GetPath('/'),
+ 'website_name' => Config::Get('view.name'),
+ 'invite_code' => $sRefCode,
+ 'ref_link' => $sRefLink,
+ 'login_url' => {router page='auth/login'}
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.reactivation.tpl b/application/frontend/skin/ifhub/emails/email.reactivation.tpl
new file mode 100644
index 0000000..9009987
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.reactivation.tpl
@@ -0,0 +1,13 @@
+{**
+ * Повторная активация
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.reactivation.text' params=[
+ 'website_url' => Router::GetPath('/'),
+ 'website_name' => Config::Get('view.name'),
+ 'activation_url' => "{router page='auth'}activate/{$oUser->getActivateKey()}/"
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.registration.tpl b/application/frontend/skin/ifhub/emails/email.registration.tpl
new file mode 100644
index 0000000..b81af91
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.registration.tpl
@@ -0,0 +1,14 @@
+{**
+ * Регистрация
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.registration.text' params=[
+ 'website_url' => Router::GetPath('/'),
+ 'website_name' => Config::Get('view.name'),
+ 'user_name' => $oUser->getLogin(),
+ 'user_password' => $sPassword
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.registration_activate.tpl b/application/frontend/skin/ifhub/emails/email.registration_activate.tpl
new file mode 100644
index 0000000..484bc5e
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.registration_activate.tpl
@@ -0,0 +1,15 @@
+{**
+ * Подтверждение регистрации
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.registration_activate.text' params=[
+ 'website_url' => Router::GetPath('/'),
+ 'website_name' => Config::Get('view.name'),
+ 'user_name' => $oUser->getLogin(),
+ 'user_password' => $sPassword,
+ 'activation_url' => "{router page='auth'}activate/{$oUser->getActivateKey()}/"
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.reminder_code.tpl b/application/frontend/skin/ifhub/emails/email.reminder_code.tpl
new file mode 100644
index 0000000..89e1a80
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.reminder_code.tpl
@@ -0,0 +1,13 @@
+{**
+ * Смена пароля
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.reminder_code.text' params=[
+ 'website_url' => Router::GetPath('/'),
+ 'website_name' => Config::Get('view.name'),
+ 'recover_url' => "{router page='auth'}password-reset/{$oReminder->getCode()}/"
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.reminder_password.tpl b/application/frontend/skin/ifhub/emails/email.reminder_password.tpl
new file mode 100644
index 0000000..af840a5
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.reminder_password.tpl
@@ -0,0 +1,11 @@
+{**
+ * Новый пароль
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.reminder_password.text' params=[
+ 'password' => $sNewPassword
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.talk_comment_new.tpl b/application/frontend/skin/ifhub/emails/email.talk_comment_new.tpl
new file mode 100644
index 0000000..a7f143f
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.talk_comment_new.tpl
@@ -0,0 +1,15 @@
+{**
+ * Оповещение о новом сообщении в диалоге
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.talk_comment_new.text' params=[
+ 'user_url' => $oUserFrom->getUserWebPath(),
+ 'user_name' => $oUserFrom->getDisplayName(),
+ 'talk_name' => $oTalk->getTitle()|escape,
+ 'message_url' => "{router page='talk'}read/{$oTalk->getId()}/#comment{$oTalkComment->getId()}",
+ 'message_text' => "{if Config::Get('sys.mail.include_comment')}{lang name='emails.common.comment_text'}:{$oTalkComment->getText()} {/if}"
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.talk_new.tpl b/application/frontend/skin/ifhub/emails/email.talk_new.tpl
new file mode 100644
index 0000000..647b969
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.talk_new.tpl
@@ -0,0 +1,15 @@
+{**
+ * Оповещение о новом сообщении
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.talk_new.text' params=[
+ 'user_url' => $oUserFrom->getUserWebPath(),
+ 'user_name' => $oUserFrom->getDisplayName(),
+ 'talk_name' => $oTalk->getTitle()|escape,
+ 'talk_url' => "{router page='talk'}read/{$oTalk->getId()}/",
+ 'talk_text' => "{if Config::Get('sys.mail.include_talk')}{lang name='emails.common.comment_text'}:{$oTalk->getText()} {/if}"
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.topic_new.tpl b/application/frontend/skin/ifhub/emails/email.topic_new.tpl
new file mode 100644
index 0000000..1832b57
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.topic_new.tpl
@@ -0,0 +1,15 @@
+{**
+ * Оповещение о новом топике
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.topic_new.text' params=[
+ 'user_url' => $oUserTopic->getUserWebPath(),
+ 'user_name' => $oUserTopic->getDisplayName(),
+ 'blog_name' => $oBlog->getTitle()|escape,
+ 'topic_url' => $oTopic->getUrl(),
+ 'topic_name' => $oTopic->getTitle()|escape
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.user_changemail_from.tpl b/application/frontend/skin/ifhub/emails/email.user_changemail_from.tpl
new file mode 100644
index 0000000..fbc4768
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.user_changemail_from.tpl
@@ -0,0 +1,15 @@
+{**
+ * Смена почты
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.user_changemail.text' params=[
+ 'user_url' => $oUser->getUserWebPath(),
+ 'user_name' => $oUser->getDisplayName(),
+ 'mail_old' => $oChangemail->getMailFrom(),
+ 'mail_new' => $oChangemail->getMailTo(),
+ 'change_url' => "{router page='profile'}changemail/confirm-from/{$oChangemail->getCodeFrom()}/"
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.user_changemail_to.tpl b/application/frontend/skin/ifhub/emails/email.user_changemail_to.tpl
new file mode 100644
index 0000000..74ced61
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.user_changemail_to.tpl
@@ -0,0 +1,15 @@
+{**
+ * Смена почты
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.user_changemail.text' params=[
+ 'user_url' => $oUser->getUserWebPath(),
+ 'user_name' => $oUser->getDisplayName(),
+ 'mail_old' => $oChangemail->getMailFrom(),
+ 'mail_new' => $oChangemail->getMailTo(),
+ 'change_url' => "{router page='profile'}changemail/confirm-to/{$oChangemail->getCodeTo()}/"
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.user_complaint.tpl b/application/frontend/skin/ifhub/emails/email.user_complaint.tpl
new file mode 100644
index 0000000..f5d03e0
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.user_complaint.tpl
@@ -0,0 +1,16 @@
+{**
+ * Жалоба
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.user_complaint.text' params=[
+ 'user_url' => $oUserFrom->getUserWebPath(),
+ 'user_name' => $oUserFrom->getDisplayName(),
+ 'user_target_url' => $oUserTarget->getUserWebPath(),
+ 'user_target_name' => $oUserTarget->getDisplayName(),
+ 'complaint_title' => $oComplaint->getTypeTitle(),
+ 'complaint_text' => "{if $oComplaint->getText()}{lang name='emails.user_changemail.more'}: {$oComplaint->getText()}{/if}"
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.user_friend_new.tpl b/application/frontend/skin/ifhub/emails/email.user_friend_new.tpl
new file mode 100644
index 0000000..f1d2e99
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.user_friend_new.tpl
@@ -0,0 +1,14 @@
+{**
+ * Заявка в друзья
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.user_friend_new.text' params=[
+ 'user_url' => $oUserFrom->getUserWebPath(),
+ 'user_name' => $oUserFrom->getDisplayName(),
+ 'text' => $sText,
+ 'url' => $sPath
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.wall.new.tpl b/application/frontend/skin/ifhub/emails/email.wall.new.tpl
new file mode 100644
index 0000000..186d00c
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.wall.new.tpl
@@ -0,0 +1,14 @@
+{**
+ * Новое сообщение на стене
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.wall_new.text' params=[
+ 'user_url' => $oUser->getUserWebPath(),
+ 'user_name' => $oUser->getDisplayName(),
+ 'wall_url' => "{$oUserWall->getUserWebPath()}wall/",
+ 'message_text' => $oWall->getText()
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/emails/email.wall.reply.tpl b/application/frontend/skin/ifhub/emails/email.wall.reply.tpl
new file mode 100644
index 0000000..eecea25
--- /dev/null
+++ b/application/frontend/skin/ifhub/emails/email.wall.reply.tpl
@@ -0,0 +1,15 @@
+{**
+ * Ответ на сообщение на стене
+ *}
+
+{extends 'component@email.email'}
+
+{block 'content'}
+ {lang name='emails.wall_reply.text' params=[
+ 'user_url' => $oUser->getUserWebPath(),
+ 'user_name' => $oUser->getDisplayName(),
+ 'wall_url' => "{$oUserWall->getUserWebPath()}wall/",
+ 'message_parent_text' => $oWallParent->getText(),
+ 'message_text' => $oWall->getText()
+ ]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/layouts/layout.activity.tpl b/application/frontend/skin/ifhub/layouts/layout.activity.tpl
new file mode 100644
index 0000000..86d004c
--- /dev/null
+++ b/application/frontend/skin/ifhub/layouts/layout.activity.tpl
@@ -0,0 +1,20 @@
+{**
+ * Активность
+ *}
+
+{extends './layout.base.tpl'}
+
+{block 'layout_options' append}
+ {$layoutNav = [[
+ hook => 'activity',
+ activeItem => $sMenuItemSelect,
+ items => [
+ [ 'name' => 'user', 'url' => "{router page='stream'}personal/", 'text' => $aLang.activity.nav.personal, 'is_enabled' => !! $oUserCurrent ],
+ [ 'name' => 'all', 'url' => "{router page='stream'}all/", 'text' => $aLang.activity.nav.all ]
+ ]
+ ]]}
+{/block}
+
+{block 'layout_page_title'}
+ {$aLang.activity.title}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/layouts/layout.admin.tpl b/application/frontend/skin/ifhub/layouts/layout.admin.tpl
new file mode 100644
index 0000000..e713e1c
--- /dev/null
+++ b/application/frontend/skin/ifhub/layouts/layout.admin.tpl
@@ -0,0 +1,11 @@
+{**
+ * Базовый шаблон админки
+ *}
+
+{extends './layout.base.tpl'}
+
+{block 'layout_page_title'}
+ {lang 'admin.title'}
+ »
+ {block 'layout_admin_page_title'}{/block}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/layouts/layout.base.tpl b/application/frontend/skin/ifhub/layouts/layout.base.tpl
new file mode 100644
index 0000000..a8d3fa3
--- /dev/null
+++ b/application/frontend/skin/ifhub/layouts/layout.base.tpl
@@ -0,0 +1,277 @@
+{**
+ * Основной лэйаут, который наследуют все остальные лэйауты
+ *
+ * @param boolean $layoutShowSidebar Показывать сайдбар или нет, сайдбар не будет выводится если он не содержит блоков
+ * @param string $layoutNavContent Название навигации
+ * @param string $layoutNavContentPath Кастомный путь до навигации контента
+ * @param string $layoutShowSystemMessages Показывать системные уведомления или нет
+ *}
+
+{extends 'component@layout.layout'}
+
+{block 'layout_options' append}
+ {$layoutShowSidebar = $layoutShowSidebar|default:true}
+ {$layoutShowSystemMessages = $layoutShowSystemMessages|default:true}
+{/block}
+
+{block 'layout_head_styles' append}
+
+
+
+
+
+
+{/block}
+
+{block 'layout_head' append}
+ {* Получаем блоки для вывода в сайдбаре *}
+ {if $layoutShowSidebar}
+ {show_blocks group='right' assign=layoutSidebarBlocks}
+
+ {$layoutSidebarBlocks = trim( $layoutSidebarBlocks )}
+ {$layoutShowSidebar = !!$layoutSidebarBlocks}
+ {/if}
+
+ {**
+ * Тип сетки сайта
+ *}
+ {if {Config::Get('view.grid.type')} == 'fluid'}
+
+ {else}
+
+ {/if}
+{/block}
+
+{block 'layout_body'}
+ {hook run='layout_body_begin'}
+ {**
+ * Основная навигация
+ *}
+
+
+
+
+
+
+
+ {if $oUserCurrent}
+ {$createMenu = []}
+
+ {foreach $LS->Topic_GetTopicTypes() as $type}
+ {$createMenu[] = [ 'name' => $type->getCode(), 'text' => $type->getName(), 'url' => $type->getUrlForAdd() ]}
+ {/foreach}
+
+ {$createMenu[] = [ 'name' => 'talk', 'text' => {lang 'modal_create.items.talk'}, 'url' => {router page='talk'} ]}
+ {$createMenu[] = [ 'name' => 'drafts', 'text' => {lang 'topic.drafts'}, 'url' => "{router page='content'}drafts/", count => $iUserCurrentCountTopicDraft ]}
+
+ {$items = [
+ [
+ 'text' => " getProfileAvatarPath(24)}\" alt=\"{$oUserCurrent->getDisplayName()}\" class=\"avatar\" /> {$oUserCurrent->getDisplayName()}",
+ 'url' => "{$oUserCurrent->getUserWebPath()}",
+ 'classes' => 'ls-nav-item--userbar-username',
+ 'menu' => [
+ 'items' => [
+ [ 'name' => 'whois', 'text' => {lang name='user.profile.nav.info'}, 'url' => "{$oUserCurrent->getUserWebPath()}" ],
+ [ 'name' => 'wall', 'text' => {lang name='user.profile.nav.wall'}, 'url' => "{$oUserCurrent->getUserWebPath()}wall/", 'count' => $iUserCurrentCountWall ],
+ [ 'name' => 'created', 'text' => {lang name='user.profile.nav.publications'}, 'url' => "{$oUserCurrent->getUserWebPath()}created/topics/", 'count' => $iUserCurrentCountCreated ],
+ [ 'name' => 'favourites', 'text' => {lang name='user.profile.nav.favourite'}, 'url' => "{$oUserCurrent->getUserWebPath()}favourites/topics/", 'count' => $iUserCurrentCountFavourite ],
+ [ 'name' => 'friends', 'text' => {lang name='user.profile.nav.friends'}, 'url' => "{$oUserCurrent->getUserWebPath()}friends/", 'count' => $iUserCurrentCountFriends ],
+ [ 'name' => 'activity', 'text' => {lang name='user.profile.nav.activity'}, 'url' => "{$oUserCurrent->getUserWebPath()}stream/" ],
+ [ 'name' => 'talk', 'text' => {lang name='user.profile.nav.messages'}, 'url' => "{router page='talk'}", 'count' => $iUserCurrentCountTalkNew ],
+ [ 'name' => 'settings', 'text' => {lang name='user.profile.nav.settings'}, 'url' => "{router page='settings'}" ],
+ [ 'name' => 'admin', 'text' => {lang name='admin.title'}, 'url' => "{router page='admin'}", 'is_enabled' => $oUserCurrent && $oUserCurrent->isAdministrator() ]
+ ]
+ ]
+ ],
+ [ 'text' => $aLang.common.create, menu => [ hook => 'create', items => $createMenu ] ],
+ [ 'text' => $aLang.talk.title, 'url' => "{router page='talk'}", 'title' => $aLang.talk.new_messages, 'is_enabled' => $iUserCurrentCountTalkNew, 'count' => $iUserCurrentCountTalkNew ],
+ [ 'text' => $aLang.auth.logout, 'url' => "{router page='auth'}logout/?security_ls_key={$LIVESTREET_SECURITY_KEY}" ]
+ ]}
+ {else}
+ {$items = [
+ [ 'text' => $aLang.auth.login.title, 'classes' => 'js-modal-toggle-login', 'url' => {router page='auth/login'} ],
+ [ 'text' => $aLang.auth.registration.title, 'classes' => 'js-modal-toggle-registration', 'url' => {router page='auth/register'} ]
+ ]}
+ {/if}
+
+ {component 'nav' hook='userbar_nav' hookParams=[ user => $oUserCurrent ] activeItem=$sMenuHeadItemSelect mods='userbar' items=$items}
+
+
+ {include 'navs/nav.main.tpl'}
+
+ {component 'search' template='main' mods='light'}
+
+ {if $oUserCurrent}
+ {component 'modal-create'}
+ {/if}
+
+ {**
+ * Основной контэйнер
+ *}
+ {* /container *}
+
+
+ {* Подключение модальных окон *}
+ {if $oUserCurrent}
+ {component 'tags-personal' template='modal'}
+ {else}
+ {component 'auth' template='modal'}
+ {/if}
+
+
+ {**
+ * Тулбар
+ * Добавление кнопок в тулбар
+ *}
+ {add_block group='toolbar' name='component@admin.toolbar.admin' priority=100}
+ {add_block group='toolbar' name='component@toolbar-scrollup.toolbar.scrollup' priority=-100}
+
+ {* Подключение тулбара *}
+ {component 'toolbar' classes='js-toolbar-default' items={show_blocks group='toolbar'}}
+
+
+
+
+{hook run='layout_body_end'}
+{/block}
diff --git a/application/frontend/skin/ifhub/layouts/layout.blog.edit.tpl b/application/frontend/skin/ifhub/layouts/layout.blog.edit.tpl
new file mode 100644
index 0000000..db81c6a
--- /dev/null
+++ b/application/frontend/skin/ifhub/layouts/layout.blog.edit.tpl
@@ -0,0 +1,18 @@
+{**
+ * Форма ред-ия блога
+ *}
+
+{extends './layout.base.tpl'}
+
+{block 'layout_options' append}
+ {if $sEvent != 'add'}
+ {$layoutNav = [[
+ hook => 'blog_edit',
+ activeItem => $sMenuItemSelect,
+ items => [
+ [ 'name' => 'profile', 'url' => "{router page='blog'}edit/{$blogEdit->getId()}/", 'text' => $aLang.blog.admin.nav.profile ],
+ [ 'name' => 'admin', 'url' => "{router page='blog'}admin/{$blogEdit->getId()}/", 'text' => $aLang.blog.admin.nav.users ]
+ ]
+ ]]}
+ {/if}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/layouts/layout.content.form.tpl b/application/frontend/skin/ifhub/layouts/layout.content.form.tpl
new file mode 100644
index 0000000..e691411
--- /dev/null
+++ b/application/frontend/skin/ifhub/layouts/layout.content.form.tpl
@@ -0,0 +1,40 @@
+{**
+ * Страница добавления контента
+ *}
+
+{extends './layout.base.tpl'}
+
+{block 'layout_options' append}
+ {if $sEvent != 'edit'}
+ {$_items = []}
+
+ {* Формируем список пунктов *}
+ {$_topicTypes = $LS->Topic_GetTopicTypes()}
+
+ {foreach $_topicTypes as $type}
+ {$_items[] = [ 'name' => $type->getCode(), 'url' => $type->getUrlForAdd(), 'text' => $type->getName() ]}
+ {/foreach}
+
+ {* Пункт "Черновики" *}
+ {$_items[] = [
+ 'name' => 'drafts',
+ 'url' => "{router page='content'}drafts/",
+ 'text' => $aLang.topic.drafts,
+ 'count' => $iUserCurrentCountTopicDraft
+ ]}
+
+ {$layoutNav = [[
+ name => 'content_form',
+ activeItem => $sMenuSubItemSelect,
+ items => $_items
+ ]]}
+ {/if}
+{/block}
+
+{block 'layout_page_title'}
+ {if $sEvent == 'add'}
+ {$aLang.topic.add.title.add}
+ {else}
+ {$aLang.topic.add.title.edit}
+ {/if}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/layouts/layout.index.tpl b/application/frontend/skin/ifhub/layouts/layout.index.tpl
new file mode 100644
index 0000000..b85b846
--- /dev/null
+++ b/application/frontend/skin/ifhub/layouts/layout.index.tpl
@@ -0,0 +1,18 @@
+{**
+ * Главная страница
+ *}
+
+{extends './layout.topics.tpl'}
+
+{block 'layout_options' prepend}
+ {* Все / Лента *}
+ {$layoutNav = [[
+ hook => 'topics',
+ activeItem => $sMenuItemSelect,
+ showSingle => false,
+ items => [
+ [ 'name' => 'index', 'url' => {router page='/'}, 'text' => {lang name='blog.menu.all'}, 'count' => $iCountTopicsNew ],
+ [ 'name' => 'feed', 'url' => {router page='feed'}, 'text' => $aLang.feed.title, 'is_enabled' => !! $oUserCurrent ]
+ ]
+ ]]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/layouts/layout.topics.tpl b/application/frontend/skin/ifhub/layouts/layout.topics.tpl
new file mode 100644
index 0000000..eba3da4
--- /dev/null
+++ b/application/frontend/skin/ifhub/layouts/layout.topics.tpl
@@ -0,0 +1,52 @@
+{**
+ * Список топиков
+ *}
+
+{extends './layout.base.tpl'}
+
+{block 'layout_options' append}
+ {* Меню фильтрации топиков *}
+ {if $sNavTopicsSubUrl}
+ {if ! isset($layoutNav)}
+ {$layoutNav = []}
+ {/if}
+
+ {$layoutNav[] = [
+ hook => 'topics_sub',
+ activeItem => $sMenuSubItemSelect,
+ items => [
+ [ 'name' => 'good', 'url' => $sNavTopicsSubUrl, 'text' => {lang name='blog.menu.all_good'} ],
+ [ 'name' => 'new', 'url' => "{$sNavTopicsSubUrl}newall/", 'text' => {lang name='blog.menu.all_new'} ],
+ [ 'name' => 'discussed', 'url' => "{$sNavTopicsSubUrl}discussed/", 'text' => {lang name='blog.menu.all_discussed'} ],
+ [ 'name' => 'top', 'url' => "{$sNavTopicsSubUrl}top/", 'text' => {lang name='blog.menu.all_top'} ]
+ ]
+ ]}
+
+ {if $periodSelectCurrent}
+ {* Фильтр по времени *}
+ {$layoutNav[] = [
+ hook => 'topics_sub_timespan',
+ activeItem => $periodSelectCurrent,
+ items => [
+ [
+ 'name' => 'good',
+ 'text' => {lang name='blog.menu.all_good'},
+ 'menu' => [
+ activeItem => $periodSelectCurrent,
+ items => [
+ [ 'name' => '1', 'url' => "{$periodSelectRoot}?period=1", 'text' => {lang 'blog.menu.top_period_1'} ],
+ [ 'name' => '7', 'url' => "{$periodSelectRoot}?period=7", 'text' => {lang 'blog.menu.top_period_7'} ],
+ [ 'name' => '30', 'url' => "{$periodSelectRoot}?period=30", 'text' => {lang 'blog.menu.top_period_30'} ],
+ [ 'name' => 'all', 'url' => "{$periodSelectRoot}?period=all", 'text' => {lang 'blog.menu.top_period_all'} ]
+ ]
+ ]
+ ]
+ ]
+ ]}
+ {/if}
+ {/if}
+{/block}
+
+{block 'layout_content'}
+ {component 'topic.list' topics=$topics paging=$paging}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/layouts/layout.user.created.tpl b/application/frontend/skin/ifhub/layouts/layout.user.created.tpl
new file mode 100644
index 0000000..c21e002
--- /dev/null
+++ b/application/frontend/skin/ifhub/layouts/layout.user.created.tpl
@@ -0,0 +1,18 @@
+{**
+ * Публикации пользователя
+ *}
+
+{extends './layout.user.tpl'}
+
+{block 'layout_options' append}
+ {$layoutNav = [[
+ hook => 'profile_created',
+ hookParams => [ 'oUserProfile' => $oUserProfile ],
+ activeItem => $sMenuSubItemSelect,
+ items => [
+ [ 'name' => 'topics', 'url' => "{$oUserProfile->getUserWebPath()}created/topics/", 'text' => {lang name='user.publications.nav.topics'}, 'count' => $iCountTopicUser ],
+ [ 'name' => 'comments', 'url' => "{$oUserProfile->getUserWebPath()}created/comments/", 'text' => {lang name='user.publications.nav.comments'}, 'count' => $iCountCommentUser ],
+ [ 'name' => 'notes', 'url' => "{$oUserProfile->getUserWebPath()}created/notes/", 'text' => {lang name='user.publications.nav.notes'}, 'count' => $iCountNoteUser, 'is_enabled' => $oUserCurrent && $oUserCurrent->getId() == $oUserProfile->getId() ]
+ ]
+ ]]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/layouts/layout.user.favourite.tpl b/application/frontend/skin/ifhub/layouts/layout.user.favourite.tpl
new file mode 100644
index 0000000..1668fe5
--- /dev/null
+++ b/application/frontend/skin/ifhub/layouts/layout.user.favourite.tpl
@@ -0,0 +1,17 @@
+{**
+ * Избранное пользователя
+ *}
+
+{extends './layout.user.tpl'}
+
+{block 'layout_options' append}
+ {$layoutNav = [[
+ hook => 'profile_created',
+ hookParams => [ 'oUserProfile' => $oUserProfile ],
+ activeItem => $sMenuSubItemSelect,
+ items => [
+ [ 'name' => 'topics', 'text' => {lang name='user.favourites.nav.topics'}, 'url' => "{$oUserProfile->getUserWebPath()}favourites/topics/", 'count' => $iCountTopicFavourite ],
+ [ 'name' => 'comments', 'text' => {lang name='user.favourites.nav.comments'}, 'url' => "{$oUserProfile->getUserWebPath()}favourites/comments/", 'count' => $iCountCommentFavourite ]
+ ]
+ ]]}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/layouts/layout.user.messages.tpl b/application/frontend/skin/ifhub/layouts/layout.user.messages.tpl
new file mode 100644
index 0000000..6c2ac6d
--- /dev/null
+++ b/application/frontend/skin/ifhub/layouts/layout.user.messages.tpl
@@ -0,0 +1,23 @@
+{**
+ * Базовый шаблон личных сообщений
+ *}
+
+{extends './layout.user.tpl'}
+
+{block 'layout_options' append}
+ {$layoutNav = [[
+ hook => 'talk',
+ activeItem => $sMenuSubItemSelect,
+ items => [
+ [ 'name' => 'inbox', 'url' => "{router page='talk'}", 'text' => $aLang.talk.nav.inbox ],
+ [ 'name' => 'new', 'url' => "{router page='talk'}inbox/new/", 'text' => $aLang.talk.nav.new, 'count' => $iUserCurrentCountTalkNew, 'is_enabled' => $iUserCurrentCountTalkNew ],
+ [ 'name' => 'add', 'url' => "{router page='talk'}add/", 'text' => $aLang.talk.nav.add ],
+ [ 'name' => 'favourites', 'url' => "{router page='talk'}favourites/", 'text' => $aLang.talk.nav.favourites, 'count' => $iCountTalkFavourite ],
+ [ 'name' => 'blacklist', 'url' => "{router page='talk'}blacklist/", 'text' => $aLang.talk.nav.blacklist ]
+ ]
+ ]]}
+{/block}
+
+{block 'layout_user_page_title'}
+ {$aLang.talk.title}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/layouts/layout.user.settings.tpl b/application/frontend/skin/ifhub/layouts/layout.user.settings.tpl
new file mode 100644
index 0000000..5b9c447
--- /dev/null
+++ b/application/frontend/skin/ifhub/layouts/layout.user.settings.tpl
@@ -0,0 +1,22 @@
+{**
+ * Базовый шаблон настроек пользователя
+ *}
+
+{extends './layout.user.tpl'}
+
+{block 'layout_options' append}
+ {$layoutNav = [[
+ hook => 'settings',
+ activeItem => $sMenuSubItemSelect,
+ items => [
+ [ 'url' => "{router page='settings'}profile/", 'text' => {lang name='user.settings.nav.profile'}, 'name' => 'profile' ],
+ [ 'url' => "{router page='settings'}account/", 'text' => {lang name='user.settings.nav.account'}, 'name' => 'account' ],
+ [ 'url' => "{router page='settings'}tuning/", 'text' => {lang name='user.settings.nav.tuning'}, 'name' => 'tuning' ],
+ [ 'url' => "{router page='settings'}invite/", 'text' => {lang name='user.settings.nav.invites'}, 'name' => 'invite' ]
+ ]
+ ]]}
+{/block}
+
+{block 'layout_user_page_title'}
+ {$aLang.user.settings.title}
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/layouts/layout.user.tpl b/application/frontend/skin/ifhub/layouts/layout.user.tpl
new file mode 100644
index 0000000..d8c61e0
--- /dev/null
+++ b/application/frontend/skin/ifhub/layouts/layout.user.tpl
@@ -0,0 +1,13 @@
+{**
+ * Базовый шаблон профиля пользователя
+ *}
+
+{extends './layout.base.tpl'}
+
+{block 'layout_content_header' prepend}
+ {component 'user' template='header' user=$oUserProfile}
+
+
+{/block}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/navs/nav.activity.tpl b/application/frontend/skin/ifhub/navs/nav.activity.tpl
new file mode 100644
index 0000000..6feca08
--- /dev/null
+++ b/application/frontend/skin/ifhub/navs/nav.activity.tpl
@@ -0,0 +1,12 @@
+{**
+ * Навигация на странице активности
+ *}
+
+{component 'nav'
+ name = 'activity'
+ activeItem = $sMenuItemSelect
+ mods = 'pills'
+ items = [
+ [ 'name' => 'user', 'url' => "{router page='stream'}personal/", 'text' => $aLang.activity.nav.personal, 'is_enabled' => !! $oUserCurrent ],
+ [ 'name' => 'all', 'url' => "{router page='stream'}all/", 'text' => $aLang.activity.nav.all ]
+ ]}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/navs/nav.blog.edit.tpl b/application/frontend/skin/ifhub/navs/nav.blog.edit.tpl
new file mode 100644
index 0000000..d73ab63
--- /dev/null
+++ b/application/frontend/skin/ifhub/navs/nav.blog.edit.tpl
@@ -0,0 +1,12 @@
+{**
+ * Навгиация редактирования блога
+ *}
+
+{component 'nav'
+ name = 'blog_edit'
+ activeItem = $sMenuItemSelect
+ mods = 'pills'
+ items = [
+ [ 'name' => 'profile', 'url' => "{router page='blog'}edit/{$blogEdit->getId()}/", 'text' => $aLang.blog.admin.nav.profile ],
+ [ 'name' => 'admin', 'url' => "{router page='blog'}admin/{$blogEdit->getId()}/", 'text' => $aLang.blog.admin.nav.users ]
+ ]}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/navs/nav.create.tpl b/application/frontend/skin/ifhub/navs/nav.create.tpl
new file mode 100644
index 0000000..eed9118
--- /dev/null
+++ b/application/frontend/skin/ifhub/navs/nav.create.tpl
@@ -0,0 +1,26 @@
+{**
+ * Навгиация создания топика
+ *}
+
+{$items = []}
+
+{* Формируем список пунктов *}
+{$topicTypes = $LS->Topic_GetTopicTypes()}
+
+{foreach $topicTypes as $type}
+ {$items[] = [ 'name' => $type->getCode(), 'url' => $type->getUrlForAdd(), 'text' => $type->getName() ]}
+{/foreach}
+
+{* Пункт "Черновики" *}
+{$items[] = [
+ 'name' => 'drafts',
+ 'url' => "{router page='content'}drafts/",
+ 'text' => $aLang.topic.drafts,
+ 'count' => $iUserCurrentCountTopicDraft
+]}
+
+{component 'nav'
+ name = 'create'
+ activeItem = $sMenuSubItemSelect
+ mods = 'pills'
+ items = $items}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/navs/nav.main.tpl b/application/frontend/skin/ifhub/navs/nav.main.tpl
new file mode 100644
index 0000000..1f50bef
--- /dev/null
+++ b/application/frontend/skin/ifhub/navs/nav.main.tpl
@@ -0,0 +1,7 @@
+{component 'nav' name='main' activeItem=$sMenuHeadItemSelect mods='main' items=[
+ [ 'text' => $aLang.blog.blogs, 'url' => {router page='blogs'}, 'name' => 'blogs' ],
+ [ 'text' => $aLang.user.users, 'url' => {router page='people'}, 'name' => 'people' ],
+ [ 'text' => $aLang.activity.title, 'url' => {router page='stream'}, 'name' => 'stream' ],
+ [ 'text' => 'Правила', 'url' => {router page='rules'}, 'name' => 'rules' ],
+ [ 'text' => ' ', 'classes' => 'search-icon' ]
+]}
diff --git a/application/frontend/skin/ifhub/navs/nav.messages.tpl b/application/frontend/skin/ifhub/navs/nav.messages.tpl
new file mode 100644
index 0000000..a70423b
--- /dev/null
+++ b/application/frontend/skin/ifhub/navs/nav.messages.tpl
@@ -0,0 +1,15 @@
+{**
+ * Навигация на странице личных сообщений
+ *}
+
+{component 'nav'
+ name = 'talk'
+ activeItem = $sMenuSubItemSelect
+ mods = 'pills'
+ items = [
+ [ 'name' => 'inbox', 'url' => "{router page='talk'}", 'text' => $aLang.talk.nav.inbox ],
+ [ 'name' => 'new', 'url' => "{router page='talk'}inbox/new/", 'text' => $aLang.talk.nav.new, 'count' => $iUserCurrentCountTalkNew, 'is_enabled' => $iUserCurrentCountTalkNew ],
+ [ 'name' => 'add', 'url' => "{router page='talk'}add/", 'text' => $aLang.talk.nav.add ],
+ [ 'name' => 'favourites', 'url' => "{router page='talk'}favourites/", 'text' => $aLang.talk.nav.favourites, 'count' => $iCountTalkFavourite ],
+ [ 'name' => 'blacklist', 'url' => "{router page='talk'}blacklist/", 'text' => $aLang.talk.nav.blacklist ]
+ ]}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/navs/nav.search.tpl b/application/frontend/skin/ifhub/navs/nav.search.tpl
new file mode 100644
index 0000000..2344a6f
--- /dev/null
+++ b/application/frontend/skin/ifhub/navs/nav.search.tpl
@@ -0,0 +1,12 @@
+{**
+ * Навигация по результатам поиска
+ *}
+
+{component 'nav'
+ name = 'search'
+ activeItem = $searchType
+ mods = 'pills'
+ items = [
+ [ 'name' => 'topics', 'url' => "{router page='search/topics'}?q={$_aRequest.q}", 'text' => $aLang.search.result.topics, 'count' => $typeCounts.topics ],
+ [ 'name' => 'comments', 'url' => "{router page='search/comments'}?q={$_aRequest.q}", 'text' => $aLang.search.result.comments, 'count' => $typeCounts.comments ]
+ ]}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/navs/nav.settings.tpl b/application/frontend/skin/ifhub/navs/nav.settings.tpl
new file mode 100644
index 0000000..96908e2
--- /dev/null
+++ b/application/frontend/skin/ifhub/navs/nav.settings.tpl
@@ -0,0 +1,14 @@
+{**
+ * Навигация на странице настроек
+ *}
+
+{component 'nav'
+ name = 'settings'
+ activeItem = $sMenuSubItemSelect
+ mods = 'pills'
+ items = [
+ [ 'url' => "{router page='settings'}profile/", 'text' => {lang name='user.settings.nav.profile'}, 'name' => 'profile' ],
+ [ 'url' => "{router page='settings'}account/", 'text' => {lang name='user.settings.nav.account'}, 'name' => 'account' ],
+ [ 'url' => "{router page='settings'}tuning/", 'text' => {lang name='user.settings.nav.tuning'}, 'name' => 'tuning' ],
+ [ 'url' => "{router page='settings'}invite/", 'text' => {lang name='user.settings.nav.invites'}, 'name' => 'invite' ]
+ ]}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/navs/nav.topics.sub.tpl b/application/frontend/skin/ifhub/navs/nav.topics.sub.tpl
new file mode 100644
index 0000000..a426893
--- /dev/null
+++ b/application/frontend/skin/ifhub/navs/nav.topics.sub.tpl
@@ -0,0 +1,21 @@
+{**
+ * Саб-навигация по топикам (Интересные, новые и т.д.)
+ *}
+
+{if $sNavTopicsSubUrl}
+ {component 'nav'
+ name = 'topics_sub'
+ activeItem = $sMenuSubItemSelect
+ mods = 'pills'
+ items = [
+ [ 'name' => 'good', 'url' => $sNavTopicsSubUrl, 'text' => {lang name='blog.menu.all_good'} ],
+ [ 'name' => 'new', 'url' => "{$sNavTopicsSubUrl}newall/", 'text' => {lang name='blog.menu.all_new'}, 'title' => {lang name='blog.menu.top_period_all'}, 'count' => $iCountTopicsSubNew ],
+ [ 'name' => 'new', 'url' => "{$sNavTopicsSubUrl}new/", 'text' => "+$iCountTopicsSubNew", 'title' => {lang name='blog.menu.top_period_1'}, 'is_enabled' => $iCountTopicsSubNew ],
+ [ 'name' => 'discussed', 'url' => "{$sNavTopicsSubUrl}discussed/", 'text' => {lang name='blog.menu.all_discussed'} ],
+ [ 'name' => 'top', 'url' => "{$sNavTopicsSubUrl}top/", 'text' => {lang name='blog.menu.all_top'} ]
+ ]}
+
+ {component 'sort' template='timespan' activeItem=$periodSelectCurrent}
+{/if}
+
+{hook run='nav_topics_sub_after' sMenuSubItemSelect=$sMenuSubItemSelect sNavTopicsSubUrl=$sNavTopicsSubUrl}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/navs/nav.topics.tpl b/application/frontend/skin/ifhub/navs/nav.topics.tpl
new file mode 100644
index 0000000..cee20ee
--- /dev/null
+++ b/application/frontend/skin/ifhub/navs/nav.topics.tpl
@@ -0,0 +1,14 @@
+{**
+ * Навигация по топикам
+ *}
+
+{component 'nav'
+ name = 'topics'
+ activeItem = $sMenuItemSelect
+ mods = 'pills'
+ items = [
+ [ 'name' => 'index', 'url' => {router page='/'}, 'text' => {lang name='blog.menu.all'}, 'count' => $iCountTopicsNew ],
+ [ 'name' => 'feed', 'url' => {router page='feed'}, 'text' => $aLang.feed.title, 'is_enabled' => !! $oUserCurrent ]
+ ]}
+
+{include 'navs/nav.topics.sub.tpl'}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/navs/nav.user.created.tpl b/application/frontend/skin/ifhub/navs/nav.user.created.tpl
new file mode 100644
index 0000000..f6fb588
--- /dev/null
+++ b/application/frontend/skin/ifhub/navs/nav.user.created.tpl
@@ -0,0 +1,14 @@
+{**
+ * Навигация в профиле пользователя в разделе "Публикации"
+ *}
+
+{component 'nav'
+ name = 'profile_created'
+ activeItem = $sMenuSubItemSelect
+ mods = 'pills'
+ hookArguments = [ 'oUserProfile' => $oUserProfile ]
+ items = [
+ [ 'name' => 'topics', 'url' => "{$oUserProfile->getUserWebPath()}created/topics/", 'text' => {lang name='user.publications.nav.topics'}, 'count' => $iCountTopicUser ],
+ [ 'name' => 'comments', 'url' => "{$oUserProfile->getUserWebPath()}created/comments/", 'text' => {lang name='user.publications.nav.comments'}, 'count' => $iCountCommentUser ],
+ [ 'name' => 'notes', 'url' => "{$oUserProfile->getUserWebPath()}created/notes/", 'text' => {lang name='user.publications.nav.notes'}, 'count' => $iCountNoteUser, 'is_enabled' => $oUserCurrent && $oUserCurrent->getId() == $oUserProfile->getId() ]
+ ]}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/navs/nav.user.favourite.tpl b/application/frontend/skin/ifhub/navs/nav.user.favourite.tpl
new file mode 100644
index 0000000..37fa88b
--- /dev/null
+++ b/application/frontend/skin/ifhub/navs/nav.user.favourite.tpl
@@ -0,0 +1,13 @@
+{**
+ * Навигация в профиле пользователя в разделе "Избранное"
+ *}
+
+{component 'nav'
+ name = 'profile_favourite'
+ activeItem = $sMenuSubItemSelect
+ mods = 'pills'
+ hookParams = [ 'oUserProfile' => $oUserProfile ]
+ items = [
+ [ 'name' => 'topics', 'text' => {lang name='user.favourites.nav.topics'}, 'url' => "{$oUserProfile->getUserWebPath()}favourites/topics/", 'count' => $iCountTopicFavourite ],
+ [ 'name' => 'comments', 'text' => {lang name='user.favourites.nav.comments'}, 'url' => "{$oUserProfile->getUserWebPath()}favourites/comments/", 'count' => $iCountCommentFavourite ]
+ ]}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/navs/nav.user.info.tpl b/application/frontend/skin/ifhub/navs/nav.user.info.tpl
new file mode 100644
index 0000000..9ed43e8
--- /dev/null
+++ b/application/frontend/skin/ifhub/navs/nav.user.info.tpl
@@ -0,0 +1,10 @@
+{**
+ * Навигация на главной странице профиля
+ *}
+
+{component 'nav'
+ name = 'profile_info'
+ activeItem = $sMenuSubItemSelect
+ mods = 'pills'
+ hookParams = [ 'oUserProfile' => $oUserProfile ]
+ items = [ [ 'text' => {lang name='user.profile.title'}, 'url' => $oUserProfile->getUserWebPath(), 'name' => 'main' ] ]}
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/og-default.png b/application/frontend/skin/ifhub/og-default.png
new file mode 100644
index 0000000..d2bd0f8
Binary files /dev/null and b/application/frontend/skin/ifhub/og-default.png differ
diff --git a/application/frontend/skin/ifhub/settings/config/config.php b/application/frontend/skin/ifhub/settings/config/config.php
new file mode 100644
index 0000000..94b27be
--- /dev/null
+++ b/application/frontend/skin/ifhub/settings/config/config.php
@@ -0,0 +1,39 @@
+
+
+
+ IFHub
+
+
+ Alexander Yakovlev
+
+ https://ifhub.club
+ 1.0
+
+ 2.0
+
+
+
+
+ Шаблон ifhub.club
+ Template for ifhub.club
+
+
+ -
+
default
+
+ по-умолчанию
+ default
+
+
+ -
+
light
+
+ облегченная
+ light
+
+
+
+
diff --git a/application/frontend/skin/ifhub/template_preview.png b/application/frontend/skin/ifhub/template_preview.png
new file mode 100644
index 0000000..27bde99
Binary files /dev/null and b/application/frontend/skin/ifhub/template_preview.png differ
diff --git a/application/frontend/skin/ifhub/themes/default/style.css b/application/frontend/skin/ifhub/themes/default/style.css
new file mode 100644
index 0000000..e69de29
diff --git a/application/frontend/skin/ifhub/themes/light/README b/application/frontend/skin/ifhub/themes/light/README
new file mode 100644
index 0000000..d514cca
--- /dev/null
+++ b/application/frontend/skin/ifhub/themes/light/README
@@ -0,0 +1,4 @@
+Light Theme
+-----------
+
+Легковесная тема для шаблона, убирает закругления и тени.
\ No newline at end of file
diff --git a/application/frontend/skin/ifhub/themes/light/style.css b/application/frontend/skin/ifhub/themes/light/style.css
new file mode 100644
index 0000000..a63c6ba
--- /dev/null
+++ b/application/frontend/skin/ifhub/themes/light/style.css
@@ -0,0 +1,4 @@
+* {
+ border-radius: 0 !important;
+ box-shadow: none !important;
+}
\ No newline at end of file
diff --git a/application/include/.htaccess b/application/include/.htaccess
new file mode 100644
index 0000000..2859d7f
--- /dev/null
+++ b/application/include/.htaccess
@@ -0,0 +1,2 @@
+Order Deny,Allow
+Deny from all
\ No newline at end of file
diff --git a/application/libs/application/.gitignore b/application/libs/application/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/application/libs/vendor/.gitignore b/application/libs/vendor/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/application/plugins/.gitignore b/application/plugins/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/application/tests/.htaccess b/application/tests/.htaccess
new file mode 100644
index 0000000..c3c2d19
--- /dev/null
+++ b/application/tests/.htaccess
@@ -0,0 +1,2 @@
+Order Deny,Allow
+Deny from all
diff --git a/application/tests/AbstractFixtures.php b/application/tests/AbstractFixtures.php
new file mode 100644
index 0000000..d161851
--- /dev/null
+++ b/application/tests/AbstractFixtures.php
@@ -0,0 +1,253 @@
+oEngine = $oEngine;
+ $this->aReferences = $aReferences;
+ $this->aActivePlugins = $oEngine->PluginManager_GetPluginsActive();
+ }
+
+ /**
+ * Add reference
+ *
+ * @param string $name
+ * @param array $data
+ * @return void
+ */
+ public function addReference($name, $data)
+ {
+ $this->aReferences[$name] = $data;
+ }
+
+ /**
+ * Get reference by key
+ *
+ * @param string $key
+ * @throws Exception if reference is not exist
+ * @return array aReferences
+ * @return void
+ */
+ public function getReference($key)
+ {
+ if (isset($this->aReferences[$key])) {
+ return $this->aReferences[$key];
+ }
+
+ throw new Exception("Fixture reference \"$key\" is not exist");
+ }
+
+ /**
+ * Get all references
+ *
+ * @return array aReferences
+ */
+ public function getReferences()
+ {
+ return $this->aReferences;
+ }
+
+ /**
+ * Creating entities and saving them to DB
+ *
+ * @return void
+ */
+ abstract public function load();
+
+ /**
+ * Get order number for fixture
+ *
+ * @return int
+ */
+ public static function getOrder()
+ {
+ return 0;
+ }
+
+ /**
+ * Get Active Plugins
+ *
+ * @return Active Plugins
+ */
+ protected function getActivePlugins()
+ {
+ return $this->aActivePlugins;
+ }
+
+ /**
+ * Create topic with default values
+ *
+ * @param int $iBlogId
+ * @param int $iUserId
+ * @param string $sTitle
+ * @param string $sText
+ * @param string $sTags
+ * @param string $sDate
+ * @param bool $bPublish
+ * @param bool $bPublishMain
+ * @param bool $bPublishDraft
+ *
+ * @throws Exception
+ *
+ * @return ModuleTopic_EntityTopic
+ */
+ protected function _createTopic(
+ $iBlogId,
+ $iUserId,
+ $sTitle,
+ $sText,
+ $sTags,
+ $sDate,
+ $bPublish = true,
+ $bPublishMain = true,
+ $bPublishDraft = true
+ ) {
+ $oTopic = Engine::GetEntity('Topic');
+ /* @var $oTopic ModuleTopic_EntityTopic */
+ $oTopic->setBlogId($iBlogId);
+ $oTopic->setUserId($iUserId);
+ $oTopic->setUserIp('127.0.0.1');
+ $oTopic->setForbidComment(false);
+ $oTopic->setType('topic');
+ $oTopic->setTitle($sTitle);
+ $oTopic->setPublish($bPublish);//
+ $oTopic->setPublishIndex($bPublishMain);//
+ $oTopic->setPublishDraft($bPublishDraft);
+ $oTopic->setDateAdd($sDate);
+ $oTopic->setTextSource($sText);
+ list($sTextShort, $sTextNew, $sTextCut) = $this->oEngine->Text_Cut($oTopic->getTextSource());
+
+ $oTopic->setCutText($sTextCut);
+ $oTopic->setText($this->oEngine->Text_Parser($sTextNew));
+ $oTopic->setTextShort($this->oEngine->Text_Parser($sTextShort));
+
+ $oTopic->setTextHash(md5($oTopic->getType() . $oTopic->getTextSource() . $oTopic->getTitle()));
+ $oTopic->setTags($sTags);
+ //with active plugin l10n added a field topic_lang
+ if (in_array('l10n', $this->getActivePlugins())) {
+ $oTopic->setTopicLang(Config::Get('lang.current'));
+ }
+ // @todo refact this
+ $oTopic->_setValidateScenario('topic');
+ $bValid = $oTopic->_Validate();
+
+ if (!$bValid) {
+ throw new Exception("Create topic - validation error");
+ }
+
+ $this->oEngine->Topic_AddTopic($oTopic);
+
+ return $oTopic;
+ }
+
+ /**
+ * Create user with default values
+ *
+ * @param string $sUserName
+ * @param string $sPassword
+ * @param string $sMail
+ * @param string $sDate
+ *
+ * @return ModuleTopic_EntityUser
+ */
+ protected function _createUser($sUserName, $sPassword, $sMail, $sDate)
+ {
+ $oUser = Engine::GetEntity('User');
+ $oUser->setLogin($sUserName);
+ $oUser->setPassword(md5($sPassword));
+ $oUser->setMail($sMail);
+ $oUser->setUserDateRegister($sDate);
+ $oUser->setUserIpRegister('127.0.0.1');
+ $oUser->setUserActivate('1');
+ $oUser->setUserActivateKey('0');
+
+ $this->oEngine->User_Add($oUser);
+
+ return $oUser;
+ }
+
+ /**
+ * Create topic comment with default values
+ *
+ * @param object $oTopic
+ * @param object $oUser
+ * @param integer $iParentId
+ * @param string $sText
+ *
+ * @return ModuleComment_EntityComment
+ */
+ protected function _createComment($oTopic, $oUser, $iParentId = null, $sText = 'fixture comment text')
+ {
+ $oComment = Engine::GetEntity('Comment');
+ $oComment->setTargetId($oTopic->getId());
+ $oComment->setTargetType('topic');
+ $oComment->setTargetParentId($oTopic->getBlogId());
+ $oComment->setUserId($oUser->getId());
+ $oComment->setText($sText);
+ $oComment->setDate(date('Y-m-d H:i:s', time()));
+ $oComment->setUserIp(func_getIp());
+ $oComment->setPid($iParentId);
+ $oComment->setTextHash(md5($sText));
+ $oComment->setPublish(true);
+
+ $oComment = $this->oEngine->Comment_AddComment($oComment);
+
+ return $oComment;
+ }
+
+ /**
+ * Create Blog Category
+ *
+ * @param string $sTitle
+ * @param string $sUrl
+ * @param integer $iSort
+ * @param integer $iPid
+ *
+ * @throws Exception
+ *
+ * @return ModuleBlog_EntityBlogCategory
+ */
+ protected function _createCategory($sTitle, $sUrl, $iSort = 0, $iPid = null)
+ {
+ $oCategory = Engine::GetEntity('ModuleBlog_EntityBlogCategory');
+ $oCategory->setTitle($sTitle);
+ $oCategory->setUrl($sUrl);
+ $oCategory->setSort($iSort);
+ $oCategory->setPid($iPid);
+
+ if ($oCategory->_Validate()) {
+ $iCategoryId = $this->oEngine->Blog_AddCategory($oCategory);
+ $oCategory = $this->oEngine->Blog_GetCategoryById($iCategoryId);
+
+ return $oCategory;
+
+ } else {
+ throw new Exception("Create category - validation error");
+ }
+ }
+}
+
diff --git a/application/tests/LoadFixtures.php b/application/tests/LoadFixtures.php
new file mode 100644
index 0000000..dbe6687
--- /dev/null
+++ b/application/tests/LoadFixtures.php
@@ -0,0 +1,183 @@
+oEngine = $oEngine;
+ $this->sDirFixtures = realpath((dirname(__FILE__)) . "/fixtures/");
+ }
+
+ public function load()
+ {
+ $this->loadFixtures();
+ }
+
+ /**
+ * Recreate database from SQL dumps
+ *
+ * @return bool
+ */
+ public function purgeDB()
+ {
+ $sDbname = Config::Get('db.params.dbname');
+
+ if (mysql_select_db($sDbname)) {
+
+ $result = mysql_query("SELECT concat('TRUNCATE TABLE ', TABLE_NAME)
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_SCHEMA = '" . $sDbname . "'");
+
+ mysql_query('SET FOREIGN_KEY_CHECKS = 0');
+ echo "TRUNCATE TABLE FROM TEST BASE\n";
+ while ($row = mysql_fetch_row($result)) {
+ if (!mysql_query($row[0])) {
+ // exception
+ throw new Exception("TRUNCATE TABLE FROM TEST BASE - Exception");
+ }
+ }
+ mysql_query('SET FOREIGN_KEY_CHECKS = 1');
+
+ mysql_free_result($result);
+ } else {
+
+ if (mysql_query("CREATE DATABASE IF NOT EXISTS $sDbname") === false) {
+ // exception
+ throw new Exception("DB \"$sDbname\" is not Created");
+ echo "CREATE DATABASE $sDbname \n";
+ return mysql_error();
+ } else {
+
+ mysql_select_db($sDbname);
+
+ // Load dump from sql.sql
+ $result = $this->oEngine->Database_ExportSQL(dirname(__FILE__) . '/fixtures/sql/sql.sql');
+
+ if (!$result['result']) {
+ // exception
+ throw new Exception("DB is not exported with file sql.sql");
+ return $result['errors'];
+ }
+ echo "ExportSQL DATABASE $sDbname -> install_base.sql \n";
+ // Load dump from geo_base.sql
+
+ if (file_exists(Config::Get('path.application.server') . '/tests/fixtures/sql/patch.sql')) {
+ $result = $this->oEngine->Database_ExportSQL(dirname(__FILE__) . '/fixtures/sql/patch.sql');
+
+ if (!$result['result']) {
+ // exception
+ throw new Exception("DB is not exported with file patch.sql");
+ return $result['errors'];
+ }
+ echo "ExportSQL DATABASE $sDbname -> patch.sql \n";
+ // Load dump from patch.sql
+ }
+
+ $result = $this->oEngine->Database_ExportSQL(dirname(__FILE__) . '/fixtures/sql/geo_base.sql');
+
+ if (!$result['result']) {
+ // exception
+ throw new Exception("DB is not exported with file geo_base.sql");
+ return $result['errors'];
+ }
+ echo "ExportSQL DATABASE $sDbname -> geo_base \n";
+
+ }
+ }
+
+ // Load dump from INSERT_BASE (SQL-Query)
+ $result = $this->oEngine->Database_ExportSQL(dirname(__FILE__) . '/fixtures/sql/insert.sql');
+
+ if (!$result['result']) {
+ // exception
+ throw new Exception("DB is not exported with file insert.sql");
+ return $result['errors'];
+ }
+ echo "Export INSERT SQL to DATABASE $sDbname\n";
+
+ return true;
+ }
+
+ /**
+ * Function of loading fixtures from tests/fixtures/
+ *
+ * @var array $aFiles
+ * @var array $iOrder
+ * @return void
+ */
+ private function loadFixtures()
+ {
+ $aFiles = glob("{$this->sDirFixtures}/*Fixtures.php");
+ $aFixtures = array();
+ foreach ($aFiles as $sFilePath) {
+ require_once "{$sFilePath}";
+ $iOrder = BlogFixtures::getOrder();
+
+ preg_match("/([a-zA-Z]+Fixtures).php$/", $sFilePath, $matches);
+ $sClassName = $matches[1];
+ $iOrder = forward_static_call(array($sClassName, 'getOrder'));
+ $aFixtures[$iOrder][] = $sClassName;
+ }
+ ksort($aFixtures);
+
+ if (count($aFixtures)) {
+ foreach ($aFixtures as $iOrder => $aClassNames) {
+ foreach ($aClassNames as $sClassName) {
+ // @todo референсы дублируются в каждом объекте фиксту + в этом объекте
+ $oFixtures = new $sClassName($this->oEngine, $this->aReferences);
+ if (!$oFixtures instanceof AbstractFixtures) {
+ throw new Exception($sClassName . " must extend of AbstractFixtures");
+ }
+ $oFixtures->load();
+ $aFixtureReference = $oFixtures->getReferences();
+ $this->aReferences = array_merge($this->aReferences, $aFixtureReference);
+ }
+ }
+ }
+ }
+
+ /**
+ * Function of loading plugin fixtures
+ *
+ * @param string $plugin
+ * @return void
+ */
+ public function loadPluginFixtures($plugin)
+ {
+ $sPath = Config::Get('path.application.plugins.server') . '/' . $plugin . '/tests/fixtures';
+ if (!is_dir($sPath)) {
+ throw new InvalidArgumentException('Plugin not found by LS directory: ' . $sPath, 10);
+ }
+
+ $this->sDirFixtures = $sPath;
+ $this->loadFixtures();
+ echo "Load Fixture Plugin ... ---> {$plugin}\n";
+ }
+}
+
diff --git a/application/tests/README.md b/application/tests/README.md
new file mode 100644
index 0000000..949ceb9
--- /dev/null
+++ b/application/tests/README.md
@@ -0,0 +1,63 @@
+Запуск функциональных тестов
+============================
+
+Для запуска тестов проекта нужно:
+
+1) Переименовать файл config/config.test.php.dist в config/config.test.php и изменить настройки подключения к тестовой БД.
+ВАЖНО! Информация в этой БД будет перезаписываться при каждом запуске теста.
+
+2) В конфиге для Behat (tests/behat/behat.yml) сменить значение опции base_url на хост, под которым проект доступен локально.
+
+3) Выполнить команду ```HTTP_APP_ENV=test php tests/behat/behat.phar -c tests/behat/behat.yml```. Примерный вывод результата работы команды:
+
+```
+DROP DATABASE social_test
+CREATE DATABASE social_test
+SELECTED DATABASE social_test
+ExportSQL DATABASE social_test
+ExportSQL DATABASE social_test -> geo_base
+Feature: LiveStreet standart features
+ Test base functionality of LiveStreet
+
+ Scenario: See main page # features/base.feature:4
+ Given I am on homepage # FeatureContext::iAmOnHomepage()
+ When I press "Войти" # FeatureContext::pressButton()
+ Then the response status code should be 200 # FeatureContext::assertResponseStatus()
+
+ Scenario: See Colective Blog # features/base.feature:9
+ Given I am on "/blog/gadgets" # FeatureContext::visit()
+ Then I should see "Gadgets" # FeatureContext::assertPageContainsText()
+ Then I should see "Offers latest gadget reviews" # FeatureContext::assertPageContainsText()
+
+ Scenario: See list of blogs # features/base.feature:14
+ Given I am on "/blogs/" # FeatureContext::visit()
+ Then I should see "Gadgets" # FeatureContext::assertPageContainsText()
+
+ Scenario: See All Topic # features/base.feature:18
+ Given I am on "/index/newall/" # FeatureContext::visit()
+ Then I should see "iPad 3 rumored to come this March with quad-core chip and 4G LTE " # FeatureContext::assertPageContainsText()
+ Then I should see "Toshiba unveils 13.3-inch AT330 Android ICS 4.0 tablet" # FeatureContext::assertPageContainsText()
+
+ Scenario: See User Profile # features/base.feature:23
+ Given I am on "/profile/Golfer/" # FeatureContext::visit()
+ Then I should see "Sergey Doryba" # FeatureContext::assertPageContainsText()
+ Then I should see "... Sergey Doryba profile description" # FeatureContext::assertPageContainsText()
+
+5 scenarios (5 passed)
+14 steps (14 passed)
+0m2.225s
+```
+
+4) Для тестирования плагинов используется команда
+ HTTP_APP_ENV=test php behat.phar --config='../../plugins/(название плагина)/tests/behat/behat.yml'
+
+
+5) При написании дополнительных тестов используются следующие правила:
+ а) Доступ из базового контекcта к контексту MINK должен производится через функцию getMinkContext()
+ Пример получения доступа к сессии: $this->getMinkContext()->getSession()
+
+б) Получение доступа к базовому обьекту Engine производится посредством метода: $this->getEngine()
+
+Прим: public function getEngine() {
+ return $this->getSubcontext('base')->getEngine();
+ }
diff --git a/application/tests/behat/behat.phar b/application/tests/behat/behat.phar
new file mode 100644
index 0000000..53c9374
Binary files /dev/null and b/application/tests/behat/behat.phar differ
diff --git a/application/tests/behat/behat.yml b/application/tests/behat/behat.yml
new file mode 100644
index 0000000..a2830cd
--- /dev/null
+++ b/application/tests/behat/behat.yml
@@ -0,0 +1,13 @@
+# behat.yml
+default:
+ extensions:
+ mink_extension.phar:
+ base_url: http://livestreet.test/
+ default_session: goutte
+ mink_loader: mink.phar
+ goutte:
+ server_parameters:
+ APP_ENV: test
+ selenium2:
+ wd_host: 'http://127.0.0.1:4444/wd/hub'
+ capabilities: { "browser": "firefox", "browserVersion": "10", "browserName": "firefox", "version": "10"}
diff --git a/application/tests/behat/features/base.feature b/application/tests/behat/features/base.feature
new file mode 100644
index 0000000..1e16e7e
--- /dev/null
+++ b/application/tests/behat/features/base.feature
@@ -0,0 +1,42 @@
+Feature: LiveStreet standart features
+ Test base functionality of LiveStreet
+
+ Scenario: See main page
+ Given I am on homepage
+ Then the response status code should be 200
+
+ Then I should see "Sony MicroVault Mach USB 3.0 flash drive"
+ Then I should see "Blogger's name user-golfer"
+
+ Then I should see "iPad 3 rumored to come this March with quad-core chip and 4G LTE "
+ Then I should see "Toshiba unveils 13.3-inch AT330 Android ICS 4.0 tablet"
+ Then I should see "Gadgets"
+
+ Scenario: See colective blog
+ Given I am on "/blog/gadgets"
+ Then the response status code should be 200
+
+ Then I should see "Gadgets"
+ Then I should see "Offers latest gadget reviews"
+
+ Scenario: See list of all blogs
+ Given I am on "/blogs/"
+ Then the response status code should be 200
+
+ Then I should see "Gadgets"
+ Then I should see "user-golfer"
+
+ Scenario: See all new topics
+ Given I am on "/index/newall/"
+ Then the response status code should be 200
+
+ Then I should see "Sony MicroVault Mach USB 3.0 flash drive"
+ Then I should see "iPad 3 rumored to come this March with quad-core chip and 4G LTE "
+ Then I should see "Toshiba unveils 13.3-inch AT330 Android ICS 4.0 tablet"
+
+ Scenario: See user profile
+ Given I am on "/profile/user-golfer/"
+ Then the response status code should be 200
+
+ Then I should see "user-golfer"
+ Then I should see "... Golfer profile description"
\ No newline at end of file
diff --git a/application/tests/behat/features/bootstrap/BaseFeatureContext.php b/application/tests/behat/features/bootstrap/BaseFeatureContext.php
new file mode 100644
index 0000000..6cdadf6
--- /dev/null
+++ b/application/tests/behat/features/bootstrap/BaseFeatureContext.php
@@ -0,0 +1,273 @@
+sDirRoot = dirname(realpath((dirname(__FILE__)) . "/../../../"));
+ $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
+ }
+
+ public function getEngine()
+ {
+ return $this->oEngine;
+ }
+
+ public function initEngine()
+ {
+ if (!$this->oEngine) {
+ $this->oEngine = Engine::getInstance();
+ $this->oEngine->Init();
+ }
+ }
+
+ /**
+ * Get fixtures loader
+ * @return LoadFixtures
+ */
+ protected function getFixturesLoader()
+ {
+ if (is_null($this->fixturesLoader)) {
+ $this->fixturesLoader = new LoadFixtures($this->getEngine());
+ }
+
+ return $this->fixturesLoader;
+ }
+
+ public function getMinkContext()
+ {
+ return $this->getMainContext();
+ }
+
+ /**
+ * Purge DB and load fixtures before running each test
+ *
+ * @BeforeScenario
+ */
+ public function prepare($event)
+ {
+ $this->initEngine();
+ $fixturesLoader = $this->getFixturesLoader();
+ $fixturesLoader->purgeDB();
+ $fixturesLoader->load();
+ }
+
+ /**
+ * Loading fixture for plugin
+ *
+ * @Given /^I load fixtures for plugin "([^"]*)"$/
+ */
+ public function loadFixturesForPlugin($plugin)
+ {
+ $fixturesLoader = $this->getFixturesLoader();
+ $fixturesLoader->loadPluginFixtures($plugin);
+ }
+
+ /**
+ * @Then /^I wait "([^"]*)"$/
+ */
+ public function iWait($time_wait)
+ {
+ $this->getMinkContext()->getSession()->wait($time_wait);
+ }
+
+ /**
+ * Check is sets are present in content
+ *
+ * @Then /^the response have sets:$/
+ */
+ public function ResponseHaveSets($table)
+ {
+ $actual = $this->getMinkContext()->getSession()->getPage()->getContent();
+
+ foreach ($table->getHash() as $genreHash) {
+ $regex = '/' . preg_quote($genreHash['value'], '/') . '/ui';
+ if (!preg_match($regex, $actual)) {
+ $message = sprintf('The string "%s" was not found anywhere in the HTML response of the current page.',
+ $genreHash['value']);
+ throw new ExpectationException($message, $this->getMinkContext()->getSession());
+ }
+ }
+ }
+
+ /**
+ * @Then /^I should see in element by css "([^"]*)" values:$/
+ */
+ public function iShouldSeeInContainerValues($objectId, TableNode $table)
+ {
+ $element = $this->getMinkContext()->getSession()->getPage()->find('css', "#{$objectId}");
+
+ if ($element) {
+ $content = $element->getHtml();
+
+ foreach ($table->getHash() as $genreHash) {
+ $regex = '/' . preg_quote($genreHash['value'], '/') . '/ui';
+ if (!preg_match($regex, $content)) {
+ $message = sprintf('The string "%s" was not found anywhere in container', $genreHash['value']);
+ throw new ExpectationException($message, $this->getMinkContext()->getSession());
+ }
+ }
+ } else {
+ throw new ExpectationException('Container not found', $this->getMinkContext()->getSession());
+ }
+ }
+
+ /**
+ * @Then /^I should not see in element by css "([^"]*)" values:$/
+ */
+ public function iShouldNotSeeInContainerValues($objectId, TableNode $table)
+ {
+ $element = $this->getMinkContext()->getSession()->getPage()->find('css', "#{$objectId}");
+
+ if ($element) {
+ $content = $element->getHtml();
+
+ foreach ($table->getHash() as $genreHash) {
+ $regex = '/' . preg_quote($genreHash['value'], '/') . '/ui';
+ if (preg_match($regex, $content)) {
+ $message = sprintf('The string "%s" was found in container', $genreHash['value']);
+ throw new ExpectationException($message, $this->getMinkContext()->getSession());
+ }
+ }
+ } else {
+ throw new ExpectationException('Container not found', $this->getMinkContext()->getSession());
+ }
+ }
+
+
+ /**
+ * Get content type and compare with set
+ *
+ * @Then /^content type is "([^"]*)"$/
+ */
+ public function contentTypeIs($contentType)
+ {
+ $header = $this->getMinkContext()->getSession()->getResponseHeaders();
+
+ if ($contentType != $header['Content-Type']) {
+ $message = sprintf('Current content type is "%s", but "%s" expected.', $header['Content-Type'],
+ $contentType);
+ throw new ExpectationException($message, $this->getMinkContext()->getSession());
+ }
+ }
+
+ /**
+ * Try to login user
+ *
+ * @Then /^I want to login as "([^"]*)"$/
+ */
+ public function iWantToLoginAs($sUserLogin)
+ {
+ $oUser = $this->getEngine()->User_GetUserByLogin($sUserLogin);
+ if (!$oUser) {
+ throw new ExpectationException(sprintf('User %s not found', $sUserLogin),
+ $this->getMinkContext()->getSession());
+ }
+
+ $this->getEngine()->User_Authorization($oUser, false);
+ $oSession = $this->getEngine()->User_GetSessionByUserId($oUser->getId());
+ if (!$oSession) {
+ throw new ExpectationException('Session non created', $this->getMinkContext()->getSession());
+ }
+
+ $this->getMinkContext()->getSession()->getDriver()->setCookie("key", $oSession->getKey());
+ }
+
+ /**
+ * @Then /^I want to logout$/
+ */
+ public function iWantToLogout()
+ {
+ $this->getMinkContext()->getSession()->getDriver()->setCookie("key", null);
+ $this->getMinkContext()->getSession()->getDriver()->setCookie('PHPSESSID', null);
+ $this->getMinkContext()->getSession()->reload();
+ }
+
+ /**
+ * Checking for activity of plugin
+ *
+ * @Then /^check is plugin active "([^"]*)"$/
+ */
+ public function CheckIsPluginActive($sPluginName)
+ {
+ $activePlugins = $this->getEngine()->PluginManager_GetPluginsActive();
+
+ if (!in_array($sPluginName, $activePlugins)) {
+ throw new ExpectationException(sprintf('Plugin %s is not active', $sPluginName),
+ $this->getMinkContext()->getSession());
+ }
+ }
+
+ /**
+ * @Given /^I press element by css "([^"]*)"$/
+ */
+ public function IPressElementCss($path)
+ {
+ $element = $this->getMinkContext()->getSession()->getPage()->find('css', $path);
+ if ($element) {
+ $element->click();
+ } else {
+ throw new ExpectationException('Button not found', $this->getMinkContext()->getSession());
+ }
+ }
+
+ /**
+ * @Then /^I set carma "([^"]*)" to user "([^"]*)"$/
+ */
+ public function iSetCarmaToUser($carmaPoints, $userName)
+ {
+ $oUser = $this->getEngine()->User_GetUserByLogin($userName);
+ if (!$oUser) {
+ throw new ExpectationException('User non exists', $this->getSession());
+ }
+
+ $oUser->setRating((int)$carmaPoints);
+ $this->getEngine()->User_Update($oUser);
+ }
+
+ /**
+ * @When /^I put the file "([^"]*)" to "([^"]*)"$/
+ */
+ public function iPutTheFileTo($fileName, $path)
+ {
+ $fixturePath = realpath((dirname(__FILE__)) . "/../../../../");
+ $this->getMinkContext()->attachFileToField($path, $fixturePath . $fileName);
+ }
+
+ /**
+ * @Then /^run script "([^"]*)" and result should contain "([^"]*)"$/
+ */
+ public function runScript($scriptPath, $regex)
+ {
+ if (!file_exists($this->sDirRoot . $scriptPath)) {
+ throw new ExpectationException('Script file not found', $this->getMinkContext()->getSession());
+ }
+
+ $response = shell_exec($this->sDirRoot . $scriptPath);
+
+ if (!preg_match($regex, $response)) {
+ throw new ExpectationException('Invalid script response', $this->getMinkContext()->getSession());
+ }
+ }
+}
diff --git a/application/tests/behat/features/bootstrap/FeatureContext.php b/application/tests/behat/features/bootstrap/FeatureContext.php
new file mode 100644
index 0000000..133d481
--- /dev/null
+++ b/application/tests/behat/features/bootstrap/FeatureContext.php
@@ -0,0 +1,21 @@
+parameters = $parameters;
+ $this->useContext('base', new BaseFeatureContext($parameters));
+ }
+
+ public function getEngine()
+ {
+ return $this->getSubcontext('base')->getEngine();
+ }
+}
diff --git a/application/tests/behat/features/comment.feature b/application/tests/behat/features/comment.feature
new file mode 100644
index 0000000..5087453
--- /dev/null
+++ b/application/tests/behat/features/comment.feature
@@ -0,0 +1,35 @@
+Feature: Test Base comment functionality (!!!SELENIUM NEEDED)
+ Test base functionality of Comments
+
+ @mink:selenium2
+ Scenario: Adding the comment
+
+ Given I am on "/login"
+ Then I want to login as "admin"
+
+ Given I am on homepage
+ Given I am on "/blog/3.html"
+
+ Then I follow "Add comment"
+ And I fill in "test comment" for "comment_text"
+ And I press "Preview"
+ Then I wait "1000"
+
+ Then I should see in element by css "content .comment-preview" values:
+ | value |
+ | test comment |
+
+ And I press "Add"
+ Then I wait "1000"
+
+ Then I should see in element by css "content .comment-content" values:
+ | value |
+ | test comment |
+
+ Then I should see in element by css "content .comment-author" values:
+ | value |
+ | /profile/admin/">admin |
+ Then I should see in element by css "content .comment-actions" values:
+ | value |
+ | Reply |
+ | Delete |
diff --git a/application/tests/behat/features/comment.feature.incomplete b/application/tests/behat/features/comment.feature.incomplete
new file mode 100644
index 0000000..15f540b
--- /dev/null
+++ b/application/tests/behat/features/comment.feature.incomplete
@@ -0,0 +1,59 @@
+Feature: Test Base comment functionality (!!!SELENIUM NEEDED)
+ Test base functionality of Comments
+
+ @mink:selenium2
+ Scenario: Adding the comment
+
+ Given I am on "/login"
+ Then I want to login as "admin"
+
+ Given I am on homepage
+ Given I am on "/blog/3.html"
+ Then I follow "Add comment"
+ And I fill in "test comment" for "comment_text"
+ And I press "Preview"
+ Then I wait "2000"
+
+ Then I should see in element by css "content .comment-preview" values:
+ | value |
+ | test comment |
+
+ And I press "Add"
+ Then I wait "1000"
+
+ Then I should see in element by css "content .comment-content" values:
+ | value |
+ | test comment |
+
+ Then I should see in element by css "content .comment-author" values:
+ | value |
+ | /profile/admin/">admin |
+ Then I should see in element by css "content .comment-actions" values:
+ | value |
+ | Reply |
+ | Delete |
+
+ @mink:selenium2
+ Scenario: Reply for comment
+ Given I am on "/login"
+ Then I want to login as "admin"
+
+ Then I am on "/blog/gadgets/1.html"
+ Then I should see "fixture comment text"
+
+ Then I should see in element by css "content .comment-wrapper .comment-actions" values:
+ | value |
+ | Reply |
+
+ And I follow "Reply"
+ Then I wait "1000"
+ And I fill in "test subcomment" for "comment_text"
+
+ #Then print last response
+
+ And I press "Add"
+ Then I wait "1000"
+
+ Then I should see in element by css "#comment_content_id_2" values:
+ | value |
+ | test subcomment |
diff --git a/application/tests/behat/mink.phar b/application/tests/behat/mink.phar
new file mode 100644
index 0000000..2027330
Binary files /dev/null and b/application/tests/behat/mink.phar differ
diff --git a/application/tests/behat/mink_extension.phar b/application/tests/behat/mink_extension.phar
new file mode 100644
index 0000000..f39b439
Binary files /dev/null and b/application/tests/behat/mink_extension.phar differ
diff --git a/application/tests/fixtures/BlogFixtures.php b/application/tests/fixtures/BlogFixtures.php
new file mode 100644
index 0000000..0e72e80
--- /dev/null
+++ b/application/tests/fixtures/BlogFixtures.php
@@ -0,0 +1,39 @@
+getReference('user-golfer');
+ $oCategory = $this->getReference('blog-category');
+
+ /* @var $oBlogGadgets ModuleBlog_EntityBlog */
+ $oBlogGadgets = Engine::GetEntity('Blog');
+ $oBlogGadgets->setOwnerId($oUserFirst->getId());
+ $oBlogGadgets->setTitle("Gadgets");
+ $oBlogGadgets->setDescription('Offers latest gadget reviews');
+ $oBlogGadgets->setType('open');
+ $oBlogGadgets->setDateAdd(date("Y-m-d H:i:s")); // @todo freeze
+ $oBlogGadgets->setUrl('gadgets');
+ $oBlogGadgets->setLimitRatingTopic(0);
+ $oBlogGadgets->setCategoryId($oCategory->getCategoryId());
+
+ $this->oEngine->Blog_AddBlog($oBlogGadgets);
+
+ $this->addReference('blog-gadgets', $oBlogGadgets);
+ }
+}
+
diff --git a/application/tests/fixtures/CategoryFixtures.php b/application/tests/fixtures/CategoryFixtures.php
new file mode 100644
index 0000000..bce9401
--- /dev/null
+++ b/application/tests/fixtures/CategoryFixtures.php
@@ -0,0 +1,24 @@
+_createCategory('First category name', 'first_category_url');
+
+ $this->addReference('blog-category', $oBlogCategory);
+ }
+}
\ No newline at end of file
diff --git a/application/tests/fixtures/CommentFixtures.php b/application/tests/fixtures/CommentFixtures.php
new file mode 100644
index 0000000..90e70fd
--- /dev/null
+++ b/application/tests/fixtures/CommentFixtures.php
@@ -0,0 +1,28 @@
+getReference('user-golfer');
+ $oTopic = $this->getReference('topic-toshiba');
+
+ $oTopicComment = $this->_createComment($oTopic, $oUserFirst, null, 'fixture comment text');
+ $this->addReference('topic-toshiba-comment', $oTopicComment);
+
+ }
+}
\ No newline at end of file
diff --git a/application/tests/fixtures/TopicFixtures.php b/application/tests/fixtures/TopicFixtures.php
new file mode 100644
index 0000000..0a55475
--- /dev/null
+++ b/application/tests/fixtures/TopicFixtures.php
@@ -0,0 +1,52 @@
+getReference('user-golfer');
+ $oBlogGadgets = $this->getReference('blog-gadgets');
+
+ $oTopicToshiba = $this->_createTopic($oBlogGadgets->getBlogId(), $oUserFirst->getId(),
+ 'Toshiba unveils 13.3-inch AT330 Android ICS 4.0 tablet',
+ 'Toshiba is to add a new Android 4.0 ICS to the mass which is known as Toshiba AT330. The device is equipped with a multi-touch capacitive touch display that packs a resolution of 1920 x 1200 pixels. The Toshiba AT330 tablet is currently at its prototype stage. We have very little details about the tablet, knowing that it’ll come equipped with HDMI port, on-board 32GB storage that’s expandable via an full-sized SD card slot. It’ll also have a built-in TV tuner and a collapsible antenna.It’ll also run an NVIDIA Tegra 3 quad-core processor. Other goodies will be a 1.3MP front-facing camera and a 5MP rear-facing camera. Currently, there is no information about its price and availability. A clip is included below showing it in action.',
+ 'gadget', '2012-10-21 00:10:20');
+ $this->addReference('topic-toshiba', $oTopicToshiba);
+
+ $oTopicIpad = $this->_createTopic($oBlogGadgets->getBlogId(), $oUserFirst->getId(),
+ 'iPad 3 rumored to come this March with quad-core chip and 4G LTE',
+ 'Another rumor for the iPad 3 has surfaced with some details given by Bloomberg, claiming that the iPad 3 production is already underway and will be ready for a launch as early as March.',
+ 'apple, ipad', '2012-10-21 1:20:30');
+ $this->addReference('topic-ipad', $oTopicIpad);
+
+ $oPersonalBlogGolfer = $this->oEngine->Blog_GetPersonalBlogByUserId($oUserFirst->getId());
+ $oTopicSony = $this->_createTopic($oPersonalBlogGolfer->getBlogId(), $oUserFirst->getId(),
+ 'Sony MicroVault Mach USB 3.0 flash drive',
+ 'Want more speeds and better protection for your data? The Sony MicroVault Mach flash USB 3.0 drive is what you need. It offers the USB 3.0 interface that delivers data at super high speeds of up to 5Gbps. It’s also backward compatible with USB 2.0.',
+ 'sony, flash, gadget', '2012-10-21 2:30:40');
+ $this->addReference('topic-sony', $oTopicSony);
+
+ $oTopicDraft = $this->_createTopic($oPersonalBlogGolfer->getBlogId(), $oUserFirst->getId(),
+ 'Draft Topic',
+ 'draft text draft text draft text draft text draft text draft text draft text',
+ 'sony, ipad', '2012-10-21 2:40:50', false);
+ $this->addReference('topic-draft', $oTopicDraft);
+ }
+}
\ No newline at end of file
diff --git a/application/tests/fixtures/UserFixtures.php b/application/tests/fixtures/UserFixtures.php
new file mode 100644
index 0000000..43dfbd5
--- /dev/null
+++ b/application/tests/fixtures/UserFixtures.php
@@ -0,0 +1,40 @@
+_createUser('user-golfer', 'qwerty', 'user_first@info.com', '2012-11-1 00:10:20');
+
+ $oUserFirst->setProfileName('Golfer FullName');
+ $oUserFirst->setProfileAbout('... Golfer profile description');
+ $oUserFirst->setProfileSex('man');
+
+ $this->oEngine->User_Update($oUserFirst);
+ $this->addReference('user-golfer', $oUserFirst);
+
+ $oUserFriend = $this->_createUser('user-friend', 'qwerty', 'user_friend@info.com', '2012-11-1 10:20:30');
+ $oUserFriend->setProfileName('Friend FullName');
+ $oUserFriend->setProfileAbout('... Friend profile description');
+ $oUserFriend->setProfileSex('man');
+
+ $this->oEngine->User_Update($oUserFriend);
+ $this->addReference('user-friend', $oUserFriend);
+
+ $friend = $this->oEngine->GetEntity('User_Friend');
+ $friend->setUserFrom($oUserFirst->getId());
+ $friend->setUserTo($oUserFriend->getId());
+ $friend->setStatusFrom(1);
+ $friend->setStatusTo(2);
+
+ $this->oEngine->User_AddFriend($friend);
+
+ }
+}
\ No newline at end of file
diff --git a/application/tests/fixtures/sql/insert.sql b/application/tests/fixtures/sql/insert.sql
new file mode 100644
index 0000000..f46c69f
--- /dev/null
+++ b/application/tests/fixtures/sql/insert.sql
@@ -0,0 +1,45 @@
+-- --------------------------------------------------------
+
+--
+-- Дамп данных таблицы `prefix_user`
+--
+
+INSERT INTO `prefix_user` (`user_id`, `user_login`, `user_password`, `user_mail`, `user_skill`, `user_date_register`, `user_date_activate`, `user_date_comment_last`, `user_ip_register`, `user_rating`, `user_count_vote`, `user_activate`, `user_activate_key`, `user_profile_name`, `user_profile_sex`, `user_profile_country`, `user_profile_region`, `user_profile_city`, `user_profile_birthday`, `user_profile_about`, `user_profile_date`, `user_profile_avatar`, `user_profile_foto`, `user_settings_notice_new_topic`, `user_settings_notice_new_comment`, `user_settings_notice_new_talk`, `user_settings_notice_reply_comment`, `user_settings_notice_new_friend`, `user_settings_timezone`) VALUES
+(1, 'admin', 'd8578edf8458ce06fbc5bb76a58c5ca4', 'admin@admin.adm', 0.000, '2012-10-1 00:00:00', NULL, NULL, '127.0.0.1', 0.000, 0, 1, NULL, NULL, 'other', NULL, NULL, NULL, NULL, NULL, NULL, '0', NULL, 1, 1, 1, 1, 1, NULL);
+
+-- --------------------------------------------------------
+
+
+--
+-- Дамп данных таблицы `prefix_user_administrator`
+--
+
+INSERT INTO `prefix_user_administrator` (`user_id`) VALUES
+(1);
+
+-- --------------------------------------------------------
+
+
+--
+-- Дамп данных таблицы `prefix_user_field`
+--
+
+INSERT INTO `prefix_user_field` (`id`, `type`, `name`, `title`, `pattern`) VALUES
+(1, 'contact', 'phone', 'Телефон', ''),
+(2, 'contact', 'mail', 'E-mail', '{*} '),
+(3, 'contact', 'skype', 'Skype', '{*} '),
+(4, 'contact', 'icq', 'ICQ', '{*} '),
+(5, 'contact', 'www', 'Сайт', '{*} '),
+(6, 'social', 'twitter', 'Twitter', '{*} '),
+(7, 'social', 'facebook', 'Facebook', '{*} '),
+(8, 'social', 'vkontakte', 'ВКонтакте', '{*} '),
+(9, 'social', 'odnoklassniki', 'Одноклассники', '{*} ');
+
+-- --------------------------------------------------------
+
+--
+-- Дамп данных таблицы `prefix_blog`
+--
+
+INSERT INTO `prefix_blog` (`blog_id`, `user_owner_id`, `blog_title`, `blog_description`, `blog_type`, `blog_date_add`, `blog_date_edit`, `blog_rating`, `blog_count_vote`, `blog_count_user`, `blog_count_topic`, `blog_limit_rating_topic`, `blog_url`, `blog_avatar`) VALUES
+(1, 1, 'Blog by admin', 'This is Admin personal blog.', 'personal', '2012-11-07 09:20:00', NULL, 0.000, 0, 0, 0, -1000.000, NULL, '0');
diff --git a/application/tests/travis/apache_setup.sh b/application/tests/travis/apache_setup.sh
new file mode 100755
index 0000000..a26ce80
--- /dev/null
+++ b/application/tests/travis/apache_setup.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+
+sudo apt-get install apache2 libapache2-mod-php5 curl
+sudo a2enmod rewrite
+echo "$(curl -fsSL https://raw.github.com/stfalcon-studio/livestreet/master/tests/travis/configs/apache_vhost)" | sed -e "s,PATH,`pwd`,g" | sudo tee -a /etc/apache2/sites-available/default > /dev/null
+echo "$(curl -fsSL https://raw.github.com/stfalcon-studio/livestreet/master/tests/travis/configs/hosts)" | sudo tee -a /etc/hosts > /dev/null
+sudo service apache2 restart
diff --git a/application/tests/travis/configs/apache_vhost b/application/tests/travis/configs/apache_vhost
new file mode 100644
index 0000000..a8fc5a0
--- /dev/null
+++ b/application/tests/travis/configs/apache_vhost
@@ -0,0 +1,29 @@
+
+ ServerAdmin webmaster@localhost
+
+ DocumentRoot PATH
+ ServerName livestreet.test
+
+ ErrorLog ${APACHE_LOG_DIR}/livestreet.log
+
+ # Possible values include: debug, info, notice, warn, error, crit,
+ # alert, emerg.
+ LogLevel warn
+
+ CustomLog ${APACHE_LOG_DIR}/livestreet.log combined
+
+
+
+ ServerAdmin webmaster@localhost
+
+ DocumentRoot PATH
+ ServerName livestreet.test
+
+ ErrorLog ${APACHE_LOG_DIR}/livestreet.log
+
+ # Possible values include: debug, info, notice, warn, error, crit,
+ # alert, emerg.
+ LogLevel warn
+
+ CustomLog ${APACHE_LOG_DIR}/livestreet.log combined
+
diff --git a/application/tests/travis/configs/hosts b/application/tests/travis/configs/hosts
new file mode 100644
index 0000000..1dba4f4
--- /dev/null
+++ b/application/tests/travis/configs/hosts
@@ -0,0 +1 @@
+127.0.0.1 livestreet.test
diff --git a/application/tests/travis/mysql_setup.sh b/application/tests/travis/mysql_setup.sh
new file mode 100755
index 0000000..e6d8fae
--- /dev/null
+++ b/application/tests/travis/mysql_setup.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+
+mysql -u root -e 'CREATE DATABASE social_test;'
+mysql -u root -B social_test < ./tests/fixtures/sql/sql.sql
+mysql -u root -B social_test < ./tests/fixtures/sql/geo_base.sql
+mysql -u root -B social_test < ./tests/fixtures/sql/patch.sql
diff --git a/application/utilities/cron/.htaccess b/application/utilities/cron/.htaccess
new file mode 100644
index 0000000..2859d7f
--- /dev/null
+++ b/application/utilities/cron/.htaccess
@@ -0,0 +1,2 @@
+Order Deny,Allow
+Deny from all
\ No newline at end of file
diff --git a/application/utilities/cron/main.php b/application/utilities/cron/main.php
new file mode 100644
index 0000000..50e1f3b
--- /dev/null
+++ b/application/utilities/cron/main.php
@@ -0,0 +1,35 @@
+Cron_RunMain();
+ }
+}
+
+/**
+ * Создаем объект крон-процесса,
+ * передавая параметром путь к лок-файлу
+ */
+$app = new CronMain(Config::Get('sys.cache.dir') . 'CronMain.lock');
+print $app->Exec();
\ No newline at end of file
diff --git a/bootstrap/.htaccess b/bootstrap/.htaccess
new file mode 100644
index 0000000..2859d7f
--- /dev/null
+++ b/bootstrap/.htaccess
@@ -0,0 +1,2 @@
+Order Deny,Allow
+Deny from all
\ No newline at end of file
diff --git a/bootstrap/start.php b/bootstrap/start.php
new file mode 100644
index 0000000..9781c66
--- /dev/null
+++ b/bootstrap/start.php
@@ -0,0 +1,120 @@
+
+ *
+ */
+
+
+/************************************************************
+ * Здесь выполняется основная подготовка движка к запуску
+ * Внимание! Инициализация ядра здесь не происходит.
+ * При необходимости нужно вручную выполнить Engine::getInstance()->Init();
+ * Подключение автозагрузчика классов происходит только при инициализации ядра.
+ */
+
+/**
+ * Формируем путь до фреймворка
+ */
+$sPathToFramework = dirname(dirname(__FILE__)) . '/framework/';
+
+/**
+ * Подключаем ядро
+ */
+require_once($sPathToFramework . '/classes/engine/Engine.class.php');
+
+/**
+ * Определяем окружение
+ * В зависимости от окружения будет дополнительно подгружаться необходимый конфиг.
+ * Например, для окружения "production" будет загружен конфиг /application/config/config.production.php
+ * По дефолту работает окружение "local"
+ */
+$sEnv = Engine::DetectEnvironment(array(
+ 'production' => array('your-machine-name'),
+));
+
+
+/**
+ * Дополнительные подготовка фреймворка
+ */
+require_once($sPathToFramework . '/bootstrap/start.php');
+
+/**
+ * Подключаем загрузчик конфигов
+ */
+require_once($sPathToFramework . '/config/loader.php');
+
+/**
+ * Определяем дополнительные параметры роутинга
+ */
+$aRouterParams = array(
+ 'callback_after_parse_url' => array(
+ function () {
+ /**
+ * Логика по ЧПУ топиков
+ * Если URL соответствует шаблону ЧПУ топика, перенаправляем обработку на экшен/евент /blog/_show_topic_url/
+ * Через свои параметры конфига передаем исходный URL
+ * Суть обработки _show_topic_url в том, чтобы определить ID топика и корректность его URL, если он некорректен, то произвести его корректировку через внешний редирект на правильный URL
+ * Если удалось определить топик и URL корректный, то происходит внутренний редирект на стандартный евент отображения топика по ID (/blog/12345.html)
+ */
+
+ $sUrlRequest = '';
+ if (Router::GetAction()) {
+ $sUrlRequest .= Router::GetAction();
+ }
+ if (Router::GetActionEvent()) {
+ $sUrlRequest .= '/' . Router::GetActionEvent();
+ }
+ if (Router::GetParams()) {
+ $sUrlRequest .= '/' . join('/', Router::GetParams());
+ }
+ /**
+ * Функция для формирования регулярного выражения по маске URL топика
+ *
+ * @param string $sUrl
+ * @return string
+ */
+ $funcMakePreg = function ($sUrl) {
+ $sUrl = preg_quote(trim($sUrl, '/ '));
+ return strtr($sUrl, Config::Get('module.topic.url_preg'));
+ };
+ $sPreg = $funcMakePreg(Config::Get('module.topic.url'));
+ if (preg_match('@^' . $sPreg . '$@iu', $sUrlRequest)) {
+ Router::SetAction(Router::getInstance()->Rewrite('blog'));
+ Router::SetActionEvent('_show_topic_url');
+ Router::SetParams(array());
+ /**
+ * Хак - через конфиг передаем нужные параметры в обработчик эвента
+ * Модуль кеша здесь нельзя использовать, т.к. еще не произошло инициализации ядра
+ */
+ Config::Set('module.topic._router_topic_original_url', $sUrlRequest);
+ }
+ }
+ )
+);
+
+
+/**
+ * Проверяем наличие директории install
+ */
+if (is_dir(rtrim(Config::Get('path.application.server'),
+ '/') . '/install') && (!isset($_SERVER['HTTP_APP_ENV']) or $_SERVER['HTTP_APP_ENV'] != 'test')
+) {
+ $sUrl = rtrim(str_replace('index.php', '', $_SERVER['PHP_SELF']), '/\\') . '/application/install/';
+ header('Location: ' . $sUrl, true, 302);
+ exit();
+}
\ No newline at end of file
diff --git a/framework/bootstrap/.htaccess b/framework/bootstrap/.htaccess
new file mode 100644
index 0000000..2859d7f
--- /dev/null
+++ b/framework/bootstrap/.htaccess
@@ -0,0 +1,2 @@
+Order Deny,Allow
+Deny from all
\ No newline at end of file
diff --git a/framework/bootstrap/start.php b/framework/bootstrap/start.php
new file mode 100644
index 0000000..69f9be0
--- /dev/null
+++ b/framework/bootstrap/start.php
@@ -0,0 +1,27 @@
+
+ *
+ */
+
+/**
+ * Проверяем на необходимость выставить тестовое окружение
+ */
+if (isset($bUseEnvironmentTesting)) {
+ Engine::SetEnvironment($sEnv = 'testing');
+}
\ No newline at end of file
diff --git a/framework/classes/.htaccess b/framework/classes/.htaccess
new file mode 100644
index 0000000..2859d7f
--- /dev/null
+++ b/framework/classes/.htaccess
@@ -0,0 +1,2 @@
+Order Deny,Allow
+Deny from all
\ No newline at end of file
diff --git a/framework/classes/engine/Action.class.php b/framework/classes/engine/Action.class.php
new file mode 100644
index 0000000..ba85e02
--- /dev/null
+++ b/framework/classes/engine/Action.class.php
@@ -0,0 +1,529 @@
+
+ *
+ */
+
+require_once("Event.class.php");
+
+/**
+ * Абстрактный класс экшена.
+ *
+ * От этого класса наследуются все экшены в движке.
+ * Предоставляет базовые метода для работы с параметрами и шаблоном при запросе страницы в браузере.
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+abstract class Action extends LsObject
+{
+ /**
+ * Список зарегистрированных евентов
+ *
+ * @var array
+ */
+ protected $aRegisterEvent = array();
+ /**
+ * Список евентов, которые нужно обрабатывать внешним обработчиком
+ *
+ * @var array
+ */
+ protected $aRegisterEventExternal = array();
+ /**
+ * Список параметров из URL
+ * /action/event/param0/param1/../paramN/
+ *
+ * @var array
+ */
+ protected $aParams = array();
+ /**
+ * Список совпадений по регулярному выражению для евента
+ *
+ * @var array
+ */
+ protected $aParamsEventMatch = array('event' => array(), 'params' => array());
+ /**
+ * Шаблон экшена
+ * @see SetTemplate
+ * @see SetTemplateAction
+ *
+ * @var string|null
+ */
+ protected $sActionTemplate = null;
+ /**
+ * Дефолтный евент
+ * @see SetDefaultEvent
+ *
+ * @var string|null
+ */
+ protected $sDefaultEvent = null;
+ /**
+ * Текущий евент
+ *
+ * @var string|null
+ */
+ protected $sCurrentEvent = null;
+ /**
+ * Имя текущий евента
+ * Позволяет именовать экшены на основе регулярных выражений
+ *
+ * @var string|null
+ */
+ protected $sCurrentEventName = null;
+ /**
+ * Текущий экшен
+ *
+ * @var null|string
+ */
+ protected $sCurrentAction = null;
+
+ /**
+ * Конструктор
+ *
+ * @param string $sAction Название экшена
+ */
+ public function __construct($sAction)
+ {
+ parent::__construct();
+ $this->RegisterEvent();
+ $this->sCurrentAction = $sAction;
+ $this->aParams = Router::GetParams();
+ }
+
+ /**
+ * Позволяет запускать не публичные методы экшена через объект
+ *
+ * @param string $sCall
+ *
+ * @return mixed
+ */
+ public function ActionCall($sCall)
+ {
+ $aArgs = func_get_args();
+ unset($aArgs[0]);
+ return call_user_func_array(array($this, $sCall), $aArgs);
+ }
+
+ /**
+ * Проверяет метод экшена на существование
+ *
+ * @param string $sCall
+ *
+ * @return bool
+ */
+ public function ActionCallExists($sCall)
+ {
+ return method_exists($this, $sCall);
+ }
+
+ /**
+ * Возвращает свойство объекта экшена
+ *
+ * @param string $sVar
+ *
+ * @return mixed
+ */
+ public function ActionGet($sVar)
+ {
+ return $this->$sVar;
+ }
+
+ /**
+ * Устанавливает свойство объекта экшена
+ *
+ * @param string $sVar
+ * @param null|mixed $mValue
+ */
+ public function ActionSet($sVar, $mValue = null)
+ {
+ $this->$sVar = $mValue;
+ }
+
+ /**
+ * Добавляет евент в экшен
+ * По сути является оберткой для AddEventPreg(), оставлен для простоты и совместимости с прошлыми версиями ядра
+ * @see AddEventPreg
+ *
+ * @param string $sEventName Название евента
+ * @param string $sEventFunction Какой метод ему соответствует
+ */
+ protected function AddEvent($sEventName, $sEventFunction)
+ {
+ $this->AddEventPreg("/^{$sEventName}$/i", $sEventFunction);
+ }
+
+ /**
+ * Добавляет евент в экшен, используя регулярное выражение для евента и параметров
+ *
+ */
+ protected function AddEventPreg()
+ {
+ $iCountArgs = func_num_args();
+ if ($iCountArgs < 2) {
+ throw new Exception("Incorrect number of arguments when adding events");
+ }
+ $aEvent = array();
+ /**
+ * Последний параметр может быть массивом - содержать имя метода и имя евента(именованный евент)
+ * Если указан только метод, то имя будет равным названию метода
+ */
+ $aNames = (array)func_get_arg($iCountArgs - 1);
+ $aEvent['method'] = $aNames[0];
+ /**
+ * Определяем наличие внешнего обработчика евента
+ */
+ $aEvent['external'] = null;
+ $aMethod = explode('::', $aEvent['method']);
+ if (count($aMethod) > 1) {
+ $aEvent['method'] = $aMethod[1];
+ $aEvent['external'] = $aMethod[0];
+ }
+
+ if (isset($aNames[1])) {
+ $aEvent['name'] = $aNames[1];
+ } else {
+ $aEvent['name'] = $aEvent['method'];
+ }
+ if (!$aEvent['external']) {
+ if (!method_exists($this, $aEvent['method'])) {
+ throw new Exception("Method of the event not found: " . $aEvent['method']);
+ }
+ }
+ $aEvent['preg'] = func_get_arg(0);
+ $aEvent['params_preg'] = array();
+ for ($i = 1; $i < $iCountArgs - 1; $i++) {
+ $aEvent['params_preg'][] = func_get_arg($i);
+ }
+ $this->aRegisterEvent[] = $aEvent;
+ }
+
+ /**
+ * Регистрируем внешние обработчики для евентов
+ *
+ * @param string $sEventName
+ * @param string|array $sExternalClass
+ */
+ protected function RegisterEventExternal($sEventName, $sExternalClass)
+ {
+ $this->aRegisterEventExternal[$sEventName] = $sExternalClass;
+ }
+
+ /**
+ * Запускает евент на выполнение
+ * Если текущий евент не определен то запускается тот которые определен по умолчанию(default event)
+ *
+ * @return mixed
+ */
+ public function ExecEvent()
+ {
+ $this->sCurrentEvent = Router::GetActionEvent();
+ if ($this->sCurrentEvent == null) {
+ $this->sCurrentEvent = $this->GetDefaultEvent();
+ Router::SetActionEvent($this->sCurrentEvent);
+ }
+ foreach ($this->aRegisterEvent as $aEvent) {
+ if (preg_match($aEvent['preg'], $this->sCurrentEvent, $aMatch)) {
+ $this->aParamsEventMatch['event'] = $aMatch;
+ $this->aParamsEventMatch['params'] = array();
+ foreach ($aEvent['params_preg'] as $iKey => $sParamPreg) {
+ if (preg_match($sParamPreg, $this->GetParam($iKey, ''), $aMatch)) {
+ $this->aParamsEventMatch['params'][$iKey] = $aMatch;
+ } else {
+ continue 2;
+ }
+ }
+ $this->sCurrentEventName = $aEvent['name'];
+ if ($aEvent['external']) {
+ if (!isset($this->aRegisterEventExternal[$aEvent['external']])) {
+ throw new Exception("External processing for event not found: " . $aEvent['external']);
+ }
+ }
+ $this->Hook_Run("action_event_" . strtolower($this->sCurrentAction) . "_before",
+ array('event' => $this->sCurrentEvent, 'params' => $this->GetParams()));
+ /**
+ * Проверяем на наличие внешнего обработчика евента
+ */
+ if ($aEvent['external']) {
+ $sEventClass = $this->Plugin_GetDelegate('event',
+ $this->aRegisterEventExternal[$aEvent['external']]);
+ $oEvent = new $sEventClass;
+ $oEvent->SetActionObject($this);
+ $oEvent->Init();
+ if (!$aEvent['method']) {
+ $result = $oEvent->Exec();
+ } else {
+ $result = call_user_func_array(array($oEvent, $aEvent['method']), array());
+ }
+ } else {
+ $result = call_user_func_array(array($this, $aEvent['method']), array());
+ }
+ $this->Hook_Run("action_event_" . strtolower($this->sCurrentAction) . "_after",
+ array('event' => $this->sCurrentEvent, 'params' => $this->GetParams()));
+ return $result;
+ }
+ }
+ return $this->EventNotFound();
+ }
+
+ /**
+ * Устанавливает евент по умолчанию
+ *
+ * @param string $sEvent Имя евента
+ */
+ public function SetDefaultEvent($sEvent)
+ {
+ $this->sDefaultEvent = $sEvent;
+ }
+
+ /**
+ * Получает евент по умолчанию
+ *
+ * @return string
+ */
+ public function GetDefaultEvent()
+ {
+ return $this->sDefaultEvent;
+ }
+
+ /**
+ * Возвращает элементы совпадения по регулярному выражению для евента
+ *
+ * @param int|null $iItem Номер совпадения
+ * @return string|null
+ */
+ protected function GetEventMatch($iItem = null)
+ {
+ if ($iItem) {
+ if (isset($this->aParamsEventMatch['event'][$iItem])) {
+ return $this->aParamsEventMatch['event'][$iItem];
+ } else {
+ return null;
+ }
+ } else {
+ return $this->aParamsEventMatch['event'];
+ }
+ }
+
+ /**
+ * Возвращает элементы совпадения по регулярному выражению для параметров евента
+ *
+ * @param int $iParamNum Номер параметра, начинается с нуля
+ * @param int|null $iItem Номер совпадения, начинается с нуля
+ * @return string|null
+ */
+ protected function GetParamEventMatch($iParamNum, $iItem = null)
+ {
+ if (!is_null($iItem)) {
+ if (isset($this->aParamsEventMatch['params'][$iParamNum][$iItem])) {
+ return $this->aParamsEventMatch['params'][$iParamNum][$iItem];
+ } else {
+ return null;
+ }
+ } else {
+ if (isset($this->aParamsEventMatch['event'][$iParamNum])) {
+ return $this->aParamsEventMatch['event'][$iParamNum];
+ } else {
+ return null;
+ }
+ }
+ }
+
+ /**
+ * Получает параметр из URL по его номеру, если его нет то null
+ *
+ * @param int $iOffset Номер параметра, начинается с нуля
+ * @return mixed
+ */
+ public function GetParam($iOffset, $default = null)
+ {
+ $iOffset = (int)$iOffset;
+ return isset($this->aParams[$iOffset]) ? $this->aParams[$iOffset] : $default;
+ }
+
+ /**
+ * Получает список параметров из УРЛ
+ *
+ * @return array
+ */
+ public function GetParams()
+ {
+ return $this->aParams;
+ }
+
+
+ /**
+ * Установить значение параметра(эмуляция параметра в URL).
+ * После установки занова считывает параметры из роутера - для корректной работы
+ *
+ * @param int $iOffset Номер параметра, но по идеи может быть не только числом
+ * @param string $value
+ */
+ public function SetParam($iOffset, $value)
+ {
+ Router::SetParam($iOffset, $value);
+ $this->aParams = Router::GetParams();
+ }
+
+ /**
+ * Устанавливает какой шаблон выводить
+ *
+ * @param string $sTemplate Путь до шаблона относительно общего каталога шаблонов
+ */
+ protected function SetTemplate($sTemplate)
+ {
+ $this->sActionTemplate = $sTemplate;
+ }
+
+ /**
+ * Устанавливает какой шаблон выводить
+ *
+ * @param string $sTemplate Путь до шаблона относительно каталога шаблонов экшена
+ */
+ protected function SetTemplateAction($sTemplate)
+ {
+ $aDelegates = $this->Plugin_GetDelegationChain('action', $this->GetActionClass());
+ $sActionTemplatePath = $sTemplate . '.tpl';
+ foreach ($aDelegates as $sAction) {
+ if (preg_match('/^(Plugin([\w]+)_)?Action([\w]+)$/i', $sAction, $aMatches)) {
+ $sTemplatePath = $this->Plugin_GetDelegate('template',
+ 'actions/Action' . ucfirst($aMatches[3]) . '/' . $sTemplate . '.tpl');
+ if (empty($aMatches[1])) {
+ $sActionTemplatePath = $sTemplatePath;
+ } else {
+ $sTemplatePath = Plugin::GetTemplatePath($sAction) . $sTemplatePath;
+ if (is_file($sTemplatePath)) {
+ $sActionTemplatePath = $sTemplatePath;
+ break;
+ }
+ }
+ }
+ }
+ $this->sActionTemplate = $sActionTemplatePath;
+ }
+
+ /**
+ * Получить шаблон
+ * Если шаблон не определен то возвращаем дефолтный шаблон евента: actions/Action{Action}/{event}.tpl
+ *
+ * @return string
+ */
+ public function GetTemplate()
+ {
+ if (is_null($this->sActionTemplate)) {
+ $this->SetTemplateAction(strtolower($this->sCurrentEvent));
+ }
+ return $this->sActionTemplate;
+ }
+
+ /**
+ * Получить каталог с шаблонами экшена(совпадает с именем класса)
+ * @see Router::GetActionClass
+ *
+ * @return string
+ */
+ public function GetActionClass()
+ {
+ return Router::GetActionClass();
+ }
+
+ /**
+ * Возвращает имя евента
+ *
+ * @return null|string
+ */
+ public function GetCurrentEventName()
+ {
+ return $this->sCurrentEventName;
+ }
+
+ /**
+ * Вызывается в том случаи если не найден евент который запросили через URL
+ * По дефолту происходит перекидывание на страницу ошибки, это можно переопределить в наследнике
+ * @see Router::Action
+ *
+ * @return string
+ */
+ protected function EventNotFound()
+ {
+ return Router::Action('error', '404');
+ }
+
+ /**
+ * Перенаправляет на страницу ошибки "доступ запрещен"
+ * @see Router::Action
+ *
+ * @return string
+ */
+ protected function EventForbiddenAccess()
+ {
+ return Router::Action('error', '403');
+ }
+
+ /**
+ * Выполняется при завершение экшена, после вызова основного евента
+ *
+ */
+ public function EventShutdown()
+ {
+
+ }
+
+ /**
+ * Выводит отладочную информацию в стандартном сообщении
+ * Этим методом можно завершать выполнение евента в случае системной ошибки, например, не удалось найти топик по его ID при голосовании в ajax обработчике
+ *
+ */
+ protected function EventErrorDebug()
+ {
+ if (Config::Get('sys.debug.action_error')) {
+ $aTrace = debug_backtrace(false);
+ $aCaller = array_shift($aTrace);
+ $aCallerSource = array_shift($aTrace);
+ $aPathinfo = pathinfo($aCaller['file']);
+
+ $sMsg = $aPathinfo['basename'] . ' [' . $aCallerSource['class'] . $aCallerSource['type'] . $aCallerSource['function'] . ': ' . $aCaller['line'] . ']';
+ $this->Message_AddErrorSingle($sMsg, 'System error');
+ if ($this->Viewer_GetResponseAjax()) {
+ return true;
+ } else {
+ return Router::Action('error', '500');
+ }
+ } else {
+ if ($this->Viewer_GetResponseAjax()) {
+ $this->Message_AddErrorSingle('System error');
+ return true;
+ } else {
+ return Router::Action('error', '500');
+ }
+ }
+ }
+
+ /**
+ * Абстрактный метод инициализации экшена
+ *
+ */
+ abstract public function Init();
+
+ /**
+ * Абстрактный метод регистрации евентов.
+ * В нём необходимо вызывать метод AddEvent($sEventName,$sEventFunction)
+ *
+ */
+ abstract protected function RegisterEvent();
+
+}
\ No newline at end of file
diff --git a/framework/classes/engine/ActionPlugin.class.php b/framework/classes/engine/ActionPlugin.class.php
new file mode 100644
index 0000000..3a3b0d8
--- /dev/null
+++ b/framework/classes/engine/ActionPlugin.class.php
@@ -0,0 +1,89 @@
+
+ *
+ */
+
+require_once('Action.class.php');
+
+/**
+ * Абстрактный класс экшена плагина.
+ * От этого класса необходимо наследовать экшены плагина, эот позволит корректно определять текущий шаблон плагина для рендеринга экшена
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+abstract class ActionPlugin extends Action
+{
+ /**
+ * Полный серверный путь до текущего шаблона плагина
+ *
+ * @var string|null
+ */
+ protected $sTemplatePathPlugin = null;
+
+ /**
+ * Возвращает путь к текущему шаблону плагина
+ *
+ * @return string
+ */
+ public function getTemplatePathPlugin()
+ {
+ if (is_null($this->sTemplatePathPlugin)) {
+ preg_match('/^Plugin([\w]+)_Action([\w]+)$/i', $this->GetActionClass(), $aMatches);
+ /**
+ * Проверяем в списке шаблонов
+ */
+ $aMatches[1] = strtolower($aMatches[1]);
+ $aPaths = glob(Config::Get('path.application.plugins.server') . '/' . $aMatches[1] . '/frontend/skin/*/actions/Action' . ucfirst($aMatches[2]),
+ GLOB_ONLYDIR);
+ $sTemplateName = ($aPaths and in_array(
+ Config::Get('view.skin'),
+ array_map(
+ function($sPath) {
+ preg_match("/skin\/([\w\-]+)\/actions/i", $sPath, $aMatches);
+ return $aMatches[1];
+ },
+ $aPaths
+ )
+ ))
+ ? Config::Get('view.skin')
+ : 'default';
+
+ $sDir = Config::Get('path.application.plugins.server') . "/{$aMatches[1]}/frontend/skin/{$sTemplateName}/";
+ $this->sTemplatePathPlugin = is_dir($sDir) ? $sDir : null;
+ }
+
+ return $this->sTemplatePathPlugin;
+ }
+
+ /**
+ * Установить значение пути к директории шаблона плагина
+ *
+ * @param string $sTemplatePath Полный серверный путь до каталога с шаблоном
+ * @return bool
+ */
+ public function setTemplatePathPlugin($sTemplatePath)
+ {
+ if (!is_dir($sTemplatePath)) {
+ return false;
+ }
+ $this->sTemplatePathPlugin = $sTemplatePath;
+ }
+
+}
\ No newline at end of file
diff --git a/framework/classes/engine/Behavior.class.php b/framework/classes/engine/Behavior.class.php
new file mode 100644
index 0000000..bc95656
--- /dev/null
+++ b/framework/classes/engine/Behavior.class.php
@@ -0,0 +1,173 @@
+
+ *
+ */
+
+/**
+ * Абстракция поведения, от которой наследуются все поведения
+ * Поведения предназначены для удобного изменения функционала другого объекта (модуля, сущности и т.п.)
+ * В основном поведения добавляют новые свойства и методы (функционируют через магические вызовы)
+ * Концептуальное отличие от наследования через плагины в том, что целевой объект сам "выбирает" какой функционал получить, а не наоборот
+ *
+ * @package framework.engine
+ * @since 2.0
+ */
+abstract class Behavior extends LsObject
+{
+
+ /**
+ * Исходный объект, к которому добавлено поведение
+ *
+ * @var LsObject|null
+ */
+ protected $oObject;
+ /**
+ * Параметры, которые указали при добавлении поведения
+ * Здесь можно определить дефолтные параметры, которые затем смержатся
+ *
+ * @var array
+ */
+ protected $aParams = array();
+ /**
+ * Список хуков, которые отслеживает поведение
+ *
+ * array(
+ * 'hook_name_1' => 'method',
+ * 'hook_name_2' => array($oObject,'method'), // callback
+ * 'hook_name_3' => array('method',100), // with priority
+ * 'hook_name_4' => array(array($oObject,'method'),100), // with callback and priority
+ * )
+ *
+ *
+ * @var array
+ */
+ protected $aHooks = array();
+
+ /**
+ * Конструктор, инициализирует параметры
+ *
+ * @param array $aParams
+ */
+ public function __construct($aParams = array())
+ {
+ parent::__construct();
+ if ($aParams) {
+ $this->aParams = array_merge($this->aParams, $aParams);
+ }
+ }
+
+ /**
+ * Инициализация поведения, выполняется автоматически после добавления (Attach) поведения
+ * Данный метод можно переопределить внутри конкретного поведения
+ */
+ protected function Init()
+ {
+
+ }
+
+ /**
+ * Добавляет поведение к объекту
+ *
+ * @param LsObject $oObject
+ */
+ public function Attach($oObject)
+ {
+ $this->oObject = $oObject;
+ foreach ($this->aHooks as $sName => $mParams) {
+ list($aCallback, $iPriority) = $this->ParseHookParams($mParams);
+ $this->oObject->AddBehaviorHook($sName, $aCallback, $iPriority);
+ }
+ $this->Init();
+ }
+
+ /**
+ * Удаляет поведение у текущего объекта
+ */
+ public function Detach()
+ {
+ if ($this->oObject) {
+ foreach ($this->aHooks as $sName => $mParams) {
+ list($aCallback,) = $this->ParseHookParams($mParams);
+ $this->oObject->RemoveBehaviorHook($sName, $aCallback);
+ }
+ $this->oObject = null;
+ }
+ }
+
+ /**
+ * Вспомогательный метод для определения коллбека из параметров
+ *
+ * @param array|string $mParams
+ *
+ * @return array
+ */
+ protected function ParseHookParams($mParams)
+ {
+ $iPriority = 1;
+ if (is_string($mParams)) {
+ $aCallback = array($this, $mParams);
+ } elseif (is_object($mParams[0])) {
+ $aCallback = $mParams;
+ } elseif (is_string($mParams[0])) {
+ $aCallback = array($this, $mParams[0]);
+ if (isset($mParams[1])) {
+ $iPriority = $mParams[1];
+ }
+ } else {
+ $aCallback = $mParams[0];
+ if (isset($mParams[1])) {
+ $iPriority = $mParams[1];
+ }
+ }
+ return array($aCallback, $iPriority);
+ }
+
+ /**
+ * Возвращает параметр по его имени
+ *
+ * @param string $sName
+ *
+ * @return mixed
+ */
+ public function getParam($sName)
+ {
+ return isset($this->aParams[$sName]) ? $this->aParams[$sName] : null;
+ }
+
+ /**
+ * Устанавливает значение параметра
+ *
+ * @param string $sName
+ * @param mixed $mValue
+ */
+ public function setParam($sName, $mValue)
+ {
+ $this->aParams[$sName] = $mValue;
+ }
+
+ /**
+ * Возвращает все параметры
+ *
+ * @return array
+ */
+ public function getParams()
+ {
+ return $this->aParams;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/engine/Block.class.php b/framework/classes/engine/Block.class.php
new file mode 100644
index 0000000..372412b
--- /dev/null
+++ b/framework/classes/engine/Block.class.php
@@ -0,0 +1,98 @@
+
+ *
+ */
+
+/**
+ * Абстрактный класс блока
+ * Это те блоки, которые обрабатывают шаблоны Smarty перед выводом (например, блок "Облако тегов")
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+abstract class Block extends LsObject
+{
+ /**
+ * Список параметров блока
+ *
+ * @var array
+ */
+ protected $aParams = array();
+ /**
+ * Шаблон блока
+ *
+ * @var string|null
+ */
+ protected $sTemplate = null;
+
+ /**
+ * При создании блока передаем в него параметры
+ *
+ * @param array $aParams Список параметров блока
+ */
+ public function __construct($aParams)
+ {
+ parent::__construct();
+ $this->aParams = $aParams;
+ }
+
+ /**
+ * Возвращает параметр по имени
+ *
+ * @param string $sName Имя параметра
+ * @param null|mixed $def Дефолтное значение параметра, возвращается если такого параметра нет
+ * @return mixed
+ */
+ protected function GetParam($sName, $def = null)
+ {
+ if (isset($this->aParams[$sName])) {
+ return $this->aParams[$sName];
+ } else {
+ return $def;
+ }
+ }
+
+ /**
+ * Возврашает шаблон блока
+ *
+ * @return null|string
+ */
+ public function GetTemplate()
+ {
+ return $this->sTemplate;
+ }
+
+ /**
+ * Устанавливает шаблон блока
+ *
+ * @param string $sTemplate Путь до файла шаблона
+ */
+ public function SetTemplate($sTemplate)
+ {
+ $this->sTemplate = $sTemplate;
+ }
+
+ /**
+ * Метод запуска обработки блока.
+ * Его необходимо определять в конкретном блоке
+ *
+ * @abstract
+ */
+ abstract public function Exec();
+}
\ No newline at end of file
diff --git a/framework/classes/engine/Cron.class.php b/framework/classes/engine/Cron.class.php
new file mode 100644
index 0000000..ab41f47
--- /dev/null
+++ b/framework/classes/engine/Cron.class.php
@@ -0,0 +1,160 @@
+
+ *
+ */
+
+/**
+ * Абстрактный класс для работы с крон-процессами.
+ * Например, его использует отложенная рассылка почтовых уведомлений для пользователей.
+ * Обработчик крона не запускается автоматически(!!), его необходимо добавлять в системный крон (nix*: crontab -e)
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+abstract class Cron extends LsObject
+{
+ /**
+ * Производить логирование или нет
+ *
+ * @var bool
+ */
+ protected $bLogEnable = true;
+ /**
+ * Дескриптор блокирующего файла
+ * Если этот файл существует, то крон не запустится повторно.
+ *
+ * @var string
+ */
+ protected $oLockFile = null;
+ /**
+ * Имя процесса, под которым будут помечены все сообщения в логах
+ *
+ * @var string
+ */
+ protected $sProcessName;
+
+ /**
+ * @param string|null $sLockFile Полный путь до лок файла, например Config::Get('sys.cache.dir').'notify.lock'
+ */
+ public function __construct($sLockFile = null)
+ {
+ parent::__construct();
+ $this->sProcessName = get_class($this);
+ $oEngine = Engine::getInstance();
+ /**
+ * Инициализируем ядро
+ */
+ $oEngine->Init();
+
+ if (!empty($sLockFile)) {
+ $this->oLockFile = fopen($sLockFile, 'a');
+ }
+ /**
+ * Инициализируем лог и делает пометку о старте процесса
+ */
+ $this->Log('Cron process started');
+ }
+
+ /**
+ * Делает запись в лог
+ *
+ * @param string $sMsg Сообщение для записи в лог
+ */
+ public function Log($sMsg)
+ {
+ if ($this->bLogEnable and Config::Get('sys.logs.cron')) {
+ $sMsg = $this->sProcessName . ': ' . $sMsg;
+ $this->Logger_Notice($sMsg, array(), 'cron');
+ }
+ }
+
+ /**
+ * Проверяет уникальность создаваемого процесса
+ *
+ * @return bool
+ */
+ public function isLock()
+ {
+ return ($this->oLockFile && !flock($this->oLockFile, LOCK_EX | LOCK_NB));
+ }
+
+ /**
+ * Снимает блокировку на повторный процесс
+ *
+ * @return bool
+ */
+ public function unsetLock()
+ {
+ return ($this->oLockFile && @flock($this->oLockFile, LOCK_UN));
+ }
+
+ /**
+ * Основной метод крон-процесса.
+ * Реализует логику работы крон процесса с последующей передачей управления на пользовательскую функцию
+ *
+ * @return string|bool
+ */
+ public function Exec()
+ {
+ /**
+ * Если выполнение процесса заблокировано, завершаемся
+ */
+ if ($this->isLock()) {
+ $this->Log('Try to exec already run process');
+ return false;
+ }
+ /**
+ * Здесь мы реализуем дополнительную логику:
+ * логирование вызова, обработка ошибок,
+ * буферизация вывода.
+ */
+ ob_start();
+ $this->Client();
+ /**
+ * Получаем весь вывод функции.
+ */
+ $sContent = ob_get_contents();
+ ob_end_clean();
+
+ return $sContent;
+ }
+
+ /**
+ * Завершение крон-процесса
+ */
+ public function Shutdown()
+ {
+ $this->unsetLock();
+ $this->Log('Cron process ended');
+ }
+
+ /**
+ * Вызывается при уничтожении объекта
+ */
+ public function __destruct()
+ {
+ $this->Shutdown();
+ }
+
+ /**
+ * Клиентская функция будет переопределяться в наследниках класса
+ * для обеспечивания выполнения основного функционала.
+ */
+ abstract public function Client();
+}
\ No newline at end of file
diff --git a/framework/classes/engine/Engine.class.php b/framework/classes/engine/Engine.class.php
new file mode 100644
index 0000000..445fc88
--- /dev/null
+++ b/framework/classes/engine/Engine.class.php
@@ -0,0 +1,1668 @@
+
+ *
+ */
+
+set_include_path(get_include_path() . PATH_SEPARATOR . dirname(__FILE__));
+
+require_once("LsObject.class.php");
+require_once("Plugin.class.php");
+require_once("Block.class.php");
+require_once("Hook.class.php");
+require_once("Module.class.php");
+require_once("Cron.class.php");
+require_once("Router.class.php");
+
+require_once("Entity.class.php");
+require_once("Behavior.class.php");
+require_once("Mapper.class.php");
+
+require_once("ModuleORM.class.php");
+require_once("EntityORM.class.php");
+require_once("MapperORM.class.php");
+
+require_once("ORMRelationManyToMany.class.php");
+
+
+/**
+ * Основной класс движка. Ядро.
+ *
+ * Производит инициализацию плагинов, модулей, хуков.
+ * Через этот класс происходит выполнение методов всех модулей, которые вызываются как $this->Module_Method();
+ * Также отвечает за автозагрузку остальных классов движка.
+ *
+ * В произвольном месте (не в классах движка у которых нет обработки метода __call() на выполнение модулей) метод модуля можно вызвать так:
+ *
+ * Engine::getInstance()->Module_Method();
+ *
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+class Engine
+{
+
+ /**
+ * Имя плагина
+ * @var int
+ */
+ const CI_PLUGIN = 1;
+
+ /**
+ * Имя экшна
+ * @var int
+ */
+ const CI_ACTION = 2;
+
+ /**
+ * Имя модуля
+ * @var int
+ */
+ const CI_MODULE = 4;
+
+ /**
+ * Имя сущности
+ * @var int
+ */
+ const CI_ENTITY = 8;
+
+ /**
+ * Имя маппера
+ * @var int
+ */
+ const CI_MAPPER = 16;
+
+ /**
+ * Имя метода
+ * @var int
+ */
+ const CI_METHOD = 32;
+
+ /**
+ * Имя хука
+ * @var int
+ */
+ const CI_HOOK = 64;
+
+ /**
+ * Имя класса наследования
+ * @var int
+ */
+ const CI_INHERIT = 128;
+
+ /**
+ * Имя блока
+ * @var int
+ */
+ const CI_BLOCK = 256;
+
+ /**
+ * Имя обработчика евента
+ * @var int
+ */
+ const CI_EVENT = 512;
+
+ /**
+ * Имя поведения
+ * @var int
+ */
+ const CI_BEHAVIOR = 1024;
+
+ /**
+ * Префикс плагина
+ * @var int
+ */
+ const CI_PPREFIX = 8192;
+
+ /**
+ * Разобранный класс наследования
+ * @var int
+ */
+ const CI_INHERITS = 16384;
+
+ /**
+ * Путь к файлу класса
+ * @var int
+ */
+ const CI_CLASSPATH = 32768;
+
+ /**
+ * Все свойства класса
+ * @var int
+ */
+ const CI_ALL = 65535;
+
+ /**
+ * Свойства по-умолчанию
+ * CI_ALL ^ (CI_CLASSPATH | CI_INHERITS | CI_PPREFIX)
+ * @var int
+ */
+ const CI_DEFAULT = 8191;
+
+ /**
+ * Объекты
+ * CI_ACTION | CI_MAPPER | CI_HOOK | CI_PLUGIN | CI_EVENT | CI_MODULE | CI_ENTITY | CI_BLOCK | CI_BEHAVIOR
+ * @var int
+ */
+ const CI_OBJECT = 1887;
+
+ /**
+ * Текущий экземпляр движка, используется для синглтона.
+ * @see getInstance использование синглтона
+ *
+ * @var Engine
+ */
+ static protected $oInstance = null;
+ /**
+ * Текущее окружение
+ *
+ * @var string
+ */
+ static protected $sEnvironment = 'local';
+ /**
+ * Автозагрузчик классов по стандарту PSR-4
+ *
+ * @var Psr4AutoloaderClass|null
+ */
+ static protected $oAutoloader = null;
+ /**
+ * Список загруженных модулей
+ *
+ * @var array
+ */
+ protected $aModules = array();
+ /**
+ * Список загруженных плагинов
+ *
+ * @var array
+ */
+ protected $aPlugins = array();
+ /**
+ * Содержит список модулей для автозагрузки.
+ * Используется для получания списка модулей для авто-загрузки. Остальные модули загружаются при первом обращении.
+ * В конфиге определен так:
+ *
+ * $config['module']['autoLoad'] = array('Hook','Cache','Security','Session','Lang','Message','User');
+ *
+ *
+ * @var array
+ */
+ protected $aModuleAutoload;
+ /**
+ * Время загрузки модулей в микросекундах
+ *
+ * @var int
+ */
+ public $iTimeLoadModule = 0;
+ /**
+ * Текущее время в микросекундах на момент инициализации ядра(движка).
+ * Определается так:
+ *
+ * $this->iTimeInit=microtime(true);
+ *
+ *
+ * @var int|null
+ */
+ protected $iTimeInit = null;
+ /**
+ * Использовать или нет авто-хуки на методы модулей
+ *
+ * @var bool
+ */
+ protected $bUseAutoHooks = false;
+
+
+ /**
+ * Вызывается при создании объекта ядра.
+ * Устанавливает время старта инициализации и обрабатывает входные параметры PHP
+ *
+ */
+ protected function __construct()
+ {
+ $this->iTimeInit = microtime(true);
+ $this->SetUseAutoHooks(Config::Get('sys.module.use_auto_hooks'));
+ $this->AutoloadRegister();
+ if (function_exists('get_magic_quotes_gpc')) {
+ if (get_magic_quotes_gpc()) {
+ func_stripslashes($_REQUEST);
+ func_stripslashes($_GET);
+ func_stripslashes($_POST);
+ func_stripslashes($_COOKIE);
+ }
+ }
+ }
+
+ /**
+ * Ограничиваем объект только одним экземпляром.
+ * Функционал синглтона.
+ *
+ * Используется так:
+ *
+ * Engine::getInstance()->Module_Method();
+ *
+ *
+ * @return Engine
+ */
+ static public function getInstance()
+ {
+ if (isset(self::$oInstance) and (self::$oInstance instanceof self)) {
+ return self::$oInstance;
+ } else {
+ self::$oInstance = new self();
+ return self::$oInstance;
+ }
+ }
+
+ /**
+ * Инициализация ядра движка
+ * todo: запретить выполнять повторную инициализацию
+ */
+ public function Init()
+ {
+ /**
+ * Загружаем плагины
+ */
+ $this->LoadPlugins();
+ /**
+ * Выполняет специальный метод BeforeInitEngine плагинов на самой ранней стадии инициализации ядра
+ */
+ $this->PreInitPlugins();
+ /**
+ * Инициализируем хуки
+ */
+ $this->InitHooks();
+ /**
+ * Загружаем модули автозагрузки
+ */
+ $this->LoadModules();
+ /**
+ * Инициализируем загруженные модули
+ */
+ $this->InitModules();
+ /**
+ * Инициализируем загруженные плагины
+ */
+ $this->InitPlugins();
+ /**
+ * Запускаем хуки для события завершения инициализации Engine
+ */
+ $this->Hook_Run('engine_init_complete');
+ }
+
+ public function SetUseAutoHooks($bUse)
+ {
+ $this->bUseAutoHooks = (bool)$bUse;
+ }
+
+ /**
+ * Завершение работы движка
+ * Завершает все модули.
+ *
+ */
+ public function Shutdown()
+ {
+ /**
+ * Запускаем хуки для события перед завершением работы Engine
+ */
+ $this->Hook_Run('engine_shutdown_prepare');
+
+ $this->ShutdownModules();
+ /**
+ * Запускаем хуки для события завершения работы Engine
+ */
+ $this->Hook_Run('engine_shutdown_complete');
+ }
+
+ /**
+ * Производит инициализацию всех модулей
+ *
+ */
+ protected function InitModules()
+ {
+ foreach ($this->aModules as $oModule) {
+ if (!$oModule->isInit()) {
+ $this->InitModule($oModule);
+ }
+ }
+ }
+
+ /**
+ * Инициализирует модуль
+ *
+ * @param Module $oModule Объект модуля
+ * @param bool $bHookParent Вызывает хук на родительском модуле, от которого наследуется текущий
+ */
+ protected function InitModule($oModule, $bHookParent = true)
+ {
+ $sOrigClassName = $sClassName = get_class($oModule);
+ $bRunHooks = false;
+
+ if ($this->isInitModule('ModuleHook')) {
+ $bRunHooks = true;
+ if ($bHookParent) {
+ while (self::GetPluginName($sClassName)) {
+ $sParentClassName = get_parent_class($sClassName);
+ if (!self::GetClassInfo($sParentClassName, self::CI_MODULE, true)) {
+ break;
+ }
+ $sClassName = $sParentClassName;
+ }
+ }
+ }
+ if ($bRunHooks || $sClassName == 'ModuleHook') {
+ $sHookPrefix = 'module_';
+ if ($sPluginName = self::GetPluginName($sClassName)) {
+ $sHookPrefix .= "plugin{$sPluginName}_";
+ }
+ $sHookPrefix .= self::GetModuleName($sClassName) . '_init_';
+ $sHookPrefix = strtolower($sHookPrefix);
+ }
+ if ($bRunHooks) {
+ $this->Hook_Run($sHookPrefix . 'before');
+ }
+ $oModule->Init();
+ $oModule->SetInit();
+ if ($bRunHooks || $sClassName == 'ModuleHook') {
+ $this->Hook_Run($sHookPrefix . 'after');
+ }
+ }
+
+ /**
+ * Проверяет модуль на инициализацию
+ *
+ * @param string $sModuleClass Класс модуля
+ * @return bool
+ */
+ public function isInitModule($sModuleClass)
+ {
+ if (!in_array($sModuleClass, array('ModulePlugin', 'ModuleHook'))) {
+ $sModuleClass = $this->Plugin_GetDelegate('module', $sModuleClass);
+ }
+ if (isset($this->aModules[$sModuleClass]) and $this->aModules[$sModuleClass]->isInit()) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Завершаем работу всех модулей
+ *
+ */
+ protected function ShutdownModules()
+ {
+ foreach ($this->aModules as $sKey => $oModule) {
+ $oModule->Shutdown();
+ }
+ }
+
+ /**
+ * Выполняет загрузку модуля по его названию
+ *
+ * @param string $sModuleClass Класс модуля
+ * @param bool $bInit Инициализировать модуль или нет
+ *
+ * @throws RuntimeException если класс $sModuleClass не существует
+ *
+ * @return Module
+ */
+ public function LoadModule($sModuleClass, $bInit = false)
+ {
+ $tm1 = microtime(true);
+
+ if (!class_exists($sModuleClass)) {
+ throw new RuntimeException(sprintf('Class "%s" not found!', $sModuleClass));
+ }
+ /**
+ * Создаем объект модуля
+ */
+ $oModule = new $sModuleClass();
+ $this->aModules[$sModuleClass] = $oModule;
+ if ($bInit or $sModuleClass == 'ModuleCache') {
+ $this->InitModule($oModule);
+ }
+ $tm2 = microtime(true);
+ $this->iTimeLoadModule += $tm2 - $tm1;
+ return $oModule;
+ }
+
+ /**
+ * Загружает модули из авто-загрузки и передает им в конструктор ядро
+ *
+ */
+ protected function LoadModules()
+ {
+ $this->LoadConfig();
+ foreach ($this->aModuleAutoload as $sModuleName) {
+ $sModuleClass = 'Module' . $sModuleName;
+ if (!in_array($sModuleName, array('Plugin', 'Hook'))) {
+ $sModuleClass = $this->Plugin_GetDelegate('module', $sModuleClass);
+ }
+
+ if (!isset($this->aModules[$sModuleClass])) {
+ $this->LoadModule($sModuleClass);
+ }
+ }
+ }
+
+ /**
+ * Выполняет загрузку конфигов
+ *
+ */
+ protected function LoadConfig()
+ {
+ $this->aModuleAutoload = Config::Get('module.autoLoad');
+ }
+
+ /**
+ * Регистрирует хуки из /classes/hooks/
+ *
+ */
+ protected function InitHooks()
+ {
+ $sDirHooks = Config::Get('path.application.server') . '/classes/hooks/';
+ $aFiles = glob($sDirHooks . 'Hook*.class.php');
+
+ if ($aFiles and count($aFiles)) {
+ foreach ($aFiles as $sFile) {
+ if (preg_match("/Hook([^_]+)\.class\.php$/i", basename($sFile), $aMatch)) {
+ //require_once($sFile);
+ $sClassName = 'Hook' . $aMatch[1];
+ $oHook = new $sClassName;
+ $oHook->RegisterHook();
+ }
+ }
+ }
+
+ /**
+ * Подгружаем хуки активных плагинов
+ */
+ $this->InitPluginHooks();
+ }
+
+ /**
+ * Инициализация хуков активированных плагинов
+ *
+ */
+ protected function InitPluginHooks()
+ {
+ if ($aPluginList = func_list_plugins()) {
+ $sDirHooks = Config::Get('path.application.plugins.server') . '/';
+
+ foreach ($aPluginList as $sPluginName) {
+ $aFiles = glob($sDirHooks . $sPluginName . '/classes/hooks/Hook*.class.php');
+ if ($aFiles and count($aFiles)) {
+ foreach ($aFiles as $sFile) {
+ if (preg_match("/Hook([^_]+)\.class\.php$/i", basename($sFile), $aMatch)) {
+ //require_once($sFile);
+ $sPluginName = func_camelize($sPluginName);
+ $sClassName = "Plugin{$sPluginName}_Hook{$aMatch[1]}";
+ $oHook = new $sClassName;
+ $oHook->RegisterHook();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Загрузка плагинов и делегирование
+ *
+ */
+ protected function LoadPlugins()
+ {
+ if ($aPluginList = func_list_plugins()) {
+ foreach ($aPluginList as $sPluginName) {
+ $sClassName = 'Plugin' . func_camelize($sPluginName);
+ $oPlugin = new $sClassName;
+ $oPlugin->Delegate();
+ $this->aPlugins[$sPluginName] = $oPlugin;
+ }
+ }
+ }
+
+ /**
+ * Инициализация активированных(загруженных) плагинов
+ *
+ */
+ protected function InitPlugins()
+ {
+ foreach ($this->aPlugins as $oPlugin) {
+ $oPlugin->Init();
+ }
+ }
+
+ /**
+ * Выполняет самую раннюю преинициализацию плагинов
+ *
+ */
+ protected function PreInitPlugins()
+ {
+ foreach ($this->aPlugins as $oPlugin) {
+ $oPlugin->BeforeInitEngine();
+ }
+ }
+
+ /**
+ * Возвращает список активных плагинов
+ *
+ * @return array
+ */
+ public function GetPlugins()
+ {
+ return $this->aPlugins;
+ }
+
+ /**
+ * Вызывает метод нужного модуля
+ *
+ * @param string $sName Название метода в полном виде.
+ * Например Module_Method
+ * @param array $aArgs Список аргументов
+ * @return mixed
+ */
+ public function _CallModule($sName, $aArgs)
+ {
+ list($oModule, $sModuleName, $sMethod) = $this->GetModule($sName);
+
+ $sModuleName = strtolower($sModuleName);
+ $aResultHook = array();
+ if ($this->bUseAutoHooks and !in_array($sModuleName, array('plugin', 'hook'))) {
+ $aResultHook = $this->_CallModule('Hook_Run',
+ array('module_' . $sModuleName . '_' . strtolower($sMethod) . '_before', &$aArgs));
+ }
+ /**
+ * Хук может делегировать результат выполнения метода модуля, сам метод при этом не выполняется, происходит только подмена результата
+ */
+ if (array_key_exists('delegate_result', $aResultHook)) {
+ $result = $aResultHook['delegate_result'];
+ } else {
+ $aArgsRef = array();
+ foreach ($aArgs as $key => $v) {
+ $aArgsRef[] =& $aArgs[$key];
+ }
+ $result = call_user_func_array(array($oModule, $sMethod), $aArgsRef);
+ }
+
+ if ($this->bUseAutoHooks and !in_array($sModuleName, array('plugin', 'hook'))) {
+ $this->Hook_Run('module_' . $sModuleName . '_' . strtolower($sMethod) . '_after',
+ array('result' => &$result, 'params' => $aArgs));
+ }
+
+ return $result;
+ }
+
+ /**
+ * Возвращает объект модуля, имя модуля и имя вызванного метода
+ *
+ * @param string $sName Имя метода модуля в полном виде
+ * Например Module_Method
+ * @return array
+ */
+ public function GetModule($sName)
+ {
+ static $aCache;
+
+ $sCacheKey = $sName;
+
+ if (!isset($aCache[$sCacheKey])) {
+ /**
+ * Поддержка полного синтаксиса при вызове метода модуля
+ */
+ $aInfo = self::GetClassInfo(
+ $sName,
+ self::CI_MODULE
+ | self::CI_PPREFIX
+ | self::CI_METHOD
+ );
+ if ($aInfo[self::CI_MODULE] && $aInfo[self::CI_METHOD]) {
+ $sName = $aInfo[self::CI_MODULE] . '_' . $aInfo[self::CI_METHOD];
+ if ($aInfo[self::CI_PPREFIX]) {
+ $sName = $aInfo[self::CI_PPREFIX] . $sName;
+ }
+ }
+
+ $aName = explode("_", $sName);
+
+ if (count($aName) == 2) {
+ $sModuleName = $aName[0];
+ $sModuleClass = 'Module' . $aName[0];
+ $sMethod = $aName[1];
+ } elseif (count($aName) == 3) {
+ $sModuleName = $aName[0] . '_' . $aName[1];
+ $sModuleClass = $aName[0] . '_Module' . $aName[1];
+ $sMethod = $aName[2];
+ } else {
+ throw new Exception("Undefined method module: " . $sName);
+ }
+ /**
+ * Подхватываем делегат модуля (в случае наличия такового)
+ */
+ if (!in_array($sModuleName, array('Plugin', 'Hook'))) {
+ $sModuleClass = $this->Plugin_GetDelegate('module', $sModuleClass);
+ }
+
+ if (isset($this->aModules[$sModuleClass])) {
+ $oModule = $this->aModules[$sModuleClass];
+ } else {
+ $oModule = $this->LoadModule($sModuleClass, true);
+ }
+
+ $aCache[$sCacheKey] = array($oModule, $sModuleName, $sMethod);
+ }
+ return $aCache[$sCacheKey];
+
+
+ }
+
+ /**
+ * Возвращает объект модуля
+ *
+ * @param string $sName Имя модуля
+ */
+ public function GetModuleObject($sName)
+ {
+ if (self::GetPluginPrefix($sName)) {
+ if (substr_count($sName, '_') < 2) {
+ $sName .= '_x';
+ }
+ } else {
+ if (substr_count($sName, '_') < 1) {
+ $sName .= '_x';
+ }
+ }
+ $aCallArray = $this->GetModule($sName);
+ return $aCallArray[0];
+ }
+
+ /**
+ * Возвращает статистику выполнения
+ *
+ * @return array
+ */
+ public function getStats()
+ {
+ return array(
+ 'sql' => $this->Database_GetStats(),
+ 'cache' => $this->Cache_GetStats(),
+ 'engine' => array('time_load_module' => round($this->iTimeLoadModule, 3))
+ );
+ }
+
+ /**
+ * Возвращает время старта выполнения движка в микросекундах
+ *
+ * @return int
+ */
+ public function GetTimeInit()
+ {
+ return $this->iTimeInit;
+ }
+
+ /**
+ * Ставим хук на вызов неизвестного метода и считаем что хотели вызвать метод какого либо модуля
+ *
+ * @param string $sName Имя метода
+ * @param array $aArgs Аргументы
+ * @return mixed
+ */
+ public function __call($sName, $aArgs)
+ {
+ return $this->_CallModule($sName, $aArgs);
+ }
+
+ /**
+ * Блокируем копирование/клонирование объекта ядра
+ *
+ */
+ protected function __clone()
+ {
+
+ }
+
+ /**
+ * Получает объект маппера
+ *
+ * @param string $sClassName Класс модуля маппера
+ * @param string|null $sName Имя маппера
+ * @param DbSimple_Mysql|null $oConnect Объект коннекта к БД
+ * Можно получить так:
+ *
+ * Engine::getInstance()->Database_GetConnect($aConfig);
+ *
+ * @return mixed
+ */
+ public static function GetMapper($sClassName, $sName = null, $oConnect = null)
+ {
+ $sModuleName = self::GetClassInfo(
+ $sClassName,
+ self::CI_MODULE,
+ true
+ );
+ if ($sModuleName) {
+ if (!$sName) {
+ $sName = $sModuleName;
+ }
+ $sClass = $sClassName . '_Mapper' . $sName;
+ if (!$oConnect) {
+ $oConnect = Engine::getInstance()->Database_GetConnect();
+ }
+ $sClass = self::getInstance()->Plugin_GetDelegate('mapper', $sClass);
+ return new $sClass($oConnect);
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает класс сущности, контролируя варианты кастомизации
+ *
+ * @param $sName
+ *
+ * @return mixed
+ * @throws Exception
+ */
+ public static function GetEntityClass($sName)
+ {
+ /**
+ * Сущности, имеющие такое же название как модуль,
+ * можно вызывать сокращенно. Например, вместо User_User -> User
+ */
+ switch (substr_count($sName, '_')) {
+ case 0:
+ $sEntity = $sModule = $sName;
+ break;
+
+ case 1:
+ /**
+ * Поддержка полного синтаксиса при вызове сущности
+ */
+ $aInfo = self::GetClassInfo(
+ $sName,
+ self::CI_ENTITY
+ | self::CI_MODULE
+ | self::CI_PLUGIN
+ );
+ if ($aInfo[self::CI_MODULE]
+ && $aInfo[self::CI_ENTITY]
+ ) {
+ $sName = $aInfo[self::CI_MODULE] . '_' . $aInfo[self::CI_ENTITY];
+ }
+
+ list($sModule, $sEntity) = explode('_', $sName, 2);
+ /**
+ * Обслуживание короткой записи сущностей плагинов
+ * PluginTest_Test -> PluginTest_ModuleTest_EntityTest
+ */
+ if ($aInfo[self::CI_PLUGIN]) {
+ $sPlugin = $aInfo[self::CI_PLUGIN];
+ $sModule = $sEntity;
+ }
+ break;
+
+ case 2:
+ /**
+ * Поддержка полного синтаксиса при вызове сущности плагина
+ */
+ $aInfo = self::GetClassInfo(
+ $sName,
+ self::CI_ENTITY
+ | self::CI_MODULE
+ | self::CI_PLUGIN
+ );
+ if ($aInfo[self::CI_PLUGIN]
+ && $aInfo[self::CI_MODULE]
+ && $aInfo[self::CI_ENTITY]
+ ) {
+ $sName = 'Plugin' . $aInfo[self::CI_PLUGIN]
+ . '_' . $aInfo[self::CI_MODULE]
+ . '_' . $aInfo[self::CI_ENTITY];
+ }
+ /**
+ * Entity плагина
+ */
+ if ($aInfo[self::CI_PLUGIN]) {
+ list(, $sModule, $sEntity) = explode('_', $sName);
+ $sPlugin = $aInfo[self::CI_PLUGIN];
+ } else {
+ throw new Exception("Unknown entity '{$sName}' given.");
+ }
+ break;
+
+ default:
+ throw new Exception("Unknown entity '{$sName}' given.");
+ }
+
+ $sClass = isset($sPlugin)
+ ? 'Plugin' . $sPlugin . '_Module' . $sModule . '_Entity' . $sEntity
+ : 'Module' . $sModule . '_Entity' . $sEntity;
+
+ /**
+ * If Plugin Entity doesn't exist, search among it's Module delegates
+ */
+ if (isset($sPlugin) && !self::GetClassPath($sClass)) {
+ $aModulesChain = Engine::GetInstance()->Plugin_GetDelegationChain('module',
+ 'Plugin' . $sPlugin . '_Module' . $sModule);
+ foreach ($aModulesChain as $sModuleName) {
+ $sClassTest = $sModuleName . '_Entity' . $sEntity;
+ if (self::GetClassPath($sClassTest)) {
+ $sClass = $sClassTest;
+ break;
+ }
+ }
+ if (!self::GetClassPath($sClass)) {
+ $sClass = 'Module' . $sModule . '_Entity' . $sEntity;
+ }
+ }
+
+ /**
+ * Определяем наличие делегата сущности
+ * Делегирование указывается только в полной форме!
+ */
+ $sClass = self::getInstance()->Plugin_GetDelegate('entity', $sClass);
+ return $sClass;
+ }
+
+ /**
+ * Возвращает класс поведения, контролируя варианты кастомизации
+ *
+ * @param $sName
+ *
+ * @return mixed
+ * @throws Exception
+ */
+ public static function GetBehaviorClass($sName)
+ {
+ /**
+ * Поведения, имеющие такое же название как модуль,
+ * можно вызывать сокращенно. Например, вместо User_User -> User
+ */
+ switch (substr_count($sName, '_')) {
+ case 0:
+ $sEntity = $sModule = $sName;
+ break;
+
+ case 1:
+ /**
+ * Поддержка полного синтаксиса при вызове сущности
+ */
+ $aInfo = self::GetClassInfo(
+ $sName,
+ self::CI_BEHAVIOR
+ | self::CI_MODULE
+ | self::CI_PLUGIN
+ );
+ if ($aInfo[self::CI_MODULE]
+ && $aInfo[self::CI_BEHAVIOR]
+ ) {
+ $sName = $aInfo[self::CI_MODULE] . '_' . $aInfo[self::CI_BEHAVIOR];
+ }
+
+ list($sModule, $sEntity) = explode('_', $sName, 2);
+ /**
+ * Обслуживание короткой записи сущностей плагинов
+ * PluginTest_Test -> PluginTest_ModuleTest_EntityTest
+ */
+ if ($aInfo[self::CI_PLUGIN]) {
+ $sPlugin = $aInfo[self::CI_PLUGIN];
+ $sModule = $sEntity;
+ }
+ break;
+
+ case 2:
+ /**
+ * Поддержка полного синтаксиса при вызове сущности плагина
+ */
+ $aInfo = self::GetClassInfo(
+ $sName,
+ self::CI_BEHAVIOR
+ | self::CI_MODULE
+ | self::CI_PLUGIN
+ );
+ if ($aInfo[self::CI_PLUGIN]
+ && $aInfo[self::CI_MODULE]
+ && $aInfo[self::CI_BEHAVIOR]
+ ) {
+ $sName = 'Plugin' . $aInfo[self::CI_PLUGIN]
+ . '_' . $aInfo[self::CI_MODULE]
+ . '_' . $aInfo[self::CI_BEHAVIOR];
+ }
+ /**
+ * Entity плагина
+ */
+ if ($aInfo[self::CI_PLUGIN]) {
+ list(, $sModule, $sEntity) = explode('_', $sName);
+ $sPlugin = $aInfo[self::CI_PLUGIN];
+ } else {
+ throw new Exception("Unknown behavior '{$sName}' given.");
+ }
+ break;
+
+ default:
+ throw new Exception("Unknown behavior '{$sName}' given.");
+ }
+
+ $sClass = isset($sPlugin)
+ ? 'Plugin' . $sPlugin . '_Module' . $sModule . '_Behavior' . $sEntity
+ : 'Module' . $sModule . '_Behavior' . $sEntity;
+
+ /**
+ * If Plugin Entity doesn't exist, search among it's Module delegates
+ */
+ if (isset($sPlugin) && !self::GetClassPath($sClass)) {
+ $aModulesChain = Engine::GetInstance()->Plugin_GetDelegationChain('module',
+ 'Plugin' . $sPlugin . '_Module' . $sModule);
+ foreach ($aModulesChain as $sModuleName) {
+ $sClassTest = $sModuleName . '_Behavior' . $sEntity;
+ if (self::GetClassPath($sClassTest)) {
+ $sClass = $sClassTest;
+ break;
+ }
+ }
+ if (!self::GetClassPath($sClass)) {
+ $sClass = 'Module' . $sModule . '_Behavior' . $sEntity;
+ }
+ }
+
+ /**
+ * Определяем наличие делегата сущности
+ * Делегирование указывается только в полной форме!
+ */
+ $sClass = self::getInstance()->Plugin_GetDelegate('behavior', $sClass);
+ return $sClass;
+ }
+
+ /**
+ * Создает объект сущности
+ *
+ * @param string $sName Имя сущности, возможны сокращенные варианты.
+ * Например ModuleUser_EntityUser эквивалентно User_User и эквивалентно User т.к. имя сущности совпадает с именем модуля
+ * @param array $aParams
+ * @return Entity
+ */
+ public static function GetEntity($sName, $aParams = array())
+ {
+ $sClass = self::GetEntityClass($sName);
+ $oEntity = new $sClass($aParams);
+ return $oEntity;
+ }
+
+ /**
+ * Создает объект поведения
+ *
+ * @param string $sName Имя поведения, возможны сокращенные варианты.
+ * Например ModuleUser_BehaviorUser эквивалентно User_User и эквивалентно User т.к. имя поведения совпадает с именем модуля
+ * @param array $aParams
+ * @return Behavior
+ */
+ public static function GetBehavior($sName, $aParams = array())
+ {
+ $sClass = self::GetBehaviorClass($sName);
+ return new $sClass($aParams);
+ }
+
+ /**
+ * Создает набор сущностей
+ *
+ * @param string $sName Имя сущности, возможны сокращенные варианты
+ * @param array $aItems Двумерный массив набора данных сущностей
+ * @param null|string $sIndexKey Ключ массива из набора данных, по которому будут формироваться ключи результирующего набора сущностей
+ *
+ * @return array
+ */
+ public static function GetEntityItems($sName, $aItems, $sIndexKey = null)
+ {
+ $aReturn = array();
+ foreach ($aItems as $aRow) {
+ if (is_null($sIndexKey)) {
+ $aReturn[] = self::GetEntity($sName, $aRow);
+ } else {
+ $aReturn[$aRow[$sIndexKey]] = self::GetEntity($sName, $aRow);
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Возвращает имя плагина модуля если модуль принадлежит плагину.
+ * Например Openid
+ *
+ * @static
+ * @param Module $oModule Объект модуля
+ * @return string|null
+ */
+ public static function GetPluginName($oModule)
+ {
+ return self::GetClassInfo($oModule, self::CI_PLUGIN, true);
+ }
+
+ /**
+ * Возвращает префикс плагина
+ * Например PluginOpenid_
+ *
+ * @static
+ * @param Module $oModule Объект модуля
+ * @return string Если плагина нет, возвращает пустую строку
+ */
+ public static function GetPluginPrefix($oModule)
+ {
+ return self::GetClassInfo($oModule, self::CI_PPREFIX, true);
+ }
+
+ /**
+ * Возвращает имя модуля
+ *
+ * @static
+ * @param Module $oModule Объект модуля
+ * @return string|null
+ */
+ public static function GetModuleName($oModule)
+ {
+ return self::GetClassInfo($oModule, self::CI_MODULE, true);
+ }
+
+ /**
+ * Возвращает имя сущности
+ *
+ * @static
+ * @param Entity $oEntity Объект сущности
+ * @return string|null
+ */
+ public static function GetEntityName($oEntity)
+ {
+ return self::GetClassInfo($oEntity, self::CI_ENTITY, true);
+ }
+
+ /**
+ * Возвращает имя поведения
+ *
+ * @static
+ * @param Behavior $oBehavior Объект сущности
+ * @return string|null
+ */
+ public static function GetBehaviorName($oBehavior)
+ {
+ return self::GetClassInfo($oBehavior, self::CI_BEHAVIOR, true);
+ }
+
+ /**
+ * Возвращает имя экшена
+ *
+ * @static
+ * @param $oAction Объект экшена
+ * @return string|null
+ */
+ public static function GetActionName($oAction)
+ {
+ return self::GetClassInfo($oAction, self::CI_ACTION, true);
+ }
+
+ /**
+ * Возвращает информацию об объекте или классе
+ *
+ * @static
+ * @param LsObject|string $oObject Объект или имя класса
+ * @param int $iFlag Маска по которой нужно вернуть рузультат. Доступные маски определены в константах CI_*
+ * Например, получить информацию о плагине и модуле:
+ *
+ * Engine::GetClassInfo($oObject,Engine::CI_PLUGIN | Engine::CI_MODULE);
+ *
+ * @param bool $bSingle Возвращать полный результат или только первый элемент
+ * @return array|string|null
+ */
+ public static function GetClassInfo($oObject, $iFlag = self::CI_DEFAULT, $bSingle = false)
+ {
+ static $aCache;
+ /**
+ * Проверяем данные в статическом кеше
+ */
+ $sClassName = is_string($oObject) ? $oObject : get_class($oObject);
+
+ $sCacheKey = $sClassName . '_' . $iFlag . '_' . intval($bSingle);
+ if (!isset($aCache[$sCacheKey])) {
+ $aResultParser = self::ParserClassInfo($sClassName);
+
+ /**
+ * Возвращаем только нужные данные
+ */
+ $aResult = array();
+ foreach ($aResultParser as $k => $v) {
+ if ($iFlag & $k) {
+ $aResult[$k] = $v;
+ }
+ }
+
+ $aCache[$sCacheKey] = $bSingle ? array_pop($aResult) : $aResult;
+ }
+
+ return $aCache[$sCacheKey];
+ }
+
+ /**
+ * Парсит имя класса на составляющие
+ *
+ * @param string $sClassName Имя класса
+ * @param int $iFlag Маска по которой нужно вернуть рузультат. Доступные маски определены в константах CI_*
+ * @return array
+ */
+ protected static function ParserClassInfo($sClassName, $iFlag = self::CI_ALL)
+ {
+ static $aCache;
+
+ $sCacheKey = $sClassName . '_' . $iFlag;
+ if (!isset($aCache[$sCacheKey])) {
+ $aResult = array();
+ if ($iFlag & self::CI_PLUGIN) {
+ $aResult[self::CI_PLUGIN] = preg_match('/^Plugin([^_]+)/', $sClassName, $aMatches)
+ ? $aMatches[1]
+ : null;
+ }
+ if ($iFlag & self::CI_ACTION) {
+ $aResult[self::CI_ACTION] = preg_match('/^(?:Plugin[^_]+_|)Action([^_]+)/', $sClassName, $aMatches)
+ ? $aMatches[1]
+ : null;
+ }
+ if ($iFlag & self::CI_MODULE) {
+ $aResult[self::CI_MODULE] = preg_match('/^(?:Plugin[^_]+_|)Module(?:ORM|)([^_]+)/', $sClassName, $aMatches)
+ ? $aMatches[1]
+ : null;
+ }
+ if ($iFlag & self::CI_ENTITY) {
+ $aResult[self::CI_ENTITY] = preg_match('/_Entity(?:ORM|)([^_]+)/', $sClassName, $aMatches)
+ ? $aMatches[1]
+ : null;
+ }
+ if ($iFlag & self::CI_BEHAVIOR) {
+ $aResult[self::CI_BEHAVIOR] = preg_match('/_Behavior([^_]+)/', $sClassName, $aMatches)
+ ? $aMatches[1]
+ : null;
+ }
+ if ($iFlag & self::CI_MAPPER) {
+ $aResult[self::CI_MAPPER] = preg_match('/_Mapper(?:ORM|)([^_]+)/', $sClassName, $aMatches)
+ ? $aMatches[1]
+ : null;
+ }
+ if ($iFlag & self::CI_HOOK) {
+ $aResult[self::CI_HOOK] = preg_match('/^(?:Plugin[^_]+_|)Hook([^_]+)$/', $sClassName, $aMatches)
+ ? $aMatches[1]
+ : null;
+ }
+ if ($iFlag & self::CI_BLOCK) {
+ $aResult[self::CI_BLOCK] = preg_match('/^(?:Plugin[^_]+_|)Block([^_]+)$/', $sClassName, $aMatches)
+ ? $aMatches[1]
+ : null;
+ }
+ if ($iFlag & self::CI_EVENT) {
+ $aResult[self::CI_EVENT] = preg_match('/_Event([^_]+)/', $sClassName, $aMatches)
+ ? $aMatches[1]
+ : null;
+ }
+ if ($iFlag & self::CI_METHOD) {
+ $sModuleName = $aResult[self::CI_MODULE];
+ $aResult[self::CI_METHOD] = preg_match('/_([^_]+)$/', $sClassName, $aMatches)
+ ? ($sModuleName && strtolower($aMatches[1]) == strtolower('module' . $sModuleName)
+ ? null
+ : $aMatches[1]
+ )
+ : null;
+ }
+ if ($iFlag & self::CI_PPREFIX) {
+ $sPluginName = $aResult[self::CI_PLUGIN];
+ $aResult[self::CI_PPREFIX] = $sPluginName
+ ? "Plugin{$sPluginName}_"
+ : '';
+ }
+ if ($iFlag & self::CI_INHERIT) {
+ $aResult[self::CI_INHERIT] = preg_match('/_Inherits?_(\w+)$/', $sClassName, $aMatches)
+ ? $aMatches[1]
+ : null;
+ }
+ if ($iFlag & self::CI_INHERITS) {
+ $sInherit = $aResult[self::CI_INHERIT];
+ $aResult[self::CI_INHERITS] = $sInherit
+ ? self::ParserClassInfo(
+ $sInherit,
+ self::CI_OBJECT)
+ : null;
+ }
+ if ($iFlag & self::CI_CLASSPATH) {
+ $aResult[self::CI_CLASSPATH] = self::GetClassPath($sClassName);
+ }
+
+ $aCache[$sCacheKey] = $aResult;
+ }
+ return $aCache[$sCacheKey];
+ }
+
+ /**
+ * Возвращает информацию о пути до файла класса.
+ * Используется в {@link autoload автозагрузке}
+ *
+ * @static
+ * @param LsObject $oObject Объект - модуль, экшен, плагин, хук, сущность
+ * @return null|string
+ */
+ public static function GetClassPath($oObject)
+ {
+ static $aCache;
+
+ $sClassName = is_string($oObject) ? $oObject : get_class($oObject);
+ $sCacheKey = $sClassName;
+
+ if (!isset($aCache[$sCacheKey])) {
+ $aInfo = self::ParserClassInfo(
+ $sClassName,
+ self::CI_OBJECT
+ );
+ $sPath = Config::get('path.application.server') . '/';
+ $sPathFramework = Config::get('path.framework.server') . '/';
+ if ($aInfo[self::CI_ENTITY]) {
+ // Сущность
+ if ($aInfo[self::CI_PLUGIN]) {
+ // Сущность модуля плагина
+ $sPath .= 'plugins/' . func_underscore($aInfo[self::CI_PLUGIN])
+ . '/classes/modules/' . func_underscore($aInfo[self::CI_MODULE])
+ . '/entity/' . $aInfo[self::CI_ENTITY] . '.entity.class.php';
+ } else {
+ // Сущность модуля ядра
+ $sFile = 'classes/modules/' . func_underscore($aInfo[self::CI_MODULE])
+ . '/entity/' . $aInfo[self::CI_ENTITY] . '.entity.class.php';
+ $sPath .= $sFile;
+ if (!is_file($sPath)) {
+ $sPath = $sPathFramework . $sFile;
+ }
+ }
+ } elseif ($aInfo[self::CI_BEHAVIOR]) {
+ // Поведение
+ if ($aInfo[self::CI_PLUGIN]) {
+ // Поведение модуля плагина
+ $sPath .= 'plugins/' . func_underscore($aInfo[self::CI_PLUGIN])
+ . '/classes/modules/' . func_underscore($aInfo[self::CI_MODULE])
+ . '/behavior/' . $aInfo[self::CI_BEHAVIOR] . '.behavior.class.php';
+ } else {
+ // Поведение модуля ядра
+ $sFile = 'classes/modules/' . func_underscore($aInfo[self::CI_MODULE])
+ . '/behavior/' . $aInfo[self::CI_BEHAVIOR] . '.behavior.class.php';
+ $sPath .= $sFile;
+ if (!is_file($sPath)) {
+ $sPath = $sPathFramework . $sFile;
+ }
+ }
+ } elseif ($aInfo[self::CI_MAPPER]) {
+ // Маппер
+ if ($aInfo[self::CI_PLUGIN]) {
+ // Маппер модуля плагина
+ $sPath .= 'plugins/' . func_underscore($aInfo[self::CI_PLUGIN])
+ . '/classes/modules/' . func_underscore($aInfo[self::CI_MODULE])
+ . '/mapper/' . $aInfo[self::CI_MAPPER] . '.mapper.class.php';
+ } else {
+ // Маппер модуля ядра
+ $sFile = 'classes/modules/' . func_underscore($aInfo[self::CI_MODULE])
+ . '/mapper/' . $aInfo[self::CI_MAPPER] . '.mapper.class.php';
+ $sPath .= $sFile;
+ if (!is_file($sPath)) {
+ $sPath = $sPathFramework . $sFile;
+ }
+ }
+ } elseif ($aInfo[self::CI_EVENT]) {
+ // Евент
+ if ($aInfo[self::CI_PLUGIN]) {
+ // Евент плагина
+ $sPath .= 'plugins/' . func_underscore($aInfo[self::CI_PLUGIN])
+ . '/classes/actions/' . lcfirst($aInfo[self::CI_ACTION]) . '/Event' . $aInfo[self::CI_EVENT] . '.class.php';
+ } else {
+ // Евент ядра
+ $sPath .= 'classes/actions/' . lcfirst($aInfo[self::CI_ACTION]) . '/Event'
+ . $aInfo[self::CI_EVENT] . '.class.php';
+ }
+ } elseif ($aInfo[self::CI_ACTION]) {
+ // Экшн
+ if ($aInfo[self::CI_PLUGIN]) {
+ // Экшн плагина
+ $sPath .= 'plugins/' . func_underscore($aInfo[self::CI_PLUGIN])
+ . '/classes/actions/Action' . $aInfo[self::CI_ACTION] . '.class.php';
+ } else {
+ // Экшн ядра
+ $sPath .= 'classes/actions/Action'
+ . $aInfo[self::CI_ACTION] . '.class.php';
+ }
+ } elseif ($aInfo[self::CI_MODULE]) {
+ // Модуль
+ if ($aInfo[self::CI_PLUGIN]) {
+ // Модуль плагина
+ $sPath .= 'plugins/' . func_underscore($aInfo[self::CI_PLUGIN])
+ . '/classes/modules/' . func_underscore($aInfo[self::CI_MODULE])
+ . '/' . $aInfo[self::CI_MODULE] . '.class.php';
+ } else {
+ // Модуль ядра
+ $sFile = 'classes/modules/' . func_underscore($aInfo[self::CI_MODULE])
+ . '/' . $aInfo[self::CI_MODULE] . '.class.php';
+ $sPath .= $sFile;
+ if (!is_file($sPath)) {
+ $sPath = $sPathFramework . $sFile;
+ }
+ }
+ } elseif ($aInfo[self::CI_HOOK]) {
+ // Хук
+ if ($aInfo[self::CI_PLUGIN]) {
+ // Хук плагина
+ $sPath .= 'plugins/' . func_underscore($aInfo[self::CI_PLUGIN])
+ . '/classes/hooks/Hook' . $aInfo[self::CI_HOOK]
+ . '.class.php';
+ } else {
+ // Хук ядра
+ $sPath .= 'classes/hooks/Hook' . $aInfo[self::CI_HOOK] . '.class.php';
+ }
+ } elseif ($aInfo[self::CI_BLOCK]) {
+ // Блок
+ if ($aInfo[self::CI_PLUGIN]) {
+ // Блок плагина
+ $sPath .= 'plugins/' . func_underscore($aInfo[self::CI_PLUGIN])
+ . '/classes/blocks/Block' . $aInfo[self::CI_BLOCK]
+ . '.class.php';
+ } else {
+ // Блок ядра
+ $sPath .= 'classes/blocks/Block' . $aInfo[self::CI_BLOCK] . '.class.php';
+ }
+ } elseif ($aInfo[self::CI_PLUGIN]) {
+ // Плагин
+ $sPath .= 'plugins/' . func_underscore($aInfo[self::CI_PLUGIN])
+ . '/Plugin' . $aInfo[self::CI_PLUGIN]
+ . '.class.php';
+ } else {
+ $sPath = $sPathFramework . 'classes/engine/' . $sClassName . '.class.php';
+ }
+ $aCache[$sCacheKey] = is_file($sPath) ? $sPath : null;
+ }
+ return $aCache[$sCacheKey];
+ }
+
+
+ /**
+ * Автозагрузка классов
+ *
+ * @param string $sClassName Название класса
+ * @return bool
+ */
+ public static function autoload($sClassName)
+ {
+ $aInfo = Engine::GetClassInfo(
+ $sClassName,
+ Engine::CI_CLASSPATH | Engine::CI_INHERIT
+ );
+ if ($aInfo[Engine::CI_INHERIT]) {
+ $sInheritClass = $aInfo[Engine::CI_INHERIT];
+ $sParentClass = Engine::getInstance()->Plugin_GetParentInherit($sInheritClass);
+ if (class_alias($sParentClass, $sClassName)) {
+ return true;
+ }
+ } elseif ($aInfo[Engine::CI_CLASSPATH]) {
+ require_once $aInfo[Engine::CI_CLASSPATH];
+ return true;
+ } elseif (!class_exists($sClassName)) {
+ /**
+ * Проверяем соответствие PSR-0
+ */
+ $sClassName = ltrim($sClassName, '\\');
+ $sFileName = '';
+ $sNameSpace = '';
+ if ($iLastNsPos = strrpos($sClassName, '\\')) {
+ $sNameSpace = substr($sClassName, 0, $iLastNsPos);
+ $sClassName = substr($sClassName, $iLastNsPos + 1);
+ $sFileName = str_replace('\\', DIRECTORY_SEPARATOR, $sNameSpace) . DIRECTORY_SEPARATOR;
+ }
+ $sFileName .= str_replace('_', DIRECTORY_SEPARATOR, $sClassName) . '.php';
+ $sFileName = Config::Get('path.framework.libs_vendor.server') . DIRECTORY_SEPARATOR . $sFileName;
+ if (file_exists($sFileName)) {
+ require_once($sFileName);
+ return true;
+ }
+ //throw new Exception("(autoload '$sClassName') Can not load CLASS-file");
+ }
+ return false;
+ }
+
+ /**
+ * Регистрация автозагрузки классов
+ */
+ protected function AutoloadRegister()
+ {
+ spl_autoload_register(array('Engine', 'autoload'));
+ /**
+ * Подключаем PSR-4 автозагрузчик
+ */
+ require_once(Config::Get('path.framework.libs_vendor.server') . DIRECTORY_SEPARATOR . 'php-fig' . DIRECTORY_SEPARATOR . 'PSR-4' . DIRECTORY_SEPARATOR . 'Psr4AutoloaderClass.php');
+ self::$oAutoloader = new Psr4AutoloaderClass();
+ self::$oAutoloader->register();
+ }
+
+ /**
+ * Добавляет базовую директорию к префиксу пространства имён.
+ *
+ * @param string $sPrefix Префикс пространства имён.
+ * @param string $sBaseDir Базовая директория для файлов классов из пространства имён.
+ * @param bool $bPrepend Если true, добавить базовую директорию в начало стека. В этом случае она будет
+ * проверяться первой.
+ * @return void
+ */
+ static public function AddAutoloaderNamespace($sPrefix, $sBaseDir, $bPrepend = false)
+ {
+ self::$oAutoloader->addNamespace($sPrefix, $sBaseDir, $bPrepend);
+ }
+
+ /**
+ * Возвращает текущее окружение
+ *
+ * @return string
+ */
+ static public function GetEnvironment()
+ {
+ return self::$sEnvironment;
+ }
+
+ /**
+ * Устанавливает текущее окружение
+ *
+ * @param string $sEnvironment
+ */
+ static public function SetEnvironment($sEnvironment)
+ {
+ self::$sEnvironment = $sEnvironment;
+ }
+
+ /**
+ * Запускает определение текущего окружения на основе параметров
+ *
+ * @param array|Closure $aEnvironments
+ *
+ * @return int|mixed|null|string
+ */
+ static public function DetectEnvironment($aEnvironments)
+ {
+ $aConsoleArgs = isset($_SERVER['argv']) ? $_SERVER['argv'] : null;
+ /**
+ * Если запуск из консоли и переданы параметры
+ */
+ if ($aConsoleArgs) {
+ $sEnv = self::DetectEnvironmentConsole($aEnvironments, $aConsoleArgs);
+ } else {
+ $sEnv = self::DetectEnvironmentWeb($aEnvironments);
+ }
+
+ if ($sEnv) {
+ self::$sEnvironment = $sEnv;
+ }
+ return self::$sEnvironment;
+ }
+
+ /**
+ * Определяет окружение на основе WEB-запроса к странице
+ *
+ * @param array|Closure $aEnvironments
+ *
+ * @return int|mixed|null|string
+ */
+ static protected function DetectEnvironmentWeb($aEnvironments)
+ {
+ if ($aEnvironments instanceof Closure) {
+ return call_user_func($aEnvironments);
+ }
+
+ foreach ($aEnvironments as $sEnvironment => $aHosts) {
+ foreach ((array)$aHosts as $sHost) {
+ if ($sHost == gethostname()) {
+ return $sEnvironment;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Определяет окружение на основе запуска через cli
+ * Окружение передается как параметр --env=production
+ *
+ * @param array|Closure $aEnvironments
+ * @param array $aConsoleArgs
+ *
+ * @return int|mixed|null|string
+ */
+ static protected function DetectEnvironmentConsole($aEnvironments, $aConsoleArgs)
+ {
+ $aArgs = array_filter($aConsoleArgs, function ($sItem) {
+ return strpos($sItem, '--env') === 0;
+ });
+
+ if ($sArg = reset($aArgs)) {
+ return reset(array_slice(explode('=', $sArg), 1));
+ } else {
+ return self::DetectEnvironmentWeb($aEnvironments);
+ }
+ }
+}
+
+
+/**
+ * Короткий алиас для вызова основных методов движка
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+class LS extends LsObject
+{
+
+ static protected $oInstance = null;
+
+ static public function getInstance()
+ {
+ if (isset(self::$oInstance) and (self::$oInstance instanceof self)) {
+ return self::$oInstance;
+ } else {
+ self::$oInstance = new self();
+ return self::$oInstance;
+ }
+ }
+
+ /**
+ * Возвращает ядро
+ * @see Engine::GetInstance
+ *
+ * @return Engine
+ */
+ static public function E()
+ {
+ return Engine::GetInstance();
+ }
+
+ /**
+ * Возвращает объект сущности
+ * @see Engine::GetEntity
+ *
+ * @param $sName Название сущности
+ * @param array $aParams Параметры для передачи в конструктор
+ * @return Entity
+ */
+ static public function Ent($sName, $aParams = array())
+ {
+ return Engine::GetEntity($sName, $aParams);
+ }
+
+ /**
+ * Возвращает объект маппера
+ * @see Engine::GetMapper
+ *
+ * @param $sClassName Класс модуля маппера
+ * @param string|null $sName Имя маппера
+ * @param DbSimple_Mysql|null $oConnect Объект коннекта к БД
+ * @return mixed
+ */
+ static public function Mpr($sClassName, $sName = null, $oConnect = null)
+ {
+ return Engine::GetMapper($sClassName, $sName, $oConnect);
+ }
+
+ /**
+ * Возвращает текущего авторизованного пользователя
+ * @see ModuleUser::GetUserCurrent
+ *
+ * @return ModuleUser_EntityUser
+ */
+ static public function CurUsr()
+ {
+ return self::E()->User_GetUserCurrent();
+ }
+
+ /**
+ * Возвращает true если текущий пользователь администратор
+ * @see ModuleUser::GetUserCurrent
+ * @see ModuleUser_EntityUser::isAdministrator
+ *
+ * @return bool
+ */
+ static public function Adm()
+ {
+ return self::CurUsr() && self::CurUsr()->isAdministrator();
+ }
+
+ /**
+ * Вызов метода модуля
+ * Например $LS->Module_Method()
+ *
+ * @param $sName Полное название метода, например Module_Method
+ * @param array $aArgs Список аргуметов метода
+ * @return mixed
+ */
+ public function __call($sName, $aArgs = array())
+ {
+ return call_user_func_array(array(self::E(), $sName), $aArgs);
+ }
+
+ /**
+ * Статический вызов метода модуля для PHP >= 5.3
+ * Например LS::Module_Method()
+ *
+ * @static
+ * @param $sName Полное название метода, например Module_Method
+ * @param array $aArgs Список аргуметов метода
+ * @return mixed
+ */
+ public static function __callStatic($sName, $aArgs = array())
+ {
+ return call_user_func_array(array(self::E(), $sName), $aArgs);
+ }
+}
diff --git a/framework/classes/engine/Entity.class.php b/framework/classes/engine/Entity.class.php
new file mode 100644
index 0000000..d3853e3
--- /dev/null
+++ b/framework/classes/engine/Entity.class.php
@@ -0,0 +1,452 @@
+
+ *
+ */
+
+/**
+ * Абстрактный класс сущности.
+ * При запросе к базе данных удобно возвращать не просто массив данных, а данные в виде специального объекта - Entity.
+ * Основные методы такого объекта делятся на два вида: get-методы и set-методы.
+ * Первые получают свойство объекта по его имени, а вторые устанавливают.
+ * Сущности поддерживает "магические" методы set* и get* , например
+ *
+ * $oEntity->getMyProperty()
+ * вернет данные по ключу/полю my_property
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+abstract class Entity extends LsObject
+{
+ /**
+ * Данные сущности, на этот массив мапятся методы set* и get*
+ *
+ * @var array
+ */
+ protected $_aData = array();
+ /**
+ * Имя поля с первичным ключом в БД
+ *
+ * @var null|string
+ */
+ protected $sPrimaryKey = null;
+ /**
+ * Список правил валидации полей
+ * @see ModuleValidate
+ *
+ * @var array
+ */
+ protected $aValidateRules = array();
+ /**
+ * Список ошибок валидации в разрезе полей, например
+ *
+ * array(
+ * 'title' => array('error one','error two'),
+ * 'name' => array('error one','error two'),
+ * )
+ *
+ *
+ * @var array
+ */
+ protected $aValidateErrors = array();
+ /**
+ * Сценарий валиадции полей
+ * @see _setValidateScenario
+ *
+ * @var string
+ */
+ protected $sValidateScenario = '';
+
+
+ /**
+ * Если передать в конструктор ассоциативный массив свойств и их значений, то они автоматом загрузятся в сущность
+ *
+ * @param array|false $aParam Ассоциативный массив данных сущности
+ */
+ public function __construct($aParam = false)
+ {
+ parent::__construct();
+ $this->_setData($aParam);
+ $this->Init();
+ }
+
+ /**
+ * Метод инициализации сущности, вызывается при её создании
+ */
+ public function Init()
+ {
+
+ }
+
+ /**
+ * Устанавливает данные сущности
+ *
+ * @param array $aData Ассоциативный массив данных сущности
+ */
+ public function _setData($aData)
+ {
+ if (is_array($aData)) {
+ foreach ($aData as $sKey => $val) {
+ $this->_aData[$sKey] = $val;
+ }
+ }
+ }
+
+ /**
+ * Устанавливает данные, но только те, которые есть в $this->aValidateRules
+ *
+ * @param array $aData Ассоциативный массив данных сущности
+ * @param array $aSetEmpty Список полей, которые устанавливаются в значение null, если их нет в первом параметре $aData
+ */
+ public function _setDataSafe($aData, $aSetEmpty = array())
+ {
+ /**
+ * Составляем список доступных полей
+ */
+ if (is_array($aData)) {
+ $sScenario = $this->_getValidateScenario();
+ $aFields = array();
+ foreach ($this->aValidateRules as $aRule) {
+ if ((empty($aRule['on']) && !$sScenario) || in_array($sScenario, $aRule['on'])) {
+ $aFields = array_merge($aFields, preg_split('/[\s,]+/', $aRule[0], -1, PREG_SPLIT_NO_EMPTY));
+ }
+ }
+ $aFields = array_unique($aFields);
+ foreach ($aData as $sKey => $val) {
+ if (in_array($sKey, $aFields)) {
+ $this->_aData[$sKey] = $val;
+ }
+ }
+ foreach ($aSetEmpty as $sFieldEmpty) {
+ if (!array_key_exists($sFieldEmpty, $aData)) {
+ $this->_aData[$sFieldEmpty] = null;
+ }
+ }
+ }
+ }
+
+ /**
+ * Получает массив данных сущности
+ *
+ * @param array|null $aKeys Список полей, данные по которым необходимо вернуть, если не передан, то возвращаются все данные
+ * @return array
+ */
+ public function _getData($aKeys = array())
+ {
+ if (!is_array($aKeys) or !count($aKeys)) {
+ return $this->_aData;
+ }
+
+ $aReturn = array();
+ foreach ($aKeys as $key) {
+ if (array_key_exists($key, $this->_aData)) {
+ $aReturn[$key] = $this->_aData[$key];
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Возвращает данные по конкретному полю
+ *
+ * @param string $sKey Название поля, например 'my_property'
+ * @return null|mixed
+ */
+ public function _getDataOne($sKey)
+ {
+ if (!is_array($sKey) && array_key_exists($sKey, $this->_aData)) {
+ return $this->_aData[$sKey];
+ }
+ return null;
+ }
+
+ /**
+ * Рекурсивное преобразование объекта и вложенных объектов в массив
+ *
+ * @return array
+ */
+ public function _getDataArray()
+ {
+ $aResult = array();
+ foreach ($this->_aData as $sKey => $sValue) {
+ if (is_object($sValue) && $sValue instanceOf Entity) {
+ $aResult[$sKey] = $sValue->_getDataArray();
+ } else {
+ $aResult[$sKey] = $sValue;
+ }
+ }
+ return $aResult;
+ }
+
+ /**
+ * Ставим хук на вызов неизвестного метода и считаем что хотели вызвать метод какого либо модуля
+ * Также производит обработку методов set* и get*
+ * @see Engine::_CallModule
+ *
+ * @param string $sName Имя метода
+ * @param array $aArgs Аргументы
+ * @return mixed
+ */
+ public function __call($sName, $aArgs)
+ {
+ $sType = strtolower(substr($sName, 0, 3));
+ if (!strpos($sName, '_') and in_array($sType, array('get', 'set'))) {
+ $sKey = func_underscore(substr($sName, 3));
+ if ($sType == 'get') {
+ if (isset($this->_aData[$sKey])) {
+ return $this->_aData[$sKey];
+ } else {
+ if (preg_match('/Entity([^_]+)/', get_class($this), $sModulePrefix)) {
+ $sModulePrefix = func_underscore($sModulePrefix[1]) . '_';
+ if (isset($this->_aData[$sModulePrefix . $sKey])) {
+ return $this->_aData[$sModulePrefix . $sKey];
+ }
+ }
+ }
+ return null;
+ } elseif ($sType == 'set' and array_key_exists(0, $aArgs)) {
+ $this->_aData[$sKey] = $aArgs[0];
+ return $this;
+ }
+ } else {
+ return parent::__call($sName, $aArgs);
+ }
+ }
+
+ /**
+ * Получение первичного ключа сущности (ключ, а не значение!)
+ * @see _getPrimaryKeyValue
+ *
+ * @return null|string
+ */
+ public function _getPrimaryKey()
+ {
+ if (!$this->sPrimaryKey) {
+ if (isset($this->_aData['id'])) {
+ $this->sPrimaryKey = 'id';
+ } else {
+ // Получение primary_key из схемы бд (пока отсутствует)
+ $this->sPrimaryKey = 'id';
+ }
+ }
+
+ return $this->sPrimaryKey;
+ }
+
+ /**
+ * Возвращает значение первичного ключа/поля
+ *
+ * @return mixed|null
+ */
+ public function _getPrimaryKeyValue()
+ {
+ return $this->_getDataOne($this->_getPrimaryKey());
+ }
+
+ /**
+ * Возвращает список правил для валидации
+ *
+ * @return array
+ */
+ public function _getValidateRules()
+ {
+ return $this->aValidateRules;
+ }
+
+ /**
+ * Хук, срабатывает перед валидацией сущности
+ *
+ * @return bool
+ */
+ protected function beforeValidate()
+ {
+ $bResult = true;
+ $this->RunBehaviorHook('before_validate', array('bResult' => &$bResult));
+ return $bResult;
+ }
+
+ /**
+ * Выполняет валидацию данных сущности
+ * Если $aFields=null, то выполняется валидация по всем полям из $this->aValidateRules, иначе по пересечению
+ *
+ * @param null|array $aFields Список полей для валидации, если null то по всем полям
+ * @param bool $bClearErrors Очищать или нет стек ошибок перед валидацией
+ *
+ * @return bool
+ */
+ public function _Validate($aFields = null, $bClearErrors = true)
+ {
+ if (!$this->beforeValidate()) {
+ return false;
+ }
+ if ($bClearErrors) {
+ $this->_clearValidateErrors();
+ }
+ foreach ($this->_getValidators() as $validator) {
+ $validator->validateEntity($this, $aFields);
+ }
+ $bResult = !$this->_hasValidateErrors();
+ $this->RunBehaviorHook('validate_after',
+ array('bResult' => &$bResult, 'aFields' => $aFields, 'bClearErrors' => $bClearErrors));
+ return $bResult;
+ }
+
+ /**
+ * Возвращает список валидаторов с учетом текущего сценария
+ *
+ * @param null|string $sField Поле сущности для которого необходимо вернуть валидаторы, если нет, то возвращается для всех полей
+ *
+ * @return array
+ */
+ public function _getValidators($sField = null)
+ {
+ $aValidators = $this->_createValidators();
+
+ $aValidatorsReturn = array();
+ $sScenario = $this->_getValidateScenario();
+ foreach ($aValidators as $oValidator) {
+ /**
+ * Проверка на текущий сценарий
+ */
+ if ($oValidator->applyTo($sScenario)) {
+ if ($sField === null || in_array($sField, $oValidator->fields, true)) {
+ $aValidatorsReturn[] = $oValidator;
+ }
+ }
+ }
+ return $aValidatorsReturn;
+ }
+
+ /**
+ * Создает и возвращает список валидаторов для сущности
+ * @see ModuleValidate::CreateValidator
+ *
+ * @return array
+ * @throws Exception
+ */
+ public function _createValidators()
+ {
+ $aValidators = array();
+ foreach ($this->aValidateRules as $aRule) {
+ if (isset($aRule[0], $aRule[1])) {
+ $aValidators[] = $this->Validate_CreateValidator($aRule[1], $this, $aRule[0], array_slice($aRule, 2));
+ } else {
+ throw new Exception(get_class($this) . ' has an invalid validation rule');
+ }
+ }
+ return $aValidators;
+ }
+
+ /**
+ * Проверяет есть ли ошибки валидации
+ *
+ * @param null|string $sField Поле сущности, если нет, то проверяется для всех полей
+ *
+ * @return bool
+ */
+ public function _hasValidateErrors($sField = null)
+ {
+ if ($sField === null) {
+ return $this->aValidateErrors !== array();
+ } else {
+ return isset($this->aValidateErrors[$sField]);
+ }
+ }
+
+ /**
+ * Возвращает список ошибок для всех полей или одного поля
+ *
+ * @param null|string $sField Поле сущности, если нет, то возвращается для всех полей
+ *
+ * @return array
+ */
+ public function _getValidateErrors($sField = null)
+ {
+ if ($sField === null) {
+ return $this->aValidateErrors;
+ } else {
+ return isset($this->aValidateErrors[$sField]) ? $this->aValidateErrors[$sField] : array();
+ }
+ }
+
+ /**
+ * Возвращает первую ошибку для поля или среди всех полей
+ *
+ * @param null|string $sField Поле сущности
+ *
+ * @return string|null
+ */
+ public function _getValidateError($sField = null)
+ {
+ if ($sField === null) {
+ foreach ($this->_getValidateErrors() as $sFieldKey => $aErros) {
+ return reset($aErros);
+ }
+ } else {
+ return isset($this->aValidateErrors[$sField]) ? reset($this->aValidateErrors[$sField]) : null;
+ }
+ }
+
+ /**
+ * Добавляет для поля ошибку в список ошибок
+ *
+ * @param string $sField Поле сущности
+ * @param string $sError Сообщение об ошибке
+ */
+ public function _addValidateError($sField, $sError)
+ {
+ $this->aValidateErrors[$sField][] = $sError;
+ }
+
+ /**
+ * Очищает список всех ошибок или для конкретного поля
+ *
+ * @param null|string $sField Поле сущности
+ */
+ public function _clearValidateErrors($sField = null)
+ {
+ if ($sField === null) {
+ $this->aValidateErrors = array();
+ } else {
+ unset($this->aValidateErrors[$sField]);
+ }
+ }
+
+ /**
+ * Возвращает текущий сценарий валидации
+ *
+ * @return string
+ */
+ public function _getValidateScenario()
+ {
+ return $this->sValidateScenario;
+ }
+
+ /**
+ * Устанавливает сценарий валидации
+ * Если использовать валидацию без сценария, то будут использоваться только те правила, где нет никаких сценариев, либо указан пустой сценарий ''
+ * Если указать сценарий, то проверка будет только по правилам, где в списке сценариев есть указанный
+ *
+ * @param string $sValue
+ */
+ public function _setValidateScenario($sValue)
+ {
+ $this->sValidateScenario = $sValue;
+ }
+}
diff --git a/framework/classes/engine/EntityORM.class.php b/framework/classes/engine/EntityORM.class.php
new file mode 100644
index 0000000..cfbb503
--- /dev/null
+++ b/framework/classes/engine/EntityORM.class.php
@@ -0,0 +1,919 @@
+
+ *
+ */
+
+/**
+ * Абстрактный класс сущности ORM - аналог active record
+ * Позволяет без написания SQL запросов работать с базой данных.
+ *
+ * $oUser=$this->User_GetUserById(1);
+ * $oUser->setName('Claus');
+ * $oUser->Update();
+ *
+ * Возможно получать списки объектов по фильтру:
+ *
+ * $aUsers=$this->User_GetUserItemsByAgeAndSex(18,'male');
+ * // эквивалентно
+ * $aUsers=$this->User_GetUserItemsByFilter(array('age'=>18,'sex'=>'male'));
+ * // эквивалентно, но при использовании #where необходимо указывать префикс таблицы "t"
+ * $aUsers=$this->User_GetUserItemsByFilter(array('#where'=>array('t.age = ?d and t.sex = ?' => array(18,'male'))));
+ *
+ *
+ * @package framework.engine.orm
+ * @since 1.0
+ */
+abstract class EntityORM extends Entity
+{
+ /**
+ * Типы связей сущностей
+ *
+ */
+ const RELATION_TYPE_BELONGS_TO = 'belongs_to';
+ const RELATION_TYPE_HAS_MANY = 'has_many';
+ const RELATION_TYPE_HAS_ONE = 'has_one';
+ const RELATION_TYPE_MANY_TO_MANY = 'many_to_many';
+ const RELATION_TYPE_TREE = 'tree';
+
+ /**
+ * Массив исходных данных сущности
+ *
+ * @var array
+ */
+ protected $_aOriginalData = array();
+ /**
+ * Список полей таблицы сущности
+ *
+ * @var array
+ */
+ protected $aFields = array();
+ /**
+ * Список связей
+ *
+ * @var array
+ */
+ protected $aRelations = array();
+ /**
+ * Список данных связей
+ *
+ * @var array
+ */
+ protected $aRelationsData = array();
+ /**
+ * Список полей, которые нужно хранить как json строку
+ *
+ * @var array
+ */
+ protected $aJsonFields = array();
+ /**
+ * Объекты связей many_to_many
+ *
+ * @var array
+ */
+ protected $_aManyToManyRelations = array();
+ /**
+ * Флаг новая или нет сущность
+ *
+ * @var bool
+ */
+ protected $bIsNew = true;
+
+ /**
+ * Установка связей
+ * @see Entity::__construct
+ *
+ * @param bool $aParam Ассоциативный массив данных сущности
+ */
+ public function __construct($aParam = false)
+ {
+ parent::__construct($aParam);
+ $this->aRelations = $this->_getRelations();
+ }
+
+ /**
+ * Устанавливает данные сущности из БД
+ *
+ * @param $aData
+ */
+ public function _setDataFromDb($aData)
+ {
+ $this->_SetIsNew(false);
+ $this->_setOriginalData($aData);
+ foreach ($aData as $sField => $mValue) {
+ if (in_array($sField, $this->aJsonFields)) {
+ if (!is_null($mValue) and $mJsonData = @json_decode($mValue, true)) {
+ $aData[$sField] = $mJsonData;
+ } else {
+ $aData[$sField] = null;
+ }
+ }
+ }
+ $this->_setData($aData);
+ }
+
+ /**
+ * Получение primary key из схемы таблицы
+ *
+ * @return string|array Если индекс составной, то возвращает массив полей
+ */
+ public function _getPrimaryKey()
+ {
+ if (!$this->sPrimaryKey) {
+ if ($aIndex = $this->ShowPrimaryIndex()) {
+ if (count($aIndex) > 1) {
+ // Составной индекс
+ $this->sPrimaryKey = $aIndex;
+ } else {
+ $this->sPrimaryKey = $aIndex[1];
+ }
+ }
+ }
+ return $this->sPrimaryKey;
+ }
+
+ /**
+ * Получение значения primary key
+ *
+ * @return string
+ */
+ public function _getPrimaryKeyValue()
+ {
+ return $this->_getDataOne($this->_getPrimaryKey());
+ }
+
+ /**
+ * Получение имени родительского поля. Используется в связи RELATION_TYPE_TREE
+ *
+ * @return string
+ */
+ public function _getTreeParentKey()
+ {
+ return 'parent_id';
+ }
+
+ /**
+ * Получение значения родителя. Используется в связи RELATION_TYPE_TREE
+ *
+ * @return string
+ */
+ public function _getTreeParentKeyValue()
+ {
+ return $this->_getDataOne($this->_getTreeParentKey());
+ }
+
+ /**
+ * Новая или нет сущность
+ * Новая - еще не сохранялась в БД
+ *
+ * @return bool
+ */
+ public function _isNew()
+ {
+ return $this->bIsNew;
+ }
+
+ /**
+ * Установка флага "новая"
+ *
+ * @param bool $bIsNew Флаг - новая сущность или нет
+ */
+ public function _SetIsNew($bIsNew)
+ {
+ $this->bIsNew = $bIsNew;
+ }
+
+ /**
+ * Добавление сущности в БД
+ *
+ * @return Entity|false
+ */
+ public function Add()
+ {
+ if ($this->beforeSave()) {
+ if ($res = $this->_Method(__FUNCTION__)) {
+ $this->afterSave();
+ return $res;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Обновление сущности в БД
+ *
+ * @return Entity|false
+ */
+ public function Update()
+ {
+ if ($this->beforeSave()) {
+ if ($res = $this->_Method(__FUNCTION__)) {
+ $this->afterSave();
+ return $res;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Сохранение сущности в БД (если новая то создается)
+ *
+ * @return Entity|false
+ */
+ public function Save()
+ {
+ if ($this->beforeSave()) {
+ if ($res = $this->_Method(__FUNCTION__)) {
+ $this->afterSave();
+ return $res;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Удаление сущности из БД
+ *
+ * @return Entity|false
+ */
+ public function Delete()
+ {
+ if ($this->beforeDelete()) {
+ if ($res = $this->_Method(__FUNCTION__)) {
+ $this->afterDelete();
+ return $res;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет данные сущности из БД
+ *
+ * @return Entity|false
+ */
+ public function Reload()
+ {
+ return $this->_Method(__FUNCTION__);
+ }
+
+ /**
+ * Возвращает список полей сущности
+ *
+ * @return array
+ */
+ public function ShowColumns()
+ {
+ return $this->_Method(__FUNCTION__ . 'From');
+ }
+
+ /**
+ * Возвращает primary индекс сущности
+ *
+ * @return array
+ */
+ public function ShowPrimaryIndex()
+ {
+ return $this->_Method(__FUNCTION__ . 'From');
+ }
+
+ /**
+ * Хук, срабатывает перед сохранением сущности
+ *
+ * @return bool
+ */
+ protected function beforeSave()
+ {
+ $bResult = true;
+ $this->RunBehaviorHook('before_save', array('bResult' => &$bResult));
+ return $bResult;
+ }
+
+ /**
+ * Хук, срабатывает после сохранения сущности
+ *
+ */
+ protected function afterSave()
+ {
+ $this->RunBehaviorHook('after_save');
+ }
+
+ /**
+ * Хук, срабатывает перед удалением сущности
+ *
+ * @return bool
+ */
+ protected function beforeDelete()
+ {
+ $bResult = true;
+ $this->RunBehaviorHook('before_delete', array('bResult' => &$bResult));
+ return $bResult;
+ }
+
+ /**
+ * Хук, срабатывает после удаления сущности
+ *
+ */
+ protected function afterDelete()
+ {
+ $this->RunBehaviorHook('after_delete');
+ }
+
+ /**
+ * Для сущности со связью RELATION_TYPE_TREE возвращает список прямых потомков
+ *
+ * @return array
+ */
+ public function getChildren()
+ {
+ if ($this->_isUsedRelationType(self::RELATION_TYPE_TREE)) {
+ return $this->_Method(__FUNCTION__ . 'Of');
+ }
+ return $this->__call(__FUNCTION__, array());
+ }
+
+ /**
+ * Для сущности со связью RELATION_TYPE_TREE возвращает список всех потомков
+ *
+ * @return array
+ */
+ public function getDescendants()
+ {
+ if ($this->_isUsedRelationType(self::RELATION_TYPE_TREE)) {
+ return $this->_Method(__FUNCTION__ . 'Of');
+ }
+ return $this->__call(__FUNCTION__, array());
+ }
+
+ /**
+ * Для сущности со связью RELATION_TYPE_TREE возвращает предка
+ *
+ * @return Entity
+ */
+ public function getParent()
+ {
+ if ($this->_isUsedRelationType(self::RELATION_TYPE_TREE)) {
+ return $this->_Method(__FUNCTION__ . 'Of');
+ }
+ return $this->__call(__FUNCTION__, array());
+ }
+
+ /**
+ * Для сущности со связью RELATION_TYPE_TREE возвращает список всех предков
+ *
+ * @return array
+ */
+ public function getAncestors()
+ {
+ if ($this->_isUsedRelationType(self::RELATION_TYPE_TREE)) {
+ return $this->_Method(__FUNCTION__ . 'Of');
+ }
+ return $this->__call(__FUNCTION__, array());
+ }
+
+ /**
+ * Для сущности со связью RELATION_TYPE_TREE устанавливает потомков
+ *
+ * @param array $aChildren Список потомков
+ */
+ public function setChildren($aChildren = array())
+ {
+ if ($this->_isUsedRelationType(self::RELATION_TYPE_TREE)) {
+ $this->aRelationsData['children'] = $aChildren;
+ } else {
+ $aArgs = func_get_args();
+ return $this->__call(__FUNCTION__, $aArgs);
+ }
+ }
+
+ /**
+ * Для сущности со связью RELATION_TYPE_TREE устанавливает потомков
+ *
+ * @param array $aDescendants Список потомков
+ */
+ public function setDescendants($aDescendants = array())
+ {
+ if ($this->_isUsedRelationType(self::RELATION_TYPE_TREE)) {
+ $this->aRelationsData['descendants'] = $aDescendants;
+ } else {
+ $aArgs = func_get_args();
+ return $this->__call(__FUNCTION__, $aArgs);
+ }
+ }
+
+ /**
+ * Для сущности со связью RELATION_TYPE_TREE устанавливает предка
+ *
+ * @param Entity $oParent Родитель
+ */
+ public function setParent($oParent = null)
+ {
+ if ($this->_isUsedRelationType(self::RELATION_TYPE_TREE)) {
+ $this->aRelationsData['parent'] = $oParent;
+ } else {
+ $aArgs = func_get_args();
+ return $this->__call(__FUNCTION__, $aArgs);
+ }
+ }
+
+ /**
+ * Для сущности со связью RELATION_TYPE_TREE устанавливает предков
+ *
+ * @param array $oParent Родитель
+ */
+ public function setAncestors($oParent = null)
+ {
+ if ($this->_isUsedRelationType(self::RELATION_TYPE_TREE)) {
+ $this->aRelationsData['ancestors'] = $oParent;
+ } else {
+ $aArgs = func_get_args();
+ return $this->__call(__FUNCTION__, $aArgs);
+ }
+ }
+
+ /**
+ * Проксирует вызов методов в модуль сущности
+ *
+ * @param string $sName Название метода
+ * @return mixed
+ */
+ protected function _Method($sName)
+ {
+ $sRootDelegater = $this->Plugin_GetRootDelegater('entity', get_class($this));
+
+ $sModuleName = Engine::GetModuleName($sRootDelegater);
+ $sPluginPrefix = Engine::GetPluginPrefix($sRootDelegater);
+ $sEntityName = Engine::GetEntityName($sRootDelegater);
+ return Engine::GetInstance()->_CallModule("{$sPluginPrefix}{$sModuleName}_{$sName}{$sEntityName}",
+ array($this));
+ }
+
+ /**
+ * Устанавливает данные сущности
+ *
+ * @param array $aData Ассоциативный массив данных сущности
+ */
+ public function _setData($aData)
+ {
+ if (is_array($aData)) {
+ foreach ($aData as $sKey => $val) {
+ if (array_key_exists($sKey, $this->aRelations)) {
+ $this->aRelationsData[$sKey] = $val;
+ } else {
+ $this->_aData[$sKey] = $val;
+ }
+ }
+ }
+ }
+
+ /**
+ * Устанавливает все оригинальные данные
+ *
+ * @param $aData
+ */
+ public function _setOriginalData($aData)
+ {
+ $this->_aOriginalData = $aData;
+ }
+
+ /**
+ * Возвращает все оригинальные данные сущности
+ *
+ * @return array
+ */
+ public function _getOriginalData()
+ {
+ return $this->_aOriginalData;
+ }
+
+ /**
+ * Возвращает "оригинальные" данные по конкретному полю
+ *
+ * @param string $sKey Название поля, например 'my_property'
+ * @return null|mixed
+ */
+ public function _getOriginalDataOne($sKey)
+ {
+ if (array_key_exists($sKey, $this->_aOriginalData)) {
+ return $this->_aOriginalData[$sKey];
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает данные для списка полей сущности
+ *
+ * @return array
+ */
+ public function _getDataFields($bOnlyChanged = false)
+ {
+ $aData = $this->_getData($this->_getFields());
+ if ($bOnlyChanged) {
+ /**
+ * Сравниваем список оригинальных значений с текущими
+ */
+ $aDataOriginal = $this->_getOriginalData();
+ foreach ($aData as $sKey => $sValue) {
+ if (array_key_exists($sKey, $aDataOriginal) and $aDataOriginal[$sKey] === $sValue) {
+ unset($aData[$sKey]);
+ }
+ }
+ }
+ return $aData;
+ }
+
+ public function _getDataFieldsForDb($bOnlyChanged = false)
+ {
+ $aData = $this->_getDataFields($bOnlyChanged);
+ /**
+ * Проверяем на json поля
+ */
+ foreach ($aData as $sField => $mValue) {
+ if (in_array($sField, $this->aJsonFields)) {
+ $aData[$sField] = json_encode($mValue);
+ }
+ }
+ return $aData;
+ }
+
+ /**
+ * Возвращает список полей сущности
+ *
+ * @return array
+ */
+ public function _getFields()
+ {
+ if (empty($this->aFields)) {
+ $this->aFields = $this->ShowColumns();
+ }
+ return $this->aFields;
+ }
+
+ /**
+ * Возвращает поле в нужном формате
+ *
+ * @param string $sField Название поля
+ * @param int $iPersistence Тип "глубины" определения поля
+ * @return null|string
+ */
+ public function _getField($sField, $iPersistence = 3)
+ {
+ $sRootDelegater = $this->Plugin_GetRootDelegater('entity', get_class($this));
+
+ if ($aFields = $this->_getFields()) {
+ if (in_array($sField, $aFields)) {
+ return $sField;
+ }
+ if ($iPersistence == 0) {
+ return null;
+ }
+ $sFieldU = func_camelize($sField);
+ $sEntityField = func_underscore(Engine::GetEntityName($sRootDelegater) . $sFieldU);
+ if (in_array($sEntityField, $aFields)) {
+ return $sEntityField;
+ }
+ if ($iPersistence == 1) {
+ return null;
+ }
+ $sModuleEntityField = func_underscore(Engine::GetModuleName($sRootDelegater) . Engine::GetEntityName($sRootDelegater) . $sFieldU);
+ if (in_array($sModuleEntityField, $aFields)) {
+ return $sModuleEntityField;
+ }
+ if ($iPersistence == 2) {
+ return null;
+ }
+ $sModuleField = func_underscore(Engine::GetModuleName($sRootDelegater) . $sFieldU);
+ if (in_array($sModuleField, $aFields)) {
+ return $sModuleField;
+ }
+ }
+ return $sField;
+ }
+
+ /**
+ * Возвращает список связей
+ *
+ * @return array
+ */
+ public function _getRelations()
+ {
+ /**
+ * Преобразуем связи к единому ассоциативному виду и проставляем дефолтные значения
+ */
+ foreach ($this->aRelations as $sName => $aParams) {
+ if (is_int($sName)) {
+ /**
+ * Старый вариант использования связи RELATION_TYPE_TREE
+ */
+ $this->aRelations[$sName] = array(
+ 'type' => self::RELATION_TYPE_TREE
+ );
+ continue;
+ }
+ if (isset($aParams['type'])) {
+ /**
+ * Проставляем дефолтные значения
+ */
+ if (!array_key_exists('filter', $aParams)) {
+ $aParams['filter'] = array();
+ }
+ if ($aParams['type'] == EntityORM::RELATION_TYPE_BELONGS_TO) {
+ if (!array_key_exists('rel_key_to', $aParams)) {
+ $aParams['rel_key_to'] = null;
+ }
+ }
+ if ($aParams['type'] == EntityORM::RELATION_TYPE_HAS_MANY) {
+ if (!array_key_exists('key_from', $aParams)) {
+ $aParams['key_from'] = null;
+ }
+ }
+ $this->aRelations[$sName] = $aParams;
+ continue;
+ }
+ /**
+ * Преобразование от старой записи к новой
+ */
+ $aParamsNew = array(
+ 'type' => $aParams[0],
+ 'rel_entity' => $aParams[1],
+ 'rel_key' => $aParams[2],
+ 'filter' => array()
+ );
+ if ($aParamsNew['type'] == EntityORM::RELATION_TYPE_BELONGS_TO) {
+ $aParams['rel_key_to'] = null;
+ }
+ if ($aParamsNew['type'] == EntityORM::RELATION_TYPE_HAS_ONE) {
+ if (isset($aParams[3])) {
+ $aParamsNew['filter'] = $aParams[3];
+ }
+ }
+ if ($aParamsNew['type'] == EntityORM::RELATION_TYPE_HAS_MANY) {
+ if (isset($aParams[3])) {
+ $aParamsNew['filter'] = $aParams[3];
+ }
+ $aParams['key_from'] = null;
+ }
+ if ($aParamsNew['type'] == EntityORM::RELATION_TYPE_MANY_TO_MANY) {
+ $aParamsNew['join_entity'] = $aParams[3];
+ $aParamsNew['join_key'] = $aParams[4];
+ if (isset($aParams[5])) {
+ $aParamsNew['filter'] = $aParams[5];
+ }
+ }
+ $this->aRelations[$sName] = $aParamsNew;
+ }
+ return $this->aRelations;
+ }
+
+ /**
+ * Проверяет факт использования сущностью определенного типа связи
+ *
+ * @param $sType
+ * @return bool
+ */
+ public function _isUsedRelationType($sType)
+ {
+ $aRelations = $this->_getRelations();
+ foreach ($aRelations as $sName => $aParams) {
+ if (isset($aParams['type']) and $aParams['type'] == $sType) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает список данных связей
+ *
+ * @param string|null $sKey
+ *
+ * @return array|null
+ */
+ public function _getRelationsData($sKey = null)
+ {
+ if ($sKey) {
+ if (array_key_exists($sKey, $this->aRelationsData)) {
+ return $this->aRelationsData[$sKey];
+ }
+ return null;
+ }
+ return $this->aRelationsData;
+ }
+
+ /**
+ * Устанавливает данные связей
+ *
+ * @param array $aData Список связанных данных
+ */
+ public function _setRelationsData($aData)
+ {
+ $this->aRelationsData = $aData;
+ }
+
+ /**
+ * Устанавливает вспомогательные объекты для связи MANY_TO_MANY
+ *
+ * @param array $aData
+ * @param string|null $sRelationKey
+ */
+ public function _setManyToManyRelations($aData, $sRelationKey = null)
+ {
+ if ($sRelationKey) {
+ $this->_aManyToManyRelations[$sRelationKey] = $aData;
+ } else {
+ $this->_aManyToManyRelations = $aData;
+ }
+ }
+
+ /**
+ * Возвращает сущность связи при MANY_TO_MANY
+ * Актуально только в том случае, если текущая сущность была получена через обращение к связи MANY_TO_MANY
+ *
+ * @return mixed|null
+ */
+ public function _getManyToManyRelationEntity()
+ {
+ return $this->_getDataOne('_relation_entity');
+ }
+
+ /**
+ * Ставим хук на вызов неизвестного метода и считаем что хотели вызвать метод какого либо модуля
+ * Также производит обработку методов set* и get*
+ * Учитывает связи и может возвращать связанные данные
+ * @see Engine::_CallModule
+ *
+ * @param string $sName Имя метода
+ * @param array $aArgs Аргументы
+ * @return mixed
+ */
+ public function __call($sName, $aArgs)
+ {
+ $sType = substr($sName, 0, strpos(func_underscore($sName), '_'));
+ if (!strpos($sName, '_') and in_array($sType, array('get', 'set', 'reload'))) {
+ $sKey = func_underscore(preg_replace('/' . $sType . '/', '', $sName, 1));
+ if ($sType == 'get') {
+ if (isset($this->_aData[$sKey])) {
+ return $this->_aData[$sKey];
+ } else {
+ $sField = $this->_getField($sKey);
+ if ($sField != $sKey && isset($this->_aData[$sField])) {
+ return $this->_aData[$sField];
+ }
+ }
+ /**
+ * Проверяем на связи
+ */
+ $aRelations = $this->_getRelations();
+ if (array_key_exists($sKey, $aRelations)) {
+ $sEntityRel = $aRelations[$sKey]['rel_entity'];
+ $sRelationType = $aRelations[$sKey]['type'];
+ $sRelationKey = $aRelations[$sKey]['rel_key'];
+
+ $sRelModuleName = Engine::GetModuleName($sEntityRel);
+ $sRelEntityName = Engine::GetEntityName($sEntityRel);
+ $sRelPluginPrefix = Engine::GetPluginPrefix($sEntityRel);
+ $sRelPrimaryKey = 'id';
+ if ($oRelEntity = Engine::GetEntity($sEntityRel)) {
+ $sRelPrimaryKey = $oRelEntity->_getPrimaryKey();
+ }
+
+ $iPrimaryKeyValue = $this->_getDataOne($this->_getPrimaryKey());
+ $bUseFilter = array_key_exists(0, $aArgs) && is_array($aArgs[0]);
+ $sCmd = '';
+ $mCmdArgs = array();
+ switch ($sRelationType) {
+ case self::RELATION_TYPE_BELONGS_TO :
+ if (!$this->_getDataOne($sRelationKey)) {
+ return null;
+ }
+ $sKeyTo = $aRelations[$sKey]['rel_key_to'] ?: $sRelPrimaryKey;
+ $sCmd = "{$sRelPluginPrefix}{$sRelModuleName}_get{$sRelEntityName}By" . func_camelize($sKeyTo);
+ $mCmdArgs = array($this->_getDataOne($sRelationKey));
+ break;
+ case self::RELATION_TYPE_HAS_ONE :
+ $aFilterAdd = $aRelations[$sKey]['filter'];
+ $sCmd = "{$sRelPluginPrefix}{$sRelModuleName}_get{$sRelEntityName}ByFilter";
+ $aFilterAdd = array_merge(array($sRelationKey => $iPrimaryKeyValue), $aFilterAdd);
+ if ($bUseFilter) {
+ $aFilterAdd = array_merge($aFilterAdd, $aArgs[0]);
+ }
+ $mCmdArgs = array($aFilterAdd);
+ break;
+ case self::RELATION_TYPE_HAS_MANY :
+ if ($aRelations[$sKey]['key_from']) {
+ $sRelationKeyValue = $this->_getDataOne($aRelations[$sKey]['key_from']);
+ } else {
+ $sRelationKeyValue = $iPrimaryKeyValue;
+ }
+ $aFilterAdd = $aRelations[$sKey]['filter'];
+ $sCmd = "{$sRelPluginPrefix}{$sRelModuleName}_get{$sRelEntityName}ItemsByFilter";
+ $aFilterAdd = array_merge(array($sRelationKey => $sRelationKeyValue), $aFilterAdd);
+ if ($bUseFilter) {
+ $aFilterAdd = array_merge($aFilterAdd, $aArgs[0]);
+ }
+ $mCmdArgs = array($aFilterAdd);
+ break;
+ case self::RELATION_TYPE_MANY_TO_MANY :
+ $sEntityJoin = $aRelations[$sKey]['join_entity'];
+ $sKeyJoin = $aRelations[$sKey]['join_key'];
+ $aFilterAdd = $aRelations[$sKey]['filter'];
+ $sCmd = "{$sRelPluginPrefix}Module{$sRelModuleName}_get{$sRelEntityName}ItemsByJoinEntity";
+ if ($bUseFilter) {
+ $aFilterAdd = array_merge($aFilterAdd, $aArgs[0]);
+ }
+ $mCmdArgs = array($sEntityJoin, $sKeyJoin, $sRelationKey, $iPrimaryKeyValue, $aFilterAdd);
+ break;
+ default:
+ break;
+ }
+ /**
+ * Если связь уже загруженна, то возвращаем результат
+ */
+ if (!$bUseFilter and array_key_exists($sKey, $this->aRelationsData)) {
+ return $this->aRelationsData[$sKey];
+ }
+ // Нужно ли учитывать дополнительный фильтр
+ $res = Engine::GetInstance()->_CallModule($sCmd, $mCmdArgs);
+
+ // Сохраняем данные только в случае "чистой" выборки
+ if (!$bUseFilter) {
+ $this->aRelationsData[$sKey] = $res;
+ }
+ // Создаём объекты-обёртки для связей MANY_TO_MANY
+ if ($sRelationType == self::RELATION_TYPE_MANY_TO_MANY) {
+ $this->_aManyToManyRelations[$sKey] = new ORMRelationManyToMany($res);
+ }
+ return $res;
+ }
+
+ return null;
+ } elseif ($sType == 'set' and array_key_exists(0, $aArgs)) {
+ if (array_key_exists($sKey, $this->aRelations)) {
+ $this->aRelationsData[$sKey] = $aArgs[0];
+ } else {
+ $this->_aData[$this->_getField($sKey)] = $aArgs[0];
+ }
+ return $this;
+ } elseif ($sType == 'reload') {
+ if (array_key_exists($sKey, $this->aRelationsData)) {
+ unset($this->aRelationsData[$sKey]);
+ return $this->__call('get' . func_camelize($sKey), $aArgs);
+ }
+ }
+ } else {
+ return parent::__call($sName, $aArgs);
+ }
+ }
+
+ /**
+ * Используется для доступа к связанным данным типа MANY_TO_MANY
+ *
+ * @param string $sName Название свойства к которому обращаемсяя
+ * @return mixed
+ */
+ public function __get($sName)
+ {
+ // Обработка обращений к обёрткам связей MANY_TO_MANY
+ // Если связь загружена, возвращаем объект связи
+ if (isset($this->_aManyToManyRelations[func_underscore($sName)])) {
+ return $this->_aManyToManyRelations[func_underscore($sName)];
+ // Есл не загружена, но связь с таким именем существет, пробуем загрузить и вернуть объект связи
+ } elseif (isset($this->aRelations[func_underscore($sName)]) && $this->aRelations[func_underscore($sName)]['type'] == self::RELATION_TYPE_MANY_TO_MANY) {
+ $sMethod = 'get' . func_camelize($sName);
+ $this->__call($sMethod, array());
+ if (isset($this->_aManyToManyRelations[func_underscore($sName)])) {
+ return $this->_aManyToManyRelations[func_underscore($sName)];
+ }
+ // В противном случае возвращаем то, что просили у объекта
+ } else {
+ return parent::__get($sName);
+ }
+ }
+
+ /**
+ * Сбрасывает данные необходимой связи
+ *
+ * @param string $sKey Ключ(поле) связи
+ */
+ public function resetRelationsData($sKey)
+ {
+ if (isset($this->aRelationsData[$sKey])) {
+ unset($this->aRelationsData[$sKey]);
+ }
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/engine/Event.class.php b/framework/classes/engine/Event.class.php
new file mode 100644
index 0000000..9a1476a
--- /dev/null
+++ b/framework/classes/engine/Event.class.php
@@ -0,0 +1,100 @@
+
+ *
+ */
+
+/**
+ * Абстрактный класс внешнего обработчика евента.
+ *
+ * От этого класса наследуются внешние обработчики евентов.
+ *
+ * @package framework.engine
+ * @since 2.0
+ */
+abstract class Event extends LsObject
+{
+
+ /**
+ * Объект текущего экшена
+ *
+ * @var null|Action
+ */
+ protected $oAction = null;
+ /**
+ * Объект для анализа структуры класса экшена
+ *
+ * @var null
+ */
+ protected $oActionReflection = null;
+
+ /**
+ * Устанавливает объект экшена
+ *
+ * @param Action $oAction Объект текущего экшена
+ */
+ public function SetActionObject($oAction)
+ {
+ $this->oAction = $oAction;
+ $this->oActionReflection = new ReflectionClass($this->oAction);
+ }
+
+ /**
+ * Запускается для обработки евента, если у него не указанно имя, например, "User::"
+ */
+ public function Exec()
+ {
+
+ }
+
+ /**
+ * Запускается всегда перед вызовом метода евента
+ */
+ public function Init()
+ {
+
+ }
+
+ public function __get($sName)
+ {
+ if ($this->oActionReflection->hasProperty($sName)) {
+ return call_user_func_array(array($this->oAction, 'ActionGet'), array($sName));
+ }
+ return parent::__get($sName);
+ }
+
+ public function __set($sName, $mValue)
+ {
+ if ($this->oActionReflection->hasProperty($sName)) {
+ return call_user_func_array(array($this->oAction, 'ActionSet'), array($sName, $mValue));
+ }
+ }
+
+ public function __call($sName, $aArgs)
+ {
+ /**
+ * Обработка вызова методов экшена
+ */
+ if ($this->oAction->ActionCallExists($sName)) {
+ array_unshift($aArgs, $sName);
+ return call_user_func_array(array($this->oAction, 'ActionCall'), $aArgs);
+ }
+
+ return parent::__call($sName, $aArgs);
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/engine/Hook.class.php b/framework/classes/engine/Hook.class.php
new file mode 100644
index 0000000..0966d56
--- /dev/null
+++ b/framework/classes/engine/Hook.class.php
@@ -0,0 +1,87 @@
+
+ *
+ */
+
+/**
+ * Абстракция хука, от которой наследуются все хуки
+ * Дает возможность создавать обработчики хуков в каталоге /hooks/
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+abstract class Hook extends LsObject
+{
+ /**
+ * Добавляет обработчик на хук
+ * @see ModuleHook::AddExecHook
+ *
+ * @param string $sName Название хука на который вешается обработчик
+ * @param string $sCallBack Название метода обработчика
+ * @param null|string $sClassNameHook Название класса обработчика, по умолчанию это текущий класс хука
+ * @param int $iPriority Приоритет обработчика хука, чем выше число, тем больше приоритет - хук обработчик выполнится раньше остальных
+ */
+ protected function AddHook($sName, $sCallBack, $sClassNameHook = null, $iPriority = 1)
+ {
+ if (is_null($sClassNameHook)) {
+ $sClassNameHook = get_class($this);
+ }
+ $this->Hook_AddExecHook($sName, $sCallBack, $iPriority, array('sClassName' => $sClassNameHook));
+ }
+
+ /**
+ * Добавляет делегирующий обработчик на хук. Актуален для хуков на выполнение методов модулей.
+ * После него другие обработчики не выполняются, а результат метода модуля заменяется на результат обработчика.
+ *
+ * @param $sName Название хука на который вешается обработчик
+ * @param $sCallBack Название метода обработчика
+ * @param null $sClassNameHook Название класса обработчика, по умолчанию это текущий класс хука
+ * @param int $iPriority Приоритет обработчика хука
+ */
+ protected function AddDelegateHook($sName, $sCallBack, $sClassNameHook = null, $iPriority = 1)
+ {
+ if (is_null($sClassNameHook)) {
+ $sClassNameHook = get_class($this);
+ }
+ $this->Hook_AddDelegateHook($sName, $sCallBack, $iPriority, array('sClassName' => $sClassNameHook));
+ }
+
+ /**
+ * Добавляет обработчик на хук по регулярному выражению
+ *
+ * @param string $sName Название хука на который вешается обработчик
+ * @param string $sCallBack Название метода обработчика
+ * @param null|string $sClassNameHook Название класса обработчика, по умолчанию это текущий класс хука
+ * @param int $iPriority Приоритет обработчика хука, чем выше число, тем больше приоритет - хук обработчик выполнится раньше остальных
+ */
+ protected function AddHookPreg($sName, $sCallBack, $sClassNameHook = null, $iPriority = 1)
+ {
+ if (is_null($sClassNameHook)) {
+ $sClassNameHook = get_class($this);
+ }
+ $this->Hook_AddExecHook($sName, $sCallBack, $iPriority, array('sClassName' => $sClassNameHook), true);
+ }
+
+ /**
+ * Обязательный метод в хуке - в нем происходит регистрация обработчиков хуков
+ *
+ * @abstract
+ */
+ abstract public function RegisterHook();
+}
\ No newline at end of file
diff --git a/framework/classes/engine/LsObject.class.php b/framework/classes/engine/LsObject.class.php
new file mode 100644
index 0000000..7471d53
--- /dev/null
+++ b/framework/classes/engine/LsObject.class.php
@@ -0,0 +1,246 @@
+
+ *
+ */
+
+/**
+ * От этого класса наследуются все остальные
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+abstract class LsObject
+{
+ /**
+ * Список поведений
+ *
+ *
+ * array(
+ * 'property'=>array(
+ * 'class'=>'ModuleProperty_BehaviorProperty',
+ * 'param1'=>12345,
+ * 'param2'=>'two'
+ * ),
+ * 'category'=>$oCategoryBehavior
+ * )
+ *
+ *
+ * @var array
+ */
+ protected $aBehaviors = array();
+ /**
+ * Список поведений в виде готовых объектов, формируется автоматически
+ *
+ * @var
+ */
+ protected $_aBehaviors;
+
+ /**
+ * Конструктор, запускается автоматически при создании объекта
+ */
+ public function __construct()
+ {
+
+ }
+
+ /**
+ * При клонировании сбрасываем поведения
+ */
+ public function __clone()
+ {
+ $this->_aBehaviors = null;
+ }
+
+ /**
+ * Возвращает все объекты поведения
+ *
+ * @return array
+ */
+ public function GetBehaviors()
+ {
+ $this->PrepareBehaviors();
+ return $this->_aBehaviors;
+ }
+
+ /**
+ * Возвращает объект поведения по его имени
+ *
+ * @param string $sName
+ *
+ * @return Behavior|null
+ */
+ public function GetBehavior($sName)
+ {
+ $this->PrepareBehaviors();
+ return isset($this->_aBehaviors[$sName]) ? $this->_aBehaviors[$sName] : null;
+ }
+
+ /**
+ * Инициализация поведений
+ */
+ protected function PrepareBehaviors()
+ {
+ if (is_null($this->_aBehaviors)) {
+ $this->_aBehaviors = array();
+ foreach ($this->aBehaviors as $sName => $mBehavior) {
+ $this->AttachBehavior($sName, $mBehavior);
+ }
+ }
+ }
+
+ /**
+ * Присоединяет поведение к объекту
+ *
+ * @param string $sName
+ * @param $mBehavior
+ *
+ * @return Behavior
+ */
+ public function AttachBehavior($sName, $mBehavior)
+ {
+ $this->PrepareBehaviors();
+ if (!($mBehavior instanceof Behavior)) {
+ if (is_string($mBehavior)) {
+ $sClass = $mBehavior;
+ $aParams = array();
+ } else {
+ if (isset($mBehavior['class'])) {
+ $sClass = $mBehavior['class'];
+ unset($mBehavior['class']);
+ } else {
+ $sClass = array_shift($mBehavior);
+ }
+ $aParams = $mBehavior;
+ }
+ $mBehavior = Engine::GetBehavior($sClass, $aParams);
+ }
+ if (isset($this->_aBehaviors[$sName])) {
+ $this->_aBehaviors[$sName]->Detach();
+ }
+ $mBehavior->Attach($this);
+ return $this->_aBehaviors[$sName] = $mBehavior;
+ }
+
+ /**
+ * Отсоединяет поведение от объекта
+ *
+ * @param string $sName
+ *
+ * @return Behavior|null
+ */
+ public function DetachBehavior($sName)
+ {
+ $this->PrepareBehaviors();
+ if (isset($this->_aBehaviors[$sName])) {
+ $oBehavior = $this->_aBehaviors[$sName];
+ unset($this->_aBehaviors[$sName]);
+ $oBehavior->Detach();
+ return $oBehavior;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Запускает хук поведения на выполнение
+ *
+ * @param string $sName
+ * @param array $aVars
+ * @param bool $bWithGlobal Запускать дополнительно одноименный глобальный (стандартный) хук
+ *
+ * @return mixed
+ */
+ public function RunBehaviorHook($sName, $aVars = array(), $bWithGlobal = false)
+ {
+ return $this->Hook_RunHookBehavior($sName, $this, $aVars, $bWithGlobal);
+ }
+
+ /**
+ * Добавляет хук поведения
+ *
+ * @param string $sName
+ * @param array $aCallback
+ * @param int $iPriority
+ *
+ * @return mixed
+ */
+ public function AddBehaviorHook($sName, $aCallback, $iPriority = 1)
+ {
+ return $this->Hook_AddHookBehavior($sName, $this, $aCallback, $iPriority);
+ }
+
+ /**
+ * Удаляет хук поведения
+ *
+ * @param string $sName
+ * @param array|null $aCallback Если null, то будут удалены все хуки
+ *
+ * @return mixed
+ */
+ public function RemoveBehaviorHook($sName, $aCallback = null)
+ {
+ return $this->Hook_RemoveHookBehavior($sName, $this, $aCallback);
+ }
+
+ /**
+ * Обработка доступа к объекту поведения
+ *
+ * @param string $sName
+ *
+ * @return mixed
+ */
+ public function __get($sName)
+ {
+ $this->PrepareBehaviors();
+ /**
+ * Проверяем на получение объекта поведения
+ */
+ if (isset($this->_aBehaviors[$sName])) {
+ return $this->_aBehaviors[$sName];
+ }
+ }
+
+ /**
+ * Ставим хук на вызов неизвестного метода и считаем что хотели вызвать метод какого либо модуля
+ * @see Engine::_CallModule
+ *
+ * @param string $sName Имя метода
+ * @param array $aArgs Аргументы
+ * @return mixed
+ */
+ public function __call($sName, $aArgs)
+ {
+ $this->PrepareBehaviors();
+ /**
+ * Проверяем на вызов метода поведения
+ * Пропускаем служебные методы поведения
+ */
+ if (!in_array(strtolower($sName), array('attach', 'detach'))) {
+ foreach ($this->_aBehaviors as $oObject) {
+ if (func_method_exists($oObject, $sName, 'public')) {
+ return call_user_func_array(array($oObject, $sName), $aArgs);
+ }
+ }
+ }
+ /**
+ * Если метод не найден, то запускаем стандартную обработку
+ */
+ return Engine::getInstance()->_CallModule($sName, $aArgs);
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/engine/Mapper.class.php b/framework/classes/engine/Mapper.class.php
new file mode 100644
index 0000000..a746006
--- /dev/null
+++ b/framework/classes/engine/Mapper.class.php
@@ -0,0 +1,53 @@
+
+ *
+ */
+
+/**
+ * Абстрактный класс мапера
+ * Вся задача маппера сводится в выполнению запроса к базе данных (или либому другому источнику данных) и возвращению результата в модуль.
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+abstract class Mapper extends LsObject
+{
+ /**
+ * Объект подключения к базе данных
+ *
+ * @var DbSimple_Database
+ */
+ protected $oDb;
+
+ /**
+ * Передаем коннект к БД
+ *
+ * @param DbSimple_Database $oDb
+ */
+ public function __construct($oDb)
+ {
+ parent::__construct();
+ $this->oDb = $oDb;
+ }
+
+ protected function IsSuccessful($mRes)
+ {
+ return $mRes === false or is_null($mRes) ? false : true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/engine/MapperORM.class.php b/framework/classes/engine/MapperORM.class.php
new file mode 100644
index 0000000..1375cf8
--- /dev/null
+++ b/framework/classes/engine/MapperORM.class.php
@@ -0,0 +1,675 @@
+
+ *
+ */
+
+/**
+ * Системный класс мапера ORM для работы с БД
+ *
+ * @package framework.engine.orm
+ * @since 1.0
+ */
+class MapperORM extends Mapper
+{
+ /**
+ * Добавление сущности в БД
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return int|bool Если есть primary индекс с автоинкрементом, то возвращает его для новой записи
+ */
+ public function AddEntity($oEntity)
+ {
+ $sTableName = self::GetTableName($oEntity);
+
+ $sql = "INSERT INTO " . $sTableName . " SET ?a ";
+ return $this->oDb->query($sql, $oEntity->_getDataFieldsForDb());
+ }
+
+ /**
+ * Обновление сущности
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return int|bool Возвращает число измененых записей в БД
+ */
+ public function UpdateEntity($oEntity)
+ {
+ $sTableName = self::GetTableName($oEntity);
+
+ if ($aPrimaryKey = $oEntity->_getPrimaryKey()) {
+ // Возможен составной ключ
+ if (!is_array($aPrimaryKey)) {
+ $aPrimaryKey = array($aPrimaryKey);
+ }
+ $sWhere = ' 1 = 1 ';
+ foreach ($aPrimaryKey as $sField) {
+ $sWhere .= ' and ' . $this->oDb->escape($sField,
+ true) . " = " . $this->oDb->escape($oEntity->_getDataOne($sField));
+ }
+ $sql = "UPDATE " . $sTableName . " SET ?a WHERE {$sWhere}";
+ $aFields = $oEntity->_getDataFieldsForDb(true);
+ return $aFields ? $this->oDb->query($sql, $aFields) : 0;
+ } else {
+ $aOriginalData = $oEntity->_getOriginalData();
+ $sWhere = implode(' AND ', array_map(function($k, $v, $oDb) {
+ return "{$oDb->escape($k, true)} = {$oDb->escape($v)}";
+ }, array_keys($aOriginalData), array_values($aOriginalData),
+ array_fill(0, count($aOriginalData), $this->oDb)));
+ $sql = "UPDATE " . $sTableName . " SET ?a WHERE 1=1 AND " . $sWhere;
+ $aFields = $oEntity->_getDataFieldsForDb(true);
+ return $aFields ? $this->oDb->query($sql, $aFields) : 0;
+ }
+ }
+
+ /**
+ * Удаление сущности
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return int|bool Возвращает число удаленных записей в БД
+ */
+ public function DeleteEntity($oEntity)
+ {
+ $sTableName = self::GetTableName($oEntity);
+
+ if ($aPrimaryKey = $oEntity->_getPrimaryKey()) {
+ // Возможен составной ключ
+ if (!is_array($aPrimaryKey)) {
+ $aPrimaryKey = array($aPrimaryKey);
+ }
+ $sWhere = ' 1 = 1 ';
+ foreach ($aPrimaryKey as $sField) {
+ $sWhere .= ' and ' . $this->oDb->escape($sField,
+ true) . " = " . $this->oDb->escape($oEntity->_getDataOne($sField));
+ }
+ $sql = "DELETE FROM " . $sTableName . " WHERE {$sWhere}";
+ return $this->oDb->query($sql);
+ } else {
+ $aOriginalData = $oEntity->_getOriginalData();
+ $sWhere = implode(' AND ', array_map(function($k, $v, $oDb) {
+ return "{$oDb->escape($k, true)} = {$oDb->escape($v)}";
+ }, array_keys($aOriginalData), array_values($aOriginalData),
+ array_fill(0, count($aOriginalData), $this->oDb)));
+ $sql = "DELETE FROM " . $sTableName . " WHERE 1=1 AND " . $sWhere;
+ return $this->oDb->query($sql);
+ }
+ }
+
+ /**
+ * Получение сущности по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param string $sEntityFull Название класса сущности
+ * @return EntityORM|null
+ */
+ public function GetByFilter($aFilter, $sEntityFull)
+ {
+ $aFilter['#limit'] = 1;
+ if ($aResults = $this->GetItemsByFilter($aFilter, $sEntityFull)) {
+ return reset($aResults);
+ }
+ return null;
+ }
+
+ /**
+ * Получение сущности по фильтру
+ *
+ * @param string $sAggregateFunction Агрегирующая функция ('max','min','sum','avg')
+ * @param string $sField Поля к оторому применяем агрегирующую функцию
+ * @param array $aFilter Фильтр
+ * @param string $sEntityFull Название класса сущности
+ * @return EntityORM|null
+ */
+ public function GetAggregateFunctionByFilter($sAggregateFunction, $sField, $aFilter, $sEntityFull)
+ {
+ $oEntitySample = Engine::GetEntity($sEntityFull);
+ $sTableName = self::GetTableName($sEntityFull);
+
+ list($aFilterFields, $sFilterFields, $sJoinTables) = $this->BuildFilter($aFilter, $oEntitySample);
+ list($sOrder, $sLimit, $sGroup, $sSelect) = $this->BuildFilterMore($aFilter, $oEntitySample);
+
+ $sAggregateFunction = strtolower($sAggregateFunction);
+ if (!in_array($sAggregateFunction, array('max', 'min', 'sum', 'avg'))) {
+ $sAggregateFunction = 'max';
+ }
+
+ /**
+ * Проверяем на отсутствие префикса таблицы у поля
+ */
+ if (!strpos($sField, '.')) {
+ $sField = 't.' . $this->oDb->escape($sField, true);
+ }
+
+ $sql = "SELECT {$sAggregateFunction}({$sField}) as value FROM " . $sTableName . " t {$sJoinTables} WHERE 1=1 {$sFilterFields} {$sGroup} ";
+ $aQueryParams = array_merge(array($sql), array_values($aFilterFields));
+
+ if ($aRow = call_user_func_array(array($this->oDb, 'selectRow'), $aQueryParams)) {
+ return $aRow['value'];
+ }
+ return null;
+ }
+
+ /**
+ * Получение списка сущностей по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param string $sEntityFull Название класса сущности
+ * @return array
+ */
+ public function GetItemsByFilter($aFilter, $sEntityFull)
+ {
+ $oEntitySample = Engine::GetEntity($sEntityFull);
+ $sTableName = self::GetTableName($sEntityFull);
+
+ list($aFilterFields, $sFilterFields, $sJoinTables) = $this->BuildFilter($aFilter, $oEntitySample);
+ list($sOrder, $sLimit, $sGroup, $sSelect) = $this->BuildFilterMore($aFilter, $oEntitySample);
+
+ if (!$sSelect) {
+ $sSelect = 't.*';
+ }
+
+ $sql = "SELECT {$sSelect} FROM " . $sTableName . " t {$sJoinTables} WHERE 1=1 {$sFilterFields} {$sGroup} {$sOrder} {$sLimit} ";
+ $aQueryParams = array_merge(array($sql), array_values($aFilterFields));
+ $aItems = array();
+ if ($aRows = call_user_func_array(array($this->oDb, 'select'), $aQueryParams)) {
+ foreach ($aRows as $aRow) {
+ $oEntity = Engine::GetEntity($sEntityFull);
+ $oEntity->_setDataFromDb($aRow);
+ $aItems[] = $oEntity;
+ }
+ }
+ return $aItems;
+ }
+
+ /**
+ * Получение числа сущностей по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param string $sEntityFull Название класса сущности
+ * @return int
+ */
+ public function GetCountItemsByFilter($aFilter, $sEntityFull)
+ {
+ $oEntitySample = Engine::GetEntity($sEntityFull);
+ $sTableName = self::GetTableName($sEntityFull);
+
+ list($aFilterFields, $sFilterFields, $sJoinTables) = $this->BuildFilter($aFilter, $oEntitySample);
+ list($sOrder, $sLimit, $sGroup) = $this->BuildFilterMore($aFilter, $oEntitySample);
+
+ if ($sGroup) {
+ /**
+ * Т.к. count меняет свою логику при наличии группировки
+ */
+ $sql = "SELECT SQL_CALC_FOUND_ROWS * FROM `" . $sTableName . "` t {$sJoinTables} WHERE 1=1 {$sFilterFields} {$sGroup} ";
+ } else {
+ $sql = "SELECT count(*) as c FROM " . $sTableName . " t {$sJoinTables} WHERE 1=1 {$sFilterFields} {$sGroup} ";
+ }
+ $aQueryParams = array_merge(array($sql), array_values($aFilterFields));
+ if ($aRow = call_user_func_array(array($this->oDb, 'selectRow'), $aQueryParams)) {
+ if ($sGroup) {
+ $aRow = $this->oDb->selectRow('SELECT FOUND_ROWS() as c;');
+ }
+ return $aRow['c'];
+ }
+ return 0;
+ }
+
+ public function GetItemsByJoinEntity(
+ $sEntityJoin,
+ $sKeyJoin,
+ $sRelationKey,
+ $aRelationValues,
+ $aFilter,
+ $sEntityFull
+ ) {
+ $oEntitySample = Engine::GetEntity($sEntityFull);
+ $oEntityJoinSample = Engine::GetEntity($sEntityJoin);
+ $sTableName = self::GetTableName($sEntityFull);
+ $sTableJoinName = self::GetTableName($sEntityJoin);
+
+ /**
+ * Формируем параметры по таблице связей
+ */
+ list($aFilterFields, $sFilterFields) = $this->BuildFilter($aFilter, $oEntityJoinSample);
+ list($sOrder, $sLimit) = $this->BuildFilterMore($aFilter, $oEntityJoinSample);
+ /**
+ * Формируем список полей для возврата у таблице связей
+ */
+ $aFieldsJoinReturn = $oEntityJoinSample->_getFields();
+ foreach ($aFieldsJoinReturn as $k => $sField) {
+ if (!is_numeric($k)) {
+ // Удаляем служебные (примари) поля
+ unset($aFieldsJoinReturn[$k]);
+ continue;
+ }
+ $aFieldsJoinReturn[$k] = "t.`{$sField}` as t_join_{$sField}";
+ }
+ $sFieldsJoinReturn = join(', ', $aFieldsJoinReturn);
+
+ if (!is_array($aRelationValues)) {
+ $aRelationValues = array($aRelationValues);
+ }
+ /**
+ * SQL и параметры
+ */
+ $sql = "SELECT {$sFieldsJoinReturn}, b.* FROM ?# t LEFT JOIN ?# b ON b.?# = t.?# WHERE t.?# in ( ?a ) {$sFilterFields} {$sOrder} {$sLimit}";
+ $aQueryParams = array_merge(array(
+ $sql,
+ $sTableJoinName,
+ $sTableName,
+ $oEntitySample->_getPrimaryKey(),
+ $sRelationKey,
+ $sKeyJoin,
+ $aRelationValues
+ ), array_values($aFilterFields));
+ $aItems = array();
+ /**
+ * Выполняем запрос
+ */
+ if ($aRows = call_user_func_array(array($this->oDb, 'select'), $aQueryParams)) {
+ foreach ($aRows as $aRow) {
+ $aData = array();
+ $aDataRelation = array();
+ foreach ($aRow as $k => $v) {
+ if (strpos($k, 't_join_') === 0) {
+ $aDataRelation[str_replace('t_join_', '', $k)] = $v;
+ } else {
+ $aData[$k] = $v;
+ }
+ }
+ $aData['_relation_entity'] = Engine::GetEntity($sEntityJoin, $aDataRelation);
+ $oEntity = Engine::GetEntity($sEntityFull);
+ $oEntity->_setDataFromDb($aData);
+ unset($aData['_relation_entity']);
+ $oEntity->_setOriginalData($aData);
+ $aItems[] = $oEntity;
+ }
+ }
+ return $aItems;
+ }
+
+ public function GetCountItemsByJoinEntity(
+ $sEntityJoin,
+ $sKeyJoin,
+ $sRelationKey,
+ $aRelationValues,
+ $aFilter,
+ $sEntityFull
+ ) {
+ $oEntitySample = Engine::GetEntity($sEntityFull);
+ $oEntityJoinSample = Engine::GetEntity($sEntityJoin);
+ $sTableName = self::GetTableName($sEntityFull);
+ $sTableJoinName = self::GetTableName($sEntityJoin);
+
+ /**
+ * Формируем параметры по таблице связей
+ */
+ list($aFilterFields, $sFilterFields) = $this->BuildFilter($aFilter, $oEntityJoinSample);
+
+ if (!is_array($aRelationValues)) {
+ $aRelationValues = array($aRelationValues);
+ }
+ /**
+ * SQL и параметры
+ */
+ $sql = "SELECT count(*) as c FROM ?# t LEFT JOIN ?# b ON b.?# = t.?# WHERE t.?# in ( ?a ) {$sFilterFields} ";
+ $aQueryParams = array_merge(array(
+ $sql,
+ $sTableJoinName,
+ $sTableName,
+ $oEntitySample->_getPrimaryKey(),
+ $sRelationKey,
+ $sKeyJoin,
+ $aRelationValues
+ ), array_values($aFilterFields));
+ if ($aRow = call_user_func_array(array($this->oDb, 'selectRow'), $aQueryParams)) {
+ return $aRow['c'];
+ }
+ return 0;
+ }
+
+ /**
+ * Построение фильтра
+ *
+ * @param array $aFilter Фильтр
+ * @param EntityORM $oEntitySample Объект сущности
+ * @return array
+ */
+ public function BuildFilter($aFilter, $oEntitySample)
+ {
+ $aFilterFields = array();
+ foreach ($aFilter as $k => $v) {
+ if (substr($k, 0, 1) == '#' || (is_string($v) && substr($v, 0, 1) == '#')) {
+
+ } else {
+ $aFilterFields[$oEntitySample->_getField($k)] = $v;
+ }
+ }
+
+ $sFilterFields = '';
+ foreach ($aFilterFields as $k => $v) {
+ $aK = explode(' ', trim($k));
+ $sFieldCurrent = $aK[0];
+ $sConditionCurrent = ' = ';
+ if (count($aK) > 1) {
+ $sConditionCurrent = strtolower($aK[1]);
+ }
+ /**
+ * У поля уже может быть указан префикс таблицы, поэтому делаем проверку
+ */
+ if (!strpos($sFieldCurrent, '.')) {
+ $sFieldCurrent = 't.' . $this->oDb->escape($sFieldCurrent, true);
+ }
+ if (strtolower($sConditionCurrent) == 'in') {
+ $sFilterFields .= " and {$sFieldCurrent} {$sConditionCurrent} ( ?a ) ";
+ } elseif (trim($sConditionCurrent) == '=' and is_null($v)) {
+ $sFilterFields .= " and {$sFieldCurrent} IS ? ";
+ } elseif (trim($sConditionCurrent) == '<>' and is_null($v)) {
+ $sFilterFields .= " and {$sFieldCurrent} IS NOT ? ";
+ } else {
+ $sFilterFields .= " and {$sFieldCurrent} {$sConditionCurrent} ? ";
+ }
+ }
+ if (isset($aFilter['#where']) and is_array($aFilter['#where'])) {
+ // '#where' => array('t.id = ?d OR t.name = ?' => array(1,'admin'));
+ foreach ($aFilter['#where'] as $sFilterKey => $aValues) {
+ $aFilterFields = array_merge($aFilterFields, $aValues);
+ $sFilterFields .= ' and ' . trim($sFilterKey) . ' ';
+ }
+ }
+ /**
+ * Формируем JOIN запрос
+ */
+ $sJoinTables = '';
+ if (isset($aFilter['#join']) and is_array($aFilter['#join'])) {
+ $aValuesForMerge = array();
+ foreach ($aFilter['#join'] as $sJoin => $aValues) {
+ if (is_int($sJoin)) {
+ $sJoinTables .= ' ' . $aValues . ' ';
+ } else {
+ $sJoinTables .= ' ' . $sJoin . ' ';
+ $aValuesForMerge = array_merge($aValuesForMerge, $aValues);
+ }
+ }
+ $aFilterFields = array_merge($aValuesForMerge, $aFilterFields);
+ }
+ return array($aFilterFields, $sFilterFields, $sJoinTables);
+ }
+
+ /**
+ * Построение дополнительного фильтра
+ * Здесь учитываются ключи фильтра вида #*
+ *
+ * @param array $aFilter Фильтр
+ * @param EntityORM $oEntitySample Объект сущности
+ * @return array
+ */
+ public function BuildFilterMore($aFilter, $oEntitySample)
+ {
+ // Сортировка
+ $sOrder = '';
+ if (isset($aFilter['#order'])) {
+ if (!is_array($aFilter['#order'])) {
+ $aFilter['#order'] = array($aFilter['#order']);
+ }
+ foreach ($aFilter['#order'] as $key => $value) {
+ if (is_numeric($key)) {
+ $key = $value;
+ $value = 'asc';
+ } elseif (!in_array($value, array('asc', 'desc'))) {
+ $value = 'asc';
+ }
+
+ if (substr($key, 0, 1) == '#') {
+ /**
+ * Используем "как есть"
+ */
+ $key = ltrim($key, '#');
+ } else {
+ /**
+ * Проверяем на простые выражения: field1 + field2 * field3
+ */
+ $aKeyPath = preg_split("#\s?([\-\+\*\\\])\s?#", $key, -1, PREG_SPLIT_DELIM_CAPTURE);
+ if (count($aKeyPath) > 2) {
+ $key = '';
+ foreach ($aKeyPath as $i => $sKey) {
+ if ($i % 2 == 0) {
+ $key .= 't.' . $this->oDb->escape($oEntitySample->_getField(trim($sKey)), true);
+ } else {
+ $key .= " {$sKey} ";
+ }
+ }
+ } else {
+ /**
+ * Проверяем на FIELD:id -> FIELD(id,?a)
+ */
+ $aKeys = explode(':', $key);
+ if (count($aKeys) == 2) {
+ if (strtolower($aKeys[0]) == 'field' and is_array($aFilter['#order'][$key]) and count($aFilter['#order'][$key])) {
+ $key = 'FIELD(t.' . $this->oDb->escape($oEntitySample->_getField(trim($aKeys[1])),
+ true) . ',' . join(',', $aFilter['#order'][$key]) . ')';
+ $value = '';
+ } else {
+ /**
+ * Неизвестное выражение
+ */
+ continue;
+ }
+ } else {
+ /**
+ * Пропускаем экранирование функций
+ */
+ if (!in_array($key, array('rand()'))) {
+ /**
+ * Проверяем наличие префикса таблицы
+ */
+ if (!strpos($oEntitySample->_getField($key), '.')) {
+ $key = 't.' . $this->oDb->escape($oEntitySample->_getField($key), true);
+ } else {
+ $key = $oEntitySample->_getField($key);
+ }
+ }
+ }
+ }
+ }
+
+ $sOrder .= " {$key} {$value},";
+ }
+ $sOrder = trim($sOrder, ',');
+ if ($sOrder != '') {
+ $sOrder = "ORDER BY {$sOrder}";
+ }
+ }
+
+ // Постраничность
+ if (isset($aFilter['#page']) and is_array($aFilter['#page']) and count($aFilter['#page']) == 2) { // array(2,15) - 2 - page, 15 - count
+ $aFilter['#limit'] = array(($aFilter['#page'][0] - 1) * $aFilter['#page'][1], $aFilter['#page'][1]);
+ }
+
+ // Лимит
+ $sLimit = '';
+ if (isset($aFilter['#limit'])) { // допустимы варианты: limit=10 , limit=array(10) , limit=array(10,15)
+ $aLimit = $aFilter['#limit'];
+ if (is_numeric($aLimit)) {
+ $iBegin = 0;
+ $iEnd = $aLimit;
+ } elseif (is_array($aLimit)) {
+ if (count($aLimit) > 1) {
+ $iBegin = $aLimit[0];
+ $iEnd = $aLimit[1];
+ } else {
+ $iBegin = 0;
+ $iEnd = $aLimit[0];
+ }
+ }
+ $sLimit = "LIMIT {$iBegin}, {$iEnd}";
+ }
+
+ // Группировка
+ $sGroup = '';
+ if (isset($aFilter['#group'])) {
+ if (!is_array($aFilter['#group'])) {
+ $aFilter['#group'] = array($aFilter['#group']);
+ }
+ foreach ($aFilter['#group'] as $sField) {
+ if (substr($sField, 0, 1) == '#') {
+ $sGroup .= ltrim($sField, '#') . ',';
+ } else {
+ $sField = $this->oDb->escape($oEntitySample->_getField($sField), true);
+ $sGroup .= " t.{$sField},";
+ }
+ }
+ $sGroup = trim($sGroup, ',');
+ if ($sGroup != '') {
+ $sGroup = "GROUP BY {$sGroup}";
+ }
+ }
+
+ // Определение полей в select
+ $sSelect = '';
+ if (isset($aFilter['#select'])) {
+ // todo: добавить экранирование полей с учетом префикса таблицы
+ if (!is_array($aFilter['#select'])) {
+ $aFilter['#select'] = array($aFilter['#select']);
+ }
+ $sSelect = join(', ', $aFilter['#select']);
+ }
+ return array($sOrder, $sLimit, $sGroup, $sSelect);
+ }
+
+ /**
+ * Список колонок/полей сущности
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return array
+ */
+ public function ShowColumnsFrom($oEntity)
+ {
+ $sTableName = self::GetTableName($oEntity);
+ return $this->ShowColumnsFromTable($sTableName);
+ }
+
+ /**
+ * Список колонок/полей таблицы
+ *
+ * @param string $sTableName Название таблицы
+ * @return array
+ */
+ public function ShowColumnsFromTable($sTableName)
+ {
+ if (false === ($aItems = Engine::getInstance()->Cache_Get("columns_table_{$sTableName}", 'file_orm', true,
+ true))
+ ) {
+ $sql = "SHOW COLUMNS FROM " . $sTableName;
+ $aItems = array();
+ if ($aRows = $this->oDb->select($sql)) {
+ foreach ($aRows as $aRow) {
+ $aItems[] = $aRow['Field'];
+ if ($aRow['Key'] == 'PRI') {
+ $aItems['#primary_key'] = $aRow['Field'];
+ }
+ }
+ }
+ Engine::getInstance()->Cache_Set($aItems, "columns_table_{$sTableName}", array(), 60 * 60 * 4, 'file_orm',
+ true);
+ }
+ return $aItems;
+ }
+
+ /**
+ * Primary индекс сущности
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return array
+ */
+ public function ShowPrimaryIndexFrom($oEntity)
+ {
+ $sTableName = self::GetTableName($oEntity);
+ return $this->ShowPrimaryIndexFromTable($sTableName);
+ }
+
+ /**
+ * Primary индекс таблицы
+ *
+ * @param string $sTableName Название таблицы
+ * @return array
+ */
+ public function ShowPrimaryIndexFromTable($sTableName)
+ {
+ if (false === ($aItems = Engine::getInstance()->Cache_Get("index_table_{$sTableName}", 'file_orm', true, true))
+ ) {
+ $sql = "SHOW INDEX FROM " . $sTableName;
+ $aItems = array();
+ if ($aRows = $this->oDb->select($sql)) {
+ foreach ($aRows as $aRow) {
+ if ($aRow['Key_name'] == 'PRIMARY') {
+ $aItems[$aRow['Seq_in_index']] = $aRow['Column_name'];
+ }
+ }
+ }
+ Engine::getInstance()->Cache_Set($aItems, "index_table_{$sTableName}", array(), 60 * 60 * 4, 'file_orm',
+ true);
+ }
+ return $aItems;
+ }
+
+ /**
+ * Возвращает имя таблицы для сущности
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return string
+ */
+ public static function GetTableName($oEntity)
+ {
+ static $aCache;
+
+ $sClass = is_object($oEntity) ? get_class($oEntity) : $oEntity;
+ $sCacheKey = $sClass;
+
+ if (!isset($aCache[$sCacheKey])) {
+ /**
+ * Варианты таблиц:
+ * prefix_user -> если модуль совпадает с сущностью
+ * prefix_user_invite -> если модуль не сопадает с сущностью
+ * Если сущность плагина:
+ * prefix_pluginname_user
+ * prefix_pluginname_user_invite
+ */
+ $sClass = Engine::getInstance()->Plugin_GetDelegater('entity', $sClass);
+ $sPluginName = func_underscore(Engine::GetPluginName($sClass));
+ $sModuleName = func_underscore(Engine::GetModuleName($sClass));
+ $sEntityName = func_underscore(Engine::GetEntityName($sClass));
+ if (strpos($sEntityName, $sModuleName) === 0) {
+ $sTable = func_underscore($sEntityName);
+ } else {
+ $sTable = func_underscore($sModuleName) . '_' . func_underscore($sEntityName);
+ }
+ if ($sPluginName) {
+ $sTable = $sPluginName . '_' . $sTable;
+ }
+ /**
+ * Если название таблиц переопределено в конфиге, то возвращаем его
+ */
+ if (Config::Get('db.table.' . $sTable)) {
+ $aCache[$sCacheKey] = Config::Get('db.table.' . $sTable);
+ } else {
+ $aCache[$sCacheKey] = Config::Get('db.table.prefix') . $sTable;
+ }
+ }
+ return $aCache[$sCacheKey];
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/engine/Module.class.php b/framework/classes/engine/Module.class.php
new file mode 100644
index 0000000..8d38297
--- /dev/null
+++ b/framework/classes/engine/Module.class.php
@@ -0,0 +1,81 @@
+
+ *
+ */
+
+/**
+ * Абстракция модуля, от которой наследуются все модули
+ * Модули предназначены для объединения часто используемого функционала, т.е. некие аналоги внутренних библиотек.
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+abstract class Module extends LsObject
+{
+ /**
+ * Указывает на то, была ли проведенна инициализация модуля
+ *
+ * @var bool
+ */
+ protected $bIsInit = false;
+
+ /**
+ * Блокируем копирование/клонирование объекта
+ *
+ */
+ public function __clone()
+ {
+ throw new Exception('Not allow clone module');
+ }
+
+ /**
+ * Абстрактный метод инициализации модуля, должен быть переопределен в модуле
+ *
+ */
+ abstract public function Init();
+
+ /**
+ * Метод срабатывает при завершении работы ядра
+ *
+ */
+ public function Shutdown()
+ {
+
+ }
+
+ /**
+ * Возвращает значение флага инициализации модуля
+ *
+ * @return bool
+ */
+ public function isInit()
+ {
+ return $this->bIsInit;
+ }
+
+ /**
+ * Помечает модуль как инициализированный
+ *
+ * @return null
+ */
+ public function SetInit()
+ {
+ $this->bIsInit = true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/engine/ModuleORM.class.php b/framework/classes/engine/ModuleORM.class.php
new file mode 100644
index 0000000..7a7aee7
--- /dev/null
+++ b/framework/classes/engine/ModuleORM.class.php
@@ -0,0 +1,1359 @@
+
+ *
+ */
+
+/**
+ * Абстракция модуля ORM
+ * Предоставляет базовые методы для работы с EntityORM, например,
+ *
+ * $aUsers=$this->User_GetUserItemsByAgeAndSex(18,'male');
+ *
+ *
+ * @package framework.engine.orm
+ * @since 1.0
+ */
+abstract class ModuleORM extends Module
+{
+ /**
+ * Объект маппера ORM
+ *
+ * @var MapperORM
+ */
+ protected $oMapperORM = null;
+
+ /**
+ * Инициализация
+ * В наследнике этот метод нельзя перекрывать, необходимо вызывать через parent::Init();
+ *
+ */
+ public function Init()
+ {
+ $this->_LoadMapperORM();
+ }
+
+ /**
+ * Загрузка маппера ORM
+ *
+ */
+ protected function _LoadMapperORM()
+ {
+ $this->oMapperORM = new MapperORM($this->Database_GetConnect());
+ }
+
+ /**
+ * Добавление сущности в БД
+ * Вызывается не напрямую, а через сущность, например
+ *
+ * $oUser->setName('Claus');
+ * $oUser->Add();
+ *
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return EntityORM|bool
+ */
+ protected function _AddEntity($oEntity)
+ {
+ $res = $this->oMapperORM->AddEntity($oEntity);
+ // сбрасываем кеш
+ if ($res === 0 or $res) {
+ $sEntity = $this->Plugin_GetRootDelegater('entity', get_class($oEntity));
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array($sEntity . '_save'));
+ }
+ if ($res === 0) {
+ // у таблицы нет автоинремента
+ $oEntity->_setOriginalData($oEntity->_getDataFieldsForDb());
+ return $oEntity;
+ } elseif ($res) {
+ // есть автоинкремент, устанавливаем его
+ $oEntity->_setData(array($oEntity->_getPrimaryKey() => $res));
+ $oEntity->_setOriginalData($oEntity->_getDataFieldsForDb());
+ /**
+ * Смотрим наличие связи many_to_many и добавляем их в бд
+ */
+ foreach ($oEntity->_getRelations() as $sRelName => $aRelation) {
+ if ($aRelation['type'] == EntityORM::RELATION_TYPE_MANY_TO_MANY) {
+ if ($oEntity->$sRelName->isUpdated()) {
+ $this->_updateManyToManyRelation($oEntity, $sRelName);
+ }
+ $oEntity->resetRelationsData($sRelName);
+ }
+ }
+ return $oEntity;
+ }
+ return false;
+ }
+
+ /**
+ * Обновление сущности в БД
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return EntityORM|bool
+ */
+ protected function _UpdateEntity($oEntity)
+ {
+ $res = $this->oMapperORM->UpdateEntity($oEntity);
+ if ($res === 0 or $res) { // запись не изменилась, либо изменилась
+ // Обновление связей many_to_many
+ foreach ($oEntity->_getRelations() as $sRelName => $aRelation) {
+ if ($aRelation['type'] == EntityORM::RELATION_TYPE_MANY_TO_MANY && $oEntity->$sRelName->isUpdated()) {
+ $this->_updateManyToManyRelation($oEntity, $sRelName);
+ $oEntity->resetRelationsData($sRelName);
+ }
+ }
+ // обновляем оригинальные данные
+ $oEntity->_setOriginalData($oEntity->_getDataFieldsForDb());
+ // сбрасываем кеш
+ $sEntity = $this->Plugin_GetRootDelegater('entity', get_class($oEntity));
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array($sEntity . '_save'));
+ return $oEntity;
+ }
+ return false;
+ }
+
+ /**
+ * Сохранение сущности в БД
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return EntityORM|bool
+ */
+ protected function _SaveEntity($oEntity)
+ {
+ if ($oEntity->_isNew()) {
+ return $this->_AddEntity($oEntity);
+ } else {
+ return $this->_UpdateEntity($oEntity);
+ }
+ }
+
+ /**
+ * Удаление сущности из БД
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return EntityORM|bool
+ */
+ protected function _DeleteEntity($oEntity)
+ {
+ $res = $this->oMapperORM->DeleteEntity($oEntity);
+ if ($res) {
+ // сбрасываем кеш
+ $sEntity = $this->Plugin_GetRootDelegater('entity', get_class($oEntity));
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array($sEntity . '_delete'));
+
+ // Удаление связей many_to_many
+ foreach ($oEntity->_getRelations() as $sRelName => $aRelation) {
+ if ($aRelation['type'] == EntityORM::RELATION_TYPE_MANY_TO_MANY) {
+ $this->_deleteManyToManyRelation($oEntity, $sRelName);
+ }
+ }
+
+ return $oEntity;
+ }
+ return false;
+ }
+
+ /**
+ * Обновляет данные сущности из БД
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return EntityORM|bool
+ */
+ protected function _ReloadEntity($oEntity)
+ {
+ if ($sPrimaryKey = $oEntity->_getPrimaryKey()) {
+ if ($sPrimaryKeyValue = $oEntity->_getDataOne($sPrimaryKey)) {
+ if ($oEntityNew = $this->GetByFilter(array($sPrimaryKey => $sPrimaryKeyValue),
+ Engine::GetEntityName($oEntity))
+ ) {
+ $oEntity->_setData($oEntityNew->_getData());
+ $oEntity->_setOriginalData($oEntity->_getDataFields());
+ $oEntity->_setRelationsData(array());
+ return $oEntity;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Список полей сущности
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return array
+ */
+ protected function _ShowColumnsFrom($oEntity)
+ {
+ return $this->oMapperORM->ShowColumnsFrom($oEntity);
+ }
+
+ /**
+ * Primary индекс сущности
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return array
+ */
+ protected function _ShowPrimaryIndexFrom($oEntity)
+ {
+ return $this->oMapperORM->ShowPrimaryIndexFrom($oEntity);
+ }
+
+ /**
+ * Для сущности со связью RELATION_TYPE_TREE возвращает список прямых потомков
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return array
+ */
+ protected function _GetChildrenOfEntity($oEntity)
+ {
+ if ($oEntity->_isUsedRelationType(EntityORM::RELATION_TYPE_TREE)) {
+ $aRelationsData = $oEntity->_getRelationsData();
+ if (array_key_exists('children', $aRelationsData)) {
+ $aChildren = $aRelationsData['children'];
+ } else {
+ $aChildren = array();
+ if ($sPrimaryKey = $oEntity->_getPrimaryKey()) {
+ if ($sPrimaryKeyValue = $oEntity->_getDataOne($sPrimaryKey)) {
+ $aChildren = $this->GetItemsByFilter(array($oEntity->_getTreeParentKey() => $sPrimaryKeyValue),
+ Engine::GetEntityName($oEntity));
+ }
+ }
+ }
+ if (is_array($aChildren)) {
+ $oEntity->setChildren($aChildren);
+ return $aChildren;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Для сущности со связью RELATION_TYPE_TREE возвращает предка
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return EntityORM|bool
+ */
+ protected function _GetParentOfEntity($oEntity)
+ {
+ if ($oEntity->_isUsedRelationType(EntityORM::RELATION_TYPE_TREE)) {
+ $aRelationsData = $oEntity->_getRelationsData();
+ if (array_key_exists('parent', $aRelationsData)) {
+ $oParent = $aRelationsData['parent'];
+ } else {
+ $oParent = null;
+ if ($sPrimaryKey = $oEntity->_getPrimaryKey()) {
+ if ($sParentId = $oEntity->_getTreeParentKeyValue()) {
+ $oParent = $this->GetByFilter(array($sPrimaryKey => $sParentId),
+ Engine::GetEntityName($oEntity));
+ }
+ }
+ }
+ $oEntity->setParent($oParent);
+ return $oParent;
+ }
+ return false;
+ }
+
+ /**
+ * Для сущности со связью RELATION_TYPE_TREE возвращает список всех предков
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return array
+ */
+ protected function _GetAncestorsOfEntity($oEntity)
+ {
+ if ($oEntity->_isUsedRelationType(EntityORM::RELATION_TYPE_TREE)) {
+ $aRelationsData = $oEntity->_getRelationsData();
+ if (array_key_exists('ancestors', $aRelationsData)) {
+ $aAncestors = $aRelationsData['ancestors'];
+ } else {
+ $aAncestors = array();
+ $oEntityParent = $oEntity->getParent();
+ while (is_object($oEntityParent)) {
+ $aAncestors[] = $oEntityParent;
+ $oEntityParent = $oEntityParent->getParent();
+ }
+ }
+ if (is_array($aAncestors)) {
+ $oEntity->setAncestors($aAncestors);
+ return $aAncestors;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Для сущности со связью RELATION_TYPE_TREE возвращает список всех потомков
+ *
+ * @param EntityORM $oEntity Объект сущности
+ * @return array
+ */
+ protected function _GetDescendantsOfEntity($oEntity)
+ {
+ if ($oEntity->_isUsedRelationType(EntityORM::RELATION_TYPE_TREE)) {
+ $aRelationsData = $oEntity->_getRelationsData();
+ if (array_key_exists('descendants', $aRelationsData)) {
+ $aDescendants = $aRelationsData['descendants'];
+ } else {
+ $aDescendants = array();
+ if ($aChildren = $oEntity->getChildren()) {
+ $aTree = self::buildTree($aChildren);
+ foreach ($aTree as $aItem) {
+ $aDescendants[] = $aItem['entity'];
+ }
+ }
+ }
+ if (is_array($aDescendants)) {
+ $oEntity->setDescendants($aDescendants);
+ return $aDescendants;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Для сущностей со связью RELATION_TYPE_TREE возвращает список сущностей в виде дерева
+ *
+ * @param array $aFilter Фильтр
+ * @param string $sEntityFull Название класса сущности
+ * @return array|bool
+ */
+ public function LoadTree($aFilter = array(), $sEntityFull = null)
+ {
+ $sEntityFull = $this->_NormalizeEntityRootName($sEntityFull);
+ if ($oEntityDefault = Engine::GetEntity($sEntityFull)) {
+ if ($oEntityDefault->_isUsedRelationType(EntityORM::RELATION_TYPE_TREE)) {
+ if ($sPrimaryKey = $oEntityDefault->_getPrimaryKey()) {
+ if ($aItems = $this->GetItemsByFilter($aFilter, $sEntityFull)) {
+ $aItemsById = array();
+ $aItemsByParentId = array();
+ foreach ($aItems as $oEntity) {
+ $oEntity->setChildren(array());
+ $aItemsById[$oEntity->_getDataOne($sPrimaryKey)] = $oEntity;
+ $sParentKeyValue = $oEntity->_getTreeParentKeyValue() ? $oEntity->_getTreeParentKeyValue() : 'root';
+ if (empty($aItemsByParentId[$sParentKeyValue])) {
+ $aItemsByParentId[$sParentKeyValue] = array();
+ }
+ $aItemsByParentId[$sParentKeyValue][] = $oEntity;
+ }
+ foreach ($aItemsByParentId as $iParentId => $aItems) {
+ if ($iParentId != 'root') {
+ if (isset($aItemsById[$iParentId])) {
+ $aItemsById[$iParentId]->setChildren($aItems);
+ foreach ($aItems as $oEntity) {
+ $oEntity->setParent($aItemsById[$iParentId]);
+ }
+ }
+ } else {
+ foreach ($aItems as $oEntity) {
+ $oEntity->setParent(null);
+ }
+ }
+ }
+ return isset($aItemsByParentId['root']) ? $aItemsByParentId['root'] : array();
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Удаляет сущности по фильтру
+ * Удаление происходит отдельно для каждой сущности через вызов метода Delete()
+ *
+ * @param array $aFilter
+ * @param null $sEntityFull
+ */
+ public function DeleteItemsByFilter($aFilter = array(), $sEntityFull = null)
+ {
+ $aItems = $this->GetItemsByFilter($aFilter, $sEntityFull);
+ foreach ($aItems as $oItem) {
+ $oItem->Delete();
+ }
+ }
+
+ /**
+ * Получить сущность по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param string $sEntityFull Название класса сущности
+ * @return EntityORM|null
+ */
+ public function GetByFilter($aFilter = array(), $sEntityFull = null)
+ {
+ $sEntityFull = $this->_NormalizeEntityRootName($sEntityFull);
+ $aFilter = $this->_applyScopes($sEntityFull, $aFilter);
+ /**
+ * Хук для возможности изменения фильтра
+ */
+ $this->RunBehaviorHook('module_orm_GetByFilter_before',
+ array('aFilter' => &$aFilter, 'sEntityFull' => $sEntityFull), true);
+ $aEntities = $this->oMapperORM->GetByFilter($aFilter, $sEntityFull);
+ /**
+ * Хук для возможности кастомной обработки результата
+ */
+ $this->RunBehaviorHook('module_orm_GetByFilter_after',
+ array('aEntities' => $aEntities, 'aFilter' => $aFilter, 'sEntityFull' => $sEntityFull), true);
+ return $aEntities;
+ }
+
+ /**
+ * Получить список сущностей по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param string|null $sEntityFull Название класса сущности
+ * @return array
+ */
+ public function GetItemsByFilter($aFilter = array(), $sEntityFull = null)
+ {
+ if (is_null($aFilter)) {
+ $aFilter = array();
+ }
+
+ $sEntityFull = $this->_NormalizeEntityRootName($sEntityFull);
+ $aFilter = $this->_applyScopes($sEntityFull, $aFilter);
+ /**
+ * Хук для возможности изменения фильтра
+ */
+ $this->RunBehaviorHook('module_orm_GetItemsByFilter_before',
+ array('aFilter' => &$aFilter, 'sEntityFull' => $sEntityFull), true);
+
+ // Если параметр #cache указан и пуст, значит игнорируем кэширование для запроса
+ if (array_key_exists('#cache', $aFilter) && !$aFilter['#cache']) {
+ $aEntities = $this->oMapperORM->GetItemsByFilter($aFilter, $sEntityFull);
+ } else {
+ $aFilterCache = $aFilter;
+ unset($aFilterCache['#with']);
+ unset($aFilterCache['#scope']);
+ $sCacheKey = $sEntityFull . '_items_by_filter_' . serialize($aFilterCache);
+ $aCacheTags = array($sEntityFull . '_save', $sEntityFull . '_delete');
+ $iCacheTime = 60 * 60 * 24; // скорее лучше хранить в свойстве сущности, для возможности выборочного переопределения
+ // переопределяем из параметров
+ if (isset($aFilter['#cache'][0])) {
+ $sCacheKey = $aFilter['#cache'][0];
+ }
+ if (isset($aFilter['#cache'][1])) {
+ $aCacheTags = $aFilter['#cache'][1];
+ }
+ if (isset($aFilter['#cache'][2])) {
+ $iCacheTime = $aFilter['#cache'][2];
+ }
+
+ if (false === ($aEntities = $this->Cache_Get($sCacheKey))) {
+ $aEntities = $this->oMapperORM->GetItemsByFilter($aFilter, $sEntityFull);
+ $this->Cache_Set($aEntities, $sCacheKey, $aCacheTags, $iCacheTime);
+ }
+ }
+ /**
+ * Если необходимо подцепить связанные данные
+ */
+ if (count($aEntities) and isset($aFilter['#with'])) {
+ if (!is_array($aFilter['#with'])) {
+ $aFilter['#with'] = array($aFilter['#with']);
+ }
+ /**
+ * Приводим значение к единой форме ассоциативного массива: array('user'=>array(), 'topic'=>array('blog_id'=>123) )
+ */
+ func_array_simpleflip($aFilter['#with'], array());
+ /**
+ * Подтягиваем связанные данные
+ */
+ $this->_attachRelationObjects($aEntities, $aFilter['#with'], $sEntityFull);
+ }
+ /**
+ * Returns assotiative array, indexed by PRIMARY KEY or another field.
+ */
+ if (in_array('#index-from-primary', $aFilter, true) || !empty($aFilter['#index-from'])) {
+ $aEntities = $this->_setIndexesFromField($aEntities, $aFilter);
+ }
+ /**
+ * Группирует результирующий массив по ключам необходимого поля
+ */
+ if (!empty($aFilter['#index-group'])) {
+ $aEntities = $this->_setIndexesGroupField($aEntities, $aFilter);
+ }
+ /**
+ * Хук для возможности кастомной обработки результата
+ */
+ $this->RunBehaviorHook('module_orm_GetItemsByFilter_after',
+ array('aEntities' => $aEntities, 'aFilter' => $aFilter, 'sEntityFull' => $sEntityFull), true);
+ /**
+ * Если запрашиваем постраничный список, то возвращаем сам список и общее количество записей
+ */
+ if (isset($aFilter['#page'])) {
+ if (isset($aFilter['#cache'][0])) {
+ /**
+ * Задан собственный ключ для хранения кеша, поэтому нужно его сменить для передачи в GetCount*
+ * Добавляем префикс 'count_'
+ */
+ $aFilter['#cache'][0] = 'count_' . $aFilter['#cache'][0];
+ }
+ return array('collection' => $aEntities, 'count' => $this->GetCountItemsByFilter($aFilter, $sEntityFull));
+ }
+ return $aEntities;
+ }
+
+ /**
+ * Returns assotiative array, indexed by PRIMARY KEY or another field.
+ *
+ * @param array $aEntities Список сущностей
+ * @param array $aFilter Фильтр
+ * @return array
+ * @throws
+ */
+ protected function _setIndexesFromField($aEntities, $aFilter)
+ {
+ $aIndexedEntities = array();
+ foreach ($aEntities as $oEntity) {
+ $sKey = in_array('#index-from-primary',
+ $aFilter, true) || (!empty($aFilter['#index-from']) && $aFilter['#index-from'] == '#primary') ?
+ $oEntity->_getPrimaryKey() :
+ $oEntity->_getField($aFilter['#index-from']);
+ if (is_array($sKey)) {
+ throw new Exception("The entity <" . get_class($oEntity) . "> allow only single key for index-from");
+ }
+ $aIndexedEntities[$oEntity->_getDataOne($sKey)] = $oEntity;
+ }
+ return $aIndexedEntities;
+ }
+
+ /**
+ * @param array $aEntities Список сущностей
+ * @param array $aWith Список связей (в фильтре используется как "#with")
+ * @param null|string $sEntityFull Исходный класс сущности для которой подтягиваем связи
+ * @throws Exception
+ */
+ protected function _attachRelationObjects($aEntities, $aWith, $sEntityFull = null)
+ {
+ if (!count($aEntities)) {
+ return;
+ }
+ func_array_simpleflip($aWith, array());
+ if (is_null($sEntityFull)) {
+ $oEntityFirst = reset($aEntities);
+ $sEntityFull = $this->_NormalizeEntityRootName($oEntityFirst);
+ }
+ /**
+ * Формируем список примари ключей
+ */
+ $aEntityPrimaryKeys = array();
+ foreach ($aEntities as $oEntity) {
+ $aEntityPrimaryKeys[] = $oEntity->_getPrimaryKeyValue();
+ }
+ $oEntityEmpty = Engine::GetEntity($sEntityFull);
+ $aRelations = $oEntityEmpty->_getRelations();
+ foreach ($aWith as $sRelationName => $aRelationFilter) {
+ if (!isset($aRelations[$sRelationName])) {
+ continue;
+ }
+ /**
+ * Если нужна дополнительная обработка через коллбек
+ * Параметр в обработчике должен приниматься по ссылке
+ */
+ if (isset($aRelationFilter['#callback-filter']) and $aRelationFilter['#callback-filter'] instanceof Closure) {
+ $callback = $aRelationFilter['#callback-filter'];
+ $callback($aEntities, $aRelationFilter);
+ }
+ /**
+ * Если необходимо, то выставляем сразу нужное значение и не делаем никаких запросов
+ */
+ if (isset($aRelationFilter['#value-set'])) {
+ foreach ($aEntities as $oEntity) {
+ $oEntity->_setData(array($sRelationName => $aRelationFilter['#value-set']));
+ }
+ continue;
+ }
+ /**
+ * Чистим фильтр от коллбека, иначе он может пройти дальше по цепочке вызовов
+ */
+ unset($aRelationFilter['#callback-filter']);
+
+ $sRelType = $aRelations[$sRelationName]['type'];
+ $sRelEntity = $this->Plugin_GetRootDelegater('entity',
+ $aRelations[$sRelationName]['rel_entity']); // получаем корневую сущность, без учета наследников
+ $sRelKey = $aRelations[$sRelationName]['rel_key'];
+
+ if (!array_key_exists($sRelationName, $aRelations) or !in_array($sRelType, array(
+ EntityORM::RELATION_TYPE_BELONGS_TO,
+ EntityORM::RELATION_TYPE_HAS_ONE,
+ EntityORM::RELATION_TYPE_HAS_MANY,
+ EntityORM::RELATION_TYPE_MANY_TO_MANY
+ ))
+ ) {
+ throw new Exception("The entity <{$sEntityFull}> not have relation <{$sRelationName}>");
+ }
+
+ /**
+ * Делаем общий запрос по всем ключам
+ */
+ $oRelEntityEmpty = Engine::GetEntity($sRelEntity);
+ $sRelModuleName = Engine::GetModuleName($sRelEntity);
+ $sRelEntityName = Engine::GetEntityName($sRelEntity);
+ $sRelPluginPrefix = Engine::GetPluginPrefix($sRelEntity);
+ $sRelPrimaryKey = method_exists($oRelEntityEmpty,
+ '_getPrimaryKey') ? $oRelEntityEmpty->_getPrimaryKey() : 'id';
+ if ($sRelType == EntityORM::RELATION_TYPE_BELONGS_TO) {
+ /**
+ * Формируем список ключей
+ */
+ $aEntityKeyValues = array();
+ foreach ($aEntities as $oEntity) {
+ $aEntityKeyValues[] = $oEntity->_getDataOne($sRelKey);
+ }
+ $aEntityKeyValues = array_unique($aEntityKeyValues);
+
+ $sKeyTo = $aRelations[$sRelationName]['rel_key_to'] ?: $sRelPrimaryKey;
+ $aFilterRel = array(
+ $sKeyTo . ' in' => $aEntityKeyValues,
+ '#index-from' => $sKeyTo
+ );
+ $aFilterRel = array_merge($aFilterRel, $aRelationFilter);
+ $aRelData = Engine::GetInstance()->_CallModule("{$sRelPluginPrefix}{$sRelModuleName}_get{$sRelEntityName}ItemsByFilter",
+ array($aFilterRel));
+ } elseif ($sRelType == EntityORM::RELATION_TYPE_HAS_ONE) {
+ $aFilterRel = array($sRelKey . ' in' => $aEntityPrimaryKeys, '#index-from' => $sRelKey);
+ $aFilterRel = array_merge($aFilterRel, $aRelationFilter, $aRelations[$sRelationName]['filter']);
+ $aRelData = Engine::GetInstance()->_CallModule("{$sRelPluginPrefix}{$sRelModuleName}_get{$sRelEntityName}ItemsByFilter",
+ array($aFilterRel));
+ } elseif ($sRelType == EntityORM::RELATION_TYPE_HAS_MANY) {
+ if ($aRelations[$sRelationName]['key_from']) {
+ /**
+ * Формируем список ключей
+ */
+ $aEntityKeyValues = array();
+ foreach ($aEntities as $oEntity) {
+ $aEntityKeyValues[] = $oEntity->_getDataOne($aRelations[$sRelationName]['key_from']);
+ }
+ $aEntityKeyValues = array_unique($aEntityKeyValues);
+ }
+
+ $aFilterRel = array(
+ $sRelKey . ' in' => $aRelations[$sRelationName]['key_from'] ? $aEntityKeyValues : $aEntityPrimaryKeys,
+ '#index-group' => $sRelKey
+ );
+ $aFilterRel = array_merge($aFilterRel, $aRelationFilter, $aRelations[$sRelationName]['filter']);
+ $aRelData = Engine::GetInstance()->_CallModule("{$sRelPluginPrefix}{$sRelModuleName}_get{$sRelEntityName}ItemsByFilter",
+ array($aFilterRel));
+ } elseif ($sRelType == EntityORM::RELATION_TYPE_MANY_TO_MANY) {
+ $sEntityJoin = $aRelations[$sRelationName]['join_entity'];
+ $sKeyJoin = $aRelations[$sRelationName]['join_key'];
+ $aFilterAdd = $aRelations[$sRelationName]['filter'];
+ if (!array_key_exists('#value-default', $aRelationFilter)) {
+ $aRelationFilter['#value-default'] = array();
+ }
+ $aFilterRel = array_merge($aFilterAdd, $aRelationFilter);
+ $aRelData = Engine::GetInstance()->_CallModule("{$sRelPluginPrefix}{$sRelModuleName}_get{$sRelEntityName}ItemsByJoinEntity",
+ array($sEntityJoin, $sKeyJoin, $sRelKey, $aEntityPrimaryKeys, $aFilterRel));
+ $aRelData = $this->_setIndexesGroupJoinField($aRelData, $sKeyJoin);
+ }
+ /**
+ * Собираем набор
+ */
+ foreach ($aEntities as $oEntity) {
+ if ($sRelType == EntityORM::RELATION_TYPE_BELONGS_TO) {
+ $sKeyData = $oEntity->_getDataOne($sRelKey);
+ } elseif (in_array($sRelType, array(
+ EntityORM::RELATION_TYPE_HAS_ONE,
+ EntityORM::RELATION_TYPE_HAS_MANY,
+ EntityORM::RELATION_TYPE_MANY_TO_MANY
+ ))) {
+ $sKeyData = $oEntity->_getPrimaryKeyValue();
+ } else {
+ break;
+ }
+ if ($sRelType == EntityORM::RELATION_TYPE_HAS_MANY and $aRelations[$sRelationName]['key_from']) {
+ $sKeyData = $oEntity->_getDataOne($aRelations[$sRelationName]['key_from']);
+ }
+ if (isset($aRelData[$sKeyData])) {
+ $oEntity->_setData(array($sRelationName => $aRelData[$sKeyData]));
+ } elseif (isset($aRelationFilter['#value-default'])) {
+ $oEntity->_setData(array($sRelationName => $aRelationFilter['#value-default']));
+ } elseif ($sRelType == EntityORM::RELATION_TYPE_HAS_MANY) {
+ $oEntity->_setData(array($sRelationName => array()));
+ }
+ if ($sRelType == EntityORM::RELATION_TYPE_MANY_TO_MANY) {
+ // Создаём объекты-обёртки для связей MANY_TO_MANY
+ $oEntity->_setManyToManyRelations(new ORMRelationManyToMany($oEntity->_getRelationsData($sRelationName)),
+ $sRelationName);
+ }
+ }
+ }
+ }
+
+ /**
+ * Возвращает сгруппированный массив по нужному полю
+ *
+ * @param array $aEntities
+ * @param array $aFilter
+ *
+ * @return array
+ */
+ protected function _setIndexesGroupField($aEntities, $aFilter)
+ {
+ $aIndexedEntities = array();
+ foreach ($aEntities as $oEntity) {
+ $sKey = $oEntity->_getField($aFilter['#index-group']);
+ $aIndexedEntities[$oEntity->_getDataOne($sKey)][] = $oEntity;
+ }
+ return $aIndexedEntities;
+ }
+
+ /**
+ * Возвращает сгруппированный массив по нужному полю из данных таблицы связей
+ *
+ * @param array $aEntities
+ * @param string $sField
+ *
+ * @return array
+ */
+ protected function _setIndexesGroupJoinField($aEntities, $sField)
+ {
+ $aIndexedEntities = array();
+ foreach ($aEntities as $oEntity) {
+ $oRelEntity = $oEntity->_getDataOne('_relation_entity');
+ if ($oRelEntity) {
+ $sVal = $oRelEntity->_getDataOne($oRelEntity->_getField($sField));
+ if (!is_null($sVal)) {
+ $aIndexedEntities[$sVal][] = $oEntity;
+ }
+ }
+ }
+ return $aIndexedEntities;
+ }
+
+ /**
+ * Применяет дополнительные фильтры scope
+ *
+ * @param $sEntityFull
+ * @param $aFilter
+ * @return array
+ */
+ protected function _applyScopes($sEntityFull, $aFilter)
+ {
+ if (isset($aFilter['#scope'])) {
+ $aScopes = $aFilter['#scope'];
+ if (!is_array($aScopes)) {
+ $aScopes = array($aScopes);
+ }
+ /**
+ * Приводим значение к единой форме ассоциативного массива: array('user'=>array(), 'topic'=>array('blog_id'=>123) )
+ */
+ func_array_simpleflip($aScopes, array());
+ $oEntityEmpty = Engine::GetEntity($sEntityFull);
+ foreach ($aScopes as $sScope => $aScopeParams) {
+ $sMethod = 'getScope' . func_camelize($sScope);
+ if (method_exists($oEntityEmpty, $sMethod)) {
+ if ($aFilterAdd = call_user_func_array(array($oEntityEmpty, $sMethod), $aScopeParams) and is_array($aFilterAdd)) {
+ $aFilter = array_merge($aFilterAdd, $aFilter);
+ }
+ }
+ }
+ }
+ return $aFilter;
+ }
+
+ /**
+ * Получить значение агрегирующей функции
+ *
+ * @param $sAggregateFunction
+ * @param $sField
+ * @param array $aFilter
+ * @param null $sEntityFull
+ *
+ * @return EntityORM|null
+ */
+ public function GetAggregateFunctionByFilter($sAggregateFunction, $sField, $aFilter = array(), $sEntityFull = null)
+ {
+ $sEntityFull = $this->_NormalizeEntityRootName($sEntityFull);
+ // Если параметр #cache указан и пуст, значит игнорируем кэширование для запроса
+ if (array_key_exists('#cache', $aFilter) && !$aFilter['#cache']) {
+ $iValue = $this->oMapperORM->GetAggregateFunctionByFilter($sAggregateFunction, $sField, $aFilter,
+ $sEntityFull);
+ } else {
+ $sCacheKey = $sEntityFull . "_aggregate_function_by_filter_{$sAggregateFunction}_{$sField}" . serialize($aFilter);
+ $aCacheTags = array($sEntityFull . '_save', $sEntityFull . '_delete');
+ $iCacheTime = 60 * 60 * 24; // скорее лучше хранить в свойстве сущности, для возможности выборочного переопределения
+ // переопределяем из параметров
+ if (isset($aFilter['#cache'][0])) {
+ $sCacheKey = $aFilter['#cache'][0];
+ }
+ if (isset($aFilter['#cache'][1])) {
+ $aCacheTags = $aFilter['#cache'][1];
+ }
+ if (isset($aFilter['#cache'][2])) {
+ $iCacheTime = $aFilter['#cache'][2];
+ }
+
+ if (false === ($iValue = $this->Cache_Get($sCacheKey))) {
+ $iValue = $this->oMapperORM->GetAggregateFunctionByFilter($sAggregateFunction, $sField, $aFilter,
+ $sEntityFull);
+ $this->Cache_Set($iValue, $sCacheKey, $aCacheTags, $iCacheTime);
+ }
+ }
+ return $iValue;
+ }
+
+ /**
+ * Получить количество сущностей по фильтру
+ *
+ * @param array $aFilter Фильтр
+ * @param string $sEntityFull Название класса сущности
+ * @return int
+ */
+ public function GetCountItemsByFilter($aFilter = array(), $sEntityFull = null)
+ {
+ $sEntityFull = $this->_NormalizeEntityRootName($sEntityFull);
+ // Если параметр #cache указан и пуст, значит игнорируем кэширование для запроса
+ if (array_key_exists('#cache', $aFilter) && !$aFilter['#cache']) {
+ $iCount = $this->oMapperORM->GetCountItemsByFilter($aFilter, $sEntityFull);
+ } else {
+ $aFilterCache = $aFilter;
+ unset($aFilterCache['#with']);
+ $sCacheKey = $sEntityFull . '_count_items_by_filter_' . serialize($aFilterCache);
+ $aCacheTags = array($sEntityFull . '_save', $sEntityFull . '_delete');
+ $iCacheTime = 60 * 60 * 24; // скорее лучше хранить в свойстве сущности, для возможности выборочного переопределения
+ // переопределяем из параметров
+ if (isset($aFilter['#cache'][0])) {
+ $sCacheKey = $aFilter['#cache'][0];
+ }
+ if (isset($aFilter['#cache'][1])) {
+ $aCacheTags = $aFilter['#cache'][1];
+ }
+ if (isset($aFilter['#cache'][2])) {
+ $iCacheTime = $aFilter['#cache'][2];
+ }
+
+ if (false === ($iCount = $this->Cache_Get($sCacheKey))) {
+ $iCount = $this->oMapperORM->GetCountItemsByFilter($aFilter, $sEntityFull);
+ $this->Cache_Set($iCount, $sCacheKey, $aCacheTags, $iCacheTime);
+ }
+ }
+ return $iCount;
+ }
+
+ /**
+ * Возвращает список сущностей по фильтру
+ * В качестве ключей возвращаемого массива используется primary key сущности
+ *
+ * @param array $aFilter Фильтр
+ * @param string|null $sEntityFull Название класса сущности
+ * @return array
+ */
+ public function GetItemsByArray($aFilter, $sEntityFull = null)
+ {
+ foreach ($aFilter as $k => $v) {
+ $aFilter["{$k} IN"] = $v;
+ unset($aFilter[$k]);
+ }
+ $aFilter[] = '#index-from-primary';
+ return $this->GetItemsByFilter($aFilter, $sEntityFull);
+ }
+
+ public function GetItemsByJoinEntity(
+ $sEntityJoin,
+ $sKeyJoin,
+ $sRelationKey,
+ $aRelationValues,
+ $aFilter,
+ $sEntityFull = null
+ ) {
+ $sEntityFull = $this->_NormalizeEntityRootName($sEntityFull);
+ /**
+ * Кеширование
+ * Если параметр #cache указан и пуст, значит игнорируем кэширование для запроса
+ */
+ if (array_key_exists('#cache', $aFilter) && !$aFilter['#cache']) {
+ $aEntities = $this->oMapperORM->GetItemsByJoinEntity($sEntityJoin, $sKeyJoin, $sRelationKey,
+ $aRelationValues, $aFilter, $sEntityFull);
+ } else {
+ $sEntityJoin = $this->Plugin_GetRootDelegater('entity', $sEntityJoin);
+
+ $sCacheKey = 'items_by_join_entity_' . serialize(array(
+ $sEntityJoin,
+ $sKeyJoin,
+ $sRelationKey,
+ $aRelationValues,
+ $aFilter,
+ $sEntityFull
+ ));
+ /**
+ * Формируем теги для сброса кеша
+ * Сброс идет по обновлению запрашиваемой сущности
+ * Дополнительно по обновлению таблицы связей
+ */
+ $aCacheTags = array(
+ $sEntityFull . '_save',
+ $sEntityFull . '_delete',
+ $sEntityJoin . '_save',
+ $sEntityJoin . '_delete'
+ );
+ $iCacheTime = 60 * 60 * 24; // todo: скорее лучше хранить в свойстве сущности, для возможности выборочного переопределения
+ /**
+ * Переопределяем из параметров
+ */
+ if (isset($aFilter['#cache'][0])) {
+ $sCacheKey = $aFilter['#cache'][0];
+ }
+ if (isset($aFilter['#cache'][1])) {
+ $aCacheTags = $aFilter['#cache'][1];
+ }
+ if (isset($aFilter['#cache'][2])) {
+ $iCacheTime = $aFilter['#cache'][2];
+ }
+ /**
+ * Смотрим в кеше
+ */
+ if (false === ($aEntities = $this->Cache_Get($sCacheKey))) {
+ $aEntities = $this->oMapperORM->GetItemsByJoinEntity($sEntityJoin, $sKeyJoin, $sRelationKey,
+ $aRelationValues, $aFilter, $sEntityFull);
+ $this->Cache_Set($aEntities, $sCacheKey, $aCacheTags, $iCacheTime);
+ }
+ }
+ /**
+ * Если запрашиваем постраничный список, то возвращаем сам список и общее количество записей
+ */
+ if (isset($aFilter['#page'])) {
+ if (isset($aFilter['#cache'][0])) {
+ /**
+ * Задан собственный ключ для хранения кеша, поэтому нужно его сменить для передачи в GetCount*
+ * Добавляем префикс 'count_'
+ */
+ $aFilter['#cache'][0] = 'count_' . $aFilter['#cache'][0];
+ }
+ return array(
+ 'collection' => $aEntities,
+ 'count' => $this->GetCountItemsByJoinEntity($sEntityJoin, $sKeyJoin, $sRelationKey,
+ $aRelationValues, $aFilter, $sEntityFull)
+ );
+ }
+ return $aEntities;
+ }
+
+ public function GetCountItemsByJoinEntity(
+ $sEntityJoin,
+ $sKeyJoin,
+ $sRelationKey,
+ $aRelationValues,
+ $aFilter,
+ $sEntityFull = null
+ ) {
+ $sEntityFull = $this->_NormalizeEntityRootName($sEntityFull);
+ /**
+ * Кеширование
+ * Если параметр #cache указан и пуст, значит игнорируем кэширование для запроса
+ */
+ if (array_key_exists('#cache', $aFilter) && !$aFilter['#cache']) {
+ $iCount = $this->oMapperORM->GetCountItemsByJoinEntity($sEntityJoin, $sKeyJoin, $sRelationKey,
+ $aRelationValues, $aFilter, $sEntityFull);
+ } else {
+ $sEntityJoin = $this->Plugin_GetRootDelegater('entity', $sEntityJoin);
+
+ $sCacheKey = 'count_items_by_join_entity_' . serialize(array(
+ $sEntityJoin,
+ $sKeyJoin,
+ $sRelationKey,
+ $aRelationValues,
+ $aFilter,
+ $sEntityFull
+ ));
+ /**
+ * Формируем теги для сброса кеша
+ * Сброс идет по обновлению таблицы связей
+ */
+ $aCacheTags = array($sEntityJoin . '_save', $sEntityJoin . '_delete');
+ $iCacheTime = 60 * 60 * 24; // todo: скорее лучше хранить в свойстве сущности, для возможности выборочного переопределения
+ /**
+ * Переопределяем из параметров
+ */
+ if (isset($aFilter['#cache'][0])) {
+ $sCacheKey = $aFilter['#cache'][0];
+ }
+ if (isset($aFilter['#cache'][1])) {
+ $aCacheTags = $aFilter['#cache'][1];
+ }
+ if (isset($aFilter['#cache'][2])) {
+ $iCacheTime = $aFilter['#cache'][2];
+ }
+ /**
+ * Смотрим в кеше
+ */
+ if (false === ($iCount = $this->Cache_Get($sCacheKey))) {
+ $iCount = $this->oMapperORM->GetCountItemsByJoinEntity($sEntityJoin, $sKeyJoin, $sRelationKey,
+ $aRelationValues, $aFilter, $sEntityFull);
+ $this->Cache_Set($iCount, $sCacheKey, $aCacheTags, $iCacheTime);
+ }
+ }
+
+ return $iCount;
+ }
+
+ /**
+ * Ставим хук на вызов неизвестного метода и считаем что хотели вызвать метод какого либо модуля.
+ * Также обрабатывает различные ORM методы сущности, например
+ *
+ * $oUser->Save();
+ * $oUser->Delete();
+ *
+ * И методы модуля ORM, например
+ *
+ * $this->User_getUserItemsByName('Claus');
+ * $this->User_getUserItemsAll();
+ *
+ * @see Engine::_CallModule
+ *
+ * @param string $sName Имя метода
+ * @param array $aArgs Аргументы
+ * @return mixed
+ */
+ public function __call($sName, $aArgs)
+ {
+ $sNameUnderscore = func_underscore($sName);
+
+ if (preg_match("@^add([a-z]+)$@i", $sName, $aMatch)) {
+ return $this->_AddEntity($aArgs[0]);
+ }
+
+ if (preg_match("@^update([a-z]+)$@i", $sName, $aMatch)) {
+ return $this->_UpdateEntity($aArgs[0]);
+ }
+
+ if (preg_match("@^save([a-z]+)$@i", $sName, $aMatch)) {
+ return $this->_SaveEntity($aArgs[0]);
+ }
+
+ if (preg_match("@^delete([a-z]+)$@i", $sName, $aMatch) and !strpos($sNameUnderscore, 'items_by_filter')) {
+ return $this->_DeleteEntity($aArgs[0]);
+ }
+
+ if (preg_match("@^reload([a-z]+)$@i", $sName, $aMatch)) {
+ return $this->_ReloadEntity($aArgs[0]);
+ }
+
+ if (preg_match("@^showcolumnsfrom([a-z]+)$@i", $sName, $aMatch)) {
+ return $this->_ShowColumnsFrom($aArgs[0]);
+ }
+
+ if (preg_match("@^showprimaryindexfrom([a-z]+)$@i", $sName, $aMatch)) {
+ return $this->_ShowPrimaryIndexFrom($aArgs[0]);
+ }
+
+ if (preg_match("@^getchildrenof([a-z]+)$@i", $sName, $aMatch)) {
+ return $this->_GetChildrenOfEntity($aArgs[0]);
+ }
+
+ if (preg_match("@^getparentof([a-z]+)$@i", $sName, $aMatch)) {
+ return $this->_GetParentOfEntity($aArgs[0]);
+ }
+
+ if (preg_match("@^getdescendantsof([a-z]+)$@i", $sName, $aMatch)) {
+ return $this->_GetDescendantsOfEntity($aArgs[0]);
+ }
+
+ if (preg_match("@^getancestorsof([a-z]+)$@i", $sName, $aMatch)) {
+ return $this->_GetAncestorsOfEntity($aArgs[0]);
+ }
+
+ if (preg_match("@^loadtreeof([a-z]+)$@i", $sName, $aMatch)) {
+ $sEntityFull = array_key_exists(1, $aMatch) ? $aMatch[1] : null;
+ return $this->LoadTree(isset($aArgs[0]) ? $aArgs[0] : array(), $sEntityFull);
+ }
+
+ $iEntityPosEnd = 0;
+ if (strpos($sNameUnderscore, '_items') >= 3) {
+ $iEntityPosEnd = strpos($sNameUnderscore, '_items');
+ } else {
+ if (strpos($sNameUnderscore, '_by') >= 3) {
+ $iEntityPosEnd = strpos($sNameUnderscore, '_by');
+ } else {
+ if (strpos($sNameUnderscore, '_all') >= 3) {
+ $iEntityPosEnd = strpos($sNameUnderscore, '_all');
+ }
+ }
+ }
+ if ($iEntityPosEnd && $iEntityPosEnd > 4) {
+ $sEntityName = substr($sNameUnderscore, 4, $iEntityPosEnd - 4);
+ } else {
+ $sEntityName = func_underscore(Engine::GetModuleName($this)) . '_';
+ $sNameUnderscore = substr_replace($sNameUnderscore, $sEntityName, 4, 0);
+ $iEntityPosEnd = strlen($sEntityName) - 1 + 4;
+ }
+
+ $sNameUnderscore = substr_replace($sNameUnderscore, str_replace('_', '', $sEntityName), 4, $iEntityPosEnd - 4);
+
+ $sEntityName = func_camelize($sEntityName);
+ /**
+ * getMaxRatingFromUserByFilter() get_max_rating_from_user_by_filter
+ */
+ if (preg_match("@^get_(max|min|avg|sum)_([a-z][_a-z0-9]*)_from_([a-z][_a-z0-9]*)_by_filter$@i",
+ func_underscore($sName), $aMatch)) {
+ return $this->GetAggregateFunctionByFilter($aMatch[1], $aMatch[2], isset($aArgs[0]) ? $aArgs[0] : array(),
+ func_camelize($aMatch[3]));
+ }
+
+ /**
+ * getMaxRatingFromUserByStatusAndActive() get_max_rating_from_user_by_status_and_active
+ */
+ if (preg_match("@^get_(max|min|avg|sum)_([a-z][_a-z0-9]*)_from_([a-z][_a-z0-9]*)_by_([_a-z]+)$@i",
+ func_underscore($sName), $aMatch)) {
+ $aSearchParams = explode('_and_', $aMatch[4]);
+ $aSplit = array_chunk($aArgs, count($aSearchParams));
+ $aFilter = array_combine($aSearchParams, $aSplit[0]);
+ if (isset($aSplit[1][0])) {
+ $aFilter = array_merge($aFilter, $aSplit[1][0]);
+ }
+ return $this->GetAggregateFunctionByFilter($aMatch[1], $aMatch[2], $aFilter, func_camelize($aMatch[3]));
+ }
+
+ /**
+ * getCountFromUserByFilter() get_count_from_user_by_filter
+ */
+ if (preg_match("@^get_count_from_([a-z][_a-z0-9]*)_by_filter$@i", func_underscore($sName), $aMatch)) {
+ return $this->GetCountItemsByFilter(isset($aArgs[0]) ? $aArgs[0] : array(), func_camelize($aMatch[1]));
+ }
+
+ /**
+ * getUserItemsByFilter() get_user_items_by_filter
+ */
+ if (preg_match("@^get_([a-z]+)((_items)|())_by_filter$@i", $sNameUnderscore, $aMatch)) {
+ if ($aMatch[2] == '_items') {
+ return $this->GetItemsByFilter($aArgs[0], $sEntityName);
+ } else {
+ return $this->GetByFilter($aArgs[0], $sEntityName);
+ }
+ }
+
+ /**
+ * deleteUserItemsByFilter() delete_user_items_by_filter
+ */
+ if (preg_match("@^delete_([a-z\_]+)_items_by_filter$@i", func_underscore($sName), $aMatch)) {
+ return $this->DeleteItemsByFilter(isset($aArgs[0]) ? $aArgs[0] : array(), func_camelize($aMatch[1]));
+ }
+
+ /**
+ * getUserItemsByArrayId() get_user_items_by_array_id
+ */
+ if (preg_match("@^get_([a-z]+)_items_by_array_([_a-z]+)$@i", $sNameUnderscore, $aMatch)) {
+ return $this->GetItemsByArray(array($aMatch[2] => $aArgs[0]), $sEntityName);
+ }
+
+ /**
+ * getUserItemsByJoinEntity() get_user_items_by_join_entity
+ */
+ if (preg_match("@^get_([a-z]+)_items_by_join_entity$@i", $sNameUnderscore, $aMatch)) {
+ return $this->GetItemsByJoinEntity($aArgs[0], $aArgs[1], $aArgs[2], $aArgs[3], $aArgs[4],
+ func_camelize($sEntityName));
+ }
+
+ /**
+ * getUserByLogin() get_user_by_login
+ * getUserByLoginAndMail() get_user_by_login_and_mail
+ * getUserItemsByName() get_user_items_by_name
+ * getUserItemsByNameAndActive() get_user_items_by_name_and_active
+ * getUserItemsByDateRegisterGte() get_user_items_by_date_register_gte (>=)
+ * getUserItemsByProfileNameLike() get_user_items_by_profile_name_like
+ * getUserItemsByCityIdIn() get_user_items_by_city_id_in
+ */
+ if (preg_match("@^get_([a-z]+)((_items)|())_by_([_a-z]+)$@i", $sNameUnderscore, $aMatch)) {
+ $aAliases = array(
+ '_gte' => ' >=',
+ '_lte' => ' <=',
+ '_gt' => ' >',
+ '_lt' => ' <',
+ '_like' => ' LIKE',
+ '_in' => ' IN'
+ );
+ $sSearchParams = str_replace(array_keys($aAliases), array_values($aAliases), $aMatch[5]);
+ $aSearchParams = explode('_and_', $sSearchParams);
+ $aSplit = array_chunk($aArgs, count($aSearchParams));
+ $aFilter = array_combine($aSearchParams, $aSplit[0]);
+ if (isset($aSplit[1][0])) {
+ $aFilter = array_merge($aFilter, $aSplit[1][0]);
+ }
+ if ($aMatch[2] == '_items') {
+ return $this->GetItemsByFilter($aFilter, $sEntityName);
+ } else {
+ return $this->GetByFilter($aFilter, $sEntityName);
+ }
+ }
+
+ /**
+ * getUserAll() get_user_all OR
+ * getUserItemsAll() get_user_items_all
+ */
+ if (preg_match("@^get_([a-z]+)_all$@i", $sNameUnderscore, $aMatch) ||
+ preg_match("@^get_([a-z]+)_items_all$@i", $sNameUnderscore, $aMatch)
+ ) {
+ $aFilter = array();
+ if (isset($aArgs[0]) and is_array($aArgs[0])) {
+ $aFilter = $aArgs[0];
+ }
+ return $this->GetItemsByFilter($aFilter, $sEntityName);
+ }
+
+ return parent::__call($sName, $aArgs);
+ }
+
+ /**
+ * Построение дерева
+ *
+ * @param array $aItems Список сущностей
+ * @param array $aList
+ * @param int $iLevel Текущий уровень вложенности
+ * @return array
+ */
+ static function buildTree($aItems, $aList = array(), $iLevel = 0)
+ {
+ if (!$aItems) {
+ return array();
+ }
+ foreach ($aItems as $oEntity) {
+ $aChildren = $oEntity->getChildren();
+ $bHasChildren = !empty($aChildren);
+ $sEntityId = $oEntity->_getDataOne($oEntity->_getPrimaryKey());
+ $aList[$sEntityId] = array(
+ 'entity' => $oEntity,
+ 'parent_id' => $oEntity->_getTreeParentKeyValue(),
+ 'children_count' => $bHasChildren ? count($aChildren) : 0,
+ 'level' => $iLevel,
+ );
+ if ($bHasChildren) {
+ $aList = self::buildTree($aChildren, $aList, $iLevel + 1);
+ }
+ }
+ return $aList;
+ }
+
+ /**
+ * Выполняет обновление связи many_to_many у сущности
+ *
+ * @param $oEntity
+ * @param $sRelationKey
+ */
+ protected function _updateManyToManyRelation($oEntity, $sRelationKey)
+ {
+ $aRelations = $oEntity->_getRelations();
+ if (!isset($aRelations[$sRelationKey]['type']) or $aRelations[$sRelationKey]['type'] != EntityORM::RELATION_TYPE_MANY_TO_MANY) {
+ return;
+ }
+ $aFilterAdd = $aRelations[$sRelationKey]['filter'];
+ $oEntityRelation = Engine::GetEntity($aRelations[$sRelationKey]['join_entity']);
+ /**
+ * По сущности связи формируем запрос за получение списка сохраненых связей в БД
+ */
+ $sCmd = Engine::GetPluginPrefix($aRelations[$sRelationKey]['join_entity']) . 'Module' . Engine::GetModuleName($aRelations[$sRelationKey]['join_entity']) . '_Get' . Engine::GetEntityName($aRelations[$sRelationKey]['join_entity']) . 'ItemsByFilter';
+ list($aFilter) = $this->oMapperORM->BuildFilter($aFilterAdd, $oEntityRelation);
+ $aDataInsert = $aFilter;
+ $aFilter['#index-from'] = $aRelations[$sRelationKey]['rel_key'];
+ $aFilter[$aRelations[$sRelationKey]['join_key']] = $oEntity->_getPrimaryKeyValue();
+ $aRelationItemsSaved = Engine::GetInstance()->_CallModule($sCmd, array($aFilter));
+ /**
+ * Получаем текущие связи из сущности
+ */
+ $aTargetItemsCurrent = $oEntity->$sRelationKey->getCollection();
+ /**
+ * Удаляем связи, которых нет в текущих связях
+ */
+ foreach ($aRelationItemsSaved as $k => $oRelationItem) {
+ if (!isset($aTargetItemsCurrent[$k])) {
+ $oRelationItem->Delete();
+ }
+ }
+ /**
+ * Создаем новые связи, которых нет в сохраненных
+ */
+ foreach ($aTargetItemsCurrent as $k => $oTargetItem) {
+ if (!isset($aRelationItemsSaved[$k])) {
+ $oRelationNew = Engine::GetEntity($aRelations[$sRelationKey]['join_entity']);
+ $aDataInsert[$aRelations[$sRelationKey]['join_key']] = $oEntity->_getPrimaryKeyValue();
+ $aDataInsert[$aRelations[$sRelationKey]['rel_key']] = $oTargetItem->_getPrimaryKeyValue();
+ $oRelationNew->_setData($aDataInsert);
+ $oRelationNew->Add();
+ }
+ }
+ }
+
+ /**
+ * Выполняет удаление всех связей many_to_many сущности
+ *
+ * @param $oEntity
+ * @param $sRelationKey
+ */
+ protected function _deleteManyToManyRelation($oEntity, $sRelationKey)
+ {
+ $aRelations = $oEntity->_getRelations();
+ if (!isset($aRelations[$sRelationKey]['type']) or $aRelations[$sRelationKey]['type'] != EntityORM::RELATION_TYPE_MANY_TO_MANY) {
+ return;
+ }
+ $aFilterAdd = $aRelations[$sRelationKey]['filter'];
+ $oEntityRelation = Engine::GetEntity($aRelations[$sRelationKey]['join_entity']);
+ /**
+ * По сущности связи формируем запрос за получение списка сохраненых связей в БД
+ */
+ $sCmd = Engine::GetPluginPrefix($aRelations[$sRelationKey]['join_entity']) . 'Module' . Engine::GetModuleName($aRelations[$sRelationKey]['join_entity']) . '_Get' . Engine::GetEntityName($aRelations[$sRelationKey]['join_entity']) . 'ItemsByFilter';
+ list($aFilter) = $this->oMapperORM->BuildFilter($aFilterAdd, $oEntityRelation);
+ $aFilter[$aRelations[$sRelationKey]['join_key']] = $oEntity->_getPrimaryKeyValue();
+ $aRelationItemsSaved = Engine::GetInstance()->_CallModule($sCmd, array($aFilter));
+ foreach ($aRelationItemsSaved as $oRelation) {
+ $oRelation->Delete();
+ }
+ }
+
+ /**
+ * Приводит название сущности к единому формату полного имени класса
+ * Если используется наследование, то возвращается корневой класс
+ * $sEntity может содержать как короткое имя сущности (без плагина и модуля), так и полное
+ *
+ * @param string|object|null $sEntity
+ *
+ * @return string
+ */
+ protected function _NormalizeEntityRootName($sEntity)
+ {
+ /**
+ * Если передан объект сущности, то просто возвращаем ее корневой класс
+ */
+ if (is_object($sEntity)) {
+ return $this->Plugin_GetRootDelegater('entity', get_class($sEntity));
+ }
+ /**
+ * Разбиваем сущность на составляющие
+ */
+ if (is_null($sEntity)) {
+ $sPluginPrefix = Engine::GetPluginPrefix($this);
+ $sModuleName = Engine::GetModuleName($this);
+ $sEntityName = Engine::GetEntityName($this) ?: $sModuleName;
+ } elseif (substr_count($sEntity, '_')) {
+ $sPluginPrefix = Engine::GetPluginPrefix($sEntity);
+ $sModuleName = Engine::GetModuleName($sEntity);
+ $sEntityName = Engine::GetEntityName($sEntity);
+ } else {
+ $sPluginPrefix = Engine::GetPluginPrefix($this);
+ $sModuleName = Engine::GetModuleName($this);
+ $sEntityName = ucfirst($sEntity);
+ }
+ /**
+ * Получаем корневой модуль
+ */
+ $sModuleRoot = $this->Plugin_GetRootDelegater('module', $sPluginPrefix . 'Module' . $sModuleName);
+ /**
+ * Возвращаем корневую сущность
+ */
+ return $this->Plugin_GetRootDelegater('entity', $sModuleRoot . '_Entity' . $sEntityName);
+ }
+}
diff --git a/framework/classes/engine/ORMRelationManyToMany.class.php b/framework/classes/engine/ORMRelationManyToMany.class.php
new file mode 100644
index 0000000..6fe5884
--- /dev/null
+++ b/framework/classes/engine/ORMRelationManyToMany.class.php
@@ -0,0 +1,158 @@
+
+ *
+ */
+
+/**
+ * Класс представляющий собой обертку для связей MANY_TO_MANY.
+ * Позволяет оперировать коллекцией загруженных по связи элементов через имя связи
+ * Например
+ *
+ * $oTopic->Tags->add($oTag)
+ * // или
+ * $oTopic->Tags->delete($oTag->getId())
+ * при
+ * наличии настроенной MANY_TO_MANY связи 'tags'
+ *
+ * @package framework.engine.orm
+ * @since 2.0
+ */
+class ORMRelationManyToMany extends LsObject
+{
+ /**
+ * Список объектов связи
+ *
+ * @var array
+ */
+ protected $aCollection = array();
+ /**
+ * Общее количество объектов связи
+ *
+ * @var integer
+ */
+ protected $iCount = 0;
+ /**
+ * Флаг обновления списка объектов связи
+ *
+ * @var bool
+ */
+ protected $bUpdated = false;
+
+ /**
+ * Устанавливает список объектов
+ *
+ * @param $aCollection Список объектов связи
+ */
+ public function __construct($aCollection)
+ {
+ parent::__construct();
+ if (!$aCollection) {
+ $aCollection = array();
+ }
+ if (!is_array($aCollection)) {
+ $aCollection = array($aCollection);
+ }
+ if (!isset($aCollection['collection'])) {
+ $this->iCount = count($aCollection);
+ } else {
+ $this->iCount = $aCollection['count'];
+ $aCollection = $aCollection['collection'];
+ }
+ foreach ($aCollection as $oEntity) {
+ $this->aCollection[$oEntity->_getPrimaryKeyValue()] = $oEntity;
+ }
+ }
+
+ /**
+ * Добавление объекта в список
+ *
+ * @param Entity $oEntity
+ */
+ public function add($oEntity)
+ {
+ $this->bUpdated = true;
+ $this->aCollection[$oEntity->_getPrimaryKeyValue()] = $oEntity;
+ }
+
+ /**
+ * Удаление объекта из списка по его id или массиву id
+ *
+ * @param int|array $iId
+ */
+ public function delete($iId)
+ {
+ $this->bUpdated = true;
+ if (is_array($iId)) {
+ foreach ($iId as $id) {
+ if (is_object($id)) {
+ $id = $id->_getPrimaryKeyValue();
+ }
+ if (isset($this->aCollection[$id])) {
+ unset($this->aCollection[$id]);
+ }
+ }
+ } else {
+ if (is_object($iId)) {
+ $iId = $iId->_getPrimaryKeyValue();
+ }
+ if (isset($this->aCollection[$iId])) {
+ unset($this->aCollection[$iId]);
+ }
+ }
+ }
+
+ /**
+ * Удаляет все объекты
+ */
+ public function clear()
+ {
+ $this->bUpdated = true;
+ $this->aCollection = array();
+ }
+
+ /**
+ * Возвращает список объектов связи
+ *
+ * @return array
+ */
+ public function getCollection()
+ {
+ return $this->aCollection;
+ }
+
+ /**
+ * Возвращает список объектов связи
+ *
+ * @return array
+ */
+ public function getCount()
+ {
+ return $this->iCount;
+ }
+
+ /**
+ * Проверка списка на обновление
+ *
+ * @return bool
+ */
+ public function isUpdated()
+ {
+ return $this->bUpdated;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/engine/Plugin.class.php b/framework/classes/engine/Plugin.class.php
new file mode 100644
index 0000000..9c61574
--- /dev/null
+++ b/framework/classes/engine/Plugin.class.php
@@ -0,0 +1,410 @@
+
+ *
+ */
+
+/**
+ * Абстракция плагина, от которой наследуются все плагины
+ * Файл плагина должен находиться в каталоге /plugins/plgname/ и иметь название PluginPlgname.class.php
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+abstract class Plugin extends LsObject
+{
+ /**
+ * Путь к шаблонам с учетом наличия соответствующего skin`a
+ *
+ * @var array
+ */
+ static protected $aTemplatePath = array();
+ /**
+ * Web-адрес директорий шаблонов с учетом наличия соответствующего skin`a
+ *
+ * @var array
+ */
+ static protected $aTemplateWebPath = array();
+ /**
+ * Массив делегатов плагина
+ *
+ * @var array
+ */
+ protected $aDelegates = array();
+ /**
+ * Массив наследуемых классов плагина
+ *
+ * @var array
+ */
+ protected $aInherits = array();
+
+ /**
+ * Метод инициализации плагина
+ *
+ */
+ public function Init()
+ {
+ }
+
+ /**
+ * Метод, который вызывается перед самой инициализацией ядра
+ */
+ public function BeforeInitEngine()
+ {
+
+ }
+
+ /**
+ * Передает информацию о делегатах в модуль ModulePlugin
+ * Вызывается Engine перед инициализацией плагина
+ * @see Engine::LoadPlugins
+ */
+ final function Delegate()
+ {
+ $aDelegates = $this->GetDelegates();
+ foreach ($aDelegates as $sObjectName => $aParams) {
+ foreach ($aParams as $sFrom => $sTo) {
+ $this->Plugin_Delegate($sObjectName, $sFrom, $sTo, get_class($this));
+ }
+ }
+
+ $aInherits = $this->GetInherits();
+ foreach ($aInherits as $sObjectName => $aParams) {
+ foreach ($aParams as $sFrom => $sTo) {
+ $this->Plugin_Inherit($sFrom, $sTo, get_class($this));
+ }
+ }
+ }
+
+ /**
+ * Возвращает массив наследников
+ *
+ * @return array
+ */
+ final function GetInherits()
+ {
+ $aReturn = array();
+ if (is_array($this->aInherits) and count($this->aInherits)) {
+ foreach ($this->aInherits as $sObjectName => $aParams) {
+ if (is_array($aParams) and count($aParams)) {
+ foreach ($aParams as $sFrom => $sTo) {
+ if (is_int($sFrom)) {
+ $sFrom = $sTo;
+ $sTo = null;
+ }
+ list($sFrom, $sTo) = $this->MakeDelegateParams($sObjectName, $sFrom, $sTo);
+ $aReturn[$sObjectName][$sFrom] = $sTo;
+ }
+ }
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Возвращает массив делегатов
+ *
+ * @return array
+ */
+ final function GetDelegates()
+ {
+ $aReturn = array();
+ if (is_array($this->aDelegates) and count($this->aDelegates)) {
+ foreach ($this->aDelegates as $sObjectName => $aParams) {
+ if (is_array($aParams) and count($aParams)) {
+ foreach ($aParams as $sFrom => $sTo) {
+ if (is_int($sFrom)) {
+ $sFrom = $sTo;
+ $sTo = null;
+ }
+ list($sFrom, $sTo) = $this->MakeDelegateParams($sObjectName, $sFrom, $sTo);
+ $aReturn[$sObjectName][$sFrom] = $sTo;
+ }
+ }
+ }
+ }
+ return $aReturn;
+ }
+
+ /**
+ * Преобразовывает краткую форму имен делегатов в полную
+ *
+ * @param $sObjectName Название типа объекта делегата
+ * @see ModulePlugin::aDelegates
+ * @param $sFrom Что делегируем
+ * @param $sTo Что делегирует
+ * @return array
+ */
+ public function MakeDelegateParams($sObjectName, $sFrom, $sTo)
+ {
+ /**
+ * Если не указан делегат TO, считаем, что делегатом является
+ * одноименный объект текущего плагина
+ */
+ if ($sObjectName == 'template') {
+ if (!$sTo) {
+ $sTo = self::GetTemplatePath(get_class($this)) . $sFrom;
+ } else {
+ $sTo = preg_replace("/^_/", $this->GetTemplatePath(get_class($this)), $sTo);
+ }
+ } else {
+ if (!$sTo) {
+ $sTo = get_class($this) . '_' . $sFrom;
+ } else {
+ $sTo = preg_replace("/^_/", get_class($this) . '_', $sTo);
+ }
+ }
+ return array($sFrom, $sTo);
+ }
+
+ /**
+ * Метод активации плагина
+ *
+ * @return bool
+ */
+ public function Activate()
+ {
+ return true;
+ }
+
+ /**
+ * Метод деактивации плагина
+ *
+ * @return bool
+ */
+ public function Deactivate()
+ {
+ return true;
+ }
+
+ /**
+ * Метод удаления плагина
+ *
+ * @return bool
+ */
+ public function Remove()
+ {
+ return true;
+ }
+
+ /**
+ * Транслирует на базу данных запросы из указанного файла
+ * @see ModuleDatabase::ExportSQL
+ *
+ * @param string $sFilePath Полный путь до файла с SQL
+ * @return array
+ */
+ protected function ExportSQL($sFilePath)
+ {
+ return $this->Database_ExportSQL($sFilePath);
+ }
+
+ /**
+ * Выполняет SQL
+ * @see ModuleDatabase::ExportSQLQuery
+ *
+ * @param string $sSql Строка SQL запроса
+ * @return array
+ */
+ protected function ExportSQLQuery($sSql)
+ {
+ return $this->Database_ExportSQLQuery($sSql);
+ }
+
+ /**
+ * Проверяет наличие таблицы в БД
+ * @see ModuleDatabase::IsTableExists
+ *
+ * @param string $sTableName Название таблицы, необходимо перед именем таблицы добавлять "prefix_", это позволит учитывать произвольный префикс таблиц у пользователя
+ *
+ * prefix_topic
+ *
+ * @return bool
+ */
+ protected function IsTableExists($sTableName)
+ {
+ return $this->Database_IsTableExists($sTableName);
+ }
+
+ /**
+ * Проверяет наличие поля в таблице
+ * @see ModuleDatabase::IsFieldExists
+ *
+ * @param string $sTableName Название таблицы, необходимо перед именем таблицы добавлять "prefix_", это позволит учитывать произвольный префикс таблиц у пользователя
+ * @param string $sFieldName Название поля в таблице
+ * @return bool
+ */
+ protected function IsFieldExists($sTableName, $sFieldName)
+ {
+ return $this->Database_IsFieldExists($sTableName, $sFieldName);
+ }
+
+ /**
+ * Добавляет новый тип в поле enum(перечисление)
+ * @see ModuleDatabase::AddEnumType
+ *
+ * @param string $sTableName Название таблицы, необходимо перед именем таблицы добавлять "prefix_", это позволит учитывать произвольный префикс таблиц у пользователя
+ * @param string $sFieldName Название поля в таблице
+ * @param string $sType Название типа
+ */
+ protected function AddEnumType($sTableName, $sFieldName, $sType)
+ {
+ $this->Database_AddEnumType($sTableName, $sFieldName, $sType);
+ }
+
+ /**
+ * Удаляет тип в поле таблицы с типом enum
+ * @see ModuleDatabase::RemoveEnumType
+ *
+ * @param string $sTableName Название таблицы, необходимо перед именем таблицы добавлять "prefix_", это позволит учитывать произвольный префикс таблиц у пользователя
+ * @param string $sFieldName Название поля в таблице
+ * @param string $sType Название типа
+ */
+ protected function RemoveEnumType($sTableName, $sFieldName, $sType)
+ {
+ $this->Database_RemoveEnumType($sTableName, $sFieldName, $sType);
+ }
+
+ /**
+ * Возвращает версию плагина
+ *
+ * @return string|null
+ */
+ public function GetVersion()
+ {
+ if ($oXml = $this->PluginManager_GetPluginXmlInfo(self::GetPluginCode($this))) {
+ return (string)$oXml->version;
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает полный серверный путь до плагина
+ *
+ * @param string $sName
+ * @return string
+ */
+ static public function GetPath($sName)
+ {
+ $sName = self::GetPluginCode($sName);
+
+ return Config::Get('path.application.plugins.server') . '/' . $sName . '/';
+ }
+
+ /**
+ * Возвращает полный web-адрес до плагина
+ *
+ * @param string $sName
+ * @return string
+ */
+ static public function GetWebPath($sName)
+ {
+ $sName = self::GetPluginCode($sName);
+
+ return Router::GetPathRootWeb() . '/application/plugins/' . $sName . '/';
+ }
+
+ /**
+ * Возвращает правильный серверный путь к директории шаблонов с учетом текущего шаблона
+ * Если пользователь использует шаблон которого нет в плагине, то возвращает путь до шабона плагина 'default'
+ *
+ * @param string $sName Название плагина или его класс
+ * @return string|null
+ */
+ static public function GetTemplatePath($sName)
+ {
+ $sName = self::GetPluginCode($sName);
+ if (!isset(self::$aTemplatePath[$sName])) {
+ $aPaths = glob(Config::Get('path.application.plugins.server') . '/' . $sName . '/frontend/skin/*',
+ GLOB_ONLYDIR);
+ $sTemplateName = ($aPaths and in_array(Config::Get('view.skin'), array_map('basename', $aPaths)))
+ ? Config::Get('view.skin')
+ : 'default';
+
+ $sDir = Config::Get('path.application.plugins.server') . "/{$sName}/frontend/skin/{$sTemplateName}/";
+ self::$aTemplatePath[$sName] = is_dir($sDir) ? $sDir : null;
+ }
+ return self::$aTemplatePath[$sName];
+ }
+
+ /**
+ * Возвращает правильный web-адрес директории шаблонов
+ * Если пользователь использует шаблон которого нет в плагине, то возвращает путь до шабона плагина 'default'
+ *
+ * @param string $sName Название плагина или его класс
+ * @return string
+ */
+ static public function GetTemplateWebPath($sName)
+ {
+ $sName = self::GetPluginCode($sName);
+ if (!isset(self::$aTemplateWebPath[$sName])) {
+ $aPaths = glob(Config::Get('path.application.plugins.server') . '/' . $sName . '/frontend/skin/*',
+ GLOB_ONLYDIR);
+ $sTemplateName = ($aPaths and in_array(Config::Get('view.skin'), array_map('basename', $aPaths)))
+ ? Config::Get('view.skin')
+ : 'default';
+
+ self::$aTemplateWebPath[$sName] = Router::GetFixPathWeb(Config::Get('path.application.plugins.web')) . "/{$sName}/frontend/skin/{$sTemplateName}/";
+ }
+ return self::$aTemplateWebPath[$sName];
+ }
+
+ /**
+ * Устанавливает значение серверного пути до шаблонов плагина
+ *
+ * @param string $sName Имя плагина
+ * @param string $sTemplatePath Серверный путь до шаблона
+ * @return bool
+ */
+ static public function SetTemplatePath($sName, $sTemplatePath)
+ {
+ if (!is_dir($sTemplatePath)) {
+ return false;
+ }
+ self::$aTemplatePath[$sName] = $sTemplatePath;
+ return true;
+ }
+
+ /**
+ * Устанавливает значение web-пути до шаблонов плагина
+ *
+ * @param string $sName Имя плагина
+ * @param string $sTemplatePath Серверный путь до шаблона
+ */
+ static public function SetTemplateWebPath($sName, $sTemplatePath)
+ {
+ self::$aTemplateWebPath[$sName] = $sTemplatePath;
+ }
+
+ /**
+ * Возвращает код плагина
+ *
+ * @param string|object $mPlugin Объект любого класса плагина или название плагина
+ *
+ * @return string
+ */
+ static public function GetPluginCode($mPlugin)
+ {
+ if (is_object($mPlugin)) {
+ $mPlugin = get_class($mPlugin);
+ }
+ return preg_match('/^Plugin([\w]+)(_[\w]+)?$/Ui', $mPlugin, $aMatches)
+ ? func_underscore($aMatches[1])
+ : func_underscore($mPlugin);
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/engine/Router.class.php b/framework/classes/engine/Router.class.php
new file mode 100644
index 0000000..5e44f1e
--- /dev/null
+++ b/framework/classes/engine/Router.class.php
@@ -0,0 +1,873 @@
+
+ *
+ */
+
+require_once("Action.class.php");
+require_once("ActionPlugin.class.php");
+
+/**
+ * Класс роутинга(контроллера)
+ * Инициализирует ядро, определяет какой экшен запустить согласно URL'у и запускает его.
+ *
+ * @package framework.engine
+ * @since 1.0
+ */
+class Router extends LsObject
+{
+ /**
+ * Конфигурация роутинга, получается из конфига
+ *
+ * @var array
+ */
+ protected $aConfigRoute = array();
+ /**
+ * Текущий префикс в URL, может указывать, например, на язык: ru или en
+ *
+ * @var string|null
+ */
+ static protected $sPrefixUrl = null;
+ /**
+ * Порт при http запросе
+ *
+ * @var null
+ */
+ static protected $iHttpPort = null;
+ /**
+ * Порт при https запросе
+ *
+ * @var null
+ */
+ static protected $iHttpSecurePort = null;
+ /**
+ * Указывает на необходимость принудительного использования https
+ *
+ * @var bool
+ */
+ static protected $bHttpSecureForce = false;
+ /**
+ * Указывает на необходимость принудительного использования http
+ *
+ * @var bool
+ */
+ static protected $bHttpNotSecureForce = false;
+ /**
+ * Текущий экшен
+ *
+ * @var string|null
+ */
+ static protected $sAction = null;
+ /**
+ * Текущий евент
+ *
+ * @var string|null
+ */
+ static protected $sActionEvent = null;
+ /**
+ * Имя текущего евента
+ *
+ * @var string|null
+ */
+ static protected $sActionEventName = null;
+ /**
+ * Класс текущего экшена
+ *
+ * @var string|null
+ */
+ static protected $sActionClass = null;
+ /**
+ * Коллбэк для обработки запроса минуя стандартную схему с экшенами/евентами
+ *
+ * @var callable|null
+ */
+ static protected $fActionCallback = null;
+ /**
+ * Текущий полный ЧПУ url
+ *
+ * @var string|null
+ */
+ static protected $sPathWebCurrent = null;
+ /**
+ * Список параметров ЧПУ url
+ * /action/event/param0/param1/../paramN/
+ *
+ * @var array
+ */
+ static protected $aParams = array();
+ /**
+ * Объект текущего экшена
+ *
+ * @var Action|null
+ */
+ protected $oAction = null;
+ /**
+ * Объект ядра
+ *
+ * @var Engine|null
+ */
+ protected $oEngine = null;
+ /**
+ * Покаывать или нет статистику выполнения
+ *
+ * @var bool
+ */
+ static protected $bShowStats = true;
+ /**
+ * Объект роутинга
+ * @see getInstance
+ *
+ * @var Router|null
+ */
+ static protected $oInstance = null;
+
+ /**
+ * Делает возможным только один экземпляр этого класса
+ *
+ * @return Router
+ */
+ static public function getInstance()
+ {
+ if (isset(self::$oInstance) and (self::$oInstance instanceof self)) {
+ return self::$oInstance;
+ } else {
+ self::$oInstance = new self();
+ return self::$oInstance;
+ }
+ }
+
+ /**
+ * Загрузка конфига роутинга при создании объекта
+ */
+ public function __construct()
+ {
+ parent::__construct();
+ $this->LoadConfig();
+ }
+
+ /**
+ * Запускает весь процесс :)
+ *
+ */
+ public function Exec($aParams = array())
+ {
+ $this->ParseUrl();
+ if (isset($aParams['callback_after_parse_url'])) {
+ if (!is_array($aParams['callback_after_parse_url'])) {
+ $aParams['callback_after_parse_url'] = array($aParams['callback_after_parse_url']);
+ }
+ /**
+ * Для возможности изменять результат парсинга URL, например, для учета поддоменов
+ */
+ foreach ($aParams['callback_after_parse_url'] as $mCallback) {
+ call_user_func($mCallback);
+ }
+ }
+ $this->DefineActionClass(); // Для возможности ДО инициализации модулей определить какой action/event запрошен
+ $this->oEngine = Engine::getInstance();
+ $this->oEngine->Init();
+ $this->Hook_Run('start_action');
+ $this->ExecAction();
+ $this->Shutdown(false);
+ }
+
+ /**
+ * Завершение работы роутинга
+ *
+ * @param bool $bExit Принудительно завершить выполнение скрипта
+ */
+ public function Shutdown($bExit = true)
+ {
+ $this->AssignVars();
+ $this->oEngine->Shutdown();
+ if (is_callable(self::$fActionCallback)) {
+ echo call_user_func(self::$fActionCallback);
+ } else {
+ $this->Viewer_Display($this->oAction->GetTemplate());
+ }
+ if ($bExit) {
+ exit();
+ }
+ }
+
+ /**
+ * Парсим URL
+ * Пример: http://site.ru/action/event/param1/param2/ на выходе получим:
+ * self::$sAction='action';
+ * self::$sActionEvent='event';
+ * self::$aParams=array('param1','param2');
+ *
+ */
+ protected function ParseUrl()
+ {
+ $sReq = $this->GetRequestUri();
+ $aRequestUrl = $this->GetRequestArray($sReq);
+
+ /**
+ * Проверяем на наличие префикса в URL
+ */
+ if ($sPrefixRule = Config::Get('router.prefix')) {
+ if (isset($aRequestUrl[0]) and preg_match('#^' . $sPrefixRule . '$#i', $aRequestUrl[0])) {
+ self::$sPrefixUrl = array_shift($aRequestUrl);
+ } elseif ($sPrefixDefault = Config::Get('router.prefix_default')) {
+ self::$sPrefixUrl = $sPrefixDefault;
+ }
+ }
+
+ $aRequestUrl = $this->RewriteRequest($aRequestUrl);
+
+ self::$sAction = array_shift($aRequestUrl);
+ self::$sActionEvent = array_shift($aRequestUrl);
+ self::$aParams = $aRequestUrl;
+ }
+
+ /**
+ * Метод выполняет первичную обработку $_SERVER['REQUEST_URI']
+ *
+ * @return string
+ */
+ protected function GetRequestUri()
+ {
+ $sReq = preg_replace("/\/+/", '/', $_SERVER['REQUEST_URI']);
+ $sReq = preg_replace("/^\/(.*)\/?$/U", '\\1', $sReq);
+ $sReq = preg_replace("/^(.*)\?.*$/U", '\\1', $sReq);
+ /**
+ * Формируем $sPathWebCurrent ДО применения реврайтов
+ */
+ self::$sPathWebCurrent = self::GetPathRootWeb() . "/" . join('/', $this->GetRequestArray($sReq));
+ return $sReq;
+ }
+
+ /**
+ * Возвращает массив реквеста
+ *
+ * @param string $sReq Строка реквеста
+ * @return array
+ */
+ protected function GetRequestArray($sReq)
+ {
+ $aRequestUrl = ($sReq == '') ? array() : explode('/', trim($sReq, '/'));
+ for ($i = 0; $i < Config::Get('path.offset_request_url'); $i++) {
+ array_shift($aRequestUrl);
+ }
+ $aRequestUrl = array_map('urldecode', $aRequestUrl);
+ return $aRequestUrl;
+ }
+
+ /**
+ * Применяет к реквесту правила реврайта из конфига Config::Get('router.uri')
+ *
+ * @param $aRequestUrl Массив реквеста
+ * @return array
+ */
+ protected function RewriteRequest($aRequestUrl)
+ {
+ /**
+ * Правила Rewrite для REQUEST_URI
+ */
+ $sReq = implode('/', $aRequestUrl);
+ if ($aRewrite = Config::Get('router.uri')) {
+ $sReq = preg_replace(array_keys($aRewrite), array_values($aRewrite), $sReq);
+ }
+ return ($sReq == '') ? array() : explode('/', $sReq);
+ }
+
+ /**
+ * Выполняет загрузку конфигов роутинга
+ *
+ */
+ public function LoadConfig()
+ {
+ //Конфиг роутинга, содержит соответствия URL и классов экшенов
+ $this->aConfigRoute = Config::Get('router');
+ // Переписываем конфиг согласно правилу rewrite
+ foreach ((array)$this->aConfigRoute['rewrite'] as $sPage => $sRewrite) {
+ if (isset($this->aConfigRoute['page'][$sPage])) {
+ $this->aConfigRoute['page'][$sRewrite] = $this->aConfigRoute['page'][$sPage];
+ unset($this->aConfigRoute['page'][$sPage]);
+ }
+ }
+ }
+
+ /**
+ * Загружает в шаблонизатор Smarty необходимые переменные
+ *
+ */
+ protected function AssignVars()
+ {
+ $this->Viewer_Assign('sAction', $this->Standart(self::$sAction));
+ $this->Viewer_Assign('sEvent', self::$sActionEvent);
+ $this->Viewer_Assign('aParams', self::$aParams);
+ $this->Viewer_Assign('PATH_WEB_CURRENT', func_urlspecialchars(self::$sPathWebCurrent));
+ }
+
+ /**
+ * Запускает на выполнение экшен
+ * Может запускаться рекурсивно если в одном экшене стоит переадресация на другой
+ *
+ */
+ public function ExecAction()
+ {
+ $this->DefineActionClass();
+ /**
+ * Сначала запускаем инициализирующий евент
+ */
+ $this->Hook_Run('init_action');
+
+ $sActionClass = $this->DefineActionClass();
+ /**
+ * Если коллбэк, то сразу возвращаем результат
+ */
+ if (is_callable($sActionClass)) {
+ self::$fActionCallback = $sActionClass;
+ return;
+ }
+ /**
+ * Определяем наличие делегата экшена
+ */
+ if ($aChain = $this->Plugin_GetDelegationChain('action', $sActionClass)) {
+ if (!empty($aChain)) {
+ $sActionClass = $aChain[0];
+ }
+ }
+ self::$sActionClass = $sActionClass;
+
+ $sClassName = $sActionClass;
+ $this->oAction = new $sClassName(self::$sAction);
+ /**
+ * Инициализируем экшен
+ */
+ $this->Hook_Run("action_init_" . strtolower($sActionClass) . "_before");
+ $sInitResult = $this->oAction->Init();
+ $this->Hook_Run("action_init_" . strtolower($sActionClass) . "_after");
+
+ if ($sInitResult === 'next') {
+ $this->ExecAction();
+ } else {
+ $mRes = $this->oAction->ExecEvent();
+ self::$sActionEventName = $this->oAction->GetCurrentEventName();
+
+ $this->Hook_Run("action_shutdown_" . strtolower($sActionClass) . "_before");
+ $this->oAction->EventShutdown();
+ $this->Hook_Run("action_shutdown_" . strtolower($sActionClass) . "_after");
+
+ if ($mRes === 'next') {
+ $this->ExecAction();
+ }
+ }
+ }
+
+ /**
+ * Определяет какой класс соответствует текущему экшену
+ *
+ * @return string
+ */
+ protected function DefineActionClass()
+ {
+ if (isset($this->aConfigRoute['page'][self::$sAction])) {
+
+ } elseif (self::$sAction === null) {
+ self::$sAction = $this->aConfigRoute['config']['default']['action'];
+ if (!is_null($sEvent = $this->aConfigRoute['config']['default']['event'])) {
+ self::$sActionEvent = $sEvent;
+ }
+ if (is_array($aParams = $this->aConfigRoute['config']['default']['params'])) {
+ self::$aParams = $aParams;
+ }
+ if (is_array($aRequest = $this->aConfigRoute['config']['default']['request'])) {
+ foreach ($aRequest as $k => $v) {
+ if (!array_key_exists($k, $_REQUEST)) {
+ $_REQUEST[$k] = $v;
+ }
+ }
+ }
+ } else {
+ //Если не находим нужного класса то отправляем на страницу ошибки
+ self::$sAction = $this->aConfigRoute['config']['action_not_found'];
+ self::$sActionEvent = '404';
+ }
+ self::$sActionClass = $this->aConfigRoute['page'][self::$sAction];
+ return self::$sActionClass;
+ }
+
+ /**
+ * Функция переадресации на другой экшен
+ * Если ею завершить евент в экшене то запуститься новый экшен
+ * Пример: return Router::Action('blog','topic'); или return Router::Action('blog/topic/1.html');
+ *
+ * @param string $sAction Экшен
+ * @param string $sEvent Евент
+ * @param array $aParams Список параметров
+ * @return string 'next'
+ */
+ static public function Action($sAction, $sEvent = null, $aParams = null)
+ {
+ $sAction = trim($sAction, '/');
+ if ($sAction and $aPart = explode('/', $sAction, 3) and count($aPart) > 1) {
+ $sAction = $aPart[0];
+ $sEvent = $aPart[1];
+ $aParams = isset($aPart[2]) ? explode('/', $aPart[2]) : null;
+ }
+
+ self::$sAction = self::getInstance()->Rewrite($sAction);
+ self::$sActionEvent = $sEvent;
+ if (is_array($aParams)) {
+ self::$aParams = $aParams;
+ }
+ return 'next';
+ }
+
+ /**
+ * Алиас короткого вызова перенаправления на экшен error с необходимым текстом ошибки
+ *
+ * @param string $sMsg Текст ошибки
+ * @param string|null $sTitle Заголовок ошибки
+ *
+ * @return string
+ */
+ static public function ActionError($sMsg, $sTitle = null)
+ {
+ self::getInstance()->Message_AddErrorSingle($sMsg, $sTitle);
+ return self::Action('error');
+ }
+
+ /**
+ * Возвращает текущий ЧПУ url
+ *
+ * @return string
+ */
+ static public function GetPathWebCurrent()
+ {
+ return self::$sPathWebCurrent;
+ }
+
+ /**
+ * Устанавливает текущий url
+ *
+ * @param string $sUrl
+ */
+ static public function SetPathWebCurrent($sUrl)
+ {
+ self::$sPathWebCurrent = $sUrl;
+ }
+
+ static public function GetFixPathWeb($sUrl, $bWithScheme = true)
+ {
+ $sResult = '';
+ $aPathFull = parse_url($sUrl);
+ $sPath = preg_replace('/^(http|https):\/\/[^\/]+/i', '', $sUrl);
+ if (isset($aPathFull['host'])) {
+ $sHost = $aPathFull['host'];
+ } elseif (isset($_SERVER['HTTP_HOST'])) {
+ $sHost = $_SERVER['HTTP_HOST'];
+ } else {
+ $sHost = isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'localhost';
+ }
+ $bSecure = self::$bHttpSecureForce ? self::$bHttpSecureForce : (self::$bHttpNotSecureForce ? false : self::GetIsSecureConnection());
+ if ($bWithScheme) {
+ $sResult = ($bSecure ? 'https' : 'http') . '://';
+ }
+ $sResult .= $sHost;
+ $iPort = $bSecure ? self::GetSecurePort() : self::GetPort();
+ if (($iPort !== 80 && !$bSecure) || ($iPort !== 443 && $bSecure)) {
+ $sResult .= ':' . $iPort;
+ }
+ $sResult .= rtrim($sPath, '\\/');
+ return $sResult;
+ }
+
+ /**
+ * Возвращает веб адрес сайта с учетом типа коннекта (http или https) и нестандартных портов
+ *
+ * @param bool $bWithScheme Возвращать в урле схему или нет
+ *
+ * @return string
+ */
+ static public function GetPathRootWeb($bWithScheme = true)
+ {
+ return self::GetFixPathWeb(Config::Get('path.root.web'), $bWithScheme);
+ }
+
+ /**
+ * Возвращает порт при https запросе
+ *
+ * @return int|null
+ */
+ static public function GetSecurePort()
+ {
+ if (is_null(self::$iHttpSecurePort)) {
+ self::$iHttpSecurePort = (self::GetIsSecureConnection() && isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] != 80) ? (int)$_SERVER['SERVER_PORT'] : 443;
+ }
+ return self::$iHttpSecurePort;
+ }
+
+ /**
+ * Устанавливает порт
+ *
+ * @param $iPort
+ */
+ static public function SetSecurePort($iPort)
+ {
+ self::$iHttpSecurePort = $iPort;
+ }
+
+ /**
+ * Возвращает порт при http запросе
+ *
+ * @return int|null
+ */
+ static public function GetPort()
+ {
+ if (is_null(self::$iHttpPort)) {
+ self::$iHttpPort = !self::GetIsSecureConnection() && isset($_SERVER['SERVER_PORT']) ? (int)$_SERVER['SERVER_PORT'] : 80;
+ }
+ return self::$iHttpPort;
+ }
+
+ /**
+ * Устанавливает порт
+ *
+ * @param $iPort
+ */
+ static public function SetPort($iPort)
+ {
+ self::$iHttpPort = $iPort;
+ }
+
+ /**
+ * Возвращает текущий префикс URL
+ *
+ * @return string
+ */
+ static public function GetPrefixUrl()
+ {
+ return self::$sPrefixUrl;
+ }
+
+ /**
+ * Устанавливает текущий префикс URL
+ *
+ * @param string $sPrefix
+ */
+ static public function SetPrefixUrl($sPrefix)
+ {
+ self::$sPrefixUrl = $sPrefix;
+ }
+
+ /**
+ * Возвращает текущий экшен
+ *
+ * @return string
+ */
+ static public function GetAction()
+ {
+ return self::getInstance()->Standart(self::$sAction);
+ }
+
+ /**
+ * Устанавливает новый текущий экшен
+ *
+ * @param string $sAction Экшен
+ */
+ static public function SetAction($sAction)
+ {
+ self::$sAction = $sAction;
+ }
+
+ /**
+ * Возвращает текущий евент
+ *
+ * @return string
+ */
+ static public function GetActionEvent()
+ {
+ return self::$sActionEvent;
+ }
+
+ /**
+ * Возвращает имя текущего евента
+ *
+ * @return string
+ */
+ static public function GetActionEventName()
+ {
+ return self::$sActionEventName;
+ }
+
+ /**
+ * Возвращает класс текущего экшена
+ *
+ * @return string
+ */
+ static public function GetActionClass()
+ {
+ return self::$sActionClass;
+ }
+
+ /**
+ * Устанавливает текущий коллбэк
+ *
+ * @param callable $fCallback
+ */
+ static public function SetActionCallback($fCallback)
+ {
+ self::$fActionCallback = $fCallback;
+ }
+
+ /**
+ * Устанавливает новый текущий евент
+ *
+ * @param string $sEvent Евент
+ */
+ static public function SetActionEvent($sEvent)
+ {
+ self::$sActionEvent = $sEvent;
+ }
+
+ /**
+ * Возвращает параметры(те которые передаются в URL)
+ *
+ * @return array
+ */
+ static public function GetParams()
+ {
+ return self::$aParams;
+ }
+
+ /**
+ * Возвращает параметр по номеру, если его нет то возвращается null
+ * Нумерация параметров начинается нуля
+ *
+ * @param int $iOffset
+ * @param mixed|null $def
+ * @return string
+ */
+ static public function GetParam($iOffset, $def = null)
+ {
+ $iOffset = (int)$iOffset;
+ return isset(self::$aParams[$iOffset]) ? self::$aParams[$iOffset] : $def;
+ }
+
+ /**
+ * Устанавливает значение параметра
+ *
+ * @param int $iOffset Номер параметра, по идее может быть не только числом
+ * @param mixed $value
+ */
+ static public function SetParam($iOffset, $value)
+ {
+ self::$aParams[$iOffset] = $value;
+ }
+
+ /**
+ * Устанавливает новые текущие параметры
+ *
+ * @param string $aParams Параметры
+ */
+ static public function SetParams($aParams)
+ {
+ self::$aParams = $aParams;
+ }
+
+ /**
+ * Показывать или нет статистику выполение скрипта
+ * Иногда бывает необходимо отключить показ, например, при выводе RSS ленты
+ *
+ * @param bool $bState
+ */
+ static public function SetIsShowStats($bState)
+ {
+ self::$bShowStats = $bState;
+ }
+
+ /**
+ * Возвращает статус показывать или нет статистику
+ *
+ * @return bool
+ */
+ static public function GetIsShowStats()
+ {
+ return self::$bShowStats;
+ }
+
+ /**
+ * Проверяет запрос послан как ajax или нет
+ *
+ * @return bool
+ */
+ static public function GetIsAjaxRequest()
+ {
+ return isAjaxRequest();
+ }
+
+ /**
+ * Проверяет тип коннекта - http или https
+ *
+ * @return bool
+ */
+ static public function GetIsSecureConnection()
+ {
+ return isset($_SERVER['HTTPS']) && (strcasecmp($_SERVER['HTTPS'], 'on') === 0 || $_SERVER['HTTPS'] == 1)
+ || isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strcasecmp($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') === 0;
+ }
+
+ /**
+ * Блокируем копирование/клонирование объекта роутинга
+ *
+ */
+ public function __clone()
+ {
+ throw new Exception('Not allow clone Router');
+ }
+
+ /**
+ * Возвращает правильную адресацию по переданому названию страницы(экшену)
+ *
+ * @param string $sAction Экшен или путь, например, "people/top" или "/"
+ * @return string
+ */
+ static public function GetPath($sAction)
+ {
+ if (!$sAction or $sAction == '/') {
+ return self::GetPathRootWeb() . (self::$sPrefixUrl ? '/' . self::$sPrefixUrl : '') . '/';
+ }
+ // Если пользователь запросил action по умолчанию
+ $sPage = ($sAction == 'default')
+ ? self::getInstance()->aConfigRoute['config']['default']['action']
+ : $sAction;
+ $aUrl = explode('/', $sPage);
+ $sPage = $sPageOriginal = array_shift($aUrl);
+ $sAdditional = join('/', $aUrl);
+ // Смотрим, есть ли правило rewrite
+ $sPage = self::getInstance()->Rewrite($sPage);
+ /**
+ * Если нет GET параметров, то добавляем в конец '/'
+ */
+ if ($sAdditional and strpos($sAdditional, '?') === false) {
+ $sAdditional .= '/';
+ }
+
+ $bHttpSecureForceOld = self::$bHttpSecureForce;
+ $bHttpNotSecureForceOld = self::$bHttpNotSecureForce;
+ /**
+ * Проверяем на необходимость принудительного использования https
+ */
+ $aActionsSecure = (array)Config::Get('router.force_secure');
+ if ($aActionsSecure) {
+ if (in_array($sPageOriginal, (array)Config::Get('router.force_secure'))) {
+ self::$bHttpSecureForce = true;
+ } else {
+ self::$bHttpNotSecureForce = true;
+ }
+ }
+ $sPath = self::GetPathRootWeb() . (self::$sPrefixUrl ? '/' . self::$sPrefixUrl : '') . "/$sPage/" . ($sAdditional ? "{$sAdditional}" : '');
+ /**
+ * Возвращаем значения обратно
+ */
+ self::$bHttpSecureForce = $bHttpSecureForceOld;
+ self::$bHttpNotSecureForce = $bHttpNotSecureForceOld;
+ return $sPath;
+ }
+
+ /**
+ * Проверяет на соответствие текущего экшена/евента переданным
+ *
+ * @param array $aActions Список экшенов с евентами в формате array('action1','action2','action3'=>array('event1','event2'))
+ * @return bool
+ */
+ static public function CheckIsCurrentAction($aActions)
+ {
+ $bAllow = false;
+ if (!is_array($aActions)) {
+ $aActions = array($aActions);
+ }
+ foreach ($aActions as $mKey => $sAction) {
+ if (is_int($mKey)) {
+ $aEvents = array();
+ } else {
+ $aEvents = $sAction;
+ $sAction = $mKey;
+ }
+ if (self::GetAction() == $sAction) {
+ if ($aEvents) {
+ if (in_array(self::GetActionEvent(), $aEvents)) {
+ $bAllow = true;
+ break;
+ }
+ } else {
+ $bAllow = true;
+ break;
+ }
+ }
+ }
+ return $bAllow;
+ }
+
+ /**
+ * Try to find rewrite rule for given page.
+ * On success return right page, else return given param.
+ *
+ * @param string $sPage
+ * @return string
+ */
+ public function Rewrite($sPage)
+ {
+ return (isset($this->aConfigRoute['rewrite'][$sPage]))
+ ? $this->aConfigRoute['rewrite'][$sPage]
+ : $sPage;
+ }
+
+ /**
+ * Стандартизирует определение внутренних ресурсов.
+ *
+ * Пытается по переданому экшену найти rewrite rule и
+ * вернуть стандартное название ресурса.
+ *
+ * @see Rewrite
+ * @param string $sPage
+ * @return string
+ */
+ public function Standart($sPage)
+ {
+ $aRewrite = array_flip($this->aConfigRoute['rewrite']);
+ return (isset($aRewrite[$sPage]))
+ ? $aRewrite[$sPage]
+ : $sPage;
+ }
+
+ /**
+ * Выполняет редирект, предварительно завершая работу Engine
+ *
+ * @param string $sLocation URL для редиректа
+ */
+ static public function Location($sLocation)
+ {
+ self::getInstance()->oEngine->Shutdown();
+ func_header_location($sLocation);
+ }
+
+ /**
+ * Выполняет локальный редирект, предварительно завершая работу Engine
+ *
+ * @param string $sLocation локальный адрес, который можно использовать в Router::GetPath();, например, 'blog/news'
+ */
+ static public function LocationAction($sLocation)
+ {
+ self::Location(self::GetPath($sLocation));
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/asset/Asset.class.php b/framework/classes/modules/asset/Asset.class.php
new file mode 100644
index 0000000..26b89d8
--- /dev/null
+++ b/framework/classes/modules/asset/Asset.class.php
@@ -0,0 +1,653 @@
+
+ *
+ */
+
+/**
+ * Модуль управления статическими файлами css стилей и js сриптов
+ * Позволяет сжимать и объединять файлы для более быстрой загрузки
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleAsset extends Module
+{
+ /**
+ * Тип для файлов стилей
+ */
+ const ASSET_TYPE_CSS = 'css';
+ /**
+ * Тип для файлов скриптов
+ */
+ const ASSET_TYPE_JS = 'js';
+ /**
+ * Каталог для проверки блокировки
+ *
+ * @var null|string
+ */
+ protected $sDirMergeLock = null;
+ /**
+ * Список файлов по типам
+ * @see Init
+ *
+ * @var array
+ */
+ protected $aAssets = array();
+
+ /**
+ * Инициалищация модуля
+ */
+ public function Init()
+ {
+ /**
+ * Задаем начальную структуру для хранения списка файлов по типам
+ */
+ $this->InitAssets();
+ }
+
+ /**
+ * Задает начальную структуры для хранения списка файлов по типам
+ */
+ protected function InitAssets()
+ {
+ $this->aAssets = array(
+ self::ASSET_TYPE_CSS => array(
+ /**
+ * Список файлов для добавления в конец списка
+ * В качестве ключей используется путь до файла либо уникальное имя, в качестве значений - дополнительные параметры
+ */
+ 'append' => array(),
+ /**
+ * Список файлов для добавления в начало списка
+ */
+ 'prepend' => array(),
+ ),
+ self::ASSET_TYPE_JS => array(
+ 'append' => array(),
+ 'prepend' => array(),
+ ),
+ );
+ }
+
+ /**
+ * Добавляет новый файл
+ *
+ * @param string $sFile Полный путь до файла
+ * @param array $aParams Дополнительные параметры
+ * @param string $sType Тип файла
+ * @param bool $bPrepend Добавлять файл в начало общего списка или нет
+ * @param bool $bReplace Если такой файл уже добавлен, то заменяет его
+ *
+ * @return bool
+ */
+ protected function Add($sFile, $aParams, $sType, $bPrepend = false, $bReplace = false)
+ {
+ if (!$this->CheckAssetType($sType)) {
+ return false;
+ }
+ $aParams['file'] = $sFile;
+ /**
+ * Подготавливаем параметры
+ */
+ $aParams = $this->PrepareParams($aParams);
+ /**
+ * В качестве уникального ключа использется имя или путь до файла
+ */
+ $sFileKey = $aParams['name'] ? $aParams['name'] : $aParams['file'];
+ /**
+ * Проверям на необходимость замены
+ */
+ foreach (array('prepend', 'append') as $sTypeAdd) {
+ if (isset($this->aAssets[$sType][$sTypeAdd][$sFileKey])) {
+ if ($bReplace) {
+ unset($this->aAssets[$sType][$sTypeAdd][$sFileKey]);
+ } else {
+ return false;
+ }
+ }
+ /**
+ * Дополнительно проверим на путь к файлу, если в качестве ключа использовалось имя
+ * todo: при таком подходе смысла в ключах массива нет и можно искать на дубли только по значению
+ */
+ if ($aParams['name']) {
+ foreach ($this->aAssets[$sType][$sTypeAdd] as $sFindKey => $aFileParams) {
+ if ($aParams['file'] == $aFileParams['file']) {
+ if ($bReplace) {
+ unset($this->aAssets[$sType][$sTypeAdd][$sFindKey]);
+ } else {
+ return false;
+ }
+ }
+ }
+ }
+ }
+
+ $this->aAssets[$sType][$bPrepend ? 'prepend' : 'append'][$sFileKey] = $aParams;
+ return true;
+ }
+
+ /**
+ * Добавляет файл css стиля
+ *
+ * @param string $sFile Полный путь до файла
+ * @param array $aParams Дополнительные параметры
+ * @param bool $bPrepend Добавлять файл в начало общего списка или нет
+ * @param bool $bReplace Если такой файл уже добавлен, то заменяет его
+ *
+ * @return bool
+ */
+ public function AddCss($sFile, $aParams, $bPrepend = false, $bReplace = false)
+ {
+ return $this->Add($sFile, $aParams, self::ASSET_TYPE_CSS, $bPrepend, $bReplace);
+ }
+
+ /**
+ * Добавляет файл js скрипта
+ *
+ * @param string $sFile Полный путь до файла
+ * @param array $aParams Дополнительные параметры
+ * @param bool $bPrepend Добавлять файл в начало общего списка или нет
+ * @param bool $bReplace Если такой файл уже добавлен, то заменяет его
+ *
+ * @return bool
+ */
+ public function AddJs($sFile, $aParams, $bPrepend = false, $bReplace = false)
+ {
+ return $this->Add($sFile, $aParams, self::ASSET_TYPE_JS, $bPrepend, $bReplace);
+ }
+
+ /**
+ * Проверяет корректность типа файла
+ *
+ * @param $sType
+ *
+ * @return bool
+ */
+ public function CheckAssetType($sType)
+ {
+ return in_array($sType, array(self::ASSET_TYPE_CSS, self::ASSET_TYPE_JS));
+ }
+
+ /**
+ * Производит предварительную обработку параметров
+ *
+ * @param $aParams
+ *
+ * @return array
+ */
+ public function PrepareParams($aParams)
+ {
+ $aResult = array();
+
+ $aResult['merge'] = (isset($aParams['merge']) and !$aParams['merge']) ? false : true;
+ $aResult['compress'] = (isset($aParams['compress']) and !$aParams['compress']) ? false : true;
+ $aResult['browser'] = (isset($aParams['browser']) and $aParams['browser']) ? $aParams['browser'] : null;
+ $aResult['plugin'] = (isset($aParams['plugin']) and $aParams['plugin']) ? $aParams['plugin'] : null;
+ $aResult['name'] = (isset($aParams['name']) and $aParams['name']) ? strtolower($aParams['name']) : null;
+ $aResult['defer'] = (isset($aParams['defer']) and $aParams['defer']) ? true : false;
+ $aResult['async'] = (isset($aParams['async']) and $aParams['async']) ? true : false;
+ if (isset($aParams['file'])) {
+ $aResult['file'] = $this->GetFileWeb($aParams['file'], $aParams);
+ } else {
+ $aResult['file'] = null;
+ }
+ return $aResult;
+ }
+
+ /**
+ * Возвращает корректный WEB путь до файла
+ *
+ * @param string $sFile Исходный путь до файла, обычно он задается в конфиге при подключении css/js, либо через методы Asset_Add*
+ * @param array $aParams
+ *
+ * @return string
+ */
+ public function GetFileWeb($sFile, $aParams = array())
+ {
+ return $this->NormalizeFilePath($sFile, $aParams);
+ }
+
+ /**
+ * Приводит путь до файла к единому виду
+ *
+ * @param $sFile
+ * @param array $aParams
+ *
+ * @return string
+ */
+ protected function NormalizeFilePath($sFile, $aParams = array())
+ {
+ /**
+ * По дефолту считаем, что это локальный абсолютный путь до файла: /var/www/site.com или c:\server\root\site.com
+ */
+ $sProtocol = '';
+ $sPath = $sFile;
+ $sSeparate = DIRECTORY_SEPARATOR;
+ /**
+ * Проверяем на URL https://site.com или http://site.com
+ */
+ if (preg_match('#^(https?://)(.*)#i', $sFile, $aMatch)) {
+ $sProtocol = $aMatch[1];
+ $sPath = $aMatch[2];
+ $sSeparate = '/';
+ /**
+ * Если необходимо, то меняем протокол на https
+ */
+ if (Router::GetIsSecureConnection() and strtolower($sProtocol) == 'http://' and Config::Get('module.asset.force_https')) {
+ $sProtocol = 'https://';
+ }
+ /**
+ * Проверяем на //site.com
+ */
+ } elseif (strpos($sFile, '//') === 0) {
+ $sProtocol = '//';
+ $sPath = substr($sFile, 2);
+ $sSeparate = '/';
+ /**
+ * Проверяем на относительный путь без протокола и без первого слеша
+ */
+ } elseif (strpos($sFile, '/') !== 0 and strpos($sFile, ':') === false) {
+ /**
+ * Считаем, что указывался путь относительно корня текущего шаблона
+ */
+ $sSeparate = '/';
+ if (isset($aParams['plugin']) and $aParams['plugin']) {
+ /**
+ * Относительно шаблона плагина
+ */
+ $sPath = Plugin::GetTemplateWebPath($aParams['plugin']) . $sFile;
+ } else {
+ $sPath = Router::GetFixPathWeb(Config::Get('path.skin.web')) . $sSeparate . $sFile;
+ }
+ return $sPath;
+ }
+ /**
+ * Могут встречаться двойные слеши, поэтому делаем замену
+ */
+ $sPath = preg_replace("#([\\\/])+#", $sSeparate, $sPath);
+ /**
+ * Возвращаем результат
+ */
+ return $sProtocol . $sPath;
+ }
+
+ /**
+ * Возвращает HTML код подключения файлов в HEAD'ер страницы
+ *
+ * @return array Список HTML оберток подключения файлов
+ */
+ public function BuildHeadItems()
+ {
+ /**
+ * Запускаем обработку
+ */
+ $aAssets = $this->Processing();
+
+ $aHeader = array_combine(array_keys($this->aAssets), array('', ''));
+ foreach ($aAssets as $sType => $aFile) {
+ if ($oType = $this->CreateObjectType($sType)) {
+ foreach ($aFile as $aParams) {
+ $sFile = $this->Fs_GetPathWeb($aParams['file']);
+ $aHeader[$sType] .= $oType->getHeadHtml($sFile, $aParams) . PHP_EOL;
+ }
+ }
+ }
+ return $aHeader;
+ }
+
+ /**
+ * Производит обработку файлов
+ *
+ * @return array Возвращает список результирующих файлов вида array( 'css'=>array( 'name'=>$aParams, ... ), ... )
+ */
+ public function Processing()
+ {
+ $aTypes = array_keys($this->aAssets);
+ $aFilesMain = $aFilesTemplate = $aResult = array_combine($aTypes, array_pad(array(), count($aTypes), array()));
+ /**
+ * Сначала добавляем файлы из конфига
+ */
+ $aConfigAssets = (array)Config::Get('head.default');
+ foreach ($aConfigAssets as $sType => $aAssets) {
+ if (!$this->CheckAssetType($sType)) {
+ continue;
+ }
+ /**
+ * Перебираем файлы
+ */
+ foreach ($aAssets as $sFile => $aParams) {
+ if (is_numeric($sFile)) {
+ $sFile = $aParams;
+ $aParams = array();
+ }
+ $aParams['file'] = $sFile;
+ /**
+ * Подготавливаем параметры
+ */
+ $aParams = $this->PrepareParams($aParams);
+ /**
+ * В качестве уникального ключа использется имя или путь до файла
+ */
+ $sFileKey = $aParams['name'] ? $aParams['name'] : $aParams['file'];
+ $aFilesMain[$sType][$sFileKey] = $aParams;
+ }
+ }
+ /**
+ * Формируем файлы из шаблона
+ */
+ $aConfigAssets = (array)Config::Get('head.template');
+ foreach ($aConfigAssets as $sType => $aAssets) {
+ if (!$this->CheckAssetType($sType)) {
+ continue;
+ }
+ /**
+ * Перебираем файлы
+ */
+ foreach ($aAssets as $sFile => $aParams) {
+ if (is_numeric($sFile)) {
+ $sFile = $aParams;
+ $aParams = array();
+ }
+ $aParams['file'] = $sFile;
+ /**
+ * Подготавливаем параметры
+ */
+ $aParams = $this->PrepareParams($aParams);
+ /**
+ * В качестве уникального ключа использется имя или путь до файла
+ */
+ $sFileKey = $aParams['name'] ? $aParams['name'] : $aParams['file'];
+ $aFilesTemplate[$sType][$sFileKey] = $aParams;
+ }
+ }
+
+ foreach ($aTypes as $sType) {
+ /**
+ * Объединяем списки
+ */
+ $aFilesMain[$sType] = array_merge(
+ $this->aAssets[$sType]['prepend'],
+ $aFilesMain[$sType],
+ $this->aAssets[$sType]['append'],
+ $aFilesTemplate[$sType]
+ );
+ /**
+ * Выделяем файлы для конкретных браузеров
+ */
+ $aFilesBrowser = array_filter(
+ $aFilesMain[$sType],
+ function ($aParams) {
+ return $aParams['browser'] ? true : false;
+ }
+ );
+ /**
+ * Выделяем файлы с атрибутом defer
+ */
+ $aFilesDefer = array_filter(
+ $aFilesMain[$sType],
+ function ($aParams) {
+ return $aParams['defer'] ? true : false;
+ }
+ );
+ /**
+ * Выделяем файлы с атрибутом async
+ */
+ $aFilesAsync = array_filter(
+ $aFilesMain[$sType],
+ function ($aParams) {
+ return $aParams['async'] ? true : false;
+ }
+ );
+ /**
+ * Исключаем файлы из основного списка
+ */
+ $aFilesMain[$sType] = array_diff_key($aFilesMain[$sType], $aFilesBrowser);
+ /**
+ * Если необходимо сливать файлы, то выделяем исключения
+ */
+ $aFilesNoMerge = array();
+ if (Config::Get("module.asset.{$sType}.merge")) {
+ $aFilesNoMerge = array_filter(
+ $aFilesMain[$sType],
+ function ($aParams) {
+ return !$aParams['merge'];
+ }
+ );
+ /**
+ * Исключаем файлы из основного списка
+ */
+ $aFilesMain[$sType] = array_diff_key($aFilesMain[$sType], $aFilesNoMerge);
+ }
+ /**
+ * Обрабатываем основной список
+ * Проверка необходимости мержа файлов
+ */
+ $bMergeComplete = false;
+ if (Config::Get("module.asset.{$sType}.merge")) {
+ /**
+ * Список файлов для основного мержа
+ */
+ $aFileNeedMerge = array_diff_key($aFilesMain[$sType], $aFilesDefer, $aFilesAsync);
+ if ($sFilePathMerge = $this->Merge($aFileNeedMerge, $sType,
+ (bool)Config::Get("module.asset.{$sType}.compress"))
+ ) {
+ $aResult[$sType][$sFilePathMerge] = array('file' => $sFilePathMerge);
+
+ /**
+ * Список файлов для мержа с атрибутом defer
+ */
+ $bMergeDeferComplete = false;
+ $aFileNeedMerge = array_diff_key($aFilesDefer, $aFilesNoMerge);
+ if ($aFileNeedMerge) {
+ if ($sFilePathMerge = $this->Merge($aFileNeedMerge, $sType,
+ (bool)Config::Get("module.asset.{$sType}.compress"))
+ ) {
+ $aResult[$sType][$sFilePathMerge] = array('file' => $sFilePathMerge, 'defer' => true);
+ $bMergeDeferComplete = true;
+ }
+ } else {
+ $bMergeDeferComplete = true;
+ }
+
+ /**
+ * Список файлов для мержа с атрибутом async
+ */
+ $bMergeAsyncComplete = false;
+ $aFileNeedMerge = array_diff_key($aFilesAsync, $aFilesNoMerge);
+ if ($aFileNeedMerge) {
+ if ($sFilePathMerge = $this->Merge($aFileNeedMerge, $sType,
+ (bool)Config::Get("module.asset.{$sType}.compress"))
+ ) {
+ $aResult[$sType][$sFilePathMerge] = array('file' => $sFilePathMerge, 'async' => true);
+ $bMergeAsyncComplete = true;
+ }
+ } else {
+ $bMergeAsyncComplete = true;
+ }
+
+ if ($bMergeDeferComplete and $bMergeAsyncComplete) {
+ $bMergeComplete = true;
+ }
+ }
+ }
+ if (!$bMergeComplete) {
+ $aResult[$sType] = array_merge($aResult[$sType], $aFilesMain[$sType]);
+ }
+ /**
+ * Обрабатываем список исключения объединения
+ */
+ $aResult[$sType] = array_merge($aResult[$sType], $aFilesNoMerge);
+ /**
+ * Обрабатываем список для отдельных браузеров
+ */
+ $aResult[$sType] = array_merge($aResult[$sType], $aFilesBrowser);
+ }
+ return $aResult;
+ }
+
+ /**
+ * Проверяет на блокировку
+ * Если нет блокировки, то создает ее
+ *
+ * @return bool
+ */
+ protected function IsLockMerge()
+ {
+ $this->sDirMergeLock = Config::Get('path.tmp.server') . '/asset-merge-lock';
+ if ($bResult = $this->Fs_IsLockDir($this->sDirMergeLock, 60 * 5)) {
+ $this->sDirMergeLock = null;
+ }
+ return $bResult;
+ }
+
+ /**
+ * Удаляет блокировку
+ */
+ protected function RemoveLockMerge()
+ {
+ if ($this->sDirMergeLock) {
+ $this->Fs_RemoveLockDir($this->sDirMergeLock);
+ $this->sDirMergeLock = null;
+ }
+ }
+
+ /**
+ * Производит объединение и сжатие файлов
+ *
+ * @param $aAssetItems
+ * @param $sType
+ * @param bool $bCompress
+ *
+ * @return string|bool Web путь до нового файла
+ */
+ protected function Merge($aAssetItems, $sType, $bCompress = false)
+ {
+ $sCacheDir = Config::Get('path.cache_assets.server') . "/" . Config::Get('view.skin');
+ $sCacheFile = $sCacheDir . "/" . md5(serialize(array_keys($aAssetItems)) . '_head') . '.' . $sType;
+ /**
+ * Если файла еще нет, то создаем его
+ */
+ if (!file_exists($sCacheFile)) {
+ /**
+ * Но только в том случае, если еще другой процесс не начал его создавать - проверка на блокировку
+ */
+ if ($this->IsLockMerge()) {
+ return false;
+ }
+ /**
+ * Создаем директорию для кеша текущего скина,
+ * если таковая отсутствует
+ */
+ if (!is_dir($sCacheDir)) {
+ @mkdir($sCacheDir, 0777, true);
+ }
+ $sContent = '';
+ foreach ($aAssetItems as $sFile => $aParams) {
+ $sFile = isset($aParams['file']) ? $aParams['file'] : $aParams['file'];
+ if (strpos($sFile, '//') === 0) {
+ /**
+ * Добавляем текущий протокол
+ */
+ $sFile = (Router::GetIsSecureConnection() ? 'https' : 'http') . ':' . $sFile;
+ }
+ $sFile = $this->Fs_GetPathServerFromWeb($sFile);
+ /**
+ * Считываем содержимое файла
+ */
+ if ($sFileContent = @file_get_contents($sFile)) {
+ /**
+ * Создаем объект
+ */
+ if ($oType = $this->CreateObjectType($sType)) {
+ $oType->setContent($sFileContent);
+ $oType->setFile($sFile);
+ unset($sFileContent);
+ $oType->prepare();
+ if ($bCompress and (!isset($aParams['compress']) or $aParams['compress'])) {
+ $oType->compress();
+ }
+ $sContent .= $oType->getContent();
+ unset($oType);
+ } else {
+ $sContent .= $sFileContent;
+ }
+ }
+ }
+ /**
+ * Создаем файл и сливаем туда содержимое
+ */
+ @file_put_contents($sCacheFile, $sContent);
+ @chmod($sCacheFile, 0766);
+ /**
+ * Удаляем блокировку
+ */
+ $this->RemoveLockMerge();
+ }
+ return $this->Fs_GetPathWebFromServer($sCacheFile);
+ }
+
+ /**
+ * Создает и возврашает объект типа
+ *
+ * @param string $sType
+ *
+ * @return bool|ModuleAsset_EntityType
+ */
+ public function CreateObjectType($sType)
+ {
+ /**
+ * Формируем имя класса для типа
+ */
+ $sClass = "ModuleAsset_EntityType" . func_camelize($sType);
+ if (class_exists(Engine::GetEntityClass($sClass))) {
+ return Engine::GetEntity($sClass);
+ }
+ return false;
+ }
+
+ public function GetRealpath($sPath)
+ {
+ if (preg_match("@^(http|https):@", $sPath)) {
+ $aUrl = parse_url($sPath);
+ $sPath = $aUrl['path'];
+
+ $aParts = array();
+ $sPath = preg_replace('~/\./~', '/', $sPath);
+ foreach (explode('/', preg_replace('~/+~', '/', $sPath)) as $sPart) {
+ if ($sPart === "..") {
+ array_pop($aParts);
+ } elseif ($sPart != "") {
+ $aParts[] = $sPart;
+ }
+ }
+ return ((array_key_exists('scheme',
+ $aUrl)) ? $aUrl['scheme'] . '://' . $aUrl['host'] : "") . "/" . implode("/", $aParts);
+ } else {
+ return realpath($sPath);
+ }
+ }
+
+ public function Shutdown()
+ {
+ /**
+ * Удаляем блокировку
+ */
+ $this->RemoveLockMerge();
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/asset/entity/Type.entity.class.php b/framework/classes/modules/asset/entity/Type.entity.class.php
new file mode 100644
index 0000000..31fc2f3
--- /dev/null
+++ b/framework/classes/modules/asset/entity/Type.entity.class.php
@@ -0,0 +1,108 @@
+
+ *
+ */
+
+/**
+ * Абстрактный класс типа assets, от него должны наследоваться все конечные типы
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+abstract class ModuleAsset_EntityType extends Entity
+{
+ /**
+ * Производит предварительную обработку содержимого
+ *
+ */
+ abstract public function prepare();
+
+ /**
+ * Выполняет сжатие содержимого
+ *
+ * @return mixed
+ */
+ abstract public function compress();
+
+ /**
+ * Возвращает HTML обертку для файла
+ *
+ * @param $sFile
+ * @param $aParams
+ *
+ * @return string
+ */
+ abstract public function getHeadHtml($sFile, $aParams);
+
+ /**
+ * Возвращает контент
+ *
+ * @return string|null
+ */
+ public function getContent()
+ {
+ return $this->_getDataOne('content');
+ }
+
+ /**
+ * Устанавливает контент
+ *
+ * @param string $sContent
+ */
+ public function setContent($sContent)
+ {
+ $this->_aData['content'] = $sContent;
+ }
+
+ /**
+ * Возвращает исходный файл
+ *
+ * @return string|null
+ */
+ public function getFile()
+ {
+ return $this->_getDataOne('file');
+ }
+
+ /**
+ * Устанавливает исходный файл
+ *
+ * @param string $sFile
+ */
+ public function setFile($sFile)
+ {
+ $this->_aData['file'] = $sFile;
+ }
+
+ /**
+ * Оборачивает HTML в зависимости от условия по браузеру
+ *
+ * @param $sHtml
+ * @param $aParams
+ *
+ * @return string
+ */
+ public function wrapForBrowser($sHtml, $aParams)
+ {
+ if (isset($aParams['browser']) and $aParams['browser']) {
+ return "";
+ }
+ return $sHtml;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/asset/entity/TypeCss.entity.class.php b/framework/classes/modules/asset/entity/TypeCss.entity.class.php
new file mode 100644
index 0000000..d63223a
--- /dev/null
+++ b/framework/classes/modules/asset/entity/TypeCss.entity.class.php
@@ -0,0 +1,122 @@
+
+ *
+ */
+
+/**
+ * Тип CSS стилей
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleAsset_EntityTypeCss extends ModuleAsset_EntityType
+{
+ /**
+ * Производит предварительную обработку содержимого
+ *
+ */
+ public function prepare()
+ {
+ $this->setContent(
+ $this->convertUrls($this->getContent(), $this->getFile())
+ );
+ }
+
+ /**
+ * Выполняет сжатие
+ *
+ * @return mixed|void
+ */
+ public function compress()
+ {
+ require_once(Config::Get('path.framework.libs_vendor.server') . '/cssmin/CssMin.php');
+ $oCssMinifier = new CssMinifier($this->getContent());
+ $this->setContent($oCssMinifier->getMinified());
+ }
+
+ /**
+ * Возвращает HTML обертку для файла
+ *
+ * @param $sFile
+ * @param $aParams
+ *
+ * @return string
+ */
+ public function getHeadHtml($sFile, $aParams)
+ {
+ $sHtml = ' ';
+ return $this->wrapForBrowser($sHtml, $aParams);
+ }
+
+ /**
+ * Конвертирует относительные пути до файлов внутри css
+ *
+ * @param $sContent
+ * @param $sFile
+ *
+ * @return mixed
+ */
+ protected function convertUrls($sContent, $sFile)
+ {
+ if (preg_match_all("/url\((.*?)\)/is", $sContent, $aMatches)) {
+ /**
+ * Обрабатываем список файлов
+ */
+ $aFiles = array_unique($aMatches[1]);
+ $sDir = dirname($sFile) . "/";
+ foreach ($aFiles as $sFilePath) {
+ /**
+ * Don't touch data URIs
+ */
+ if (strstr($sFilePath, "data:")) {
+ continue;
+ }
+ $sFilePathAbsolute = preg_replace("@'|\"@", "", trim($sFilePath));
+ /**
+ * Если путь является абсолютным, необрабатываем
+ */
+ if (substr($sFilePathAbsolute, 0, 1) == "/" || in_array(substr($sFilePathAbsolute, 0, 6),
+ array('http:/', 'https:'))
+ ) {
+ continue;
+ }
+ /**
+ * Обрабатываем относительный путь
+ * ../foo.jpg
+ * ../foo.jpg?query
+ * ../foo.jpg#hash
+ * ../foo.jpg?#hash
+ */
+ $aPath = explode('?', $sFilePathAbsolute, 2);
+ $sFilePathAbsolute = $aPath[0];
+ $aPartHash = explode('#', $sFilePathAbsolute, 2);
+ $sFilePathAbsolute = $aPartHash[0];
+ $sGetParams = isset($aPath[1]) ? $aPath[1] : '';
+ $sHashParams = isset($aPartHash[1]) ? $aPartHash[1] : '';
+ $sFilePathAbsolute = $this->Asset_GetRealpath($sDir . $sFilePathAbsolute);
+ $sFilePathAbsolute = $this->Fs_GetPathWebFromServer($sFilePathAbsolute) . ($sGetParams ? "?{$sGetParams}" : '') . ($sHashParams ? "#{$sHashParams}" : '');
+ /**
+ * Заменяем относительные пути в файле на абсолютные
+ */
+ $sContent = str_replace($sFilePath, $sFilePathAbsolute, $sContent);
+ }
+ }
+ return $sContent;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/asset/entity/TypeJs.entity.class.php b/framework/classes/modules/asset/entity/TypeJs.entity.class.php
new file mode 100644
index 0000000..f71a480
--- /dev/null
+++ b/framework/classes/modules/asset/entity/TypeJs.entity.class.php
@@ -0,0 +1,72 @@
+
+ *
+ */
+
+/**
+ * Тип JS скриптов
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleAsset_EntityTypeJs extends ModuleAsset_EntityType
+{
+ /**
+ * Производит предварительную обработку содержимого
+ *
+ */
+ public function prepare()
+ {
+ $this->setContent(
+ rtrim($this->getContent(), ";") . ";" . PHP_EOL
+ );
+ }
+
+ /**
+ * Выполняет сжатие
+ *
+ * @return mixed|void
+ */
+ public function compress()
+ {
+ $oJSqueeze = new \Patchwork\JSqueeze();
+ $this->setContent($oJSqueeze->squeeze(
+ $this->getContent(),
+ true, // $singleLine
+ false, // $keepImportantComments
+ false // $specialVarRx
+ ));
+ }
+
+ /**
+ * Возвращает HTML обертку для файла
+ *
+ * @param $sFile
+ * @param $aParams
+ *
+ * @return string
+ */
+ public function getHeadHtml($sFile, $aParams)
+ {
+ $sDefer = (isset($aParams['defer']) and $aParams['defer']) ? ' defer ' : '';
+ $sAsync = (isset($aParams['async']) and $aParams['async']) ? ' async ' : '';
+ $sHtml = '';
+ return $this->wrapForBrowser($sHtml, $aParams);
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/cache/Cache.class.php b/framework/classes/modules/cache/Cache.class.php
new file mode 100644
index 0000000..d3cd19b
--- /dev/null
+++ b/framework/classes/modules/cache/Cache.class.php
@@ -0,0 +1,492 @@
+
+ *
+ */
+
+require_once(Config::Get('path.framework.libs_vendor.server') . '/DklabCache/config.php');
+require_once(LS_DKCACHE_PATH . 'Zend/Cache.php');
+require_once(LS_DKCACHE_PATH . 'Cache/Backend/Profiler.php');
+
+/**
+ * Модуль кеширования.
+ * Для реализации кеширования используетс библиотека Zend_Cache с бэкэндами File, Memcache и XCache.
+ * Т.к. в memcache нет встроенной поддержки тегирования при кешировании, то для реализации тегов используется враппер от Дмитрия Котерова - Dklab_Cache_Backend_TagEmuWrapper.
+ *
+ * Пример использования:
+ *
+ * // Получает пользователя по его логину
+ * public function GetUserByLogin($sLogin) {
+ * // Пытаемся получить значение из кеша
+ * if (false === ($oUser = $this->Cache_Get("user_login_{$sLogin}"))) {
+ * // Если значение из кеша получить не удалось, то обращаемся к базе данных
+ * $oUser = $this->oMapper->GetUserByLogin($sLogin);
+ * // Записываем значение в кеш
+ * $this->Cache_Set($oUser, "user_login_{$sLogin}", array(), 60*60*24*5);
+ * }
+ * return $oUser;
+ * }
+ *
+ * // Обновляет пользователя в БД
+ * public function UpdateUser($oUser) {
+ * // Удаляем кеш конкретного пользователя
+ * $this->Cache_Delete("user_login_{$oUser->getLogin()}");
+ * // Удалем кеш со списком всех пользователей
+ * $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG,array('user_update'));
+ * // Обновлем пользовател в базе данных
+ * return $this->oMapper->UpdateUser($oUser);
+ * }
+ *
+ * // Получает список всех пользователей
+ * public function GetUsers() {
+ * // Пытаемся получить значение из кеша
+ * if (false === ($aUserList = $this->Cache_Get("users"))) {
+ * // Если значение из кеша получить не удалось, то обращаемся к базе данных
+ * $aUserList = $this->oMapper->GetUsers();
+ * // Записываем значение в кеш
+ * $this->Cache_Set($aUserList, "users", array('user_update'), 60*60*24*5);
+ * }
+ * return $aUserList;
+ * }
+ *
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModuleCache extends Module
+{
+
+ /**
+ * Список бекендов кеширования
+ *
+ * @var array
+ */
+ protected $aCacheBackends = array();
+ /**
+ * Дефолтный тип кеширования
+ *
+ * @var string|null
+ */
+ protected $sCacheType = null;
+ /**
+ * Разрешать или нет кеширование
+ *
+ * @var bool
+ */
+ protected $bAllowUse = false;
+ /**
+ * Возможность принудительно использовать кешировоание, даже если оно отключено в конфиге
+ *
+ * @var bool
+ */
+ protected $bAllowForce = true;
+ /**
+ * Статистика кеширования
+ *
+ * @var array
+ */
+ protected $aStats = array(
+ 'time' => 0,
+ 'count' => 0,
+ 'count_get' => 0,
+ 'count_set' => 0,
+ );
+ /**
+ * Префикс для "умного" кеширования
+ * @see SmartSet
+ * @see SmartGet
+ *
+ * @var string
+ */
+ protected $sPrefixSmartCache = 'for-smart-cache-';
+
+ /**
+ * Инициализация
+ */
+ public function Init()
+ {
+ $this->InitParams();
+ }
+
+ /**
+ * Инициализация необходимых параметров модуля
+ */
+ public function InitParams()
+ {
+ $this->bAllowUse = (bool)Config::Get('sys.cache.use');
+ if (is_bool(Config::Get('sys.cache.force'))) {
+ $this->bAllowForce = Config::Get('sys.cache.force');
+ }
+ $this->sCacheType = strtolower(Config::Get('sys.cache.type'));
+ }
+
+ /**
+ * Удаляет старый кеш в случайном порядке
+ * Рекомендуется запускать раз в пару дней из под крона
+ */
+ public function ClearOldCache()
+ {
+ $this->Clean(Zend_Cache::CLEANING_MODE_OLD);
+ }
+
+ /**
+ * Возвращает объект бекенда кеша
+ *
+ * @param string|null $sCacheType Тип кеша
+ *
+ * @return ModuleCache_EntityBackend Объект бекенда кеша
+ * @throws Exception
+ */
+ protected function GetCacheBackend($sCacheType = null)
+ {
+ if ($sCacheType) {
+ $sCacheType = strtolower($sCacheType);
+ } else {
+ $sCacheType = $this->sCacheType;
+ }
+ /**
+ * Устанавливает алиас memory == memcached
+ */
+ if ($sCacheType == 'memory') {
+ $sCacheType = 'memcached';
+ }
+ if (isset($this->aCacheBackends[$sCacheType])) {
+ return $this->aCacheBackends[$sCacheType];
+ }
+ $sCacheTypeCam = func_camelize($sCacheType);
+ /**
+ * Формируем имя класса бекенда
+ */
+ $sClass = "ModuleCache_EntityBackend{$sCacheTypeCam}";
+ $sClass = Engine::GetEntityClass($sClass);
+ if (class_exists($sClass)) {
+ /**
+ * Создаем объект и проверяем доступность его использования
+ */
+ $oBackend = new $sClass;
+ if (true === ($mResult = $oBackend->IsAvailable())) {
+ $oBackend->Init(array('stats_callback' => array($this, 'CalcStats')));
+ $this->aCacheBackends[$sCacheType] = $oBackend;
+ return $oBackend;
+ } else {
+ throw new Exception("Cache '{$sCacheTypeCam}' not available: {$mResult}");
+ }
+ }
+ throw new Exception("Not found class for cache type: " . $sCacheTypeCam);
+ }
+
+ /**
+ * Формирует хеш от имени ключа кеша
+ *
+ * @param string $sName Имя ключа кеша
+ *
+ * @return string
+ */
+ protected function HashName($sName)
+ {
+ return md5(Config::Get('sys.cache.prefix') . $sName);
+ }
+
+ /**
+ * Получить значение из кеша
+ *
+ * @param string|array $sName Имя ключа
+ * @param string|null $sCacheType Тип кеша
+ * @param bool $bForce Принудительно использовать кеширование, даже если оно отключено в конфиге
+ * @param bool $bKeepInMemory Если true, то данные дополнительно будут сохранены в памяти на время выполнения запроса (скрипта).
+ * В результате чего повторные Get() запросы к кешу будут значительно быстрее. Параметр не поддерживает мульти-запросы к кешу.
+ * Данные параметр следует использовать очень осторожно, т.к. кешированные данные нельзя будет обновить/удалить до конца выполнения запроса.
+ *
+ * @return mixed|bool
+ */
+ public function Get($sName, $sCacheType = null, $bForce = false, $bKeepInMemory = false)
+ {
+ if (!$this->bAllowUse and !($this->bAllowForce and $bForce)) {
+ return false;
+ }
+ /**
+ * Запрос сразу на несколько ключей?
+ */
+ if (is_array($sName)) {
+ return $this->MultiGet($sName, $sCacheType);
+ }
+ /**
+ * При необходимости смотрим в памяти
+ */
+ if ($bKeepInMemory) {
+ $sKeyMemory = 'auto_keep_in_memory_' . $sName;
+ if (false !== ($mData = $this->GetLife($sKeyMemory))) {
+ return $mData;
+ }
+ }
+
+ $oCacheBackend = $this->GetCacheBackend($sCacheType);
+ $mData = $oCacheBackend->Get($this->HashName($sName));
+ /**
+ * Сохраняем в памяти
+ */
+ if ($bKeepInMemory and $mData !== false) {
+ $this->SetLife($mData, $sKeyMemory);
+ }
+ return $mData;
+ }
+
+ /**
+ * Получения значения из "умного" кеша для борьбы с конкурирующими запросами
+ * Если кеш "протух", и за ним обращаются много запросов, то только первый запрос вернет FALSE, остальные будут получать чуть устаревшие данные из временного кеша, пока их не обновит первый запрос
+ * Текущая реализация имеет недостаток - размер кеша увеличивается в два раза
+ *
+ * @param string $sName Имя ключа
+ * @param string|null $sCacheType Тип кеша
+ * @param bool $bForce Принудительно использовать кеширование, даже если оно отключено в конфиге
+ *
+ * @return bool|mixed
+ */
+ public function SmartGet($sName, $sCacheType = null, $bForce = false)
+ {
+ if (!$this->bAllowUse and !($this->bAllowForce and $bForce)) {
+ return false;
+ }
+ /**
+ * Если данных в основном кеше нет, то перекладываем их из временного
+ */
+ if (($data = $this->Get($sName, $sCacheType, $bForce)) === false) {
+ $this->Set($this->Get($this->sPrefixSmartCache . $sName, $sCacheType, $bForce), $sName, array(), 60,
+ $sCacheType, $bForce); // храним данные из временного в основном не долго
+ }
+ return $data;
+ }
+
+ /**
+ * Поддержка мульти-запросов к кешу
+ * Такие запросы поддерживает только memcached, поэтому для остальных типов делаем эмуляцию
+ *
+ * @param array $aName Имя ключа
+ * @param string|null $sCacheType Тип кеша
+ * @return bool|array
+ */
+ protected function MultiGet($aName, $sCacheType = null)
+ {
+ if (!count($aName)) {
+ return false;
+ }
+ $oCacheBackend = $this->GetCacheBackend($sCacheType);
+ if ($oCacheBackend->IsAllowMultiGet()) {
+ $aKeys = array();
+ $aKv = array();
+ foreach ($aName as $sName) {
+ $sHash = $this->HashName($sName);
+ $aKeys[] = $sHash;
+ $aKv[$sHash] = $sName;
+ }
+ $data = $oCacheBackend->Get($aKeys);
+ if ($data and is_array($data)) {
+ $aData = array();
+ foreach ($data as $key => $value) {
+ $aData[$aKv[$key]] = $value;
+ }
+ if (count($aData) > 0) {
+ return $aData;
+ }
+ }
+ return false;
+ } else {
+ $aData = array();
+ foreach ($aName as $sName) {
+ if ((false !== ($data = $oCacheBackend->Get($this->HashName($sName))))) {
+ $aData[$sName] = $data;
+ }
+ }
+ if (count($aData) > 0) {
+ return $aData;
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Записать значение в кеш
+ *
+ * @param mixed $mData Данные для хранения в кеше
+ * @param string $sName Имя ключа
+ * @param array|string $aTags Список тегов, для возможности удалять сразу несколько кешей по тегу
+ * @param int|bool $iTimeLife Время жизни кеша в секундах
+ * @param string|null $sCacheType Тип кеша
+ * @param bool $bForce Принудительно использовать кеширование, даже если оно отключено в конфиге
+ *
+ * @return bool
+ */
+ public function Set($mData, $sName, $aTags = array(), $iTimeLife = false, $sCacheType = null, $bForce = false)
+ {
+ if (!$this->bAllowUse and !($this->bAllowForce and $bForce)) {
+ return false;
+ }
+ if (!is_array($aTags)) {
+ $aTags = array($aTags);
+ }
+ /**
+ * Переводим теги с нижний регистр
+ */
+ $aTags = array_map('strtolower', $aTags);
+ /**
+ * Сохраняем данные в кеш
+ */
+ $oCacheBackend = $this->GetCacheBackend($sCacheType);
+ return $oCacheBackend->Set($mData, $this->HashName($sName), $aTags, $iTimeLife);
+ }
+
+ /**
+ * Устанавливаем значение в "умном" кеша для борьбы с конкурирующими запросами
+ * Дополнительно сохраняет значение во временном кеше на чуть большее время
+ *
+ * @param mixed $data Данные для хранения в кеше
+ * @param string $sName Имя ключа
+ * @param array $aTags Список тегов, для возможности удалять сразу несколько кешей по тегу
+ * @param int|bool $iTimeLife Время жизни кеша в секундах
+ * @param string|null $sCacheType Тип кеша
+ * @param bool $bForce Принудительно использовать кеширование, даже если оно отключено в конфиге
+ *
+ * @return bool
+ */
+ public function SmartSet($data, $sName, $aTags = array(), $iTimeLife = false, $sCacheType = null, $bForce = false)
+ {
+ $this->Set($data, $this->sPrefixSmartCache . $sName, array(), $iTimeLife !== false ? $iTimeLife + 60 : false,
+ $sCacheType, $bForce);
+ return $this->Set($data, $sName, $aTags, $iTimeLife, $sCacheType, $bForce);
+ }
+
+ /**
+ * Метод для удобного обращения к кешу с одновременной записью в него (если данных не было в кеше)
+ *
+ * @param string $sName Имя ключа
+ * @param callable $callback Коллбэк функция, которая должна возвращать данные
+ * @param int $iTimeLife Время жизни кеша в секундах
+ * @param array $aTags Список тегов, для возможности удалять сразу несколько кешей по тегу
+ * @param null $sCacheType Тип кеша
+ * @param bool $bForce Принудительно использовать кеширование, даже если оно отключено в конфиге
+ * @return bool|mixed
+ */
+ public function Remember($sName, \Closure $callback, $iTimeLife = 0, $aTags = array(), $sCacheType = null, $bForce = false) {
+ if (!$this->bAllowUse and !($this->bAllowForce and $bForce)) {
+ return $callback();
+ }
+
+ if (false !== ($mData = $this->Get($sName, $sCacheType, $bForce))) {
+ return $mData;
+ }
+ $this->Set($mData = $callback(), $sName, $aTags, $iTimeLife, $sCacheType, $bForce);
+ return $mData;
+ }
+
+ /**
+ * Удаляет значение из кеша по ключу(имени)
+ *
+ * @param string $sName
+ * @param string|null $sCacheType
+ * @param bool $bForce
+ *
+ * @return bool
+ */
+ public function Delete($sName, $sCacheType = null, $bForce = false)
+ {
+ if (!$this->bAllowUse and !($this->bAllowForce and $bForce)) {
+ return false;
+ }
+ /**
+ * Удаляем данные их кеша
+ */
+ $oCacheBackend = $this->GetCacheBackend($sCacheType);
+ return $oCacheBackend->Delete($this->HashName($sName));
+ }
+
+ /**
+ * Чистит кеши
+ *
+ * @param string $cMode Режим очистки кеша
+ * @param array $aTags Список тегов, актуально для режима Zend_Cache::CLEANING_MODE_MATCHING_TAG
+ * @param string|null $sCacheType Тип кеша
+ * @param bool $bForce Принудительно использовать кеширование, даже если оно отключено в конфиге
+ *
+ * @return bool
+ */
+ public function Clean($cMode = Zend_Cache::CLEANING_MODE_ALL, $aTags = array(), $sCacheType = null, $bForce = false)
+ {
+ if (!$this->bAllowUse and !($this->bAllowForce and $bForce)) {
+ return false;
+ }
+ if (!is_array($aTags)) {
+ $aTags = array($aTags);
+ }
+ /**
+ * Переводим теги с нижний регистр
+ */
+ $aTags = array_map('strtolower', $aTags);
+ $oCacheBackend = $this->GetCacheBackend($sCacheType);
+ return $oCacheBackend->Clean($cMode, $aTags);
+ }
+
+ /**
+ * Получает значение из текущего кеша сессии
+ *
+ * @param string $sName Имя ключа
+ * @return mixed
+ */
+ public function GetLife($sName)
+ {
+ return $this->Get($sName, 'life', true);
+ }
+
+ /**
+ * Сохраняет значение в кеше на время исполнения скрипта(сессии), некий аналог Registry
+ *
+ * @param mixed $mData Данные для сохранения в кеше
+ * @param string $sName Имя ключа
+ */
+ public function SetLife($mData, $sName)
+ {
+ $this->Set($mData, $sName, array(), false, 'life', true);
+ }
+
+ /**
+ * Подсчет статистики использования кеша
+ *
+ * @param int $iTime Время выполнения метода
+ * @param string $sMethod имя метода
+ */
+ public function CalcStats($iTime, $sMethod)
+ {
+ $this->aStats['time'] += $iTime;
+ $this->aStats['count']++;
+ if ($sMethod == 'Dklab_Cache_Backend_Profiler::load') {
+ $this->aStats['count_get']++;
+ }
+ if ($sMethod == 'Dklab_Cache_Backend_Profiler::save') {
+ $this->aStats['count_set']++;
+ }
+ }
+
+ /**
+ * Возвращает статистику использования кеша
+ *
+ * @return array
+ */
+ public function GetStats()
+ {
+ return $this->aStats;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/cache/entity/Backend.entity.class.php b/framework/classes/modules/cache/entity/Backend.entity.class.php
new file mode 100644
index 0000000..b18b5e4
--- /dev/null
+++ b/framework/classes/modules/cache/entity/Backend.entity.class.php
@@ -0,0 +1,96 @@
+
+ *
+ */
+
+/**
+ * Абстрактный объект бекенда кеша, от него должны наследоваться все конечные бекенды
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+abstract class ModuleCache_EntityBackend
+{
+ /**
+ * Объект бекенда кеша
+ *
+ * @var null|object
+ */
+ protected $oCacheBackend = null;
+
+ /**
+ * Инициализация бекенда
+ *
+ * @param array $aParams
+ *
+ * @return mixed
+ */
+ abstract public function Init($aParams = array());
+
+ /**
+ * Проверяет доступность использования текущего бекенда
+ *
+ * @return mixed
+ */
+ abstract public function IsAvailable();
+
+ /**
+ * Проверяет доступность использование мульти-get запросов к кешу (указывать сразу несколько ключей)
+ *
+ * @return mixed
+ */
+ abstract public function IsAllowMultiGet();
+
+ /**
+ * Получить значение из кеша
+ *
+ * @param string $sName Имя ключа
+ * @return mixed|bool
+ */
+ abstract public function Get($sName);
+
+ /**
+ * Записать значение в кеш
+ *
+ * @param mixed $mData Данные для хранения в кеше
+ * @param string $sName Имя ключа
+ * @param array $aTags Список тегов, для возможности удалять сразу несколько кешей по тегу
+ * @param int|bool $iTimeLife Время жизни кеша в секундах
+ * @return bool
+ */
+ abstract public function Set($mData, $sName, $aTags = array(), $iTimeLife = false);
+
+ /**
+ * Удаляет значение из кеша по ключу(имени)
+ *
+ * @param string $sName Имя ключа
+ * @return bool
+ */
+ abstract public function Delete($sName);
+
+ /**
+ * Чистит кеши
+ *
+ * @param string $cMode Режим очистки кеша
+ * @param array $aTags Список тегов, актуально для режима Zend_Cache::CLEANING_MODE_MATCHING_TAG
+ * @return bool
+ */
+ abstract public function Clean($cMode = Zend_Cache::CLEANING_MODE_ALL, $aTags = array());
+
+}
\ No newline at end of file
diff --git a/framework/classes/modules/cache/entity/BackendFile.entity.class.php b/framework/classes/modules/cache/entity/BackendFile.entity.class.php
new file mode 100644
index 0000000..eec0fc1
--- /dev/null
+++ b/framework/classes/modules/cache/entity/BackendFile.entity.class.php
@@ -0,0 +1,146 @@
+
+ *
+ */
+
+/**
+ * Бекенд файлового кеша
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleCache_EntityBackendFile extends ModuleCache_EntityBackend
+{
+ /**
+ * Проверяет доступность использования текущего бекенда
+ *
+ * @return mixed
+ */
+ public function IsAvailable()
+ {
+ $sDirCache = $this->GetCacheDir();
+ if (!is_dir($sDirCache)) {
+ @mkdir($sDirCache, 0777, true);
+ }
+ if (is_writable($sDirCache)) {
+ return true;
+ }
+ return "cache dir '{$sDirCache}' is not writable";
+ }
+
+ /**
+ * Проверяет доступность использование мульти-get запросов к кешу (указывать сразу несколько ключей)
+ *
+ * @return mixed
+ */
+ public function IsAllowMultiGet()
+ {
+ return false;
+ }
+
+ /**
+ * Возвращает каталог для кеша
+ *
+ * @return string
+ */
+ protected function GetCacheDir()
+ {
+ return Config::Get('sys.cache.dir') . '/system/';
+ }
+
+ /**
+ * Инициализация бекенда
+ *
+ * @param array $aParams
+ *
+ * @return mixed
+ */
+ public function Init($aParams = array())
+ {
+ require_once(LS_DKCACHE_PATH . 'Zend/Cache/Backend/File.php');
+ $sDirCache = $this->GetCacheDir();
+ $oCahe = new Zend_Cache_Backend_File(
+ array(
+ 'cache_dir' => $sDirCache,
+ 'file_name_prefix' => Config::Get('sys.cache.prefix'),
+ 'read_control_type' => 'crc32',
+ 'hashed_directory_level' => Config::Get('sys.cache.directory_level'),
+ 'read_control' => true,
+ 'file_locking' => true,
+ )
+ );
+ if (isset($aParams['stats_callback'])) {
+ $this->oCacheBackend = new Dklab_Cache_Backend_Profiler($oCahe, $aParams['stats_callback']);
+ } else {
+ $this->oCacheBackend = $oCahe;
+ }
+ }
+
+ /**
+ * Получить значение из кеша
+ *
+ * @param string $sName Имя ключа
+ * @return mixed|bool
+ */
+ public function Get($sName)
+ {
+ $mData = $this->oCacheBackend->load($sName);
+ if ($mData and is_string($mData)) {
+ return unserialize($mData);
+ }
+ return $mData;
+ }
+
+ /**
+ * Записать значение в кеш
+ *
+ * @param mixed $mData Данные для хранения в кеше
+ * @param string $sName Имя ключа
+ * @param array $aTags Список тегов, для возможности удалять сразу несколько кешей по тегу
+ * @param int|bool $iTimeLife Время жизни кеша в секундах
+ * @return bool
+ */
+ public function Set($mData, $sName, $aTags = array(), $iTimeLife = false)
+ {
+ return $this->oCacheBackend->save(serialize($mData), $sName, $aTags, $iTimeLife);
+ }
+
+ /**
+ * Удаляет значение из кеша по ключу(имени)
+ *
+ * @param string $sName Имя ключа
+ * @return bool
+ */
+ public function Delete($sName)
+ {
+ return $this->oCacheBackend->remove($sName);
+ }
+
+ /**
+ * Чистит кеши
+ *
+ * @param string $cMode Режим очистки кеша
+ * @param array $aTags Список тегов, актуально для режима Zend_Cache::CLEANING_MODE_MATCHING_TAG
+ * @return bool
+ */
+ public function Clean($cMode = Zend_Cache::CLEANING_MODE_ALL, $aTags = array())
+ {
+ return $this->oCacheBackend->clean($cMode, $aTags);
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/cache/entity/BackendFileOrm.entity.class.php b/framework/classes/modules/cache/entity/BackendFileOrm.entity.class.php
new file mode 100644
index 0000000..0ee259f
--- /dev/null
+++ b/framework/classes/modules/cache/entity/BackendFileOrm.entity.class.php
@@ -0,0 +1,40 @@
+
+ *
+ */
+
+/**
+ * Бекенд служебного файлового кеша для ORM
+ * Используется для хранения схемы БД
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleCache_EntityBackendFileOrm extends ModuleCache_EntityBackendFile
+{
+ /**
+ * Возвращает каталог для кеша
+ *
+ * @return string
+ */
+ protected function GetCacheDir()
+ {
+ return Config::Get('sys.cache.dir') . '/database/';
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/cache/entity/BackendLibmemcached.entity.class.php b/framework/classes/modules/cache/entity/BackendLibmemcached.entity.class.php
new file mode 100644
index 0000000..8fe8b1a
--- /dev/null
+++ b/framework/classes/modules/cache/entity/BackendLibmemcached.entity.class.php
@@ -0,0 +1,122 @@
+
+ *
+ */
+
+/**
+ * Бекенд Libmemcached
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleCache_EntityBackendLibmemcached extends ModuleCache_EntityBackend
+{
+ /**
+ * Проверяет доступность использования текущего бекенда
+ *
+ * @return mixed
+ */
+ public function IsAvailable()
+ {
+ if (extension_loaded('memcached')) {
+ return true;
+ }
+ return 'The memcached extension must be loaded for using this backend!';
+ }
+
+ /**
+ * Проверяет доступность использование мульти-get запросов к кешу (указывать сразу несколько ключей)
+ *
+ * @return mixed
+ */
+ public function IsAllowMultiGet()
+ {
+ return false;
+ }
+
+ /**
+ * Инициализация бекенда
+ *
+ * @param array $aParams
+ *
+ * @return mixed
+ */
+ public function Init($aParams = array())
+ {
+ require_once(LS_DKCACHE_PATH . 'Cache/Backend/TagEmuWrapper.php');
+ require_once(LS_DKCACHE_PATH . 'Zend/Cache/Backend/Libmemcached.php');
+ $aConfig = Config::Get('libmemcached');
+
+ $oCahe = new Zend_Cache_Backend_Libmemcached(is_array($aConfig) ? $aConfig : array());
+ if (isset($aParams['stats_callback'])) {
+ $this->oCacheBackend = new Dklab_Cache_Backend_TagEmuWrapper(new Dklab_Cache_Backend_Profiler($oCahe,
+ $aParams['stats_callback']));
+ } else {
+ $this->oCacheBackend = new Dklab_Cache_Backend_TagEmuWrapper($oCahe);
+ }
+ }
+
+ /**
+ * Получить значение из кеша
+ *
+ * @param string $sName Имя ключа
+ * @return mixed|bool
+ */
+ public function Get($sName)
+ {
+ return $this->oCacheBackend->load($sName);
+ }
+
+ /**
+ * Записать значение в кеш
+ *
+ * @param mixed $mData Данные для хранения в кеше
+ * @param string $sName Имя ключа
+ * @param array $aTags Список тегов, для возможности удалять сразу несколько кешей по тегу
+ * @param int|bool $iTimeLife Время жизни кеша в секундах
+ * @return bool
+ */
+ public function Set($mData, $sName, $aTags = array(), $iTimeLife = false)
+ {
+ return $this->oCacheBackend->save($mData, $sName, $aTags, $iTimeLife);
+ }
+
+ /**
+ * Удаляет значение из кеша по ключу(имени)
+ *
+ * @param string $sName Имя ключа
+ * @return bool
+ */
+ public function Delete($sName)
+ {
+ return $this->oCacheBackend->remove($sName);
+ }
+
+ /**
+ * Чистит кеши
+ *
+ * @param string $cMode Режим очистки кеша
+ * @param array $aTags Список тегов, актуально для режима Zend_Cache::CLEANING_MODE_MATCHING_TAG
+ * @return bool
+ */
+ public function Clean($cMode = Zend_Cache::CLEANING_MODE_ALL, $aTags = array())
+ {
+ return $this->oCacheBackend->clean($cMode, $aTags);
+ }
+}
diff --git a/framework/classes/modules/cache/entity/BackendLife.entity.class.php b/framework/classes/modules/cache/entity/BackendLife.entity.class.php
new file mode 100644
index 0000000..4738380
--- /dev/null
+++ b/framework/classes/modules/cache/entity/BackendLife.entity.class.php
@@ -0,0 +1,117 @@
+
+ *
+ */
+
+/**
+ * Бекенд сессионного кеша
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleCache_EntityBackendLife extends ModuleCache_EntityBackend
+{
+
+ protected $aStoreLife = array();
+
+ /**
+ * Проверяет доступность использования текущего бекенда
+ *
+ * @return mixed
+ */
+ public function IsAvailable()
+ {
+ return true;
+ }
+
+ /**
+ * Проверяет доступность использование мульти-get запросов к кешу (указывать сразу несколько ключей)
+ *
+ * @return mixed
+ */
+ public function IsAllowMultiGet()
+ {
+ return false;
+ }
+
+ /**
+ * Инициализация бекенда
+ *
+ * @param array $aParams
+ *
+ * @return mixed
+ */
+ public function Init($aParams = array())
+ {
+ $this->aStoreLife = array();
+ }
+
+ /**
+ * Получить значение из кеша
+ *
+ * @param string $sName Имя ключа
+ * @return mixed|bool
+ */
+ public function Get($sName)
+ {
+ if (array_key_exists($sName, $this->aStoreLife)) {
+ return @unserialize($this->aStoreLife[$sName]);
+ }
+ return false;
+ }
+
+ /**
+ * Записать значение в кеш
+ *
+ * @param mixed $mData Данные для хранения в кеше
+ * @param string $sName Имя ключа
+ * @param array $aTags Список тегов, для возможности удалять сразу несколько кешей по тегу
+ * @param int|bool $iTimeLife Время жизни кеша в секундах
+ * @return bool
+ */
+ public function Set($mData, $sName, $aTags = array(), $iTimeLife = false)
+ {
+ $this->aStoreLife[$sName] = serialize($mData);
+ }
+
+ /**
+ * Удаляет значение из кеша по ключу(имени)
+ *
+ * @param string $sName Имя ключа
+ * @return bool
+ */
+ public function Delete($sName)
+ {
+ unset($this->aStoreLife[$sName]);
+ }
+
+ /**
+ * Чистит кеши
+ *
+ * @param string $cMode Режим очистки кеша
+ * @param array $aTags Список тегов, актуально для режима Zend_Cache::CLEANING_MODE_MATCHING_TAG
+ * @return bool
+ */
+ public function Clean($cMode = Zend_Cache::CLEANING_MODE_ALL, $aTags = array())
+ {
+ if ($cMode == Zend_Cache::CLEANING_MODE_ALL) {
+ $this->aStoreLife = array();
+ }
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/cache/entity/BackendMemcached.entity.class.php b/framework/classes/modules/cache/entity/BackendMemcached.entity.class.php
new file mode 100644
index 0000000..564e464
--- /dev/null
+++ b/framework/classes/modules/cache/entity/BackendMemcached.entity.class.php
@@ -0,0 +1,122 @@
+
+ *
+ */
+
+/**
+ * Бекенд memcache
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleCache_EntityBackendMemcached extends ModuleCache_EntityBackend
+{
+ /**
+ * Проверяет доступность использования текущего бекенда
+ *
+ * @return mixed
+ */
+ public function IsAvailable()
+ {
+ if (extension_loaded('memcache')) {
+ return true;
+ }
+ return 'The memcache extension must be loaded for using this backend!';
+ }
+
+ /**
+ * Проверяет доступность использование мульти-get запросов к кешу (указывать сразу несколько ключей)
+ *
+ * @return mixed
+ */
+ public function IsAllowMultiGet()
+ {
+ return true;
+ }
+
+ /**
+ * Инициализация бекенда
+ *
+ * @param array $aParams
+ *
+ * @return mixed
+ */
+ public function Init($aParams = array())
+ {
+ require_once(LS_DKCACHE_PATH . 'Cache/Backend/TagEmuWrapper.php');
+ require_once(LS_DKCACHE_PATH . 'Cache/Backend/MemcachedMultiload.php');
+ $aConfig = Config::Get('memcache');
+
+ $oCahe = new Dklab_Cache_Backend_MemcachedMultiload(is_array($aConfig) ? $aConfig : array());
+ if (isset($aParams['stats_callback'])) {
+ $this->oCacheBackend = new Dklab_Cache_Backend_TagEmuWrapper(new Dklab_Cache_Backend_Profiler($oCahe,
+ $aParams['stats_callback']));
+ } else {
+ $this->oCacheBackend = new Dklab_Cache_Backend_TagEmuWrapper($oCahe);
+ }
+ }
+
+ /**
+ * Получить значение из кеша
+ *
+ * @param string $sName Имя ключа
+ * @return mixed|bool
+ */
+ public function Get($sName)
+ {
+ return $this->oCacheBackend->load($sName);
+ }
+
+ /**
+ * Записать значение в кеш
+ *
+ * @param mixed $mData Данные для хранения в кеше
+ * @param string $sName Имя ключа
+ * @param array $aTags Список тегов, для возможности удалять сразу несколько кешей по тегу
+ * @param int|bool $iTimeLife Время жизни кеша в секундах
+ * @return bool
+ */
+ public function Set($mData, $sName, $aTags = array(), $iTimeLife = false)
+ {
+ return $this->oCacheBackend->save($mData, $sName, $aTags, $iTimeLife);
+ }
+
+ /**
+ * Удаляет значение из кеша по ключу(имени)
+ *
+ * @param string $sName Имя ключа
+ * @return bool
+ */
+ public function Delete($sName)
+ {
+ return $this->oCacheBackend->remove($sName);
+ }
+
+ /**
+ * Чистит кеши
+ *
+ * @param string $cMode Режим очистки кеша
+ * @param array $aTags Список тегов, актуально для режима Zend_Cache::CLEANING_MODE_MATCHING_TAG
+ * @return bool
+ */
+ public function Clean($cMode = Zend_Cache::CLEANING_MODE_ALL, $aTags = array())
+ {
+ return $this->oCacheBackend->clean($cMode, $aTags);
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/cache/entity/BackendXcache.entity.class.php b/framework/classes/modules/cache/entity/BackendXcache.entity.class.php
new file mode 100644
index 0000000..3a9d9c9
--- /dev/null
+++ b/framework/classes/modules/cache/entity/BackendXcache.entity.class.php
@@ -0,0 +1,122 @@
+
+ *
+ */
+
+/**
+ * Бекенд xcache
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleCache_EntityBackendXcache extends ModuleCache_EntityBackend
+{
+ /**
+ * Проверяет доступность использования текущего бекенда
+ *
+ * @return mixed
+ */
+ public function IsAvailable()
+ {
+ if (extension_loaded('xcache')) {
+ return true;
+ }
+ return 'The xcache extension must be loaded for using this backend!';
+ }
+
+ /**
+ * Проверяет доступность использование мульти-get запросов к кешу (указывать сразу несколько ключей)
+ *
+ * @return mixed
+ */
+ public function IsAllowMultiGet()
+ {
+ return false;
+ }
+
+ /**
+ * Инициализация бекенда
+ *
+ * @param array $aParams
+ *
+ * @return mixed
+ */
+ public function Init($aParams = array())
+ {
+ require_once(LS_DKCACHE_PATH . 'Cache/Backend/TagEmuWrapper.php');
+ require_once(LS_DKCACHE_PATH . 'Zend/Cache/Backend/Xcache.php');
+ $aConfig = Config::Get('xcache');
+
+ $oCahe = new Zend_Cache_Backend_Xcache(is_array($aConfig) ? $aConfig : array());
+ if (isset($aParams['stats_callback'])) {
+ $this->oCacheBackend = new Dklab_Cache_Backend_TagEmuWrapper(new Dklab_Cache_Backend_Profiler($oCahe,
+ $aParams['stats_callback']));
+ } else {
+ $this->oCacheBackend = new Dklab_Cache_Backend_TagEmuWrapper($oCahe);
+ }
+ }
+
+ /**
+ * Получить значение из кеша
+ *
+ * @param string $sName Имя ключа
+ * @return mixed|bool
+ */
+ public function Get($sName)
+ {
+ return $this->oCacheBackend->load($sName);
+ }
+
+ /**
+ * Записать значение в кеш
+ *
+ * @param mixed $mData Данные для хранения в кеше
+ * @param string $sName Имя ключа
+ * @param array $aTags Список тегов, для возможности удалять сразу несколько кешей по тегу
+ * @param int|bool $iTimeLife Время жизни кеша в секундах
+ * @return bool
+ */
+ public function Set($mData, $sName, $aTags = array(), $iTimeLife = false)
+ {
+ return $this->oCacheBackend->save($mData, $sName, $aTags, $iTimeLife);
+ }
+
+ /**
+ * Удаляет значение из кеша по ключу(имени)
+ *
+ * @param string $sName Имя ключа
+ * @return bool
+ */
+ public function Delete($sName)
+ {
+ return $this->oCacheBackend->remove($sName);
+ }
+
+ /**
+ * Чистит кеши
+ *
+ * @param string $cMode Режим очистки кеша
+ * @param array $aTags Список тегов, актуально для режима Zend_Cache::CLEANING_MODE_MATCHING_TAG
+ * @return bool
+ */
+ public function Clean($cMode = Zend_Cache::CLEANING_MODE_ALL, $aTags = array())
+ {
+ return $this->oCacheBackend->clean($cMode, $aTags);
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/component/Component.class.php b/framework/classes/modules/component/Component.class.php
new file mode 100644
index 0000000..8cec7d5
--- /dev/null
+++ b/framework/classes/modules/component/Component.class.php
@@ -0,0 +1,630 @@
+
+ *
+ */
+
+/**
+ * Модуль управления компонентами frontenda'а - независимые единицы (кирпичики) шаблона, состоящие из tpl, css, js
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleComponent extends Module
+{
+
+ /**
+ * Список компонентов для подключения
+ * В качестве ключей указывается название компонента, а в значениях возможные параметры
+ *
+ * @var array
+ */
+ protected $aComponentsList = array();
+ /**
+ * Кеш для данных компонентов - json и каталоги
+ * Для каждого компонента есть ключи paths и json
+ *
+ * @var array
+ */
+ protected $aComponentsData = array();
+ /**
+ * Служебный счетчик для предотвращения зацикливания
+ *
+ * @var int
+ */
+ protected $iCountDependsRecursive = 0;
+
+ /**
+ * Инициализация модуля
+ */
+ public function Init()
+ {
+ $this->InitComponentsList();
+ }
+
+ /**
+ * Инициализация начального списка необходимых для загрузки компонентов
+ */
+ public function InitComponentsList()
+ {
+ if ($aList = Config::Get('components') and is_array($aList)) {
+ func_array_simpleflip($aList, array());
+ $this->aComponentsList = array_merge_recursive($this->aComponentsList, $aList);
+ }
+ }
+
+ /**
+ * Выполняет загрузку необходимых компонентов
+ * Под загрузкой понимается автоматическое подключение необходимых css, js
+ */
+ public function LoadAll()
+ {
+ /**
+ * Подгрузка из кеша данных компонентов
+ */
+ $this->RetrieveComponentsDataCache();
+ /**
+ * Для каждого компонента считываем данные из json
+ */
+ $aComponentsName = array_keys($this->aComponentsList);
+ /**
+ * Используем кеширование построения дерева компонентов
+ */
+ $bCacheUse = Config::Get('module.component.cache_tree');
+ $sCacheKey = 'components-tree-' . json_encode($aComponentsName);
+
+ if (!$bCacheUse or false === ($aTree = $this->Cache_Get($sCacheKey))) {
+ /**
+ * Строим дерево компонентов с учетом зависимостей
+ */
+ $aTree = array();
+ foreach ($aComponentsName as $sName) {
+ list($sComponentPlugin, $sComponentName) = $this->ParseName($sName);
+ $aTree[$sName] = array();
+ /**
+ * Считываем данные компонента
+ */
+ $aData = $this->GetComponentData($sName);
+ $aData = $aData['json'];
+ /**
+ * Проверяем зависимости
+ */
+ if (isset($aData['dependencies']) and is_array($aData['dependencies'])) {
+ foreach ($aData['dependencies'] as $mKey => $mValue) {
+ if (!is_int($mKey) and $mValue === false) {
+ /**
+ * Пропускаем отмененную зависимость
+ */
+ continue;
+ }
+ $sNameDepend = is_int($mKey) ? $mValue : $mKey;
+ list($sComponentDependPlugin, $sComponentDependName) = $this->ParseName($sNameDepend);
+ if (is_null($sComponentDependPlugin) and $sComponentPlugin) {
+ $sNameDepend = $sComponentPlugin . ':' . $sComponentDependName;
+ }
+ $sNameDepend = trim($sNameDepend, ':');
+ $aTree[$sName][] = strtolower($sNameDepend);
+ }
+ }
+ }
+ /**
+ * Сортируем компоненты с учетом зависимостей
+ */
+ $this->iCountDependsRecursive = 0;
+ $aTree = $this->GetSortedByDepends($aTree);
+
+ if ($bCacheUse) {
+ $this->Cache_Set($aTree, $sCacheKey, array(), 60 * 60 * 24);
+ }
+ }
+
+ /**
+ * Подключаем каждый компонент
+ */
+ foreach ($aTree as $sName => $aDepends) {
+ $this->Load($sName);
+ }
+ /**
+ * Информация по компонентам сохраняем в кеше
+ */
+ $this->StoreComponentsDataCache();
+ }
+
+ public function StoreComponentsDataCache()
+ {
+ if (!Config::Get('module.component.cache_data')) {
+ return;
+ }
+
+ $sCacheKey = 'components-data-' . json_encode(array_keys($this->aComponentsList));
+ $this->Cache_Set($this->aComponentsData, $sCacheKey, array(), 60 * 60 * 24);
+ }
+
+ public function RetrieveComponentsDataCache()
+ {
+ if (!Config::Get('module.component.cache_data')) {
+ return;
+ }
+ $sCacheKey = 'components-data-' . json_encode(array_keys($this->aComponentsList));
+ if (false !== ($aComponentsData = $this->Cache_Get($sCacheKey))) {
+ foreach ($aComponentsData as $sName => $aData) {
+ $this->aComponentsData[$sName] = $aData;
+ }
+ }
+ }
+
+ /**
+ * Загружает/подключает компонент
+ *
+ * @param $sName
+ */
+ public function Load($sName)
+ {
+ /**
+ * Json данные
+ */
+ $aData = $this->GetComponentData($sName);
+ $aDataMeta = $aData['json'];
+ /**
+ * Подключаем стили
+ */
+ if (isset($aDataMeta['styles']) and is_array($aDataMeta['styles'])) {
+ foreach ($aDataMeta['styles'] as $mName => $mAsset) {
+ $aParams = array();
+ if (is_array($mAsset)) {
+ $sAsset = isset($mAsset['file']) ? $mAsset['file'] : 'not_found_file_param';
+ unset($mAsset['file']);
+ $aParams = $mAsset;
+ } else {
+ $sAsset = $mAsset;
+ }
+ if ($sAsset === false) {
+ continue;
+ }
+ /**
+ * Может быть внешний ресурс
+ */
+ $iPos = strpos($sAsset, '//');
+ if ($iPos !== false and $iPos < 7) {
+ $sFile = $sAsset;
+ } else {
+ /**
+ * Смотрим в каком каталоге есть файл
+ */
+ foreach ($aData['paths'] as $sPath) {
+ $sFile = $sPath . '/' . $sAsset;
+ if (file_exists($sFile)) {
+ break;
+ }
+ }
+ }
+ $sFileName = (is_int($mName) ? md5($sAsset) : $mName);
+ $aParams['name'] = "component.{$sName}.{$sFileName}";
+ $this->Viewer_PrependStyle($sFile, $aParams);
+ }
+ }
+ /**
+ * Подключаем скрипты
+ */
+ if (isset($aDataMeta['scripts']) and is_array($aDataMeta['scripts'])) {
+ foreach ($aDataMeta['scripts'] as $mName => $mAsset) {
+ $aParams = array();
+ if (is_array($mAsset)) {
+ $sAsset = isset($mAsset['file']) ? $mAsset['file'] : 'not_found_file_param';
+ unset($mAsset['file']);
+ $aParams = $mAsset;
+ } else {
+ $sAsset = $mAsset;
+ }
+ if ($sAsset === false) {
+ continue;
+ }
+ /**
+ * Может быть внешний ресурс
+ */
+ $iPos = strpos($sAsset, '//');
+ if ($iPos !== false and $iPos < 7) {
+ $sFile = $sAsset;
+ } else {
+ /**
+ * Смотрим в каком каталоге есть файл
+ */
+ foreach ($aData['paths'] as $sPath) {
+ $sFile = $sPath . '/' . $sAsset;
+ if (file_exists($sFile)) {
+ break;
+ }
+ }
+ }
+ $sFileName = (is_int($mName) ? md5($sAsset) : $mName);
+ $aParams['name'] = "component.{$sName}.{$sFileName}";
+ $this->Viewer_PrependScript($sFile, $aParams);
+ }
+ }
+ }
+
+ /**
+ * Добавляет новый компонент в список для загрузки
+ *
+ * @param $sName
+ * @param $aParams
+ */
+ public function Add($sName, $aParams = array())
+ {
+ $sName = strtolower($sName);
+ if (!array_key_exists($sName, $this->aComponentsList)) {
+ $this->aComponentsList[$sName] = $aParams;
+ }
+ }
+
+ /**
+ * Удаляет компонент из списка загрузки
+ *
+ * @param $sName
+ */
+ public function Remove($sName)
+ {
+ $sName = strtolower($sName);
+ unset($this->aComponentsList[$sName]);
+ }
+
+ /**
+ * Удаляет все компоненты из загрузки
+ */
+ public function RemoveAll()
+ {
+ $this->aComponentsList = array();
+ }
+
+ /**
+ * Возвращает полные серверные пути до компонента
+ *
+ * @param string $sName Имя компонента. Может содержать название плагина, например, "page:alert" - компонент alert плагина page
+ * @return string
+ */
+ public function GetPaths($sName)
+ {
+ $aData = $this->GetComponentData($sName);
+ return $aData['paths'];
+ }
+
+ /**
+ * Возвращает полный серверный путь до компонента
+ * Т.к. путей может быть несколько, то возвращаем первый по приоритету
+ *
+ * @param string $sName Имя компонента. Может содержать название плагина, например, "page:alert" - компонент alert плагина page
+ * @return string
+ */
+ public function GetPath($sName)
+ {
+ $aPaths = $this->GetPaths($sName);
+ return reset($aPaths);
+ }
+
+ /**
+ * Возвращает полный web путь до компонента с учетом текущей схемы (http/https)
+ * Т.к. путей может быть несколько, то возвращаем первый по приоритету
+ *
+ * @param $sName
+ * @return bool
+ */
+ public function GetWebPath($sName)
+ {
+ if ($sPathServer = $this->GetPath($sName)) {
+ return $this->Fs_GetPathWebFromServer($sPathServer);
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает путь до шаблона
+ * Путь может быть как абсолютным, так и относительным корня шаблона
+ * Метод учитывает возможное наследование плагинами, а также учитывает приоритет шаблона (tpl шаблона -> application -> framework)
+ *
+ * @param $sNameFull
+ * @param $sTemplate
+ * @param $bCheckDelegate
+ * @return string
+ */
+ public function GetTemplatePath($sNameFull, $sTemplate = null, $bCheckDelegate = true)
+ {
+ list($sPlugin, $sName) = $this->ParseName($sNameFull);
+ /**
+ * По дефолту используем в качестве имени шаблона название компонента
+ */
+ if (!$sTemplate) {
+ $sTemplate = $sName;
+ }
+ if ($bCheckDelegate) {
+ /**
+ * Базовое название компонента
+ */
+ $sNameBase = ($sPlugin ? "{$sPlugin}:" : '') . "component.{$sName}.{$sTemplate}";
+ /**
+ * Проверяем наследование по базовому имени
+ */
+ $sNameBaseInherit = $this->Plugin_GetDelegate('template', $sNameBase);
+ if ($sNameBaseInherit != $sNameBase) {
+ return $sNameBaseInherit;
+ }
+ }
+ /**
+ * Компонент не наследуется, поэтому получаем до него полный серверный путь
+ */
+ $aData = $this->GetComponentData($sNameFull);
+ $aDataJson = $aData['json'];
+ foreach ($aData['paths'] as $sPath) {
+ if (isset($aDataJson['templates'][$sTemplate])) {
+ $sTpl = $aDataJson['templates'][$sTemplate];
+ } else {
+ $sTpl = "{$sTemplate}.tpl";
+ }
+ $sFile = $sPath . '/' . $sTpl;
+ if (file_exists($sFile)) {
+ return $sFile;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает полный серверный путь до css/js компонента
+ *
+ * @param $sNameFull
+ * @param $sAssetType
+ * @param $sAssetName
+ * @return bool|string
+ */
+ public function GetAssetPath($sNameFull, $sAssetType, $sAssetName)
+ {
+ $aData = $this->GetComponentData($sNameFull);
+
+ if (in_array($sAssetType, array('scripts', 'js'))) {
+ $sAssetType = 'scripts';
+ $sAssetExt = 'js';
+ } else {
+ $sAssetType = 'styles';
+ $sAssetExt = 'css';
+ }
+ /**
+ * Получаем путь до файла из json
+ */
+ $aDataJson = $aData['json'];
+ if (isset($aDataJson[$sAssetType][$sAssetName])) {
+ $sAsset = $aDataJson[$sAssetType][$sAssetName];
+ } else {
+ $sAsset = "{$sAssetName}.{$sAssetExt}";
+ }
+ if ($sAsset === false) {
+ return false;
+ }
+ foreach ($aData['paths'] as $sPath) {
+ $sFile = $sPath . '/' . $sAsset;
+ if (file_exists($sFile)) {
+ return $sFile;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Парсит имя компонента
+ * Имя может содержать название плагина - plugin:component
+ *
+ * @param $sName
+ * @return array Массив из двух элементов, первый - имя плагина, воторой - имя компонента. Если плагина нет, то null вместо его имени
+ */
+ protected function ParseName($sName)
+ {
+ $sName = strtolower($sName);
+ $aPath = explode(':', $sName);
+ if (count($aPath) == 2) {
+ return array($aPath[0], $aPath[1]);
+ }
+ //if (preg_match("#^\{([\w_-]+)\}/?([\w_-]+)$#", $sName, $aMatch)) {
+ return array(null, $sName);
+ }
+
+ /**
+ * Вспомогательный метод для сортировки компонентов по зависимостям
+ *
+ * @param $aComp
+ * @param $aSorted
+ * @param $sName
+ * @return bool
+ */
+ protected function GetDepends($aComp, $aSorted, $sName)
+ {
+ if (isset($aComp[$sName])) {
+ foreach ($aComp[$sName] as $sItem) {
+ if (!isset($aSorted[$sItem])) {
+ $this->iCountDependsRecursive++;
+ if ($this->iCountDependsRecursive > 2000) {
+ return false;
+ } else {
+ return $this->GetDepends($aComp, $aSorted, $sItem);
+ }
+ }
+ }
+ }
+ return $sName;
+ }
+
+ /**
+ * Сортирует компоненты по зависимостям - зависимые подключаются ниже
+ *
+ * @param $aComp
+ * @return array|bool
+ */
+ protected function GetSortedByDepends($aComp)
+ {
+ $aSorted = array();
+ foreach ($aComp as $sName => $void) {
+ do {
+ if ($sCompDepend = $this->GetDepends($aComp, $aSorted, $sName)) {
+ if (isset($aComp[$sCompDepend])) {
+ $aSorted[$sCompDepend] = $aComp[$sCompDepend];
+ } else {
+ $aSorted[$sCompDepend] = array();
+ }
+ } else {
+ $aSorted = false;
+ break;
+ }
+ } while ($sCompDepend != $sName);
+ }
+ return $aSorted;
+ }
+
+ /**
+ * Возвращает данные компонента
+ *
+ * @param $sName
+ * @return array
+ */
+ protected function GetComponentData($sName)
+ {
+ /**
+ * Смотрим в кеше
+ */
+ if (isset($this->aComponentsData[$sName])) {
+ return $this->aComponentsData[$sName];
+ }
+ /**
+ * Получаем список каталогов, где находится компонент и json мета информацию
+ */
+ $aPaths = $this->GetComponentPaths($sName);
+ $this->aComponentsData[$sName] = array(
+ 'json' => $this->GetComponentJson($aPaths),
+ 'paths' => $aPaths,
+
+ );
+ return $this->aComponentsData[$sName];
+ }
+
+ /**
+ * Возвращает список каталогов, где находится компонент.
+ * Каталоги возвращаются согласно приоритету - сначала идут самые приоритетные.
+ *
+ * @param $sName
+ * @return array
+ */
+ protected function GetComponentPaths($sName)
+ {
+ list($sPlugin, $sName) = $this->ParseName($sName);
+ $sPath = 'components/' . $sName;
+ $aPaths = array();
+ if ($sPlugin) {
+ /**
+ * Проверяем наличие компонента в каталоге текущего шаблона плагина
+ */
+ $sPathTemplate = Plugin::GetTemplatePath($sPlugin);
+ if (file_exists($sPathTemplate . $sPath)) {
+ $aPaths[] = $sPathTemplate . $sPath;
+ }
+ /**
+ * Проверяем наличие компонента в общем каталоге плагина
+ */
+ $sPathTemplate = Config::Get('path.application.plugins.server') . "/{$sPlugin}/frontend";
+ if (file_exists($sPathTemplate . '/' . $sPath)) {
+ $aPaths[] = $sPathTemplate . '/' . $sPath;
+ }
+ } else {
+ /**
+ * Проверяем наличие компонента в каталоге текущего шаблона
+ */
+ $sPathTemplate = $this->Fs_GetPathServerFromWeb(Config::Get('path.skin.web'));
+ if (file_exists($sPathTemplate . '/' . $sPath)) {
+ $aPaths[] = $sPathTemplate . '/' . $sPath;
+ }
+ }
+
+ /**
+ * Проверяем на компонент приложения
+ */
+ $sPathTemplate = Config::Get('path.application.server') . '/frontend';
+ if (file_exists($sPathTemplate . '/' . $sPath)) {
+ $aPaths[] = $sPathTemplate . '/' . $sPath;
+ }
+ /**
+ * Проверяем на компонент фреймворка
+ */
+ $sPathTemplate = Config::Get('path.framework.server') . '/frontend';
+ if (file_exists($sPathTemplate . '/' . $sPath)) {
+ $aPaths[] = $sPathTemplate . '/' . $sPath;
+ }
+ return $aPaths;
+ }
+
+ /**
+ * Возвращает json данные компонента с учетом наследования
+ *
+ * @param $aPaths
+ * @return array|mixed
+ */
+ protected function GetComponentJson(&$aPaths)
+ {
+ /**
+ * Получаем пути в обратном порядке, т.к. будем мержить данные
+ */
+ $aPaths = array_reverse($aPaths);
+ $aPathsNew = array();
+ $aJson = array();
+ foreach ($aPaths as $sPath) {
+ $sFileJson = $sPath . '/component.json';
+ if (file_exists($sFileJson)) {
+ if ($sContent = @file_get_contents($sFileJson)) {
+ if ($aData = @json_decode($sContent, true)) {
+ if (isset($aData['mode']) and $aData['mode'] == 'delegate') {
+ $aJson = $aData;
+ /**
+ * Удаляем прошлые каталоги
+ */
+ $aPathsNew = array();
+ } else {
+ $aJson = func_array_merge_assoc($aJson, $aData);
+ }
+ } elseif (!is_array($aData)) {
+ $this->Logger_Error('Invalid format component.json', array('file' => $sFileJson));
+ }
+ }
+ }
+ $aPathsNew[] = $sPath;
+ }
+ /**
+ * Подменяем пути
+ */
+ $aPaths = array_reverse($aPathsNew);
+ return $aJson;
+ }
+
+ /**
+ * Возвращает отрендеренный шаблон компонента
+ *
+ * @param string $sComponent Имя компонента
+ * @param string|null $sTemplate Название шаблона, если null то будет использоваться шаблон по имени компонента
+ * @param array $aParams Список параметров, которые необходимо прогрузить в шаблон. Параметры прогружаются как локальные.
+ * @return string
+ */
+ public function Fetch($sComponent, $sTemplate = null, $aParams = array())
+ {
+ $oViewer = $this->Viewer_GetLocalViewer();
+ $oViewer->Assign($aParams, null, true);
+ return $oViewer->Fetch('component@' . $sComponent . ($sTemplate ? '.' . $sTemplate : ''));
+ }
+}
diff --git a/framework/classes/modules/cron/Cron.class.php b/framework/classes/modules/cron/Cron.class.php
new file mode 100644
index 0000000..085e476
--- /dev/null
+++ b/framework/classes/modules/cron/Cron.class.php
@@ -0,0 +1,167 @@
+
+ *
+ */
+
+/**
+ * Модуль управления центральным кроном - запуск запланированных задач
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleCron extends ModuleORM
+{
+
+ const TASK_STATE_NOT_ACTIVE = 0;
+ const TASK_STATE_ACTIVE = 1;
+
+ /**
+ * Запускает выполнение центрального крона
+ * Выбирает необходимые задачи и выполняет их
+ */
+ public function RunMain()
+ {
+ /**
+ * Получаем список активных задач
+ * TODO: можно сделать выборку нужных задач сразу из БД, а не сравнивать в php время
+ */
+ $aTasks = $this->GetTaskItemsByFilter(array(
+ 'state' => self::TASK_STATE_ACTIVE,
+ '#where' => array(
+ '(t.time_start IS NULL OR t.time_start <= ?)' => array(date('H:i')),
+ '(t.time_end IS NULL OR t.time_end >= ?)' => array(date('H:i'))
+ )
+ ));
+ $aTasksReady = array();
+ foreach ($aTasks as $oTask) {
+ if (!$oTask->getDateRunLast() or (strtotime($oTask->getDateRunLast()) + $oTask->getPeriodRun() * 60 < time())) {
+ $aTasksReady[] = $oTask;
+ }
+ }
+
+ if (Config::Get('module.cron.use_fork')) {
+ $that = $this;
+ $aResult = \iFixit\Forker\Forker::map($aTasksReady, function ($iIndex, $oTask) use ($that) {
+ /**
+ * Производим переподключение к основной БД
+ */
+ $that->Database_ReConnect();
+ $oTask->beforeRun(true);
+ return $oTask->run();
+ });
+ } else {
+ foreach ($aTasksReady as $oTask) {
+ $oTask->beforeRun(false);
+ $oTask->run();
+ }
+ }
+ }
+
+ /**
+ * Запускает задачу на выполнение
+ *
+ * @param $oTask
+ *
+ * @return array
+ */
+ public function RunTask($oTask)
+ {
+ $aLog = array(
+ 'state' => 'successful',
+ 'return' => null,
+ );
+ /**
+ * Запускаем
+ */
+ try {
+ $aLog['return'] = call_user_func(array($this, $oTask->getMethod()), $oTask);
+ } catch (Exception $e) {
+ $aLog['state'] = 'error';
+ $aLog['message'] = $e->getMessage() . ' (code:' . $e->getCode() . ';line:' . $e->getLine() . ')';
+ }
+ /**
+ * Обновляем количество пусков и дату последнего запуска
+ */
+ $oTask->setCountRun($oTask->getCountRun() + 1);
+ $oTask->setDateRunLast(date('Y-m-d H:i:s'));
+ $oTask->Update();
+ /**
+ * Записываем в лог
+ */
+ $this->WriteLog('Run cron task "' . $oTask->getTitleWithLang() . '"', $aLog);
+ return $aLog;
+ }
+
+ /**
+ * Записывает сообщение в лог крона
+ *
+ * @param $sMsg
+ * @param array $aData
+ */
+ protected function WriteLog($sMsg, $aData = array())
+ {
+ $this->Logger_Notice($sMsg, $aData, 'cron');
+ }
+
+ /**
+ * Создает новую задачу в БД
+ * Метод предназначен для использования в плагинах в момент их активации
+ *
+ * @param $sTitle
+ * @param $sMethod
+ * @param $iPeriod
+ * @param string $sPlugin
+ *
+ * @return ModuleCron_EntityTask|string
+ */
+ public function CreateTask($sTitle, $sMethod, $iPeriod, $sPlugin = null)
+ {
+ $sPlugin = $sPlugin ? Plugin::GetPluginCode($sPlugin) : '';
+ if ($oTask = $this->GetTaskByMethodAndPlugin($sMethod, $sPlugin)) {
+ return $oTask;
+ }
+ $oTask = Engine::GetEntity('ModuleCron_EntityTask');
+ $oTask->setTitle($sTitle);
+ $oTask->setMethod($sMethod);
+ $oTask->setPeriodRun($iPeriod);
+ $oTask->setPlugin($sPlugin);
+ $oTask->setState(self::TASK_STATE_ACTIVE);
+ if ($oTask->_Validate()) {
+ $oTask->Add();
+ return $oTask;
+ } else {
+ return $oTask->_getValidateError();
+ }
+ }
+
+ /**
+ * Удаляет все крон-задачи конкретного плагина
+ *
+ * @param $sPlugin
+ */
+ public function RemoveTasksByPlugin($sPlugin)
+ {
+ if ($sPlugin = Plugin::GetPluginCode($sPlugin)) {
+ $aTasks = $this->GetTaskItemsByPlugin($sPlugin);
+ foreach ($aTasks as $oTask) {
+ $oTask->Delete();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/cron/entity/Task.entity.class.php b/framework/classes/modules/cron/entity/Task.entity.class.php
new file mode 100644
index 0000000..035b66b
--- /dev/null
+++ b/framework/classes/modules/cron/entity/Task.entity.class.php
@@ -0,0 +1,136 @@
+
+ *
+ */
+
+/**
+ * Сущность крон-задачи
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleCron_EntityTask extends EntityORM
+{
+
+ /**
+ * Определяем правила валидации
+ *
+ * @var array
+ */
+ protected $aValidateRules = array(
+ array('title', 'string', 'max' => 200, 'min' => 1, 'allowEmpty' => false, 'label' => 'Название'),
+ array('method', 'string', 'max' => 300, 'min' => 1, 'allowEmpty' => false, 'label' => 'Метод'),
+ array('period_run', 'number', 'integerOnly' => true, 'min' => 2, 'allowEmpty' => false, 'label' => 'Период'),
+ array('title', 'title_check'),
+ array('method', 'method_check'),
+ array('plugin', 'plugin_check'),
+ array('state', 'state_check'),
+ array('time_start', 'date', 'format' => 'HH:mm'),
+ array('time_end', 'date', 'format' => 'HH:mm'),
+ array('time_end', 'times_check'),
+ );
+
+ /**
+ * Выполняется перед сохранением
+ *
+ * @return bool
+ */
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+
+ public function ValidateTitleCheck()
+ {
+ if (!$this->_hasValidateErrors()) {
+ $this->setTitle(htmlspecialchars($this->getTitle()));
+ }
+ return true;
+ }
+
+ public function ValidateMethodCheck()
+ {
+ if (!$this->_hasValidateErrors()) {
+ $this->setMethod(htmlspecialchars($this->getMethod()));
+ }
+ return true;
+ }
+
+ public function ValidatePluginCheck()
+ {
+ if (!$this->_hasValidateErrors() and $this->getPlugin()) {
+ $this->setPlugin(htmlspecialchars($this->getPlugin()));
+ }
+ return true;
+ }
+
+ public function ValidateStateCheck($sValue, $aParams)
+ {
+ $this->setState($this->getState() == ModuleCron::TASK_STATE_ACTIVE ? ModuleCron::TASK_STATE_ACTIVE : ModuleCron::TASK_STATE_NOT_ACTIVE);
+ return true;
+ }
+
+ public function ValidateTimesCheck($sValue, $aParams)
+ {
+ if (!$this->getTimeStart()) {
+ $this->setTimeStart(null);
+ }
+ if (!$this->getTimeEnd()) {
+ $this->setTimeEnd(null);
+ }
+ return true;
+ }
+
+ /**
+ * Возвращает заголовок задачи, считая, что в поле содержится языковой код текстовки
+ *
+ * @return string
+ */
+ public function getTitleWithLang()
+ {
+ return $this->Lang_Get($this->getTitle());
+ }
+
+ /**
+ * Запускает задачу на выполнение
+ *
+ * @return mixed
+ */
+ public function run()
+ {
+ return $this->Cron_RunTask($this);
+ }
+
+ /**
+ * Выполняется перед запуском задачи
+ * В этом методе должна быть реализованна логика по инициализации окружения для выполнения задачи,
+ * например, при $bFork=true нужно убедиться в корректности ресурсов, таких как подключение к БД и т.п.
+ *
+ * @param $bFork
+ */
+ public function beforeRun($bFork)
+ {
+
+ }
+}
diff --git a/framework/classes/modules/database/Database.class.php b/framework/classes/modules/database/Database.class.php
new file mode 100644
index 0000000..b5b3537
--- /dev/null
+++ b/framework/classes/modules/database/Database.class.php
@@ -0,0 +1,410 @@
+
+ *
+ */
+
+require_once(Config::Get('path.framework.libs_vendor.server') . '/DbSimple/Generic.php');
+
+/**
+ * Модуль для работы с базой данных
+ * Создаёт объект БД библиотеки DbSimple Дмитрия Котерова
+ * Модуль используется в основном для создания коннекта к БД и передачи его в маппер
+ * @see Mapper::__construct
+ * Так же предоставляет методы для быстрого выполнения запросов/дампов SQL, актуально для плагинов
+ * @see Plugin::ExportSQL
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModuleDatabase extends Module
+{
+ /**
+ * Массив инстанцируемых объектов БД, или проще говоря уникальных коннектов к БД
+ *
+ * @var array
+ */
+ protected $aInstance = array();
+
+ /**
+ * Инициализация модуля
+ *
+ */
+ public function Init()
+ {
+
+ }
+
+ /**
+ * Получает объект БД
+ *
+ * @param array|null $aConfig - конфиг подключения к БД(хост, логин, пароль, тип бд, имя бд), если null, то используются параметры из конфига Config::Get('db.params')
+ * @param bool $bForce Создавать принудительно новый коннект, даже если он уже существует
+ * @return DbSimple_Generic_Database DbSimple
+ */
+ public function GetConnect($aConfig = null, $bForce = false)
+ {
+ /**
+ * Получаем DSN
+ */
+ $sDSN = $this->GetDSNByConfig($aConfig);
+ /**
+ * Создаём хеш подключения, уникальный для каждого конфига
+ */
+ $sDSNKey = md5($sDSN);
+ /**
+ * Проверяем создавали ли уже коннект с такими параметрами подключения(DSN)
+ */
+ if (isset($this->aInstance[$sDSNKey]) and !$bForce) {
+ return $this->aInstance[$sDSNKey];
+ } else {
+ /**
+ * Если такого коннекта еще не было то создаём его
+ */
+ $oDbSimple = DbSimple_Generic::connect($sDSN);
+ /**
+ * Устанавливаем хук на перехват ошибок при работе с БД
+ */
+ $oDbSimple->setErrorHandler(array($this, 'CallbackError'));
+ /**
+ * Если нужно логировать все SQL запросы то подключаем логгер
+ */
+ if (Config::Get('sys.logs.sql_query')) {
+ $oDbSimple->setLogger(array($this, 'CallbackQuery'));
+ }
+ /**
+ * Устанавливаем настройки соединения, по хорошему этого здесь не должно быть :)
+ * считайте это костылём
+ */
+ if ($sSqlInit = Config::Get('db.init_sql')) {
+ $oDbSimple->query($sSqlInit);
+ }
+ /**
+ * Сохраняем коннект
+ */
+ $this->aInstance[$sDSNKey] = $oDbSimple;
+ /**
+ * Возвращаем коннект
+ */
+ return $oDbSimple;
+ }
+ }
+
+ /**
+ * Производит переподключение к БД
+ * @param null $aConfig
+ *
+ * @return bool
+ */
+ public function ReConnect($aConfig = null)
+ {
+ /**
+ * Получаем DSN
+ */
+ $sDSN = $this->GetDSNByConfig($aConfig);
+ /**
+ * Создаём хеш подключения, уникальный для каждого конфига
+ */
+ $sDSNKey = md5($sDSN);
+ if (isset($this->aInstance[$sDSNKey])) {
+ if ($this->aInstance[$sDSNKey]->reconnect() !== false) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Производит переподключение ко всем БД
+ */
+ public function ReConnectAll()
+ {
+ foreach ($this->aInstance as $oDb) {
+ $oDb->reconnect();
+ }
+ }
+
+ /**
+ * Возвращает DSN строку из конфига
+ *
+ * @param null $aConfig
+ *
+ * @return string
+ */
+ protected function GetDSNByConfig($aConfig = null)
+ {
+ /**
+ * Если конфиг не передан то используем главный конфиг БД из config.php
+ */
+ if (is_null($aConfig)) {
+ $aConfig = Config::Get('db.params');
+ }
+ return $aConfig['type'] . '://' . $aConfig['user'] . ':' . $aConfig['pass'] . '@' . $aConfig['host'] . ':' . $aConfig['port'] . '/' . $aConfig['dbname'];
+ }
+
+ /**
+ * Возвращает статистику использования БД - время и количество запросов
+ *
+ * @return array
+ */
+ public function GetStats()
+ {
+ $aQueryStats = array('time' => 0, 'count' => 0);
+ foreach ($this->aInstance as $oDb) {
+ $aStats = $oDb->getStatistics();
+ $aQueryStats['time'] += $aStats['time'];
+ $aQueryStats['count'] += $aStats['count'];
+ }
+ $aQueryStats['time'] = round($aQueryStats['time'], 3);
+ if ($aQueryStats['count'] > 0) {
+ $aQueryStats['count']--; // не считаем тот самый костыльный запрос, который устанавливает настройки DB соединения
+ }
+ return $aQueryStats;
+ }
+
+ /**
+ * Экспорт SQL дампа в БД
+ * @see ExportSQLQuery
+ *
+ * @param string $sFilePath Полный путь до файла SQL
+ * @param array|null $aConfig Конфиг подключения к БД
+ * @return array
+ */
+ public function ExportSQL($sFilePath, $aConfig = null)
+ {
+ if (!is_file($sFilePath)) {
+ return array('result' => false, 'errors' => array("cant find file '$sFilePath'"));
+ } elseif (!is_readable($sFilePath)) {
+ return array('result' => false, 'errors' => array("cant read file '$sFilePath'"));
+ }
+ $sFileQuery = file_get_contents($sFilePath);
+ return $this->ExportSQLQuery($sFileQuery, $aConfig);
+ }
+
+ /**
+ * Экспорт SQL в БД
+ *
+ * @param string $sFileQuery Строка с SQL запросом
+ * @param array|null $aConfig Конфиг подключения к БД
+ * @return array Возвращает массив вида array('result'=>bool,'errors'=>array())
+ */
+ public function ExportSQLQuery($sFileQuery, $aConfig = null)
+ {
+ /**
+ * Замена префикса таблиц
+ */
+ $sFileQuery = str_replace('prefix_', Config::Get('db.table.prefix'), $sFileQuery);
+
+ /**
+ * Массивы запросов и пустой контейнер для сбора ошибок
+ */
+ $aErrors = array();
+ $aQuery = preg_split("#;(\n|\r)+#", $sFileQuery, null, PREG_SPLIT_NO_EMPTY);
+ /**
+ * Выполняем запросы по очереди
+ */
+ $oDb = $this->GetConnect($aConfig);
+ foreach ($aQuery as $sQuery) {
+ $sQuery = trim($sQuery);
+ /**
+ * Заменяем движек, если таковой указан в запросе
+ */
+ if (Config::Get('db.tables.engine') != 'InnoDB') {
+ $sQuery = str_ireplace('ENGINE=InnoDB', "ENGINE=" . Config::Get('db.tables.engine'), $sQuery);
+ }
+
+ if ($sQuery != '') {
+ $bResult = $oDb->query($sQuery);
+ if ($bResult === false) {
+ $aErrors[] = $oDb->errmsg;
+ }
+ }
+ }
+ /**
+ * Возвращаем результат выполнения, взависимости от количества ошибок
+ */
+ if (count($aErrors) == 0) {
+ return array('result' => true, 'errors' => null);
+ }
+ return array('result' => false, 'errors' => $aErrors);
+ }
+
+ /**
+ * Проверяет существование таблицы
+ *
+ * @param string $sTableName Название таблицы, необходимо перед именем таблицы добавлять "prefix_", это позволит учитывать произвольный префикс таблиц у пользователя
+ * @param array|null $aConfig Конфиг подключения к БД
+ * @return bool
+ */
+ public function IsTableExists($sTableName, $aConfig = null)
+ {
+ $sTableName = str_replace('prefix_', Config::Get('db.table.prefix'), $sTableName);
+ $sQuery = "SHOW TABLES LIKE '{$sTableName}'";
+ if ($aRows = $this->GetConnect($aConfig)->select($sQuery)) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет существование поля в таблице
+ *
+ * @param string $sTableName Название таблицы, необходимо перед именем таблицы добавлять "prefix_", это позволит учитывать произвольный префикс таблиц у пользователя
+ * @param string $sFieldName Название поля в таблице
+ * @param array|null $aConfig Конфиг подключения к БД
+ * @return bool
+ */
+ public function IsFieldExists($sTableName, $sFieldName, $aConfig = null)
+ {
+ $sTableName = str_replace('prefix_', Config::Get('db.table.prefix'), $sTableName);
+ $sQuery = "SHOW FIELDS FROM `{$sTableName}`";
+ if ($aRows = $this->GetConnect($aConfig)->select($sQuery)) {
+ foreach ($aRows as $aRow) {
+ if ($aRow['Field'] == $sFieldName) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Добавляет новый тип в поле таблицы с типом enum
+ *
+ * @param string $sTableName Название таблицы, необходимо перед именем таблицы добавлять "prefix_", это позволит учитывать произвольный префикс таблиц у пользователя
+ * @param string $sFieldName Название поля в таблице
+ * @param string $sType Название типа
+ * @param array|null $aConfig Конфиг подключения к БД
+ */
+ public function AddEnumType($sTableName, $sFieldName, $sType, $aConfig = null)
+ {
+ $sTableName = str_replace('prefix_', Config::Get('db.table.prefix'), $sTableName);
+ $sQuery = "SHOW COLUMNS FROM `{$sTableName}`";
+
+ if ($aRows = $this->GetConnect($aConfig)->select($sQuery)) {
+ foreach ($aRows as $aRow) {
+ if ($aRow['Field'] == $sFieldName) {
+ break;
+ }
+ }
+ if (strpos($aRow['Type'], "'{$sType}'") === false) {
+ $aRow['Type'] = str_ireplace('enum(', "enum('{$sType}',", $aRow['Type']);
+ $sQuery = "ALTER TABLE `{$sTableName}` MODIFY `{$sFieldName}` " . $aRow['Type'];
+ $sQuery .= ($aRow['Null'] == 'NO') ? ' NOT NULL ' : ' NULL ';
+ $sQuery .= is_null($aRow['Default']) ? ' DEFAULT NULL ' : " DEFAULT '{$aRow['Default']}' ";
+ $this->GetConnect($aConfig)->select($sQuery);
+ }
+ }
+ }
+
+ /**
+ * Удаляет тип в поле таблицы с типом enum
+ *
+ * @param string $sTableName Название таблицы, необходимо перед именем таблицы добавлять "prefix_", это позволит учитывать произвольный префикс таблиц у пользователя
+ * @param string $sFieldName Название поля в таблице
+ * @param string $sType Название типа
+ * @param array|null $aConfig Конфиг подключения к БД
+ * @return bool
+ */
+ public function RemoveEnumType($sTableName, $sFieldName, $sType, $aConfig = null)
+ {
+ $sTableName = str_replace('prefix_', Config::Get('db.table.prefix'), $sTableName);
+ $sQuery = "SHOW COLUMNS FROM `{$sTableName}`";
+
+ if ($aRows = $this->GetConnect($aConfig)->select($sQuery)) {
+ foreach ($aRows as $aRow) {
+ if ($aRow['Field'] == $sFieldName) {
+ break;
+ }
+ }
+ if (strpos($aRow['Type'], "'{$sType}'") !== false) {
+ $aRow['Type'] = preg_replace('#^enum\((.+)\)$#i', "\$1", $aRow['Type']);
+ $aTypePart = explode(',', $aRow['Type']);
+ foreach ($aTypePart as $k => $v) {
+ if ($v == "'{$sType}'") {
+ unset($aTypePart[$k]);
+ }
+ }
+ if (!count($aTypePart)) {
+ return false;
+ }
+ $aRow['Type'] = 'enum(' . join(',', $aTypePart) . ')';
+ $sQuery = "ALTER TABLE `{$sTableName}` MODIFY `{$sFieldName}` " . $aRow['Type'];
+ $sQuery .= ($aRow['Null'] == 'NO') ? ' NOT NULL ' : ' NULL ';
+ if (is_null($aRow['Default'])) {
+ $sQuery .= ' DEFAULT NULL ';
+ } elseif ($aRow['Default'] != $sType) {
+ $sQuery .= " DEFAULT '{$aRow['Default']}' ";
+ }
+
+ $this->GetConnect($aConfig)->select($sQuery);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Коллбек обработки SQL ошибок
+ *
+ * @param string $sMessage Сообщение об ошибке
+ * @param array $aInfo Список информации об ошибке
+ */
+ public function CallbackError($sMessage, $aInfo)
+ {
+ /**
+ * Записываем информацию об ошибке в переменную $msg
+ */
+ $sMessage = "SQL Error: $sMessage \n";
+ $sMessage .= print_r($aInfo, true);
+ /**
+ * Если нужно логировать SQL ошибке то пишем их в лог
+ */
+ if (Config::Get('sys.logs.sql_error')) {
+ /**
+ * Логируем
+ */
+ $this->Logger_Critical($sMessage, array(), 'db_error');
+ }
+ /**
+ * Если стоит вывод ошибок то выводим ошибку на экран(браузер)
+ */
+ if (error_reporting() && ini_get('display_errors')) {
+ exit($sMessage);
+ }
+ }
+
+ /**
+ * Коллбек логгирования SQL запросов
+ *
+ * @param object $oDb
+ * @param array $aSql
+ */
+ public function CallbackQuery($oDb, $aSql)
+ {
+ /**
+ * Получаем информацию о запросе и сохраняем её в переменной $msg
+ */
+ $sMsg = print_r($aSql, true);
+ /**
+ * Логируем
+ */
+ $this->Logger_Debug($sMsg, array(), 'db_query');
+ }
+}
+
diff --git a/framework/classes/modules/fs/Fs.class.php b/framework/classes/modules/fs/Fs.class.php
new file mode 100644
index 0000000..53011cf
--- /dev/null
+++ b/framework/classes/modules/fs/Fs.class.php
@@ -0,0 +1,485 @@
+
+ *
+ */
+
+/**
+ * Модуль для работы с файловой системой
+ * TODO: проверить работу под Windows
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleFs extends Module
+{
+ /**
+ * Список типов файловых путей.
+ * Путь задается в виде "[web]http://site.com/image.jpg", [web] - префикс с типом пути
+ */
+ const PATH_TYPE_WEB = 'web';
+ /**
+ * Серверный путь [server]/home/webmaster/site.com/image.jpg
+ */
+ const PATH_TYPE_SERVER = 'server';
+ /**
+ * Относительный путь от корня сайта [relative]/image.jpg
+ */
+ const PATH_TYPE_RELATIVE = 'relative';
+
+ /**
+ * Дефолтный тип пути, используется если префикс не указан
+ *
+ * @var string|null
+ */
+ protected $sPathTypeDefault = null;
+
+
+ public function Init()
+ {
+ /**
+ * Определяем дефолтный тип
+ */
+ $this->sPathTypeDefault = self::PATH_TYPE_SERVER;
+ }
+
+ /**
+ * Формирует полный путь с учетом типа
+ *
+ * @param string $sPath
+ * @param string $sType
+ *
+ * @return string
+ */
+ public function MakePath($sPath, $sType)
+ {
+ return '[' . $sType . ']' . $sPath;
+ }
+
+ /**
+ * Возвращает серверный путь
+ *
+ * @param string $sPath Исходный путь с префиксом, например, [relative]/image.jpg
+ * @param bool $bWithType
+ *
+ * @return string
+ */
+ public function GetPathServer($sPath, $bWithType = false)
+ {
+ list($sType, $sPath) = $this->GetParsedPath($sPath);
+ if ($sType != self::PATH_TYPE_SERVER) {
+ /**
+ * Пробуем вызвать метод GetPathServerFrom[Type]()
+ */
+ $sMethod = 'GetPathServerFrom' . func_camelize($sType);
+ if (method_exists($this, $sMethod)) {
+ $sPath = $this->$sMethod($sPath);
+ }
+ }
+ if ($bWithType) {
+ $sPath = $this->MakePath($sPath, self::PATH_TYPE_SERVER);
+ }
+ return $sPath;
+ }
+
+ /**
+ * Возвращает веб путь (URL)
+ *
+ * @param string $sPath Исходный путь с префиксом, например, [relative]/image.jpg
+ * @param bool $bWithType
+ *
+ * @return string
+ */
+ public function GetPathWeb($sPath, $bWithType = false)
+ {
+ list($sType, $sPath) = $this->GetParsedPath($sPath);
+ if ($sType != self::PATH_TYPE_WEB) {
+ /**
+ * Пробуем вызвать метод GetPathWebFrom[Type]()
+ */
+ $sMethod = 'GetPathWebFrom' . func_camelize($sType);
+ if (method_exists($this, $sMethod)) {
+ $sPath = $this->$sMethod($sPath);
+ }
+ }
+ if ($bWithType) {
+ $sPath = $this->MakePath($sPath, self::PATH_TYPE_WEB);
+ }
+ return $sPath;
+ }
+
+ /**
+ * Возвращает относительный путь
+ *
+ * @param string $sPath Исходный путь с префиксом, например, [server]/home/webmaster/site.com/image.jpg
+ * @param bool $bWithType
+ *
+ * @return string
+ */
+ public function GetPathRelative($sPath, $bWithType = false)
+ {
+ list($sType, $sPath) = $this->GetParsedPath($sPath);
+ if ($sType != self::PATH_TYPE_RELATIVE) {
+ /**
+ * Пробуем вызвать метод GetPathRelativeFrom[Type]()
+ */
+ $sMethod = 'GetPathRelativeFrom' . func_camelize($sType);
+ if (method_exists($this, $sMethod)) {
+ $sPath = $this->$sMethod($sPath);
+ }
+ }
+ if ($bWithType) {
+ $sPath = $this->MakePath($sPath, self::PATH_TYPE_RELATIVE);
+ }
+ return $sPath;
+ }
+
+ /**
+ * Возвращает серверный путь из веб
+ *
+ * @param string $sPath
+ *
+ * @return string
+ */
+ public function GetPathServerFromWeb($sPath)
+ {
+ /**
+ * Определяем, принадлежит ли этот адрес основному домену
+ */
+ if (parse_url($sPath, PHP_URL_HOST) != parse_url(Router::GetPathRootWeb(), PHP_URL_HOST)) {
+ return $sPath;
+ }
+ /**
+ * Выделяем адрес пути
+ */
+ $sPath = ltrim(parse_url($sPath, PHP_URL_PATH), '/');
+ if ($iOffset = Config::Get('path.offset_request_url')) {
+ $sPath = preg_replace('#^([^/]+/*){' . $iOffset . '}#msi', '', $sPath);
+ }
+ return rtrim(Config::Get('path.root.server'), '/') . '/' . $sPath;
+ }
+
+ /**
+ * Возвращает относительный путь из серверного
+ *
+ * @param string $sPath
+ *
+ * @return string
+ */
+ public function GetPathRelativeFromServer($sPath)
+ {
+ $sServerPath = rtrim(str_replace(DIRECTORY_SEPARATOR, '/', Config::Get('path.root.server')), '/');
+ return str_ireplace($sServerPath, '', str_replace(DIRECTORY_SEPARATOR, '/', $sPath));
+ }
+
+ /**
+ * Возвращает относительный путь из веб
+ *
+ * @param string $sPath
+ *
+ * @return string
+ */
+ public function GetPathRelativeFromWeb($sPath)
+ {
+ $sPath = ltrim(parse_url($sPath, PHP_URL_PATH), '/');
+ if ($iOffset = Config::Get('path.offset_request_url')) {
+ $sPath = preg_replace('#^([^/]+/*){' . $iOffset . '}#msi', '', $sPath);
+ }
+ return '/' . $sPath;
+ }
+
+ /**
+ * Возвращает веб путь из серверного
+ *
+ * @param string $sPath
+ *
+ * @return string
+ */
+ public function GetPathWebFromServer($sPath)
+ {
+ static $sWebPath;
+ static $sServerPath;
+ if (!$sServerPath) {
+ $sServerPath = rtrim(str_replace(DIRECTORY_SEPARATOR, '/', Config::Get('path.root.server')), '/');
+ }
+ if (!$sWebPath) {
+ $sWebPath = Router::GetPathRootWeb();
+ }
+ return str_ireplace($sServerPath, $sWebPath, str_replace(DIRECTORY_SEPARATOR, '/', $sPath));
+ }
+
+ /**
+ * Возвращает серверный путь из относительного
+ *
+ * @param string $sPath
+ *
+ * @return string
+ */
+ public function GetPathServerFromRelative($sPath)
+ {
+ return rtrim(Config::Get('path.root.server'), '/') . '/' . ltrim($sPath, '/');
+ }
+
+ /**
+ * Возвращает веб путь из относительного
+ *
+ * @param string $sPath
+ *
+ * @return string
+ */
+ public function GetPathWebFromRelative($sPath)
+ {
+ return Router::GetPathRootWeb() . '/' . ltrim($sPath, '/');
+ }
+
+ /**
+ * Проверяет принадлежность пути нужному типу
+ *
+ * @param string $sPath Исходный путь с префиксом, например, [relative]/image.jpg
+ * @param string $sType
+ *
+ * @return bool
+ */
+ public function IsPathType($sPath, $sType)
+ {
+ return $this->GetPathType($sPath) == $sType;
+ }
+
+ /**
+ * Возвращает тип из пути
+ *
+ * @param string $sPath Исходный путь с префиксом, например, [relative]/image.jpg
+ *
+ * @return mixed
+ */
+ public function GetPathType($sPath)
+ {
+ list($sType,) = $this->GetParsedPath($sPath);
+ return $sType;
+ }
+
+ /**
+ * Парсит путь и возвращет его составляющие - массив вида array(0=>'relative', 1=>'/image.jpg')
+ *
+ * @param string $sPath Исходный путь с префиксом, например, [relative]/image.jpg
+ *
+ * @return array
+ */
+ public function GetParsedPath($sPath)
+ {
+ if (preg_match("#^\[([a-z_0-9]*)\](.*)$#i", $sPath, $aMatch)) {
+ return array($aMatch[1] ? $aMatch[1] : self::PATH_TYPE_SERVER, $aMatch[2]);
+ }
+ return array(self::PATH_TYPE_SERVER, $sPath);
+ }
+
+ /**
+ * Сохраняет(копирует) файл на локальном(текущем) сервере
+ *
+ * @param string $sFileSource Полный путь до исходного файла
+ * @param string $sFileDest Полный путь до файла для сохранения
+ * @param int|null $iMode Права chmod для файла, например, 0777
+ * @param bool $bRemoveSource Удалять исходный файл или нет
+ * @return bool
+ */
+ public function SaveFileLocal($sFileSource, $sFileDest, $iMode = null, $bRemoveSource = false)
+ {
+ $bResult = copy($sFileSource, $sFileDest);
+ if ($bResult and !is_null($iMode)) {
+ chmod($sFileDest, $iMode);
+ }
+ if ($bRemoveSource) {
+ unlink($sFileSource);
+ }
+ return $bResult;
+ }
+
+ /**
+ * Сохраняет(копирует) файл на локальном(текущем) сервере
+ * Основное отличие от SaveLocal() в том, что здесь для указания целевого файла используется относительный каталог (если он не существует, то создается автоматически)
+ * И в случае успешного копирования метод возвращает путь до целевого файла
+ *
+ * @param string $sFileSource Полный путь до исходного файла
+ * @param string $sDirDest Каталог для сохранения файла относительно корня сайта
+ * @param string $sFileDest Имя файла для сохранения
+ * @param int|null $iMode Права chmod для файла, например, 0777
+ * @param bool $bRemoveSource Удалять исходный файл или нет
+ * @return bool | string При успешном сохранении возвращает полный серверный путь до файла
+ */
+ public function SaveFileLocalSmart($sFileSource, $sDirDest, $sFileDest, $iMode = null, $bRemoveSource = false)
+ {
+ $sFileDestFullPath = rtrim(Config::Get('path.root.server'), "/") . '/' . trim($sDirDest,
+ "/") . '/' . $sFileDest;
+ $this->CreateDirectoryLocalSmart($sDirDest);
+
+ if ($this->SaveFileLocal($sFileSource, $sFileDestFullPath, $iMode, $bRemoveSource)) {
+ return $sFileDestFullPath;
+ }
+ return false;
+ }
+
+ /**
+ * Создает каталог на локальном(текущем) сервере
+ *
+ * @param string $sDirDest Полный путь до каталога
+ */
+ public function CreateDirectoryLocal($sDirDest)
+ {
+ if (!is_dir($sDirDest)) {
+ @mkdir($sDirDest, 0755, true);
+ }
+ }
+
+ /**
+ * Создает каталог на локальном(текущем) сервере
+ * Отличие от CreateDirectoryLocal() в том, что здесь используется каталог относительно корня сайта
+ *
+ * @param string $sDirDest Каталог относительно корня сайта
+ */
+ public function CreateDirectoryLocalSmart($sDirDest)
+ {
+ $this->CreateDirectoryLocal(rtrim(Config::Get('path.root.server'), '/') . '/' . ltrim($sDirDest, '/'));
+ }
+
+ /**
+ * Удаляет локальный файл
+ *
+ * @param string $sFile
+ *
+ * @return bool
+ */
+ public function RemoveFileLocal($sFile)
+ {
+ if (file_exists($sFile)) {
+ return @unlink($sFile);
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает размер файла с учетом типа
+ *
+ * @param $sFile
+ * @return int|null
+ */
+ public function GetFileSize($sFile)
+ {
+ $sType = $this->GetPathType($sFile);
+ if (in_array($sType, array(self::PATH_TYPE_SERVER, self::PATH_TYPE_RELATIVE, self::PATH_TYPE_WEB))) {
+ if (file_exists($sFileServer = $this->GetPathServer($sFile))) {
+ return filesize($sFileServer);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Проверяет на существование локальный файл
+ *
+ * @param string $sFile
+ *
+ * @return bool
+ */
+ public function IsExistsFileLocal($sFile)
+ {
+ return file_exists($sFile);
+ }
+
+ /**
+ * Проверяет наличие блокировки
+ *
+ * @param resource $hDescriptor Дексриптор открытого файла для блокировки
+ *
+ * @return bool
+ */
+ public function IsLock($hDescriptor)
+ {
+ if (!$hDescriptor) {
+ return false;
+ }
+ return !$this->CreateLock($hDescriptor);
+ }
+
+ /**
+ * Создает блокировку
+ *
+ * @param resource $hDescriptor Дексриптор открытого файла для блокировки
+ *
+ * @return bool
+ */
+ public function CreateLock($hDescriptor)
+ {
+ return flock($hDescriptor, LOCK_EX | LOCK_NB);
+ }
+
+ /**
+ * Удаляет блокировку
+ *
+ * @param resource $hDescriptor Дексриптор открытого файла для блокировки
+ *
+ * @return bool
+ */
+ public function RemoveLock($hDescriptor)
+ {
+ return ($hDescriptor && @flock($hDescriptor, LOCK_UN));
+ }
+
+ /**
+ * Проверяет наличие блокировки с использованием каталога
+ *
+ * @param $sDir
+ * @param int $iLifeTime
+ * @return bool
+ */
+ public function IsLockDir($sDir, $iLifeTime = 60)
+ {
+ clearstatcache();
+ if ($iLifeCreate = @filectime($sDir) and $iLifeTime < (time() - $iLifeCreate)) {
+ $this->RemoveLockDir($sDir);
+ }
+ return !$this->CreateLockDir($sDir);
+ }
+
+ /**
+ * Создает блокировку с использованием каталога
+ *
+ * @param $sDir
+ * @return bool
+ */
+ public function CreateLockDir($sDir)
+ {
+ if (file_exists($sDir)) {
+ return false;
+ }
+ if (@mkdir($sDir)) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Удаляет блокировку с использованием каталога
+ *
+ * @param $sDir
+ * @return bool
+ */
+ public function RemoveLockDir($sDir)
+ {
+ return @rmdir($sDir);
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/hook/Hook.class.php b/framework/classes/modules/hook/Hook.class.php
new file mode 100644
index 0000000..d5b9650
--- /dev/null
+++ b/framework/classes/modules/hook/Hook.class.php
@@ -0,0 +1,445 @@
+
+ *
+ */
+
+/**
+ * Модуль обработки хуков(hooks)
+ * В различных местах кода могут быть определеные вызовы хуков, например:
+ *
+ * $this->Hook_Run('topic_edit_before', array('oTopic'=>$oTopic,'oBlog'=>$oBlog));
+ *
+ * Данный вызов "вешает" хук "topic_edit_before"
+ * Чтобы повесить обработчик на этот хук, его нужно объявить, например, через файл в /classes/hooks/HookTest.class.php
+ *
+ * class HookTest extends Hook {
+ * // Регистрируем хуки (вешаем обработчики)
+ * public function RegisterHook() {
+ * $this->AddHook('topic_edit_before','TopicEdit');
+ * }
+ * // обработчик хука
+ * public function TopicEdit($aParams) {
+ * $oTopic=$aParams['oTopic'];
+ * $oTopic->setTitle('My title!');
+ * }
+ * }
+ *
+ * В данном примере после редактирования топика заголовок у него поменяется на "My title!"
+ *
+ * Если хук объявлен в шаблоне, например,
+ *
+ * {hook run='html_head_end'}
+ *
+ * То к имени хука автоматически добаляется префикс "template_" и обработчик на него вешать нужно так:
+ *
+ * $this->AddHook('template_html_head_end','InjectHead');
+ *
+ *
+ * Так же существуют блочные хуки, который объявляются в шаблонах так:
+ *
+ * {hookb run="registration_captcha"}
+ * ... html ...
+ * {/hookb}
+ *
+ * Они позволяют заменить содержимое между {hookb ..} {/hookb} или добавить к нему произвольный контент. К имени такого хука добавляется префикс "template_block_"
+ *
+ * class HookTest extends Hook {
+ * // Регистрируем хуки (вешаем обработчики)
+ * public function RegisterHook() {
+ * $this->AddHook('template_block_registration_captcha','MyCaptcha');
+ * }
+ * // обработчик хука
+ * public function MyCaptcha($aParams) {
+ * $sContent=$aParams['content'];
+ * return $sContent.'My captcha!';
+ * }
+ * }
+ *
+ * В данном примере в конце вывода каптчи будет добавлено "My captcha!"
+ * Обратите внимание, что в обработчик в параметре "content" передается исходное содержание блока.
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModuleHook extends Module
+{
+ /**
+ * Содержит список хуков
+ *
+ * @var array( 'name' => array(
+ * array(
+ * 'type' => 'module' | 'hook' | 'function',
+ * 'callback' => 'callback_name',
+ * 'priority' => 1,
+ * 'params' => array()
+ * ),
+ * ),
+ * )
+ */
+ protected $aHooks = array();
+ /**
+ * Список хуков по регулярному выражению
+ *
+ * @var array
+ */
+ protected $aHooksPreg = array();
+ /**
+ * Список объектов обработки хуков, для их кеширования
+ *
+ * @var array
+ */
+ protected $aHooksObject = array();
+ /**
+ * Список хуков для поведений - в них есть привязка к конкретному объекту
+ *
+ * @var array
+ */
+ protected $aHooksBehavior = array();
+
+ /**
+ * Инициализация модуля
+ *
+ */
+ public function Init()
+ {
+
+ }
+
+ /**
+ * Добавление хука для поведения
+ *
+ * @param string $sName Имя хука
+ * @param LsObject $oObject Объект которому принадлежит хук
+ * @param array $aCallback Коллбек
+ * @param int $iPriority Приоритет
+ */
+ public function AddHookBehavior($sName, $oObject, $aCallback, $iPriority = 1)
+ {
+ $sName = strtolower($sName);
+ $sObjectHash = spl_object_hash($oObject);
+ $this->aHooksBehavior[$sName][$sObjectHash][] = array('callback' => $aCallback, 'priority' => (int)$iPriority);
+ }
+
+ /**
+ * Удаляет хук поведения
+ *
+ * @param string $sName Имя хука
+ * @param LsObject $oObject Объект которому принадлежит хук
+ * @param array|null $aCallback Коллбек, если не задан, то будут удалены все коллбеки
+ *
+ * @return bool
+ */
+ public function RemoveHookBehavior($sName, $oObject, $aCallback = null)
+ {
+ $sName = strtolower($sName);
+ $sObjectHash = spl_object_hash($oObject);
+ if (!isset($this->aHooksBehavior[$sName][$sObjectHash])) {
+ return false;
+ }
+ if (is_null($aCallback)) {
+ unset($this->aHooksBehavior[$sName][$sObjectHash]);
+ return true;
+ }
+ $bRemoved = false;
+ foreach ($this->aHooksBehavior[$sName][$sObjectHash] as $i => $aHook) {
+ if ($aHook['callback'] === $aCallback) {
+ unset($this->aHooksBehavior[$sName][$sObjectHash][$i]);
+ $bRemoved = true;
+ }
+ }
+ if ($bRemoved) {
+ $this->aHooksBehavior[$sName][$sObjectHash] = array_values($this->aHooksBehavior[$sName][$sObjectHash]);
+ }
+ return $bRemoved;
+ }
+
+ /**
+ * @param string $sName Имя хука
+ * @param LsObject $oObject Объект которому принадлежит хук
+ * @param array $aVars Параметры хука. Конкретные параметры можно передавать по ссылке, например, array('bResult'=>&$bResult)
+ * @param bool $bWithGlobal Запускать дополнительно одноименный глобальный (стандартный) хук
+ *
+ * @return array
+ */
+ public function RunHookBehavior($sName, $oObject, $aVars = array(), $bWithGlobal = false)
+ {
+ $result = array();
+ $sName = strtolower($sName);
+ $sObjectHash = spl_object_hash($oObject);
+ if (isset($this->aHooksBehavior[$sName][$sObjectHash])) {
+ $aHooks = array();
+ for ($i = 0; $i < count($this->aHooksBehavior[$sName][$sObjectHash]); $i++) {
+ $aHooks[$i] = $this->aHooksBehavior[$sName][$sObjectHash][$i]['priority'];
+ }
+ arsort($aHooks, SORT_NUMERIC);
+ /**
+ * Сначала запускаем на выполнение
+ */
+
+ foreach ($aHooks as $iKey => $iPr) {
+ $aHook = $this->aHooksBehavior[$sName][$sObjectHash][$iKey];
+ $aHook['type'] = 'callback';
+ $this->RunType($aHook, $aVars, $sName);
+ }
+ }
+ if ($bWithGlobal) {
+ $this->Run($sName, $aVars);
+ }
+ return $result;
+ }
+
+ /**
+ * Добавление обработчика на хук
+ *
+ * @param string $sName Имя хука
+ * @param string $sType Тип хука, возможны: module, function, hook
+ * @param string $sCallBack Функция/метод обработки хука
+ * @param int $iPriority Приоритер обработки, чем выше, тем раньше сработает хук относительно других
+ * @param array $aParams Список дополнительных параметров, анпример, имя класса хука
+ * @return bool
+ */
+ public function Add($sName, $sType, $sCallBack, $iPriority = 1, $aParams = array(), $bPreg = false)
+ {
+ $sName = strtolower($sName);
+ $sType = strtolower($sType);
+ if (!in_array($sType, array('module', 'hook', 'function'))) {
+ return false;
+ }
+ $aHook = array(
+ 'type' => $sType,
+ 'callback' => $sCallBack,
+ 'params' => $aParams,
+ 'priority' => (int)$iPriority
+ );
+ if (!$bPreg) {
+ $this->aHooks[$sName][] = $aHook;
+ } else {
+ $this->aHooksPreg[$sName][] = $aHook;
+ }
+ }
+
+ /**
+ * Добавляет обработчик хука с типом "module"
+ * Позволяет в качестве обработчика использовать метод модуля
+ * @see Add
+ *
+ * @param string $sName Имя хука
+ * @param string $sCallBack Полное имя метода обработки хука, например, "Mymodule_CallBack"
+ * @param int $iPriority Приоритер обработки, чем выше, тем раньше сработает хук относительно других
+ * @return bool
+ */
+ public function AddExecModule($sName, $sCallBack, $iPriority = 1, $bPreg = false)
+ {
+ return $this->Add($sName, 'module', $sCallBack, $iPriority, array(), $bPreg);
+ }
+
+ /**
+ * Добавляет обработчик хука с типом "function"
+ * Позволяет в качестве обработчика использовать функцию
+ * @see Add
+ *
+ * @param string $sName Имя хука
+ * @param string $sCallBack Функция обработки хука, например, "var_dump"
+ * @param int $iPriority Приоритер обработки, чем выше, тем раньше сработает хук относительно других
+ * @return bool
+ */
+ public function AddExecFunction($sName, $sCallBack, $iPriority = 1, $bPreg = false)
+ {
+ return $this->Add($sName, 'function', $sCallBack, $iPriority, array(), $bPreg);
+ }
+
+ /**
+ * Добавляет обработчик хука с типом "hook"
+ * Позволяет в качестве обработчика использовать метод хука(класса хука из каталога /classes/hooks/)
+ * @see Add
+ * @see Hook::AddHook
+ *
+ * @param string $sName Имя хука
+ * @param string $sCallBack Метод хука, например, "InitAction"
+ * @param int $iPriority Приоритер обработки, чем выше, тем раньше сработает хук относительно других
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function AddExecHook($sName, $sCallBack, $iPriority = 1, $aParams = array(), $bPreg = false)
+ {
+ return $this->Add($sName, 'hook', $sCallBack, $iPriority, $aParams, $bPreg);
+ }
+
+ /**
+ * Добавляет делегирующий обработчик хука с типом "module"
+ * Делегирующий хук применяется для перекрытия метода модуля, результат хука возвращает вместо результата метода модуля
+ * Позволяет в качестве обработчика использовать метод модуля
+ * @see Add
+ * @see Engine::_CallModule
+ *
+ * @param string $sName Имя хука
+ * @param string $sCallBack Полное имя метода обработки хука, например, "Mymodule_CallBack"
+ * @param int $iPriority Приоритер обработки, чем выше, тем раньше сработает хук относительно других
+ * @return bool
+ */
+ public function AddDelegateModule($sName, $sCallBack, $iPriority = 1, $bPreg = false)
+ {
+ return $this->Add($sName, 'module', $sCallBack, $iPriority, array('delegate' => true), $bPreg);
+ }
+
+ /**
+ * Добавляет делегирующий обработчик хука с типом "function"
+ * Делегирующий хук применяется для перекрытия метода модуля, результат хука возвращает вместо результата метода модуля
+ * Позволяет в качестве обработчика использовать функцию
+ * @see Add
+ *
+ * @param string $sName Имя хука
+ * @param string $sCallBack Функция обработки хука, например, "var_dump"
+ * @param int $iPriority Приоритер обработки, чем выше, тем раньше сработает хук относительно других
+ * @return bool
+ */
+ public function AddDelegateFunction($sName, $sCallBack, $iPriority = 1, $bPreg = false)
+ {
+ return $this->Add($sName, 'function', $sCallBack, $iPriority, array('delegate' => true), $bPreg);
+ }
+
+ /**
+ * Добавляет делегирующий обработчик хука с типом "hook"
+ * Делегирующий хук применяется для перекрытия метода модуля, результат хука возвращает вместо результата метода модуля
+ * Позволяет в качестве обработчика использовать метод хука(класса хука из каталога /classes/hooks/)
+ * @see Add
+ * @see Hook::AddHook
+ *
+ * @param string $sName Имя хука
+ * @param string $sCallBack Метод хука, например, "InitAction"
+ * @param int $iPriority Приоритер обработки, чем выше, тем раньше сработает хук относительно других
+ * @param array $aParams Параметры
+ * @return bool
+ */
+ public function AddDelegateHook($sName, $sCallBack, $iPriority = 1, $aParams = array(), $bPreg = false)
+ {
+ $aParams['delegate'] = true;
+ return $this->Add($sName, 'hook', $sCallBack, $iPriority, $aParams, $bPreg);
+ }
+
+ /**
+ * Запускает обаботку хуков
+ *
+ * @param $sName Имя хука
+ * @param array $aVars Список параметров хука, передаются в обработчик
+ * @return array
+ */
+ public function Run($sName, &$aVars = array())
+ {
+ $result = array();
+ $sName = strtolower($sName);
+ /**
+ * Массив хуков для исполнения по имени
+ */
+ $aRunHooks = isset($this->aHooks[$sName]) ? $this->aHooks[$sName] : array();
+ /**
+ * Добавляем хуки по регулярке
+ */
+ foreach ((array)$this->aHooksPreg as $sHookPreg => $aHooks) {
+ if (preg_match($sHookPreg, $sName, $aMatch)) {
+ $aRunHooks = array_merge($aRunHooks, $aHooks);
+ }
+ }
+ if ($aRunHooks) {
+ $bTemplateHook = strpos($sName, 'template_') === 0 ? true : false;
+ $aHookNum = array();
+ $aHookNumDelegate = array();
+ /**
+ * Все хуки делим на обычные(exec) и делигирующие(delegate)
+ */
+ for ($i = 0; $i < count($aRunHooks); $i++) {
+ if (isset($aRunHooks[$i]['params']['delegate']) and $aRunHooks[$i]['params']['delegate']) {
+ $aHookNumDelegate[$i] = $aRunHooks[$i]['priority'];
+ } else {
+ $aHookNum[$i] = $aRunHooks[$i]['priority'];
+ }
+ }
+ arsort($aHookNum, SORT_NUMERIC);
+ arsort($aHookNumDelegate, SORT_NUMERIC);
+ /**
+ * Сначала запускаем на выполнение простые
+ */
+ foreach ($aHookNum as $iKey => $iPr) {
+ $aHook = $aRunHooks[$iKey];
+ if ($bTemplateHook) {
+ /**
+ * Если это шаблонных хук то сохраняем результат
+ */
+ $result['template_result'][] = $this->RunType($aHook, $aVars, $sName);
+ } else {
+ $this->RunType($aHook, $aVars, $sName);
+ }
+ }
+ /**
+ * Теперь запускаем делигирующие
+ * Делегирующий хук должен вернуть результат в формате:
+ *
+ */
+ foreach ($aHookNumDelegate as $iKey => $iPr) {
+ $aHook = $aRunHooks[$iKey];
+ $result = array(
+ 'delegate_result' => $this->RunType($aHook, $aVars, $sName)
+ );
+ /**
+ * На данный момент только один хук может быть делегирующим
+ */
+ break;
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * Запускает обработчик хука в зависимости от типа обработчика
+ *
+ * @param array $aHook Данные хука
+ * @param array $aVars Параметры переданные в хук
+ * @return mixed|null
+ */
+ protected function RunType($aHook, &$aVars, $sName)
+ {
+ $result = null;
+ switch ($aHook['type']) {
+ case 'callback':
+ $result = call_user_func_array($aHook['callback'], array(&$aVars, $sName));
+ break;
+ case 'module':
+ $result = call_user_func_array(array($this, $aHook['callback']), array(&$aVars, $sName));
+ break;
+ case 'function':
+ $result = call_user_func_array($aHook['callback'], array(&$aVars, $sName));
+ break;
+ case 'hook':
+ $sHookClass = isset($aHook['params']['sClassName']) ? $aHook['params']['sClassName'] : null;
+ if ($sHookClass and class_exists($sHookClass)) {
+ if (isset($this->aHooksObject[$sHookClass])) {
+ $oHook = $this->aHooksObject[$sHookClass];
+ } else {
+ $oHook = new $sHookClass;
+ $this->aHooksObject[$sHookClass] = $oHook;
+ }
+ $result = call_user_func_array(array($oHook, $aHook['callback']), array(&$aVars, $sName));
+ }
+ break;
+ default:
+ break;
+ }
+ return $result;
+ }
+}
diff --git a/framework/classes/modules/image/Image.class.php b/framework/classes/modules/image/Image.class.php
new file mode 100644
index 0000000..c16b219
--- /dev/null
+++ b/framework/classes/modules/image/Image.class.php
@@ -0,0 +1,390 @@
+
+ *
+ */
+
+/**
+ * Модуль обработки изображений
+ * Используется библиотека Imagine
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleImage extends Module
+{
+
+ const INTERLACE_NONE = 'none';
+ const INTERLACE_LINE = 'line';
+ const INTERLACE_PLANE = 'plane';
+ const INTERLACE_PARTITION = 'partition';
+
+ const ERROR_CODE_UNDEFINED = 0;
+ const ERROR_CODE_WRONG_FORMAT = 1;
+ const ERROR_CODE_WRONG_MAX_SIZE = 2;
+
+ /**
+ * Дефолтные параметры
+ * Основная задача в определении списка доступных ключей массива с параметрами
+ *
+ * @var array
+ */
+ protected $aParamsDefault = array(
+ 'size_max_width' => 7000,
+ 'size_max_height' => 7000,
+ 'format' => 'jpg',
+ 'format_auto' => true,
+ 'quality' => 95,
+ 'interlace' => self::INTERLACE_PLANE,
+ 'watermark_use' => false,
+ 'watermark_type' => 'image',
+ 'watermark_image' => null,
+ 'watermark_position' => 'bottom-right',
+ 'watermark_min_width' => 100,
+ 'watermark_min_height' => 100,
+ );
+ /**
+ * Тескт последней ошибки
+ *
+ * @var string
+ */
+ protected $sLastErrorText = null;
+ /**
+ * Код последней ошибки
+ *
+ * @var int
+ */
+ protected $iLastErrorCode = null;
+ /**
+ * Список поддерживаемых драйверов обработки изображений
+ *
+ * @var array
+ */
+ protected $aDriversSupport = array(
+ 'gd',
+ 'imagick',
+ 'gmagick'
+ );
+
+ /**
+ * Текущий драйвер обработки изображений
+ *
+ * @var string
+ */
+ protected $sDriverCurrent = 'gd';
+
+ public function Init()
+ {
+ $this->SetDriverCurrent(Config::Get('module.image.driver'));
+ }
+
+ /**
+ * Устанавливает текущий драйвер обработки изображений
+ *
+ * @param $sDriver
+ *
+ * @return bool
+ */
+ public function SetDriverCurrent($sDriver)
+ {
+ $sDriver = strtolower($sDriver);
+ if (in_array($sDriver, $this->aDriversSupport)) {
+ $this->sDriverCurrent = $sDriver;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает текущий драйвер
+ *
+ * @return string
+ */
+ public function GetDriverCurrent()
+ {
+ return $this->sDriverCurrent;
+ }
+
+ /**
+ * Получает текст последней ошибки
+ *
+ * @return string
+ */
+ public function GetLastError()
+ {
+ return $this->sLastErrorText;
+ }
+
+ /**
+ * Получает код последней ошибки
+ *
+ * @return int
+ */
+ public function GetLastErrorCode()
+ {
+ return $this->iLastErrorCode;
+ }
+
+ /**
+ * Устанавливает текст последней ошибки
+ *
+ * @param string $sText Текст ошибки
+ * @param int|null $iCode Код ошибки
+ */
+ public function SetLastError($sText, $iCode = null)
+ {
+ $this->sLastErrorText = $sText;
+ $this->SetLastErrorCode($iCode);
+ }
+
+ /**
+ * Устанавливает код последней ошибки
+ *
+ * @param int|null $iCode
+ */
+ public function SetLastErrorCode($iCode)
+ {
+ $this->iLastErrorCode = $iCode;
+ }
+
+ /**
+ * Очищает текст последней ошибки
+ *
+ */
+ public function ClearLastError()
+ {
+ $this->SetLastError(null);
+ }
+
+ /**
+ * Возвращает класс текущего драйвера
+ *
+ * @return string
+ */
+ protected function GetClassDriverCurrent()
+ {
+ $sDrive = ucfirst($this->sDriverCurrent);
+ return "Imagine\\{$sDrive}\\Imagine";
+ }
+
+ /**
+ * Открывает файл изображения и возвращает объект
+ *
+ * @param $sFile Локальный путь до изображения
+ * @param $aParams Параметры
+ * @return ModuleImage_EntityImage|bool
+ */
+ public function Open($sFile, $aParams = null)
+ {
+ if (!is_array($aParams)) {
+ $aParams = $this->BuildParams();
+ } else {
+ $aParams = func_array_merge_assoc($this->aParamsDefault, $aParams);
+ }
+
+ $sClassDriver = $this->GetClassDriverCurrent();
+
+ try {
+ /**
+ * Создаем объект изображения библиотеки Imagine
+ */
+ $oImagine = new $sClassDriver();
+ $oImageObject = $oImagine->open($sFile);
+
+ if (!$aSize = getimagesize($sFile, $aImageInfo)) {
+ $this->SetLastError('The file is not an image', self::ERROR_CODE_WRONG_FORMAT);
+ return false;
+ }
+ /**
+ * Проверяем на максимальный размер
+ */
+ $oBox = $oImageObject->getSize();
+ if ($oBox->getWidth() > $aParams['size_max_width'] or $oBox->getHeight() > $aParams['size_max_height']) {
+ $this->SetLastError('Maximum size image ' . $aParams['size_max_width'] . 'x' . $aParams['size_max_height'], self::ERROR_CODE_WRONG_MAX_SIZE);
+ return false;
+ }
+ /**
+ * Создаем объект для работы с изображением
+ */
+ $oImage = Engine::GetEntity('ModuleImage_EntityImage');
+ $oImage->setImage($oImageObject);
+ $oImage->setParams($aParams);
+ $oImage->setInfoSize($aSize);
+ $oImage->setFileOriginalPath($sFile);
+ $oImage->setInfoAdditional($aImageInfo);
+ return $oImage;
+ } catch (Imagine\Exception\Exception $e) {
+ $this->SetLastError($this->Lang_Get('image.error.not_open'), self::ERROR_CODE_UNDEFINED);
+ // write to log
+ $this->Logger_Warning('Image error: ' . $e->getMessage(), array('exception' => $e));
+ return false;
+ }
+ }
+
+ /**
+ * Создает пустой объект изображения
+ *
+ * @param int $iWidth
+ * @param int $iHeight
+ * @param array|null $aParams
+ *
+ * @return ModuleImage_EntityImage|bool
+ */
+ public function Create($iWidth, $iHeight, $aParams = null)
+ {
+ if (!is_array($aParams)) {
+ $aParams = $this->BuildParams();
+ } else {
+ $aParams = func_array_merge_assoc($this->aParamsDefault, $aParams);
+ }
+
+ $sClassDriver = $this->GetClassDriverCurrent();
+
+ try {
+ /**
+ * Создаем объект изображения библиотеки Imagine
+ */
+ $oImagine = new $sClassDriver();
+ $oImageObject = $oImagine->create(new Imagine\Image\Box($iWidth, $iHeight));
+ /**
+ * Создаем объект для работы с изображением
+ */
+ $oImage = Engine::GetEntity('ModuleImage_EntityImage');
+ $oImage->setImage($oImageObject);
+ $oImage->setParams($aParams);
+ return $oImage;
+ } catch (Imagine\Exception\Exception $e) {
+ $this->SetLastError($e->getMessage());
+ // write to log
+ $this->Logger_Warning('Image error: ' . $e->getMessage(), array('exception' => $e));
+ return false;
+ }
+ }
+
+ /**
+ * Возврашает параметры для группы, если каких-то параметров в группе нет, то используются дефолтные
+ *
+ * @param string $sName Имя группы
+ * @return array
+ */
+ public function BuildParams($sName = null)
+ {
+ $aDefault = func_array_merge_assoc($this->aParamsDefault, (array)Config::Get('module.image.params.default'));
+ if (is_null($sName)) {
+ return $aDefault;
+ }
+ $aNamed = (array)Config::Get('module.image.params.' . strtolower($sName));
+ return func_array_merge_assoc($aDefault, $aNamed);
+ }
+
+
+ /**
+ * Сохраняет(копирует) файл изображения на сервер
+ * Если переопределить данный метод, то можно сохранять изображения, например, на Amazon S3
+ *
+ * @param string $sFileSource Полный путь до исходного файла
+ * @param string $sDirDest Каталог для сохранения файла относительно корня сайта
+ * @param string $sFileDest Имя файла для сохранения
+ * @param int|null $iMode Права chmod для файла, например, 0777
+ * @param bool $bRemoveSource Удалять исходный файл или нет
+ * @return bool | string При успешном сохранении возвращает относительный путь до файла с типом, например, [relative]/image.jpg
+ */
+ public function SaveFileSmart($sFileSource, $sDirDest, $sFileDest, $iMode = null, $bRemoveSource = false)
+ {
+ if ($sPathFile = $this->Fs_SaveFileLocalSmart($sFileSource, $sDirDest, $sFileDest, $iMode, $bRemoveSource)) {
+ return $this->Fs_MakePath($this->Fs_GetPathRelativeFromServer($sPathFile), ModuleFs::PATH_TYPE_RELATIVE);
+ }
+ return false;
+ }
+
+ /**
+ * Сохраняет(копирует) файл изображения на сервер
+ * Если переопределить данный метод, то можно сохранять изображения, например, на Amazon S3
+ *
+ * @param string $sFileSource Полный путь до исходного файла
+ * @param string $sFileDest Полный путь до файла для сохранения с типом, например, [server]/home/var/site.ru/image.jpg
+ * @param int|null $iMode Права chmod для файла, например, 0777
+ * @param bool $bRemoveSource Удалять исходный файл или нет
+ * @return bool | string При успешном сохранении возвращает относительный путь до файла с типом, например, [relative]/image.jpg
+ */
+ public function SaveFile($sFileSource, $sFileDest, $iMode = null, $bRemoveSource = false)
+ {
+ if ($this->Fs_SaveFileLocal($sFileSource, $this->Fs_GetPathServer($sFileDest), $iMode, $bRemoveSource)) {
+ return $this->Fs_MakePath($this->Fs_GetPathRelativeFromServer($sFileDest), ModuleFs::PATH_TYPE_RELATIVE);
+ }
+ return false;
+ }
+
+ /**
+ * Удаляет файл изображения
+ * Если переопределить данный метод, то можно удалять изображения, например, с Amazon S3
+ *
+ * @param string $sPathFile Полный путь до файла с типом, например, [relative]/image.jpg
+ *
+ * @return mixed
+ */
+ public function RemoveFile($sPathFile)
+ {
+ $sPathFile = $this->Fs_GetPathServer($sPathFile);
+ return $this->Fs_RemoveFileLocal($sPathFile);
+ }
+
+ /**
+ * Проверяет изображение на существование
+ * Если переопределить данный метод, то можно проверить существование изображения, например, на Amazon S3
+ *
+ * @param string $sPathFile Полный путь до файла с типом, например, [relative]/image.jpg
+ *
+ * @return mixed
+ */
+ public function IsExistsFile($sPathFile)
+ {
+ $sPathFile = $this->Fs_GetPathServer($sPathFile);
+ return $this->Fs_IsExistsFileLocal($sPathFile);
+ }
+
+ /**
+ * Открывает файл изображения, в качестве источника изображения может использоваться полный путь до файла с типом, например, [relative]/image.jpg
+ * Если переопределить данный метод, то можно открывать изображения, например, с Amazon S3
+ *
+ * @param string $sFile Полный путь до файла с типом, например, [relative]/image.jpg
+ * @param null $aParams
+ *
+ * @return bool|ModuleImage_EntityImage
+ */
+ public function OpenFrom($sFile, $aParams = null)
+ {
+ $sFile = $this->Fs_GetPathServer($sFile);
+ return $this->Open($sFile, $aParams);
+ }
+
+ /**
+ * Получает директорию для загрузки изображений
+ * Используется фомат хранения данных (/images/subdir/obj/ect/id/yyyy/mm/dd/file.jpg)
+ * Например, для хранения изображений пользователя (аватары и т.п.) c ID=1 можно так: /images/users/000/000/001/2014/02/15/avatar.jpg
+ *
+ * @param int $sId Целое число, обычно это ID объекта
+ * @param string $sSubDir Подкаталог
+ * @return string
+ */
+ public function GetIdDir($sId, $sSubDir = null)
+ {
+ return Config::Get('path.uploads.images') . '/' . ($sSubDir ? $sSubDir . '/' : '') . preg_replace('~(.{3})~U',
+ "\\1/", str_pad($sId, 9, "0", STR_PAD_LEFT)) . date('Y/m/d');
+ }
+}
diff --git a/framework/classes/modules/image/entity/Image.entity.class.php b/framework/classes/modules/image/entity/Image.entity.class.php
new file mode 100644
index 0000000..7474510
--- /dev/null
+++ b/framework/classes/modules/image/entity/Image.entity.class.php
@@ -0,0 +1,534 @@
+
+ *
+ */
+
+/**
+ * Сущность для работы с изображением
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleImage_EntityImage extends Entity
+{
+ /**
+ * Возвращает конкретный параметр
+ *
+ * @param $sName
+ *
+ * @return null
+ */
+ public function getParam($sName)
+ {
+ $aParams = $this->getParams();
+ return isset($aParams[$sName]) ? $aParams[$sName] : null;
+ }
+
+ /**
+ * Возвращает ширину изображения
+ *
+ * @return int|null
+ */
+ public function getWidth()
+ {
+ if ($oImage = $this->getImage()) {
+ $oBox = $oImage->getSize();
+ return $oBox->getWidth();
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает высоту изображения
+ *
+ * @return int|null
+ */
+ public function getHeight()
+ {
+ if ($oImage = $this->getImage()) {
+ $oBox = $oImage->getSize();
+ return $oBox->getHeight();
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает формат изображения (его расширение)
+ *
+ * @return null|string
+ */
+ public function getFormat()
+ {
+ $aSize = $this->getInfoSize();
+ if (isset($aSize['mime'])) {
+ switch ($aSize['mime']) {
+ case 'image/png':
+ case "image/x-png":
+ return 'png';
+ break;
+ case 'image/gif':
+ return 'gif';
+ break;
+ case "image/pjpeg":
+ case "image/jpeg":
+ case "image/jpg":
+ return 'jpg';
+ break;
+ default:
+ return 'jpg';
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Изменяет размеры изображения
+ *
+ * @param int|null $iWidthDest Ширина необходимого изображения на выходе
+ * @param int|null $iHeightDest Высота необходимого изображения на выходе
+ * @param bool $bForcedMinSize Растягивать изображение по ширине или нет, если исходное меньше. При false - изображение будет растянуто
+ *
+ * @return ModuleImage_EntityImage
+ */
+ public function resize($iWidthDest, $iHeightDest = null, $bForcedMinSize = true)
+ {
+ if ($oImage = $this->getImage()) {
+ try {
+ $oBox = $oImage->getSize();
+
+ if ($bForcedMinSize) {
+ if ($iWidthDest and $iWidthDest > $oBox->getWidth()) {
+ $iWidthDest = $oBox->getWidth();
+ }
+ if ($iHeightDest and $iHeightDest > $oBox->getHeight()) {
+ $iHeightDest = $oBox->getHeight();
+ }
+ }
+ if (!$iHeightDest) {
+ /**
+ * Производим пропорциональное уменьшение по ширине
+ */
+ $oBoxResize = $oBox->widen($iWidthDest);
+ } elseif (!$iWidthDest) {
+ /**
+ * Производим пропорциональное уменьшение по высоте
+ */
+ $oBoxResize = $oBox->heighten($iHeightDest);
+ } else {
+ $oBoxResize = new Imagine\Image\Box($iWidthDest, $iHeightDest);
+ }
+
+ $oImage->resize($oBoxResize);
+ return $this;
+ } catch (Imagine\Exception\Exception $e) {
+ $this->setLastError($e->getMessage());
+ // write to log
+ $this->Logger_Warning('Image error: ' . $e->getMessage(), array('exception' => $e));
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Вырезает максимально возможный прямоугольный в нужной пропорции
+ *
+ * @param float $fProp Пропорция в которой вырезать кроп, расчитывается как Width/Height
+ * @param string $sPosition Вырезать из центра
+ * @return ModuleImage_EntityImage
+ */
+ public function cropProportion($fProp, $sPosition = 'center')
+ {
+ if ($oImage = $this->getImage()) {
+ try {
+ $oBox = $oImage->getSize();
+ $iWidth = $oBox->getWidth();
+ $iHeight = $oBox->getHeight();
+ /**
+ * Если высота и ширина уже в нужных пропорциях, то возвращаем изначальный вариант
+ */
+ $iProp = round($fProp, 2);
+ if (round($iWidth / $iHeight, 2) == $iProp) {
+ return $this;
+ }
+ /**
+ * Вырезаем прямоугольник из центра
+ */
+ if (round($iWidth / $iHeight, 2) <= $iProp) {
+ $iNewWidth = $iWidth;
+ $iNewHeight = round($iNewWidth / $iProp);
+ } else {
+ $iNewHeight = $iHeight;
+ $iNewWidth = $iNewHeight * $iProp;
+ }
+
+ $oBoxCrop = new Imagine\Image\Box($iNewWidth, $iNewHeight);
+ if ($sPosition == 'center') {
+ $oPointStart = new Imagine\Image\Point(($iWidth - $iNewWidth) / 2, ($iHeight - $iNewHeight) / 2);
+ } else {
+ $oPointStart = new Imagine\Image\Point(0, 0);
+ }
+ $oImage->crop($oPointStart, $oBoxCrop);
+ return $this;
+ } catch (Imagine\Exception\Exception $e) {
+ $this->setLastError($e->getMessage());
+ // write to log
+ $this->Logger_Warning('Image error: ' . $e->getMessage(), array('exception' => $e));
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Вырезает максимально возможный квадрат
+ *
+ * @param string $sPosition Вырезать из центра
+ * @return ModuleImage_EntityImage
+ */
+ public function cropSquare($sPosition = 'center')
+ {
+ return $this->cropProportion(1, $sPosition);
+ }
+
+ /**
+ * Вырезает область выделенную пользователем с помощью библиотеки jCrop
+ *
+ * @param $aSelectedSize
+ * @param null $iCanvasWidth
+ *
+ * @return ModuleImage_EntityImage
+ */
+ public function cropFromSelected($aSelectedSize, $iCanvasWidth = null)
+ {
+ if ($oImage = $this->getImage()) {
+ $iWSource = $this->getWidth();
+ $iHSource = $this->getHeight();
+ /**
+ * Определяем коэффициент масштабируемости
+ */
+ $fRation = 1;
+ if ($iWSource and $iCanvasWidth) {
+ $fRation = $iWSource / $iCanvasWidth;
+ if ($fRation < 1) {
+ $fRation = 1;
+ }
+ }
+ /**
+ * Проверяем корректность выделенной области
+ */
+ if (isset($aSelectedSize['x']) and is_numeric($aSelectedSize['x'])
+ and isset($aSelectedSize['y']) and is_numeric($aSelectedSize['y'])
+ and isset($aSelectedSize['x2']) and is_numeric($aSelectedSize['x2'])
+ and isset($aSelectedSize['y2']) and is_numeric($aSelectedSize['y2'])
+ ) {
+ $aSelectedSize = array(
+ 'x1' => round($fRation * $aSelectedSize['x']),
+ 'y1' => round($fRation * $aSelectedSize['y']),
+ 'x2' => round($fRation * $aSelectedSize['x2']),
+ 'y2' => round($fRation * $aSelectedSize['y2'])
+ );
+ } else {
+ $this->setLastError('Incorrect image selected size');
+ return $this;
+ }
+ /**
+ * Достаем переменные x1 и т.п. из $aSelectedSize
+ */
+ extract($aSelectedSize, EXTR_PREFIX_SAME, 'ops');
+ if ($x1 > $x2) {
+ // меняем значения переменных
+ $x1 = $x1 + $x2;
+ $x2 = $x1 - $x2;
+ $x1 = $x1 - $x2;
+ }
+ if ($y1 > $y2) {
+ $y1 = $y1 + $y2;
+ $y2 = $y1 - $y2;
+ $y1 = $y1 - $y2;
+ }
+ if ($x1 < 0) {
+ $x1 = 0;
+ }
+ if ($y1 < 0) {
+ $y1 = 0;
+ }
+ if ($x2 > $iWSource) {
+ $x2 = $iWSource;
+ }
+ if ($y2 > $iHSource) {
+ $y2 = $iHSource;
+ }
+
+ $iW = $x2 - $x1;
+ // Допускаем минимальный клип в 32px (исключая маленькие изображения)
+ if ($iW < 32 && $x1 + 32 <= $iWSource) {
+ $iW = 32;
+ }
+ $iH = $y2 - $y1;
+ /**
+ * Вырезаем
+ */
+ try {
+ $oPointStart = new Imagine\Image\Point($x1, $y1);
+ $oBoxCrop = new Imagine\Image\Box($iW, $iH);
+ $oImage->crop($oPointStart, $oBoxCrop);
+ } catch (Imagine\Exception\Exception $e) {
+ $this->setLastError($e->getMessage());
+ // write to log
+ $this->Logger_Warning('Image error: ' . $e->getMessage(), array('exception' => $e));
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Сохраняет изображение в файл
+ *
+ * @param string $sFile Полный путь до файла сохранения
+ * @param array $aParamsSave Дополнительные опции сохранения, например, не делать вотермарк
+ *
+ * @return bool | string При успешном сохранении возвращает полный путь до файла
+ */
+ public function save($sFile, $aParamsSave = array())
+ {
+ $_this = $this;
+ return $this->callExceptionMethod(function ($oImage) use ($_this, $sFile, $aParamsSave) {
+
+ $sFormat = ($_this->getParam('format_auto') && $_this->getFormat()) ? $_this->getFormat() : $_this->getParam('format');
+ $sFileTmp = Config::Get('path.tmp.server') . DIRECTORY_SEPARATOR . func_generator(20);
+ $_this->internalSave($sFileTmp, $sFormat, $aParamsSave);
+
+ return $_this->Image_SaveFile($sFileTmp, $sFile, 0666, true);
+
+ });
+ }
+
+ /**
+ * Сохраняет изображение во временный локальный файл
+ *
+ * @return bool | string При успешном сохранении возвращает полный локальный путь до файла
+ */
+ public function saveTmp()
+ {
+ $_this = $this;
+ return $this->callExceptionMethod(function ($oImage) use ($_this) {
+
+ $sDirTmp = Config::Get('path.tmp.server') . DIRECTORY_SEPARATOR . 'image';
+ if (!is_dir($sDirTmp)) {
+ @mkdir($sDirTmp, 0777, true);
+ }
+ $sFormat = ($_this->getParam('format_auto') && $_this->getFormat()) ? $_this->getFormat() : $_this->getParam('format');
+ $sFileTmp = $sDirTmp . DIRECTORY_SEPARATOR . func_generator(20);
+ $_this->internalSave($sFileTmp, $sFormat, array('skip_watermark' => true));
+ return $sFileTmp;
+
+ });
+ }
+
+ /**
+ * Сохраняет изображение в файл
+ *
+ * @param string $sDir Директория куда нужно сохранить изображение относительно корня сайта (path.root.server)
+ * @param string $sFile Имя файла для сохранения, без расширения (расширение подставляется автоматически в зависимости от типа изображения)
+ * @param array $aParamsSave Дополнительные опции сохранения, например, не делать вотермарк
+ *
+ * @return bool | string При успешном сохранении возвращает полный путь до файла
+ */
+ public function saveSmart($sDir, $sFile, $aParamsSave = array())
+ {
+ $_this = $this;
+ return $this->callExceptionMethod(function ($oImage) use ($_this, $sDir, $sFile, $aParamsSave) {
+
+ $sFormat = ($_this->getParam('format_auto') && $_this->getFormat()) ? $_this->getFormat() : $_this->getParam('format');
+ $sFileTmp = Config::Get('path.tmp.server') . DIRECTORY_SEPARATOR . func_generator(20);
+ $_this->internalSave($sFileTmp, $sFormat, $aParamsSave);
+
+ $sFile .= '.' . $sFormat;
+ return $_this->Image_SaveFileSmart($sFileTmp, $sDir, $sFile, 0666, true);
+
+ });
+ }
+
+ /**
+ * Сохраняет оригинальный файл без изменений
+ *
+ * @param string $sFile Полный путь до файла сохранения
+ * @return bool
+ */
+ public function saveOriginal($sFile)
+ {
+ if ($sFileOriginal = $this->getFileOriginalPath()) {
+ return $this->Image_SaveFile($sFileOriginal, $sFile, 0666);
+ }
+ return false;
+ }
+
+ /**
+ * Сохраняет оригинальный файл без изменений
+ *
+ * @param string $sDir Директория куда нужно сохранить изображение относительно корня сайта (path.root.server)
+ * @param string $sFile Имя файла для сохранения, без расширения (расширение подставляется автоматически в зависимости от типа изображения)
+ * @return bool
+ */
+ public function saveOriginalSmart($sDir, $sFile)
+ {
+ if ($sFileOriginal = $this->getFileOriginalPath()) {
+ $sFormat = ($this->getParam('format_auto') && $this->getFormat()) ? $this->getFormat() : $this->getParam('format');
+ $sFile .= '.' . $sFormat;
+ return $this->Image_SaveFileSmart($sFileOriginal, $sDir, $sFile, 0666);
+ }
+ return false;
+ }
+
+ /**
+ * Обертка для удобного вызова методов Imagine с обработкой исключений
+ *
+ * @param callable $fCallback
+ * @return bool
+ */
+ public function callExceptionMethod(\Closure $fCallback)
+ {
+ if (!$oImage = $this->getImage()) {
+ return false;
+ }
+ try {
+ return $fCallback($oImage);
+ } catch (Exception $e) {
+ $this->setLastError($e->getMessage());
+ // write to log
+ $this->Logger_Warning('Image error: ' . $e->getMessage(), array('exception' => $e));
+ // TODO: fix exception for Gd driver
+ if (strpos($e->getFile(), 'Imagine' . DIRECTORY_SEPARATOR . 'Gd')) {
+ restore_error_handler();
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Устанавливает режим сохранения изображения
+ * ModuleImage::INTERLACE_PLANE - прогрессивный режим
+ *
+ * @param $sScheme ModuleImage::INTERLACE_*
+ * @return $this
+ */
+ public function interlace($sScheme)
+ {
+ $_this = $this;
+ $this->callExceptionMethod(function ($oImage) use ($_this, $sScheme) {
+
+ $aMap = array(
+ ModuleImage::INTERLACE_NONE => \Imagine\Image\ImageInterface::INTERLACE_NONE,
+ ModuleImage::INTERLACE_LINE => \Imagine\Image\ImageInterface::INTERLACE_LINE,
+ ModuleImage::INTERLACE_PLANE => \Imagine\Image\ImageInterface::INTERLACE_PLANE,
+ ModuleImage::INTERLACE_PARTITION => \Imagine\Image\ImageInterface::INTERLACE_PARTITION,
+ );
+ $sScheme = array_key_exists($sScheme,
+ $aMap) ? $aMap[$sScheme] : \Imagine\Image\ImageInterface::INTERLACE_NONE;
+ $oImage->interlace($sScheme);
+
+ });
+ return $this;
+ }
+
+ /**
+ * Накладывает ватермарк-изображение
+ *
+ * @param string $sFile Полный локальный путь до ватермарка
+ * @param string|\Imagine\Image\Point $mPosition Позиция в которую нужно вставить ватермарк. bottom-left, bottom-right, top-left, top-right, center
+ * @return $this
+ */
+ public function watermark($sFile, $mPosition)
+ {
+ $_this = $this;
+ $this->callExceptionMethod(function ($oImage) use ($_this, $sFile, $mPosition) {
+
+ $oWatermark = $_this->Image_Open($sFile);
+ if (!$oWatermark or !($oWatermark = $oWatermark->getImage())) {
+ return false;
+ }
+
+ $oSize = $oImage->getSize();
+ /**
+ * Проверяем минимальный допустимый размер изображения
+ */
+ if (($_this->getParam('watermark_min_width') and $_this->getParam('watermark_min_width') > $oSize->getWidth())
+ or ($_this->getParam('watermark_min_height') and $_this->getParam('watermark_min_height') > $oSize->getHeight())
+ ) {
+ return false;
+ }
+ if (!is_object($mPosition)) {
+ $oSizeW = $oWatermark->getSize();
+ /**
+ * Определяем координаты позиции ватермарка
+ */
+ if ($mPosition == 'bottom-left') {
+ $oPosition = new Imagine\Image\Point(0, $oSize->getHeight() - $oSizeW->getHeight());
+ } elseif ($mPosition == 'top-left') {
+ $oPosition = new Imagine\Image\Point(0, 0);
+ } elseif ($mPosition == 'top-right') {
+ $oPosition = new Imagine\Image\Point($oSize->getWidth() - $oSizeW->getWidth(), 0);
+ } elseif ($mPosition == 'center') {
+ $oPosition = new Imagine\Image\Point(round(($oSize->getWidth() - $oSizeW->getWidth()) / 2),
+ round(($oSize->getHeight() - $oSizeW->getHeight()) / 2));
+ } else {
+ // bottom-right and other
+ $oPosition = new Imagine\Image\Point($oSize->getWidth() - $oSizeW->getWidth(),
+ $oSize->getHeight() - $oSizeW->getHeight());
+ }
+ } else {
+ $oPosition = $mPosition;
+ }
+ $oImage->paste($oWatermark, $oPosition);
+
+ });
+ return $this;
+ }
+
+ /**
+ * Сохраняет изображение в локальный файл
+ * Не рекомендуется использовать этот метод напрямую
+ *
+ * @param string $sFile Полный путь до локального файла
+ * @param string $sFormat Формат сохранения: jpg, gif, png
+ * @param array $aParamsSave Дополнительные опции сохранения, например, не делать вотермарк
+ *
+ * @return bool
+ */
+ public function internalSave($sFile, $sFormat, $aParamsSave = array())
+ {
+ if (!$oImage = $this->getImage()) {
+ return false;
+ }
+ $aParamsSave = array_merge(array(
+ 'skip_watermark' => false
+ ), $aParamsSave);
+ if ($this->getParam('interlace')) {
+ $this->interlace($this->getParam('interlace'));
+ }
+ if (!$aParamsSave['skip_watermark'] and $this->getParam('watermark_use')) {
+ if ($this->getParam('watermark_type') == 'image') {
+ $this->watermark($this->getParam('watermark_image'), $this->getParam('watermark_position'));
+ }
+ }
+ $oImage->save($sFile, array(
+ 'format' => $sFormat,
+ 'quality' => $this->getParam('quality'),
+ ));
+ }
+}
diff --git a/framework/classes/modules/lang/Lang.class.php b/framework/classes/modules/lang/Lang.class.php
new file mode 100644
index 0000000..5b9139d
--- /dev/null
+++ b/framework/classes/modules/lang/Lang.class.php
@@ -0,0 +1,571 @@
+
+ *
+ */
+
+/**
+ * Модуль поддержки языковых файлов
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModuleLang extends Module
+{
+ /**
+ * Текущий язык ресурса
+ *
+ * @var string
+ */
+ protected $sCurrentLang;
+ /**
+ * Язык ресурса, используемый по умолчанию
+ *
+ * @var string
+ */
+ protected $sDefaultLang;
+ /**
+ * Путь к языковым файлам
+ *
+ * @var string
+ */
+ protected $sLangPath;
+ /**
+ * Список языковых текстовок
+ *
+ * @var array
+ */
+ protected $aLangMsg = array();
+ /**
+ * Список текстовок для JS
+ *
+ * @var array
+ */
+ protected $aLangMsgJs = array();
+
+ /**
+ * Инициализация модуля
+ *
+ */
+ public function Init()
+ {
+ $this->Hook_Run('lang_init_start');
+
+ $this->InitConfig();
+ $this->InitLang();
+ }
+
+ /**
+ * Инициализирует языковые параметры из конфига
+ */
+ protected function InitConfig()
+ {
+ $this->sCurrentLang = Config::Get('lang.current');
+ $this->sDefaultLang = Config::Get('lang.default');
+ $this->sLangPath = Config::Get('lang.path');
+ }
+
+ /**
+ * Инициализирует языковой файл
+ *
+ */
+ protected function InitLang()
+ {
+ /**
+ * Если используется кеширование через memcaсhed, то сохраняем данные языкового файла в кеш
+ */
+ if (Config::Get('sys.cache.type') == 'memory') {
+ if (false === ($this->aLangMsg = $this->Cache_Get("lang_{$this->sCurrentLang}_" . Config::Get('view.skin')))) {
+ $this->aLangMsg = array();
+ $this->LoadLangFiles($this->sDefaultLang);
+ if ($this->sCurrentLang != $this->sDefaultLang) {
+ $this->LoadLangFiles($this->sCurrentLang);
+ }
+ $this->Cache_Set($this->aLangMsg, "lang_{$this->sCurrentLang}_" . Config::Get('view.skin'), array(),
+ 60 * 60);
+ }
+ } else {
+ $this->LoadLangFiles($this->sDefaultLang);
+ if ($this->sCurrentLang != $this->sDefaultLang) {
+ $this->LoadLangFiles($this->sCurrentLang);
+ }
+ }
+
+ $this->LoadLangJs();
+ }
+
+ /**
+ * Загружает из конфига текстовки для JS
+ *
+ */
+ protected function LoadLangJs()
+ {
+ $aMsg = Config::Get('lang.load_to_js');
+ if (is_array($aMsg) and count($aMsg)) {
+ $this->aLangMsgJs = $aMsg;
+ }
+ }
+
+ public function GetLangJs($bPrepare = true)
+ {
+ if ($bPrepare) {
+ $aLangMsg = array();
+ foreach ($this->aLangMsgJs as $sName) {
+ $aLangMsg[$sName] = $this->Get($sName, array(), false);
+ }
+ return $aLangMsg;
+ } else {
+ return $this->aLangMsgJs;
+ }
+ }
+
+ /**
+ * Добавляет текстовку к JS
+ *
+ * @param array $aKeys Список текстовок
+ */
+ public function AddLangJs($aKeys)
+ {
+ if (!is_array($aKeys)) {
+ $aKeys = array($aKeys);
+ }
+ $this->aLangMsgJs = array_merge($this->aLangMsgJs, $aKeys);
+ }
+
+ /**
+ * Загружает текстовки из языковых файлов
+ *
+ * @param $sLangName Язык для загрузки
+ */
+ protected function LoadLangFiles($sLangName)
+ {
+ /**
+ * Загружаем текстовки фреймворка
+ */
+ $sLangFilePath = Config::Get('path.framework.server') . '/frontend/i18n/' . $sLangName . '.php';
+ if (file_exists($sLangFilePath)) {
+ $this->AddMessages(include($sLangFilePath));
+ }
+ /**
+ * Загружаем текстовки приложения
+ */
+ $sLangFilePath = $this->sLangPath . '/' . $sLangName . '.php';
+ if (file_exists($sLangFilePath)) {
+ $this->AddMessages(include($sLangFilePath));
+ }
+ /**
+ * Ищет языковые файлы модулей и объединяет их с текущим
+ */
+ $sDirConfig = $this->sLangPath . '/modules/';
+ if (is_dir($sDirConfig) and $hDirConfig = opendir($sDirConfig)) {
+ while (false !== ($sDirModule = readdir($hDirConfig))) {
+ if ($sDirModule != '.' and $sDirModule != '..' and is_dir($sDirConfig . $sDirModule)) {
+ $sFileConfig = $sDirConfig . $sDirModule . '/' . $sLangName . '.php';
+ if (file_exists($sFileConfig)) {
+ $this->AddMessages(include($sFileConfig), array('category' => 'module', 'name' => $sDirModule));
+ }
+ }
+ }
+ closedir($hDirConfig);
+ }
+ /**
+ * Ищет языковые файлы активированных плагинов
+ */
+ if ($aPluginList = Engine::getInstance()->GetPlugins()) {
+ $aPluginList = array_keys($aPluginList);
+ $sDir = Config::Get('path.application.plugins.server') . '/';
+
+ foreach ($aPluginList as $sPluginName) {
+ $aFiles = glob($sDir . $sPluginName . '/frontend/' . Config::Get('lang.dir') . '/' . $sLangName . '.php');
+ if ($aFiles and count($aFiles)) {
+ foreach ($aFiles as $sFile) {
+ if (file_exists($sFile)) {
+ $this->AddMessages(include($sFile), array('category' => 'plugin', 'name' => $sPluginName));
+ }
+ }
+ }
+ }
+
+ }
+ /**
+ * Ищет языковой файл текущего шаблона
+ */
+ $this->LoadLangFileTemplate($sLangName);
+ }
+
+ /**
+ * Загружает языковой файл текущего шаблона
+ *
+ * @param string $sLangName Язык для загрузки
+ */
+ public function LoadLangFileTemplate($sLangName)
+ {
+ $sFile = Config::Get('path.smarty.template') . '/settings/' . Config::Get('lang.dir') . '/' . $sLangName . '.php';
+ if (file_exists($sFile)) {
+ $this->AddMessages(include($sFile));
+ }
+ }
+
+ /**
+ * Установить текущий язык
+ *
+ * @param string $sLang Название языка
+ */
+ public function SetLang($sLang)
+ {
+ $this->sCurrentLang = $sLang;
+ $this->InitLang();
+ }
+
+ /**
+ * Получить текущий язык
+ *
+ * @return string
+ */
+ public function GetLang()
+ {
+ return $this->sCurrentLang;
+ }
+
+ /**
+ * Получить дефолтный язык
+ *
+ * @return string
+ */
+ public function GetLangDefault()
+ {
+ return $this->sDefaultLang;
+ }
+
+ /**
+ * Получить список текстовок
+ *
+ * @return array
+ */
+ public function GetLangMsg()
+ {
+ return $this->aLangMsg;
+ }
+
+ /**
+ * Получить список текстовок по ссылке
+ *
+ * @return array
+ */
+ public function &GetLangMsgRef()
+ {
+ return $this->aLangMsg;
+ }
+
+ /**
+ * Получает текстовку по её имени
+ *
+ * @param string $sName Имя текстовки
+ * @param array $aReplace Список параметром для замены в текстовке
+ * @param bool $bDelete Удалять или нет параметры, которые не были заменены
+ * @return string
+ */
+ public function Get($sName, $aReplace = array(), $bDelete = true)
+ {
+ if (strpos($sName, '.')) {
+ $sLang = $this->aLangMsg;
+ $aKeys = explode('.', $sName);
+ foreach ($aKeys as $k) {
+ if (isset($sLang[$k])) {
+ $sLang = $sLang[$k];
+ } else {
+ return $sName;
+ }
+ }
+ } else {
+ if (isset($this->aLangMsg[$sName])) {
+ $sLang = $this->aLangMsg[$sName];
+ } else {
+ return $sName;
+ }
+ }
+ /**
+ * Заменяем вхождение других ключей вида ___lang_key___
+ */
+ $sLang = $this->ReplaceKey($sLang);
+
+ if (is_array($aReplace) && count($aReplace) && is_string($sLang)) {
+ foreach ($aReplace as $sFrom => $sTo) {
+ $aReplacePairs["%%{$sFrom}%%"] = $sTo;
+ }
+ $sLang = strtr($sLang, $aReplacePairs);
+ }
+
+ if (Config::Get('module.lang.delete_undefined') and $bDelete and is_string($sLang)) {
+ $sLang = preg_replace("/\%\%[\S]+\%\%/U", '', $sLang);
+ }
+ return $sLang;
+ }
+
+ /**
+ * Заменяет плейсхолдеры ключей в значениях текстовки
+ *
+ * @param string|array $msg Значение текстовки
+ * @return array|mixed
+ */
+ protected function ReplaceKey($msg)
+ {
+ if (is_array($msg)) {
+ foreach ($msg as $k => $v) {
+ $k_replaced = $this->ReplaceKey($k);
+ if ($k == $k_replaced) {
+ $msg[$k] = $this->ReplaceKey($v);
+ } else {
+ $msg[$k_replaced] = $this->ReplaceKey($v);
+ unset($msg[$k]);
+ }
+ }
+ } else {
+ if (preg_match_all('~___([\S|\.]+)___~Ui', $msg, $aMatch, PREG_SET_ORDER)) {
+ foreach ($aMatch as $aItem) {
+ $msg = str_replace('___' . $aItem[1] . '___', $this->Get($aItem[1]), $msg);
+ }
+ }
+ }
+ return $msg;
+ }
+
+ /**
+ * Добавить к текстовкам массив сообщений
+ *
+ * @param array $aMessages Список текстовок для добавления
+ * @param array|null $aParams Параметры, позволяют хранить текстовки в структурированном виде, например, тестовки плагина "test" получать как Get('plugin.name.test')
+ */
+ public function AddMessages($aMessages, $aParams = null)
+ {
+ if (is_array($aMessages)) {
+ if (isset($aParams['name'])) {
+ $sMsgs = $aMessages;
+ if (isset($aParams['category'])) {
+ if (isset($this->aLangMsg[$aParams['category']][$aParams['name']])) {
+ $sMsgs = func_array_merge_assoc($this->aLangMsg[$aParams['category']][$aParams['name']],
+ $sMsgs);
+ }
+ $this->aLangMsg[$aParams['category']][$aParams['name']] = $sMsgs;
+ } else {
+ if (isset($this->aLangMsg[$aParams['name']])) {
+ $sMsgs = func_array_merge_assoc($this->aLangMsg[$aParams['name']], $sMsgs);
+ }
+ $this->aLangMsg[$aParams['name']] = $sMsgs;
+ }
+ } else {
+ $this->aLangMsg = func_array_merge_assoc($this->aLangMsg, $aMessages);
+ }
+ }
+ }
+
+ /**
+ * Добавить к текстовкам отдельное сообщение
+ *
+ * @param string $sKey Имя текстовки
+ * @param string $sMessage Значение текстовки
+ */
+ public function AddMessage($sKey, $sMessage)
+ {
+ $this->aLangMsg[$sKey] = $sMessage;
+ }
+
+ /**
+ * Возвращает нужную форму слова во множественном числе
+ *
+ * @param int $iNumber
+ * @param string|array $mText
+ * @param string|null $sLang
+ *
+ * @return string|mixed
+ */
+ public function Pluralize($iNumber, $mText, $sLang = null)
+ {
+ if (is_null($sLang)) {
+ $sLang = $this->GetLang();
+ }
+ if ('pt_BR' === $sLang) {
+ // temporary set a locale for brazilian
+ $sLang = 'xbr';
+ }
+
+ if (strlen($sLang) > 3) {
+ $sLang = substr($sLang, 0, -strlen(strrchr($sLang, '_')));
+ }
+ $iNumber = abs($iNumber);
+ $iForm = 0;
+ /*
+ * The plural rules are derived from code of the Zend Framework (2010-09-25),
+ * which is subject to the new BSD license (http://framework.zend.com/license/new-bsd).
+ * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
+ */
+ switch ($sLang) {
+ case 'bo':
+ case 'dz':
+ case 'id':
+ case 'ja':
+ case 'jv':
+ case 'ka':
+ case 'km':
+ case 'kn':
+ case 'ko':
+ case 'ms':
+ case 'th':
+ case 'tr':
+ case 'vi':
+ case 'zh':
+ $iForm = 0;
+ break;
+
+ case 'af':
+ case 'az':
+ case 'bn':
+ case 'bg':
+ case 'ca':
+ case 'da':
+ case 'de':
+ case 'el':
+ case 'en':
+ case 'eo':
+ case 'es':
+ case 'et':
+ case 'eu':
+ case 'fa':
+ case 'fi':
+ case 'fo':
+ case 'fur':
+ case 'fy':
+ case 'gl':
+ case 'gu':
+ case 'ha':
+ case 'he':
+ case 'hu':
+ case 'is':
+ case 'it':
+ case 'ku':
+ case 'lb':
+ case 'ml':
+ case 'mn':
+ case 'mr':
+ case 'nah':
+ case 'nb':
+ case 'ne':
+ case 'nl':
+ case 'nn':
+ case 'no':
+ case 'om':
+ case 'or':
+ case 'pa':
+ case 'pap':
+ case 'ps':
+ case 'pt':
+ case 'so':
+ case 'sq':
+ case 'sv':
+ case 'sw':
+ case 'ta':
+ case 'te':
+ case 'tk':
+ case 'ur':
+ case 'zu':
+ $iForm = ($iNumber == 1) ? 0 : 1;
+ break;
+
+ case 'am':
+ case 'bh':
+ case 'fil':
+ case 'fr':
+ case 'gun':
+ case 'hi':
+ case 'ln':
+ case 'mg':
+ case 'nso':
+ case 'xbr':
+ case 'ti':
+ case 'wa':
+ $iForm = (($iNumber == 0) || ($iNumber == 1)) ? 0 : 1;
+ break;
+
+ case 'be':
+ case 'bs':
+ case 'hr':
+ case 'ru':
+ case 'sr':
+ case 'uk':
+ $iForm = (($iNumber % 10 == 1) && ($iNumber % 100 != 11)) ? 0 : ((($iNumber % 10 >= 2) && ($iNumber % 10 <= 4) && (($iNumber % 100 < 10) || ($iNumber % 100 >= 20))) ? 1 : 2);
+ break;
+
+ case 'cs':
+ case 'sk':
+ $iForm = ($iNumber == 1) ? 0 : ((($iNumber >= 2) && ($iNumber <= 4)) ? 1 : 2);
+ break;
+
+ case 'ga':
+ $iForm = ($iNumber == 1) ? 0 : (($iNumber == 2) ? 1 : 2);
+ break;
+
+ case 'lt':
+ $iForm = (($iNumber % 10 == 1) && ($iNumber % 100 != 11)) ? 0 : ((($iNumber % 10 >= 2) && (($iNumber % 100 < 10) || ($iNumber % 100 >= 20))) ? 1 : 2);
+ break;
+
+ case 'sl':
+ $iForm = ($iNumber % 100 == 1) ? 0 : (($iNumber % 100 == 2) ? 1 : ((($iNumber % 100 == 3) || ($iNumber % 100 == 4)) ? 2 : 3));
+ break;
+
+ case 'mk':
+ $iForm = ($iNumber % 10 == 1) ? 0 : 1;
+ break;
+
+ case 'mt':
+ $iForm = ($iNumber == 1) ? 0 : ((($iNumber == 0) || (($iNumber % 100 > 1) && ($iNumber % 100 < 11))) ? 1 : ((($iNumber % 100 > 10) && ($iNumber % 100 < 20)) ? 2 : 3));
+ break;
+
+ case 'lv':
+ $iForm = ($iNumber == 0) ? 0 : ((($iNumber % 10 == 1) && ($iNumber % 100 != 11)) ? 1 : 2);
+ break;
+
+ case 'pl':
+ $iForm = ($iNumber == 1) ? 0 : ((($iNumber % 10 >= 2) && ($iNumber % 10 <= 4) && (($iNumber % 100 < 12) || ($iNumber % 100 > 14))) ? 1 : 2);
+ break;
+
+ case 'cy':
+ $iForm = ($iNumber == 1) ? 0 : (($iNumber == 2) ? 1 : ((($iNumber == 8) || ($iNumber == 11)) ? 2 : 3));
+ break;
+
+ case 'ro':
+ $iForm = ($iNumber == 1) ? 0 : ((($iNumber == 0) || (($iNumber % 100 > 0) && ($iNumber % 100 < 20))) ? 1 : 2);
+ break;
+
+ case 'ar':
+ $iForm = ($iNumber == 0) ? 0 : (($iNumber == 1) ? 1 : (($iNumber == 2) ? 2 : ((($iNumber % 100 >= 3) && ($iNumber % 100 <= 10)) ? 3 : ((($iNumber % 100 >= 11) && ($iNumber % 100 <= 99)) ? 4 : 5))));
+ break;
+
+ default:
+ $iForm = 0;
+ }
+ if (is_array($mText)) {
+ $aText = $mText;
+ } else {
+ $aText = explode(';', $mText);
+ }
+ /**
+ * Возвращаем нужную форму слова, либо исходный текст
+ */
+ return array_key_exists($iForm, $aText) ? $aText[$iForm] : $mText;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/logger/Logger.class.php b/framework/classes/modules/logger/Logger.class.php
new file mode 100644
index 0000000..9cdfe09
--- /dev/null
+++ b/framework/classes/modules/logger/Logger.class.php
@@ -0,0 +1,322 @@
+
+ *
+ */
+
+/**
+ * Модуль логирования
+ * В качестве бекенда используется библиотека Monolog
+ *
+ * $this->Logger_Debug('Debug message');
+ *
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModuleLogger extends Module
+{
+ /**
+ * Список инстанций логов, в качестве ключей используются названия инстансов
+ *
+ * @var array
+ */
+ protected $aInstances = array();
+ /**
+ * Список уровней логирования
+ *
+ * @var array
+ */
+ protected $aLevels = array(
+ 'debug',
+ 'info',
+ 'notice',
+ 'warning',
+ 'error',
+ 'critical',
+ 'alert',
+ 'emergency',
+ );
+ /**
+ * Инстанция для записи логов PHP ошибок
+ *
+ * @var string
+ */
+ protected $sInstanceForPHPError = 'default';
+
+ /**
+ * Инициализация модуля
+ */
+ public function Init()
+ {
+ /**
+ * Подключаем логирование всех PHP ошибок
+ */
+ if (Config::Get('sys.logs.php')) {
+ \Monolog\ErrorHandler::register($this->GetInstance($this->sInstanceForPHPError));
+ }
+ }
+
+ /**
+ * Возвращает инстанс по имени
+ *
+ * @param string $sInstance
+ *
+ * @return mixed
+ * @throws Exception
+ */
+ public function GetInstance($sInstance)
+ {
+ if (!isset($this->aInstances[$sInstance])) {
+ /**
+ * Создаем новый инстанс
+ */
+ $aConfig = Config::Get('sys.logs.instances.' . $sInstance);
+ if (!$aConfig) {
+ throw new Exception("Log instance '{$sInstance}' not available");
+ }
+ $oInstance = new \Monolog\Logger($sInstance);
+ /**
+ * Список обработчиков
+ */
+ foreach ($aConfig['handlers'] as $sHandler => $aParams) {
+ /**
+ * Формируем список параметром инициализации хендлера, учитываем только числовые ключи
+ */
+ $aParamsInit = array();
+ foreach ($aParams as $k => $v) {
+ if (is_int($k)) {
+ if (in_array($v, $this->aLevels)) {
+ $v = $this->ConvertLevel($v);
+ }
+ $aParamsInit[] = $v;
+ }
+ }
+ $sHandler = ucfirst($sHandler);
+ $oRefClass = new ReflectionClass("Monolog\\Handler\\{$sHandler}Handler");
+ $oHandler = $oRefClass->newInstanceArgs($aParamsInit);
+ $oInstance->pushHandler($oHandler);
+ /**
+ * Устанавливаем формат логов
+ */
+ if (isset($aParams['formatter'])) {
+ $aFormatter = $aParams['formatter'];
+ $sFormatterName = ucfirst(array_shift($aFormatter));
+ $oRefClass = new ReflectionClass("Monolog\\Formatter\\{$sFormatterName}Formatter");
+ $oFormatter = $oRefClass->newInstanceArgs($aFormatter);
+ $oHandler->setFormatter($oFormatter);
+ }
+ }
+ /**
+ * Список пре-обработчиков
+ */
+ if (isset($aConfig['processors'])) {
+ foreach ($aConfig['processors'] as $sProcessors => $aParams) {
+ if (is_int($sProcessors)) {
+ $sProcessors = $aParams;
+ $aParams = array();
+ }
+ $sProcessors = ucfirst($sProcessors);
+ $sClass = "Monolog\\Processor\\{$sProcessors}Processor";
+ if ($aParams) {
+ $oRefClass = new ReflectionClass($sClass);
+ $oProcessors = $oRefClass->newInstanceArgs($aParams);
+ } else {
+ // for bug PHP 5.3.3 https://bugs.php.net/bug.php?id=52854
+ $oProcessors = new $sClass;
+ }
+ $oInstance->pushProcessor($oProcessors);
+ }
+ }
+ $this->aInstances[$sInstance] = $oInstance;
+ }
+ return $this->aInstances[$sInstance];
+ }
+
+ /**
+ * Добавляет запись в лог
+ *
+ * @param string|int $sLevel Уровень логирования
+ * @param string $sMsg Сообщение
+ * @param array $aContext Дополнительные параметры для логирования
+ * @param string $sInstance Имя инстанса
+ */
+ public function Write($sLevel, $sMsg, $aContext = array(), $sInstance = 'default')
+ {
+ $oInstance = $this->GetInstance($sInstance);
+ $oInstance->log($sLevel, $sMsg, $aContext);
+ }
+
+ /**
+ * Логирует с уровнем Debug
+ *
+ * @param string $sMsg Сообщение
+ * @param array $aContext Дополнительные параметры для логирования
+ * @param string $sInstance Имя инстанса
+ */
+ public function Debug($sMsg, $aContext = array(), $sInstance = 'default')
+ {
+ return $this->Write(strtolower(__FUNCTION__), $sMsg, $aContext, $sInstance);
+ }
+
+ /**
+ * Логирует с уровнем Info
+ *
+ * @param string $sMsg Сообщение
+ * @param array $aContext Дополнительные параметры для логирования
+ * @param string $sInstance Имя инстанса
+ */
+ public function Info($sMsg, $aContext = array(), $sInstance = 'default')
+ {
+ return $this->Write(strtolower(__FUNCTION__), $sMsg, $aContext, $sInstance);
+ }
+
+ /**
+ * Логирует с уровнем Notice
+ *
+ * @param string $sMsg Сообщение
+ * @param array $aContext Дополнительные параметры для логирования
+ * @param string $sInstance Имя инстанса
+ */
+ public function Notice($sMsg, $aContext = array(), $sInstance = 'default')
+ {
+ return $this->Write(strtolower(__FUNCTION__), $sMsg, $aContext, $sInstance);
+ }
+
+ /**
+ * Логирует с уровнем Warning
+ *
+ * @param string $sMsg Сообщение
+ * @param array $aContext Дополнительные параметры для логирования
+ * @param string $sInstance Имя инстанса
+ */
+ public function Warning($sMsg, $aContext = array(), $sInstance = 'default')
+ {
+ return $this->Write(strtolower(__FUNCTION__), $sMsg, $aContext, $sInstance);
+ }
+
+ /**
+ * Логирует с уровнем Error
+ *
+ * @param string $sMsg Сообщение
+ * @param array $aContext Дополнительные параметры для логирования
+ * @param string $sInstance Имя инстанса
+ */
+ public function Error($sMsg, $aContext = array(), $sInstance = 'default')
+ {
+ return $this->Write(strtolower(__FUNCTION__), $sMsg, $aContext, $sInstance);
+ }
+
+ /**
+ * Логирует с уровнем Critical
+ *
+ * @param string $sMsg Сообщение
+ * @param array $aContext Дополнительные параметры для логирования
+ * @param string $sInstance Имя инстанса
+ */
+ public function Critical($sMsg, $aContext = array(), $sInstance = 'default')
+ {
+ return $this->Write(strtolower(__FUNCTION__), $sMsg, $aContext, $sInstance);
+ }
+
+ /**
+ * Логирует с уровнем Alert
+ *
+ * @param string $sMsg Сообщение
+ * @param array $aContext Дополнительные параметры для логирования
+ * @param string $sInstance Имя инстанса
+ */
+ public function Alert($sMsg, $aContext = array(), $sInstance = 'default')
+ {
+ return $this->Write(strtolower(__FUNCTION__), $sMsg, $aContext, $sInstance);
+ }
+
+ /**
+ * Логирует с уровнем Emergency
+ *
+ * @param string $sMsg Сообщение
+ * @param array $aContext Дополнительные параметры для логирования
+ * @param string $sInstance Имя инстанса
+ */
+ public function Emergency($sMsg, $aContext = array(), $sInstance = 'default')
+ {
+ return $this->Write(strtolower(__FUNCTION__), $sMsg, $aContext, $sInstance);
+ }
+
+ /**
+ * Выводит данные в консоль браузера
+ *
+ * @return bool|void
+ */
+ public function Console()
+ {
+ if (!Config::Get('sys.logs.console') or isAjaxRequest()) {
+ return false;
+ }
+ $aArgs = func_get_args();
+ if (count($aArgs)) {
+ if (is_string($aArgs[0]) or is_numeric($aArgs[0])) {
+ $sMsg = array_shift($aArgs);
+ } else {
+ $sMsg = '';
+ }
+ return $this->Info($sMsg, $aArgs, 'console');
+ }
+ return false;
+ }
+
+ /**
+ * Конвертирует уровень в значение библиотеки Monolog
+ *
+ * @param string $sLevel
+ *
+ * @return int
+ * @throws Exception
+ */
+ protected function ConvertLevel($sLevel)
+ {
+ switch ($sLevel) {
+ case 'debug':
+ return Monolog\Logger::DEBUG;
+
+ case 'info':
+ return Monolog\Logger::INFO;
+
+ case 'notice':
+ return Monolog\Logger::NOTICE;
+
+ case 'warning':
+ return Monolog\Logger::WARNING;
+
+ case 'error':
+ return Monolog\Logger::ERROR;
+
+ case 'critical':
+ return Monolog\Logger::CRITICAL;
+
+ case 'alert':
+ return Monolog\Logger::ALERT;
+
+ case 'emergency':
+ return Monolog\Logger::EMERGENCY;
+
+ default:
+ throw new Exception("Invalid log level: {$sLevel}");
+ }
+ }
+}
diff --git a/framework/classes/modules/ls/Ls.class.php b/framework/classes/modules/ls/Ls.class.php
new file mode 100644
index 0000000..b0592f1
--- /dev/null
+++ b/framework/classes/modules/ls/Ls.class.php
@@ -0,0 +1,355 @@
+
+ *
+ */
+
+/**
+ * Модуль Ls
+ * Для выполнения служебных действий LiveStreet CMS.
+ * В частности для отправки на сервер LiveStreet информации о домене сайта, версии плагинов и LS.
+ * Эти данные не разглашаются и используются исключительно в целях развития LiveStreet CMS, оценки спроса, отслеживания интересов аудитории.
+ * Так же вы можете благодаря этому получать уведомления о новых версиях установленных плагинов и шаблонов.
+ * Вы всегда можете отключить передачу данных в конфиге, но просим этого не далать, тем самым вы поможете развитию LS CMS. Это важно для нас.
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModuleLs extends Module
+{
+ /**
+ * Адрес шлюза
+ *
+ * @var string
+ */
+ protected $sUrlLs = 'http://sender.livestreetcms.com/push/';
+ /**
+ * Список данных для отправки
+ *
+ * @var array
+ */
+ protected $aDataForSend = array();
+
+ /**
+ * Инициализируем модуль
+ *
+ */
+ public function Init()
+ {
+
+ }
+
+ /**
+ * Запуск сбора данных
+ *
+ * @return bool
+ */
+ public function SenderRun()
+ {
+ $this->CheckVerificationKey();
+ if (!Config::Get('module.ls.send_general')) {
+ return false;
+ }
+ /**
+ * Вставка счетчика
+ */
+ if (Config::Get('module.ls.use_counter')) {
+ // лучше вставлять в html_head_end, но здесь нужно постараться вставить код в самом конце, чтобы уменьшить вероятность повторного вызова GA, если сайт его использует
+ $this->Hook_AddExecModule('template_body_end', 'Ls_InjectCounter', -10000);
+ }
+ /**
+ * Отправка данных
+ */
+ $this->SendToLs();
+ }
+
+ /**
+ * Проверка ключа, в ответ браузеру выдается только сообщение "ok" или "no"
+ */
+ public function CheckVerificationKey()
+ {
+ if (Router::GetAction() == 'error' and isset($_GET['livestreet_check_verification_key'])) {
+ $sKey = trim((string)Config::Get('module.ls.verification_key'));
+ if ($sKey and $_GET['livestreet_check_verification_key'] === $sKey) {
+ echo('ok');
+ exit();
+ }
+ echo('no');
+ exit();
+ }
+ }
+
+ /**
+ * Вставка счетчика GA с учетом его возможного повторного использования
+ *
+ * @return mixed
+ */
+ public function InjectCounter()
+ {
+ /**
+ * Если _gaq уже определена, значит загружать js код GA не нужно
+ */
+ $sCounter = "
+
+ ";
+
+ return $sCounter;
+ }
+
+ /**
+ * Отправка данных на шлюз LS
+ */
+ protected function SendToLs()
+ {
+ /**
+ * Ограничения на запуск отправки, чтобы не нагружать сайт
+ * Отправляем 1 раз в день ночью в промежутке 00:00-07:00, делаем по 10 попыток отправки в день
+ */
+ if ((int)date('G') >= 7) {
+ return;
+ }
+ if ($aData = $this->GetMarkerFile(date("Y-m-d")) and (isset($aData['is_send']) or (isset($aData['count_try']) and $aData['count_try'] > 10))) {
+ return;
+ }
+
+ $this->aDataForSend = $this->GetDataForSendToLs();
+
+ $bOk = false;
+ $sResponse = $this->getUrl($this->sUrlLs, $this->aDataForSend);
+ if ($sResponse === false) {
+ /**
+ * Отправка не удалась, скорее всего нет нужных расширений, пробуем передать данные через клиента инжекцией тега
+ * Такой способ отправки нужно делать только для админа сайта, чтобы не "засветить" данные третьим лицам.
+ */
+ //$this->Hook_AddExecModule('template_body_end','Ls_InjectImgForSendToLs',-2000);
+ } else {
+ if ($sResponse == 'accepted') {
+ $bOk = true;
+ } else {
+ // очень странная ситуация, скорее всего временно не работает сервер
+ }
+ }
+ /**
+ * Отмечаем факт отправки
+ */
+ if ($bOk) {
+ $this->SuccessfulSendToLs();
+ } else {
+ $this->ErrorSendToLs();
+ }
+ }
+
+ /**
+ * Отмечает факт ошибки при отправки данных, увеличиваем число попыток
+ */
+ protected function ErrorSendToLs()
+ {
+ if (!($aData = $this->GetMarkerFile(date("Y-m-d")))) {
+ $aData = array();
+ }
+ if (isset($aData['count_try'])) {
+ $aData['count_try']++;
+ } else {
+ $aData['count_try'] = 1;
+ }
+ $this->SetMarkerFile($aData);
+ }
+
+ /**
+ * Отмечает факт успешной отправки данных
+ */
+ protected function SuccessfulSendToLs()
+ {
+ $this->SetMarkerFile(array('is_send' => 1));
+ }
+
+ /**
+ * Читает данные из файла
+ *
+ * @param null $sDateCheck
+ * @return bool|mixed
+ */
+ protected function GetMarkerFile($sDateCheck = null)
+ {
+ $sFile = Config::Get('sys.cache.dir') . 'lssender.dat';
+ if (!file_exists($sFile)) {
+ return false;
+ }
+ if ($aData = @unserialize(file_get_contents($sFile))) {
+ if ($sDateCheck) {
+ if (isset($aData['date']) and $aData['date'] == $sDateCheck) {
+ return $aData;
+ }
+ } else {
+ return $aData;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Записывает данные в файл
+ *
+ * @param array $aData Данные
+ * @return bool
+ */
+ protected function SetMarkerFile($aData)
+ {
+ $aData['date'] = date('Y-m-d');
+ $sFile = Config::Get('sys.cache.dir') . 'lssender.dat';
+ if (@file_put_contents($sFile, serialize($aData))) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает строчку для инжекции в шаблон
+ *
+ * @return string
+ */
+ public function InjectImgForSendToLs()
+ {
+ $this->SuccessfulSendToLs();
+ $sUrl = $this->sUrlLs . 'img/?' . $this->makeGetParams($this->aDataForSend);
+ return ' ';
+ }
+
+ /**
+ * Возвращает данные для отправки
+ *
+ * @return array
+ */
+ protected function GetDataForSendToLs()
+ {
+ /**
+ * Формируем данные для отправки
+ */
+ $aData = array();
+ $aData['ls_v'] = LS_VERSION;
+ /**
+ * Список плагинов с версиями
+ */
+ $aPlugins = $this->PluginManager_GetPluginsItems();
+ foreach ($aPlugins as $aPlugin) {
+ $aData['plugins']['code'][] = $aPlugin['code'];
+ $aData['plugins']['ia'][] = $aPlugin['is_active'];
+ $aData['plugins']['v'][] = $aPlugin['property']->version;
+ }
+ /**
+ * Домен
+ */
+ $aData['domain'] = Config::Get('path.root.web');
+ /**
+ * Шаблон
+ */
+ $aData['template'] = Config::Get('view.skin');
+ /**
+ * Ключ верификации (подтверждения прав на сайт)
+ */
+ $aData['key'] = (string)Config::Get('module.ls.verification_key');
+
+ return $aData;
+ }
+
+ /**
+ * Чтение URL
+ *
+ * @param string $sUrl Урл
+ * @param array $aParams параметры
+ * @return bool|string
+ */
+ protected function getUrl($sUrl, $aParams)
+ {
+ if (function_exists('curl_init')) {
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $sUrl);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+ curl_setopt($ch, CURLOPT_POST, 1);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $this->makeGetParams($aParams));
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
+
+ $sData = curl_exec($ch);
+ if (curl_errno($ch)) {
+ curl_close($ch);
+ return false;
+ }
+ curl_close($ch);
+ return $sData;
+ } else {
+ $aUrl = parse_url($sUrl);
+ $socket = @fsockopen($aUrl['host'], isset($aUrl['port']) ? $aUrl['port'] : 80, $errno, $errstr, 5);
+
+ if (!$socket) {
+ return false;
+ }
+
+ //собираем данные
+ $data = $this->makeGetParams($aParams);
+
+ fwrite($socket, "POST {$aUrl['path']} HTTP/1.1\r\n");
+ fwrite($socket, "Host: {$aUrl['host']}\r\n");
+ fwrite($socket, "Content-type: application/x-www-form-urlencoded\r\n");
+ fwrite($socket, "Content-length:" . strlen($data) . "\r\n");
+ fwrite($socket, "Accept:*/*\r\n");
+ fwrite($socket, "User-agent:Opera 10.00\r\n");
+ fwrite($socket, "Connection: Close\r\n");
+ fwrite($socket, "\r\n");
+ fwrite($socket, "$data\r\n");
+ fwrite($socket, "\r\n");
+
+ $sData = '';
+ while (($line = fgets($socket, 4096)) !== false) {
+ $sData .= $line;
+ }
+ //закрываем сокет
+ fclose($socket);
+ $sData = trim(substr($sData, strpos($sData, "\r\n\r\n") + 4));
+ return $sData;
+ }
+ }
+
+ /**
+ * Формирует строку GET параметров
+ *
+ * @param array $aParams Параметры
+ * @return string
+ */
+ protected function makeGetParams($aParams = array())
+ {
+ $sGetParams = '';
+ if (is_string($aParams) or count($aParams)) {
+ $sGetParams = is_array($aParams) ? http_build_query($aParams, '', '&') : $aParams;
+ }
+ return $sGetParams;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/mail/Mail.class.php b/framework/classes/modules/mail/Mail.class.php
new file mode 100644
index 0000000..0eef6f4
--- /dev/null
+++ b/framework/classes/modules/mail/Mail.class.php
@@ -0,0 +1,334 @@
+
+ *
+ */
+
+require_once(Config::Get('path.framework.libs_vendor.server') . '/phpMailer/PHPMailerAutoload.php');
+
+/**
+ * Модуль для отправки почты(e-mail) через phpMailer
+ *
+ * $this->Mail_SetAdress('claus@mail.ru','Claus');
+ * $this->Mail_SetSubject('Hi!');
+ * $this->Mail_SetBody('How are you?');
+ * $this->Mail_setHTML();
+ * $this->Mail_Send();
+ *
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModuleMail extends Module
+{
+ /**
+ * Основной объект рассыльщика
+ *
+ * @var phpmailer
+ */
+ protected $oMailer;
+ /**
+ * Настройки SMTP сервера для отправки писем
+ *
+ */
+ /**
+ * Хост smtp
+ *
+ * @var string
+ */
+ protected $sHost;
+ /**
+ * Порт smtp
+ *
+ * @var int
+ */
+ protected $iPort;
+ /**
+ * Логин smtp
+ *
+ * @var string
+ */
+ protected $sUsername;
+ /**
+ * Пароль smtp
+ *
+ * @var string
+ */
+ protected $sPassword;
+ /**
+ * Треубется или нет авторизация на smtp
+ *
+ * @var bool
+ */
+ protected $bSmtpAuth;
+ /**
+ * Префикс соединения к smtp - "", "ssl" или "tls"
+ *
+ * @var string
+ */
+ protected $sSmtpSecure;
+ /**
+ * Метод отправки почты
+ *
+ * @var string
+ */
+ protected $sMailerType;
+ /**
+ * Кодировка писем
+ *
+ * @var string
+ */
+ protected $sCharSet;
+ /**
+ * Делать или нет перенос строк в письме
+ *
+ * @var int
+ */
+ protected $iWordWrap = 0;
+
+ /**
+ * Мыло от кого отправляется вся почта
+ *
+ * @var string
+ */
+ protected $sFrom;
+ /**
+ * Имя от кого отправляется вся почта
+ *
+ * @var string
+ */
+ protected $sFromName;
+ /**
+ * Тема письма
+ *
+ * @var string
+ */
+ protected $sSubject = '';
+ /**
+ * Текст письма
+ *
+ * @var string
+ */
+ protected $sBody = '';
+ /**
+ * Альтернативный текст письма в формате plain/text
+ *
+ * @var string
+ */
+ protected $sAltBody = '';
+ /**
+ * Строка последней ошибки
+ *
+ * @var string
+ */
+ protected $sError;
+
+ /**
+ * Инициализация модуля
+ *
+ */
+ public function Init()
+ {
+ /**
+ * Настройки SMTP сервера для отправки писем
+ */
+ $this->sHost = Config::Get('sys.mail.smtp.host');
+ $this->iPort = Config::Get('sys.mail.smtp.port');
+ $this->sUsername = Config::Get('sys.mail.smtp.user');
+ $this->sPassword = Config::Get('sys.mail.smtp.password');
+ $this->bSmtpAuth = Config::Get('sys.mail.smtp.auth');
+ $this->sSmtpSecure = Config::Get('sys.mail.smtp.secure');
+ /**
+ * Метод отправки почты
+ */
+ $this->sMailerType = Config::Get('sys.mail.type');
+ /**
+ * Кодировка писем
+ */
+ $this->sCharSet = Config::Get('sys.mail.charset');
+ /**
+ * Мыло от кого отправляется вся почта
+ */
+ $this->sFrom = Config::Get('sys.mail.from_email');
+ /**
+ * Имя от кого отправляется вся почта
+ */
+ $this->sFromName = Config::Get('sys.mail.from_name');
+
+ /**
+ * Создаём объект phpMailer и устанвливаем ему необходимые настройки
+ */
+ $this->oMailer = new phpmailer();
+ $this->oMailer->Host = $this->sHost;
+ $this->oMailer->Port = $this->iPort;
+ $this->oMailer->Username = $this->sUsername;
+ $this->oMailer->Password = $this->sPassword;
+ $this->oMailer->SMTPAuth = $this->bSmtpAuth;
+ $this->oMailer->SMTPSecure = $this->sSmtpSecure;
+ $this->oMailer->Mailer = $this->sMailerType;
+ $this->oMailer->WordWrap = $this->iWordWrap;
+ $this->oMailer->CharSet = $this->sCharSet;
+
+ $this->oMailer->From = $this->sFrom;
+ $this->oMailer->Sender = $this->sFrom;
+ $this->oMailer->FromName = $this->sFromName;
+ /**
+ * Настройки DKIM
+ */
+ $this->oMailer->DKIM_selector = Config::Get('sys.mail.dkim.selector');
+ $this->oMailer->DKIM_identity = Config::Get('sys.mail.dkim.identity') ?: $this->sFrom;
+ $this->oMailer->DKIM_passphrase = Config::Get('sys.mail.dkim.passphrase');
+ $this->oMailer->DKIM_domain = Config::Get('sys.mail.dkim.domain');
+ $this->oMailer->DKIM_private = Config::Get('sys.mail.dkim.private');
+ }
+
+ /**
+ * Устанавливает тему сообщения
+ *
+ * @param string $sText Тема сообщения
+ */
+ public function SetSubject($sText)
+ {
+ $this->sSubject = $sText;
+ }
+
+ /**
+ * Устанавливает текст сообщения
+ *
+ * @param string $sText Текст сообщения
+ */
+ public function SetBody($sText)
+ {
+ $this->sBody = trim($sText);
+ }
+
+ /**
+ * Устанавливает альтернативный текст сообщения
+ *
+ * @param string $sText Текст сообщения
+ */
+ public function SetAltBody($sText)
+ {
+ $this->sAltBody = $sText ? trim($sText) : $sText;
+ }
+
+ /**
+ * Добавляем новый адрес получателя
+ *
+ * @param string $sMail Емайл
+ * @param string $sName Имя
+ */
+ public function AddAdress($sMail, $sName = null)
+ {
+ ob_start();
+ $this->oMailer->AddAddress($sMail, $sName);
+ $this->sError = ob_get_clean();
+ }
+
+ /**
+ * Добавляем прикрепляемый файл
+ *
+ * @param string $sPath Абсолютный путь к файлу
+ * @param string $sName Свое имя файла
+ * @param string $sEncoding Кодированик файла
+ * @param string $sType Расширение файла (MIME).
+ */
+ public function AddAttachment($sPath, $sName = '', $sEncoding = 'base64', $sType = 'application/octet-stream')
+ {
+ ob_start();
+ $this->oMailer->AddAttachment($sPath, $sName, $sEncoding, $sType);
+ $this->sError = ob_get_clean();
+ }
+
+ /**
+ * Отправляет сообщение(мыло)
+ *
+ * @return bool
+ */
+ public function Send()
+ {
+ $this->oMailer->Subject = $this->sSubject;
+ $this->oMailer->Body = $this->sBody;
+ if ($this->sAltBody) {
+ $this->oMailer->AltBody = $this->oMailer->normalizeBreaks($this->oMailer->html2text($this->sAltBody));
+ }
+ ob_start();
+ $bResult = $this->oMailer->Send();
+ $this->sError = ob_get_clean();
+ return $bResult;
+ }
+
+ /**
+ * Очищает все адреса получателей
+ *
+ */
+ public function ClearAddresses()
+ {
+ $this->oMailer->ClearAddresses();
+ }
+
+ /**
+ * Устанавливает единственный адрес получателя
+ *
+ * @param string $sMail Емайл
+ * @param string $sName Имя
+ */
+ public function SetAdress($sMail, $sName = null)
+ {
+ $this->ClearAddresses();
+ ob_start();
+ $this->oMailer->AddAddress($sMail, $sName);
+ $this->sError = ob_get_clean();
+ }
+
+ /**
+ * Устанавливает режим отправки письма как HTML
+ *
+ */
+ public function setHTML()
+ {
+ $this->oMailer->IsHTML(true);
+ }
+
+ /**
+ * Устанавливает режим отправки письма как Text(Plain)
+ *
+ */
+ public function setPlain()
+ {
+ $this->oMailer->IsHTML(false);
+ }
+
+ /**
+ * Возвращает строку последней ошибки
+ *
+ * @return string
+ */
+ public function GetError()
+ {
+ return $this->sError;
+ }
+
+ /**
+ * @return phpmailer
+ */
+ public function GetMailer()
+ {
+ return $this->oMailer;
+ }
+}
diff --git a/framework/classes/modules/message/Message.class.php b/framework/classes/modules/message/Message.class.php
new file mode 100644
index 0000000..e5ac696
--- /dev/null
+++ b/framework/classes/modules/message/Message.class.php
@@ -0,0 +1,226 @@
+
+ *
+ */
+
+/**
+ * Модуль системных сообщений
+ * Позволяет показывать пользователю сообщения двух видов - об ошибке и об успешном действии.
+ *
+ * $this->Message_AddErrorSingle($this->Lang_Get('common.error.not_access'),$this->Lang_Get('common.error.error'));
+ *
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModuleMessage extends Module
+{
+ /**
+ * Массив сообщений со статусом ОШИБКА
+ *
+ * @var array
+ */
+ protected $aMsgError = array();
+ /**
+ * Массив сообщений со статусом СООБЩЕНИЕ
+ *
+ * @var array
+ */
+ protected $aMsgNotice = array();
+ /**
+ * Массив сообщений, который будут показаны на СЛЕДУЮЩЕЙ страничке
+ *
+ * @var array
+ */
+ protected $aMsgNoticeSession = array();
+ /**
+ * Массив ошибок, который будут показаны на СЛЕДУЮЩЕЙ страничке
+ *
+ * @var array
+ */
+ protected $aMsgErrorSession = array();
+
+ /**
+ * Инициализация модуля
+ *
+ */
+ public function Init()
+ {
+ /**
+ * Добавляем сообщения и ошибки, которые содержались в сессии
+ */
+ $aNoticeSession = $this->Session_Get('message_notice_session');
+ if (is_array($aNoticeSession) and count($aNoticeSession)) {
+ $this->aMsgNotice = $aNoticeSession;
+ }
+ $aErrorSession = $this->Session_Get('message_error_session');
+ if (is_array($aErrorSession) and count($aErrorSession)) {
+ $this->aMsgError = $aErrorSession;
+ }
+ }
+
+ /**
+ * При завершении работы модуля передаем списки сообщений в шаблоны Smarty
+ *
+ */
+ public function Shutdown()
+ {
+ /**
+ * Добавляем в сессию те сообщения, которые были отмечены для сессионного использования
+ */
+ $this->Session_Set('message_notice_session', $this->GetNoticeSession());
+ $this->Session_Set('message_error_session', $this->GetErrorSession());
+
+ $this->Viewer_Assign('aMsgError', $this->GetError());
+ $this->Viewer_Assign('aMsgNotice', $this->GetNotice());
+ }
+
+ /**
+ * Переносит все сообщения в массив для сессионного использования
+ * Используется перед выполнением редиректа
+ *
+ */
+ public function SaveMessages()
+ {
+ $this->aMsgErrorSession = array_merge($this->GetErrorSession(), $this->GetError());
+ $this->aMsgNoticeSession = array_merge($this->GetNoticeSession(), $this->GetNotice());
+ $this->aMsgError = array();
+ $this->aMsgNotice = array();
+ }
+
+ /**
+ * Добавляет новое сообщение об ошибке
+ *
+ * @param string $sMsg Сообщение
+ * @param string $sTitle Заголовок
+ * @param bool $bUseSession Показать сообщение при следующем обращении пользователя к сайту
+ */
+ public function AddError($sMsg, $sTitle = null, $bUseSession = false)
+ {
+ if (!$bUseSession) {
+ $this->aMsgError[] = array('msg' => $sMsg, 'title' => $sTitle);
+ } else {
+ $this->aMsgErrorSession[] = array('msg' => $sMsg, 'title' => $sTitle);
+ }
+ }
+
+ /**
+ * Создаёт единственное сообщение об ошибке(т.е. очищает все предыдущие)
+ *
+ * @param string $sMsg Сообщение
+ * @param string $sTitle Заголовок
+ * @param bool $bUseSession Показать сообщение при следующем обращении пользователя к сайту
+ */
+ public function AddErrorSingle($sMsg, $sTitle = null, $bUseSession = false)
+ {
+ $this->ClearError();
+ $this->AddError($sMsg, $sTitle, $bUseSession);
+ }
+
+ /**
+ * Добавляет новое сообщение
+ *
+ * @param string $sMsg Сообщение
+ * @param string $sTitle Заголовок
+ * @param bool $bUseSession Показать сообщение при следующем обращении пользователя к сайту
+ */
+ public function AddNotice($sMsg, $sTitle = null, $bUseSession = false)
+ {
+ if (!$bUseSession) {
+ $this->aMsgNotice[] = array('msg' => $sMsg, 'title' => $sTitle);
+ } else {
+ $this->aMsgNoticeSession[] = array('msg' => $sMsg, 'title' => $sTitle);
+ }
+ }
+
+ /**
+ * Создаёт единственное сообщение, удаляя предыдущие
+ *
+ * @param string $sMsg Сообщение
+ * @param string $sTitle Заголовок
+ * @param bool $bUseSession Показать сообщение при следующем обращении пользователя к сайту
+ */
+ public function AddNoticeSingle($sMsg, $sTitle = null, $bUseSession = false)
+ {
+ $this->ClearNotice();
+ $this->AddNotice($sMsg, $sTitle, $bUseSession);
+ }
+
+ /**
+ * Очищает стек сообщений
+ *
+ */
+ public function ClearNotice()
+ {
+ $this->aMsgNotice = array();
+ $this->aMsgNoticeSession = array();
+ }
+
+ /**
+ * Очищает стек ошибок
+ *
+ */
+ public function ClearError()
+ {
+ $this->aMsgError = array();
+ $this->aMsgErrorSession = array();
+ }
+
+ /**
+ * Получает список сообщений об ошибке
+ *
+ * @return array
+ */
+ public function GetError()
+ {
+ return $this->aMsgError;
+ }
+
+ /**
+ * Получает список сообщений
+ *
+ * @return array
+ */
+ public function GetNotice()
+ {
+ return $this->aMsgNotice;
+ }
+
+ /**
+ * Возвращает список сообщений,
+ * которые необходимо поместить в сессию
+ *
+ * @return array
+ */
+ public function GetNoticeSession()
+ {
+ return $this->aMsgNoticeSession;
+ }
+
+ /**
+ * Возвращает список ошибок,
+ * которые необходимо поместить в сессию
+ *
+ * @return array
+ */
+ public function GetErrorSession()
+ {
+ return $this->aMsgErrorSession;
+ }
+}
diff --git a/framework/classes/modules/notify/Notify.class.php b/framework/classes/modules/notify/Notify.class.php
new file mode 100644
index 0000000..05d4f81
--- /dev/null
+++ b/framework/classes/modules/notify/Notify.class.php
@@ -0,0 +1,229 @@
+
+ *
+ */
+
+/**
+ * Модуль рассылок уведомлений пользователям
+ *
+ * @package application.modules.notify
+ * @since 1.0
+ */
+class ModuleNotify extends Module
+{
+ /**
+ * Статусы степени обработки заданий отложенной публикации в базе данных
+ */
+ const NOTIFY_TASK_STATUS_NULL = 1;
+ /**
+ * Объект локального вьювера для рендеринга сообщений
+ *
+ * @var ModuleViewer
+ */
+ protected $oViewerLocal = null;
+ /**
+ * Массив заданий на удаленную публикацию
+ *
+ * @var array
+ */
+ protected $aTask = array();
+ /**
+ * Объект маппера
+ *
+ * @var ModuleNotify_MapperNotify
+ */
+ protected $oMapper = null;
+
+ /**
+ * Префикс шаблонов
+ *
+ * @var string
+ */
+ protected $sPrefix = '';
+
+ /**
+ * Название директории с шаблономи
+ *
+ * @var string
+ */
+ protected $sDir = '';
+
+ /**
+ * Инициализация модуля
+ * Создаём локальный экземпляр модуля Viewer
+ * Момент довольно спорный, но позволяет избавить основной шаблон от мусора уведомлений
+ *
+ */
+ public function Init()
+ {
+ $this->oViewerLocal = $this->Viewer_GetLocalViewer();
+ $this->oMapper = Engine::GetMapper(__CLASS__);
+ $this->sDir = Config::Get('module.notify.dir');
+ $this->sPrefix = Config::Get('module.notify.prefix');
+ }
+
+ /**
+ * Универсальный метод отправки уведомлений на email
+ *
+ * @param ModuleUser_EntityUser|string $oUserTo Кому отправляем (пользователь или email)
+ * @param string $sTemplate Шаблон для отправки
+ * @param string $sSubject Тема письма
+ * @param array $aAssign Ассоциативный массив для загрузки переменных в шаблон письма
+ * @param string|null $sPluginName Плагин из которого происходит отправка
+ * @param bool $bForceSend Отправлять сразу, даже при опции module.notify.delayed = true
+ */
+ public function Send($oUserTo, $sTemplate, $sSubject, $aAssign = array(), $sPluginName = null, $bForceSend = false)
+ {
+ if ($oUserTo instanceof ModuleUser_EntityUser) {
+ $sMail = $oUserTo->getMail();
+ $sName = $oUserTo->getLogin();
+ } else {
+ $sMail = $oUserTo;
+ $sName = '';
+ }
+ /**
+ * Передаём в шаблон переменные
+ */
+ foreach ($aAssign as $k => $v) {
+ $this->oViewerLocal->Assign($k, $v);
+ }
+ /**
+ * Формируем шаблон
+ */
+ $this->oViewerLocal->Assign('isText', false);
+ $sBody = $this->oViewerLocal->Fetch($this->GetTemplatePath($sTemplate, $sPluginName));
+ /**
+ * Альтернативный текст (plain/text)
+ */
+ $this->oViewerLocal->Assign('isText', true);
+ $sBodyAlt = $this->oViewerLocal->Fetch($this->GetTemplatePath($sTemplate, $sPluginName));
+ /**
+ * Если в конфигураторе указан отложенный метод отправки,
+ * то добавляем задание в массив. В противном случае,
+ * сразу отсылаем на email
+ */
+ $oNotifyTask = Engine::GetEntity('Notify_Task');
+ $oNotifyTask->setUserMail($sMail);
+ $oNotifyTask->setUserLogin($sName);
+ $oNotifyTask->setNotifyText($sBody);
+ $oNotifyTask->setNotifyTextAlt($sBodyAlt);
+ $oNotifyTask->setNotifySubject($sSubject);
+ $oNotifyTask->setDateCreated(date("Y-m-d H:i:s"));
+ $oNotifyTask->setNotifyTaskStatus(self::NOTIFY_TASK_STATUS_NULL);
+
+ if (Config::Get('module.notify.delayed') and !$bForceSend) {
+ if (Config::Get('module.notify.insert_single')) {
+ $this->aTask[] = $oNotifyTask;
+ } else {
+ $this->oMapper->AddTask($oNotifyTask);
+ }
+ } else {
+ /**
+ * Отправляем мыло
+ */
+ $this->SendTask($oNotifyTask);
+ }
+ }
+
+ /**
+ * При завершении работы модуля проверяем наличие
+ * отложенных заданий в массиве и при необходимости
+ * передаем их в меппер
+ */
+ public function Shutdown()
+ {
+ if (!empty($this->aTask) && Config::Get('module.notify.delayed')) {
+ $this->oMapper->AddTaskArray($this->aTask);
+ $this->aTask = array();
+ }
+ }
+
+ /**
+ * Получает массив заданий на публикацию из базы с указанным количественным ограничением (выборка FIFO)
+ *
+ * @param int $iLimit Количество
+ * @return array
+ */
+ public function GetTasksDelayed($iLimit = 10)
+ {
+ return ($aResult = $this->oMapper->GetTasks($iLimit))
+ ? $aResult
+ : array();
+ }
+
+ /**
+ * Отправляет на e-mail
+ *
+ * @param ModuleNotify_EntityTask $oTask Объект задания на отправку
+ */
+ public function SendTask($oTask)
+ {
+ $this->Mail_SetAdress($oTask->getUserMail(), $oTask->getUserLogin());
+ $this->Mail_SetSubject($oTask->getNotifySubject());
+ $this->Mail_SetBody($oTask->getNotifyText());
+ if ($oTask->getNotifyTextAlt()) {
+ $this->Mail_SetAltBody($oTask->getNotifyTextAlt());
+ }
+ $this->Mail_setHTML();
+ $this->Mail_Send();
+ }
+
+ /**
+ * Удаляет отложенное Notify-задание из базы
+ *
+ * @param ModuleNotify_EntityTask $oTask Объект задания на отправку
+ * @return bool
+ */
+ public function DeleteTask($oTask)
+ {
+ return $this->oMapper->DeleteTask($oTask);
+ }
+
+ /**
+ * Удаляет отложенные Notify-задания по списку идентификаторов
+ *
+ * @param array $aArrayId Список ID заданий на отправку
+ * @return bool
+ */
+ public function DeleteTaskByArrayId($aArrayId)
+ {
+ return $this->oMapper->DeleteTaskByArrayId($aArrayId);
+ }
+
+ /**
+ * Возвращает путь к шаблону по переданному имени
+ *
+ * @param string $sName Название шаблона
+ * @param string $sPluginName Название или класс плагина
+ * @return string
+ */
+ public function GetTemplatePath($sName, $sPluginName = null)
+ {
+ $sName = $this->sPrefix ? $this->sPrefix . '.' . $sName : $sName;
+ if ($sPluginName) {
+ $sPluginName = preg_match('/^Plugin([\w]+)(_[\w]+)?$/Ui', $sPluginName, $aMatches)
+ ? strtolower($aMatches[1])
+ : strtolower($sPluginName);
+
+ return Plugin::GetTemplatePath($sPluginName) . $this->sDir . '/' . $sName;
+ } else {
+ return $this->sDir . '/' . $sName;
+ }
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/notify/entity/Task.entity.class.php b/framework/classes/modules/notify/entity/Task.entity.class.php
new file mode 100644
index 0000000..becb4e1
--- /dev/null
+++ b/framework/classes/modules/notify/entity/Task.entity.class.php
@@ -0,0 +1,190 @@
+
+ *
+ */
+
+/**
+ * Объект сущности задания на отправку емайла
+ *
+ * @package application.modules.notify
+ * @since 1.0
+ */
+class ModuleNotify_EntityTask extends Entity
+{
+ /**
+ * Возвращает ID задания
+ *
+ * @return int|null
+ */
+ public function getTaskId()
+ {
+ return $this->_getDataOne('notify_task_id');
+ }
+
+ /**
+ * Возвращает емайл
+ *
+ * @return string|null
+ */
+ public function getUserMail()
+ {
+ return $this->_getDataOne('user_mail');
+ }
+
+ /**
+ * Возвращает логин пользователя
+ *
+ * @return string|null
+ */
+ public function getUserLogin()
+ {
+ return $this->_getDataOne('user_login');
+ }
+
+ /**
+ * Возвращает текст сообщения
+ *
+ * @return string|null
+ */
+ public function getNotifyText()
+ {
+ return $this->_getDataOne('notify_text');
+ }
+
+ /**
+ * Возвращает альтернативный текст сообщения (plain/text)
+ *
+ * @return string|null
+ */
+ public function getNotifyTextAlt()
+ {
+ return $this->_getDataOne('notify_text_alt');
+ }
+
+ /**
+ * Возвращает дату создания сообщения
+ *
+ * @return string|null
+ */
+ public function getDateCreated()
+ {
+ return $this->_getDataOne('date_created');
+ }
+
+ /**
+ * Возвращает статус отправки
+ *
+ * @return int|null
+ */
+ public function getTaskStatus()
+ {
+ return $this->_getDataOne('notify_task_status');
+ }
+
+ /**
+ * Возвращает тему сообщения
+ *
+ * @return string|null
+ */
+ public function getNotifySubject()
+ {
+ return $this->_getDataOne('notify_subject');
+ }
+
+
+ /**
+ * Устанавливает ID задания
+ *
+ * @param int $data
+ */
+ public function setTaskId($data)
+ {
+ $this->_aData['notify_task_id'] = $data;
+ }
+
+ /**
+ * Устанавливает емайл
+ *
+ * @param string $data
+ */
+ public function setUserMail($data)
+ {
+ $this->_aData['user_mail'] = $data;
+ }
+
+ /**
+ * Устанавливает логин
+ *
+ * @param string $data
+ */
+ public function setUserLogin($data)
+ {
+ $this->_aData['user_login'] = $data;
+ }
+
+ /**
+ * Устанавливает текст уведомления
+ *
+ * @param string $data
+ */
+ public function setNotifyText($data)
+ {
+ $this->_aData['notify_text'] = $data;
+ }
+
+ /**
+ * Устанавливает альтернативный текст уведомления (plain/text)
+ *
+ * @param string $data
+ */
+ public function setNotifyTextAlt($data)
+ {
+ $this->_aData['notify_text_alt'] = $data;
+ }
+
+ /**
+ * Устанавливает дату создания задания
+ *
+ * @param string $data
+ */
+ public function setDateCreated($data)
+ {
+ $this->_aData['date_created'] = $data;
+ }
+
+ /**
+ * Устанавливает статус задания
+ *
+ * @param int $data
+ */
+ public function setTaskStatus($data)
+ {
+ $this->_aData['notify_task_status'] = $data;
+ }
+
+ /**
+ * Устанавливает тему сообщения
+ *
+ * @param string $data
+ */
+ public function setNotifySubject($data)
+ {
+ $this->_aData['notify_subject'] = $data;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/notify/mapper/Notify.mapper.class.php b/framework/classes/modules/notify/mapper/Notify.mapper.class.php
new file mode 100644
index 0000000..94b68fc
--- /dev/null
+++ b/framework/classes/modules/notify/mapper/Notify.mapper.class.php
@@ -0,0 +1,150 @@
+
+ *
+ */
+
+/**
+ * Маппер для работы с БД
+ *
+ * @package application.modules.notify
+ * @since 1.0
+ */
+class ModuleNotify_MapperNotify extends Mapper
+{
+ /**
+ * Добавляет задание
+ *
+ * @param ModuleNotify_EntityTask $oNotifyTask Объект задания
+ * @return bool
+ */
+ public function AddTask(ModuleNotify_EntityTask $oNotifyTask)
+ {
+ $sql = "
+ INSERT INTO " . Config::Get('db.table.notify_task') . "
+ ( user_login, user_mail, notify_subject, notify_text, notify_text_alt, date_created, notify_task_status )
+ VALUES
+ ( ?, ?, ?, ?, ?, ?, ?d )
+ ";
+
+ if ($this->oDb->query(
+ $sql,
+ $oNotifyTask->getUserLogin(),
+ $oNotifyTask->getUserMail(),
+ $oNotifyTask->getNotifySubject(),
+ $oNotifyTask->getNotifyText(),
+ $oNotifyTask->getNotifyTextAlt(),
+ $oNotifyTask->getDateCreated(),
+ $oNotifyTask->getTaskStatus()
+ ) === 0
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Добавляет задания списком
+ *
+ * @param array $aTasks Список объектов заданий
+ * @return bool
+ */
+ public function AddTaskArray($aTasks)
+ {
+ if (!is_array($aTasks) && count($aTasks) == 0) {
+ return false;
+ }
+
+ $aValues = array();
+ foreach ($aTasks as $oTask) {
+ $aValues[] = "(" . implode(',',
+ array(
+ $this->oDb->escape($oTask->getUserLogin()),
+ $this->oDb->escape($oTask->getUserMail()),
+ $this->oDb->escape($oTask->getNotifySubject()),
+ $this->oDb->escape($oTask->getNotifyText()),
+ $this->oDb->escape($oTask->getNotifyTextAlt()),
+ $this->oDb->escape($oTask->getDateCreated()),
+ $this->oDb->escape($oTask->getTaskStatus())
+ )
+ ) . ")";
+ }
+ $sql = "
+ INSERT INTO " . Config::Get('db.table.notify_task') . "
+ ( user_login, user_mail, notify_subject, notify_text, notify_text_alt, date_created, notify_task_status )
+ VALUES
+ " . implode(', ', $aValues);
+
+ return $this->oDb->query($sql);
+ }
+
+ /**
+ * Удаляет задание
+ *
+ * @param ModuleNotify_EntityTask $oNotifyTask Объект задания
+ * @return bool
+ */
+ public function DeleteTask(ModuleNotify_EntityTask $oNotifyTask)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.notify_task') . "
+ WHERE
+ notify_task_id = ?d
+ ";
+ $res = $this->oDb->query($sql, $oNotifyTask->getTaskId());
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Удаляет отложенные Notify-задания по списку идентификаторов
+ *
+ * @param array $aTaskId Список ID заданий на отправку
+ * @return bool
+ */
+ public function DeleteTaskByArrayId($aTaskId)
+ {
+ $sql = "
+ DELETE FROM " . Config::Get('db.table.notify_task') . "
+ WHERE
+ notify_task_id IN(?a)
+ ";
+ $res = $this->oDb->query($sql, $aTaskId);
+ return $this->IsSuccessful($res);
+ }
+
+ /**
+ * Получает массив заданий на публикацию из базы с указанным количественным ограничением (выборка FIFO)
+ *
+ * @param int $iLimit Количество
+ * @return array
+ */
+ public function GetTasks($iLimit)
+ {
+ $sql = "SELECT *
+ FROM " . Config::Get('db.table.notify_task') . "
+ ORDER BY date_created ASC
+ LIMIT ?d";
+ $aTasks = array();
+ if ($aRows = $this->oDb->select($sql, $iLimit)) {
+ foreach ($aRows as $aTask) {
+ $aTasks[] = Engine::GetEntity('Notify_Task', $aTask);
+ }
+ }
+ return $aTasks;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/plugin/Plugin.class.php b/framework/classes/modules/plugin/Plugin.class.php
new file mode 100644
index 0000000..2f9b535
--- /dev/null
+++ b/framework/classes/modules/plugin/Plugin.class.php
@@ -0,0 +1,415 @@
+
+ *
+ */
+
+/**
+ * Модуль управления плагинами
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModulePlugin extends Module
+{
+ /**
+ * Файл описания плагина
+ *
+ * @var string
+ */
+ const PLUGIN_XML_FILE = 'plugin.xml';
+ /**
+ * Список плагинов
+ *
+ * @var array
+ */
+ protected $aPluginsList = array();
+ /**
+ * Список engine-rewrite`ов (модули, мапперы, экшены, сущности, шаблоны, блоки, евенты, поведения)
+ * Определяет типы объектов, которые может переопределить/унаследовать плагин
+ *
+ * @var array
+ */
+ protected $aDelegates = array(
+ 'module' => array(),
+ 'mapper' => array(),
+ 'action' => array(),
+ 'entity' => array(),
+ 'template' => array(),
+ 'block' => array(),
+ 'event' => array(),
+ 'behavior' => array(),
+ );
+ /**
+ * Стек наследований
+ *
+ * @var array
+ */
+ protected $aInherits = array();
+
+ /**
+ * Инициализация модуля
+ *
+ */
+ public function Init()
+ {
+
+ }
+
+ /**
+ * Перенаправление вызовов на модули, экшены, сущности
+ *
+ * @param string $sType
+ * @param string $sFrom
+ * @param string $sTo
+ * @param string $sSign
+ */
+ public function Delegate($sType, $sFrom, $sTo, $sSign = __CLASS__)
+ {
+ /**
+ * Запрещаем неподписанные делегаты
+ */
+ if (!is_string($sSign) or !strlen($sSign)) {
+ return null;
+ }
+ if (!in_array($sType, array_keys($this->aDelegates)) or !$sFrom or !$sTo) {
+ return null;
+ }
+
+ $this->aDelegates[$sType][trim($sFrom)] = array(
+ 'delegate' => trim($sTo),
+ 'sign' => $sSign
+ );
+ }
+
+ /**
+ * Добавляет в стек наследника класса
+ *
+ * @param string $sFrom
+ * @param string $sTo
+ * @param string $sSign
+ */
+ public function Inherit($sFrom, $sTo, $sSign = __CLASS__)
+ {
+ if (!is_string($sSign) or !strlen($sSign)) {
+ return null;
+ }
+ if (!$sFrom or !$sTo) {
+ return null;
+ }
+
+ $this->aInherits[trim($sFrom)]['items'][] = array(
+ 'inherit' => trim($sTo),
+ 'sign' => $sSign
+ );
+ $this->aInherits[trim($sFrom)]['position'] = count($this->aInherits[trim($sFrom)]['items']) - 1;
+ }
+
+ /**
+ * Сбрасывает текущее положение в цепочке наследования на начало
+ *
+ * @param string $sFrom
+ *
+ * @return bool
+ */
+ public function ResetInheritPosition($sFrom)
+ {
+ $sFrom = trim($sFrom);
+ if (!isset($this->aInherits[$sFrom]['position'])) {
+ return false;
+ }
+ $this->aInherits[$sFrom]['position'] = count($this->aInherits[$sFrom]['items']) - 1;
+ return $this->aInherits[$sFrom]['position'];
+ }
+
+ /**
+ * Получает следующего родителя у наследника.
+ * ВНИМАНИЕ! Данный метод нужно вызвать только из __autoload()
+ *
+ * @param string $sFrom
+ * @return string
+ */
+ public function GetParentInherit($sFrom)
+ {
+ if (!isset($this->aInherits[$sFrom]['items']) or count($this->aInherits[$sFrom]['items']) <= 1 or $this->aInherits[$sFrom]['position'] < 1) {
+ return $sFrom;
+ }
+ $this->aInherits[$sFrom]['position']--;
+ return $this->aInherits[$sFrom]['items'][$this->aInherits[$sFrom]['position']]['inherit'];
+ }
+
+ /**
+ * Возвращает список наследуемых классов
+ *
+ * @param string $sFrom
+ * @return null|array
+ */
+ public function GetInherits($sFrom)
+ {
+ if (isset($this->aInherits[trim($sFrom)])) {
+ return $this->aInherits[trim($sFrom)]['items'];
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает последнего наследника в цепочке
+ *
+ * @param $sFrom
+ * @return null|string
+ */
+ public function GetLastInherit($sFrom)
+ {
+ if (isset($this->aInherits[trim($sFrom)])) {
+ return $this->aInherits[trim($sFrom)]['items'][count($this->aInherits[trim($sFrom)]['items']) - 1];
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает делегат модуля, экшена, сущности.
+ * Если делегат не определен, пытается найти наследника, иначе отдает переданный в качестве sender`a параметр
+ *
+ * @param string $sType
+ * @param string $sFrom
+ * @return string
+ */
+ public function GetDelegate($sType, $sFrom)
+ {
+ if (isset($this->aDelegates[$sType][$sFrom]['delegate'])) {
+ return $this->aDelegates[$sType][$sFrom]['delegate'];
+ } elseif ($aInherit = $this->GetLastInherit($sFrom)) {
+ return $aInherit['inherit'];
+ }
+ return $sFrom;
+ }
+
+ /**
+ * @param string $sType
+ * @param string $sFrom
+ * @return array|null
+ */
+ public function GetDelegates($sType, $sFrom)
+ {
+ if (isset($this->aDelegates[$sType][$sFrom]['delegate'])) {
+ return array($this->aDelegates[$sType][$sFrom]['delegate']);
+ } else {
+ if ($aInherits = $this->GetInherits($sFrom)) {
+ $aReturn = array();
+ foreach (array_reverse($aInherits) as $v) {
+ $aReturn[] = $v['inherit'];
+ }
+ return $aReturn;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает цепочку делегатов
+ *
+ * @param string $sType
+ * @param string $sTo
+ * @return array
+ */
+ public function GetDelegationChain($sType, $sTo)
+ {
+ $sRootDelegater = $this->GetRootDelegater($sType, $sTo);
+ return $this->collectAllDelegatesRecursive($sType, array($sRootDelegater));
+ }
+
+ /**
+ * Возвращает делегируемый класс
+ *
+ * @param string $sType
+ * @param string $sTo
+ * @return string
+ */
+ public function GetRootDelegater($sType, $sTo)
+ {
+ $sItem = $sTo;
+ $sItemDelegater = $this->GetDelegater($sType, $sTo);
+ while (empty($sRootDelegater)) {
+ if ($sItem == $sItemDelegater) {
+ $sRootDelegater = $sItem;
+ }
+ $sItem = $sItemDelegater;
+ $sItemDelegater = $this->GetDelegater($sType, $sItemDelegater);
+ }
+ return $sRootDelegater;
+ }
+
+ /**
+ * Составляет цепочку делегатов
+ *
+ * @param string $sType
+ * @param string $aDelegates
+ * @return array
+ */
+ public function collectAllDelegatesRecursive($sType, $aDelegates)
+ {
+ foreach ($aDelegates as $sClass) {
+ if ($aNewDelegates = $this->GetDelegates($sType, $sClass)) {
+ $aDelegates = array_merge($this->collectAllDelegatesRecursive($sType, $aNewDelegates), $aDelegates);
+ }
+ }
+ return $aDelegates;
+ }
+
+ /**
+ * Возвращает делегирующий объект по имени делегата
+ *
+ * @param string $sType Объект
+ * @param string $sTo Делегат
+ * @return string
+ */
+ public function GetDelegater($sType, $sTo)
+ {
+ static $aCache;
+
+ $sCacheKey = $sType . '+' . $sTo;
+
+ if (!isset($aCache[$sCacheKey])) {
+ $aDelegateMapper = array();
+ foreach ($this->aDelegates[$sType] as $kk => $vv) {
+ if ($vv['delegate'] == $sTo) {
+ $aDelegateMapper[$kk] = $vv;
+ }
+ }
+ if (is_array($aDelegateMapper) and count($aDelegateMapper)) {
+ $aKeys = array_keys($aDelegateMapper);
+ return $aCache[$sCacheKey] = array_shift($aKeys);
+ }
+ foreach ($this->aInherits as $k => $v) {
+ $aInheritMapper = array();
+ foreach ($v['items'] as $kk => $vv) {
+ if ($vv['inherit'] == $sTo) {
+ $aInheritMapper[$kk] = $vv;
+ }
+ }
+ if (is_array($aInheritMapper) and count($aInheritMapper)) {
+ return $aCache[$sCacheKey] = $k;
+ }
+ }
+ $aCache[$sCacheKey] = $sTo;
+ }
+ return $aCache[$sCacheKey];
+ }
+
+ /**
+ * Возвращает подпись делегата модуля, экшена, сущности.
+ *
+ * @param string $sType
+ * @param string $sFrom
+ * @return string|null
+ */
+ public function GetDelegateSign($sType, $sFrom)
+ {
+ if (isset($this->aDelegates[$sType][$sFrom]['sign'])) {
+ return $this->aDelegates[$sType][$sFrom]['sign'];
+ }
+ if ($aInherit = $this->GetLastInherit($sFrom)) {
+ return $aInherit['sign'];
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает true, если установлено правило делегирования
+ * и класс является базовым в данном правиле
+ *
+ * @param string $sType
+ * @param string $sFrom
+ * @return bool
+ */
+ public function isDelegater($sType, $sFrom)
+ {
+ if (isset($this->aDelegates[$sType][$sFrom]['delegate'])) {
+ return true;
+ } elseif ($aInherit = $this->GetLastInherit($sFrom)) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает true, если устано
+ *
+ * @param string $sType
+ * @param string $sTo
+ * @return bool
+ */
+ public function isDelegated($sType, $sTo)
+ {
+ /**
+ * Фильтруем маппер делегатов/наследников
+ * @var array
+ */
+ $aDelegateMapper = array();
+ foreach ($this->aDelegates[$sType] as $kk => $vv) {
+ if ($vv['delegate'] == $sTo) {
+ $aDelegateMapper[$kk] = $vv;
+ }
+ }
+ if (is_array($aDelegateMapper) and count($aDelegateMapper)) {
+ return true;
+ }
+ foreach ($this->aInherits as $k => $v) {
+ $aInheritMapper = array();
+ foreach ($v['items'] as $kk => $vv) {
+ if ($vv['inherit'] == $sTo) {
+ $aInheritMapper[$kk] = $vv;
+ }
+ }
+ if (is_array($aInheritMapper) and count($aInheritMapper)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает список объектов, доступных для делегирования
+ *
+ * @return array
+ */
+ public function GetDelegateObjectList()
+ {
+ return array_keys($this->aDelegates);
+ }
+
+ /**
+ * Возвращает полный список всех делегатов
+ *
+ * @return array
+ */
+ public function GetDelegatesAll()
+ {
+ return $this->aDelegates;
+ }
+
+ /**
+ * Возвращает полый список всех наследований
+ *
+ * @return array
+ */
+ public function GetInheritsAll()
+ {
+ return $this->aInherits;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/plugin_manager/PluginManager.class.php b/framework/classes/modules/plugin_manager/PluginManager.class.php
new file mode 100644
index 0000000..1dfa2d3
--- /dev/null
+++ b/framework/classes/modules/plugin_manager/PluginManager.class.php
@@ -0,0 +1,709 @@
+
+ *
+ */
+
+/**
+ * Модуль управления плагинами - установка, обновление, удаление
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModulePluginManager extends ModuleORM
+{
+ /**
+ * Путь к директории с плагинами
+ *
+ * @var string
+ */
+ protected $sPluginsDir;
+
+ /**
+ * Инициализация модуля
+ *
+ */
+ public function Init()
+ {
+ parent::Init();
+ $this->sPluginsDir = Config::Get('path.application.plugins.server') . '/';
+ }
+
+ /**
+ * Выполняет активацию плагина
+ *
+ * @param $sPlugin
+ *
+ * @return bool
+ */
+ public function ActivatePlugin($sPlugin)
+ {
+ if (!$this->CheckPluginsFileWritable()) {
+ $this->Message_AddError($this->Lang_Get('admin.plugins.notices.activation_file_write_error'),
+ $this->Lang_Get('common.error.error'), true);
+ return false;
+ }
+ $sPlugin = strtolower($sPlugin);
+ /**
+ * Получаем xml информацию
+ */
+ if (!$oXml = $this->GetPluginXmlInfo($sPlugin)) {
+ return false;
+ }
+ $sClassPlugin = 'Plugin' . func_camelize($sPlugin);
+ if (!class_exists($sClassPlugin)) {
+ return false;
+ }
+ $aPluginItemsActive = $this->GetPluginsActive();
+ $oPlugin = new $sClassPlugin;
+
+ if (in_array($sPlugin, $aPluginItemsActive)) {
+ $this->Message_AddError($this->Lang_Get('admin.plugins.notices.activation_already_error'),
+ $this->Lang_Get('common.error.error'), true);
+ return false;
+ }
+ /**
+ * Проверяем совместимость с версией LS
+ */
+ if (defined('LS_VERSION')
+ and version_compare(LS_VERSION, (string)$oXml->requires->livestreet, '<')
+ ) {
+ $this->Message_AddError(
+ $this->Lang_Get('admin.plugins.notices.activation_version_error',
+ array('version' => $oXml->requires->livestreet)),
+ $this->Lang_Get('common.error.error'), true
+ );
+ return false;
+ }
+ /**
+ * Проверяем наличие require-плагинов
+ */
+ if ($oXml->requires->plugins) {
+ $iConflict = 0;
+ foreach ($oXml->requires->plugins->children() as $sReqPlugin) {
+ if (!in_array($sReqPlugin, $aPluginItemsActive)) {
+ $iConflict++;
+ $this->Message_AddError(
+ $this->Lang_Get('admin.plugins.notices.activation_requires_error',
+ array('plugin' => func_camelize($sReqPlugin))),
+ $this->Lang_Get('common.error.error'), true
+ );
+ }
+ }
+ if ($iConflict) {
+ return false;
+ }
+ }
+ /**
+ * Проверяем на конфликт делегатов
+ */
+ $aPluginDelegates = $oPlugin->GetDelegates();
+ $aPluginInherits = $oPlugin->GetInherits();
+ $aAllDelegates = $this->Plugin_GetDelegatesAll();
+ $aAllInherits = $this->Plugin_GetInheritsAll();
+ /**
+ * Проверяем, не вступает ли данный плагин в конфликт с уже активированными
+ * (по поводу объявленных делегатов)
+ */
+ $iConflict = 0;
+ foreach ($aAllDelegates as $sGroup => $aReplaceList) {
+ $iCount = 0;
+ if (isset($aPluginDelegates[$sGroup])
+ and is_array($aPluginDelegates[$sGroup])
+ and $iCount = count($aOverlap = array_intersect_key($aReplaceList, $aPluginDelegates[$sGroup]))
+ ) {
+ $iConflict += $iCount;
+ foreach ($aOverlap as $sResource => $aConflict) {
+ $this->Message_AddError(
+ $this->Lang_Get('admin.plugins.notices.activation_overlap', array(
+ 'resource' => $sResource,
+ 'delegate' => $aConflict['delegate'],
+ 'plugin' => $aConflict['sign']
+ )), $this->Lang_Get('common.error.error'), true
+ );
+ }
+ }
+ if (isset($aPluginInherits[$sGroup])
+ and is_array($aPluginInherits[$sGroup])
+ and $iCount = count($aOverlap = array_intersect_key($aReplaceList, $aPluginInherits[$sGroup]))
+ ) {
+ $iConflict += $iCount;
+ foreach ($aOverlap as $sResource => $aConflict) {
+ $this->Message_AddError(
+ $this->Lang_Get('admin.plugins.notices.activation_overlap', array(
+ 'resource' => $sResource,
+ 'delegate' => $aConflict['delegate'],
+ 'plugin' => $aConflict['sign']
+ )), $this->Lang_Get('common.error.error'), true
+ );
+ }
+ }
+ if ($iCount) {
+ return false;
+ }
+ }
+ /**
+ * Проверяем на конфликт с наследуемыми классами
+ */
+ $iConflict = 0;
+ foreach ($aPluginDelegates as $sGroup => $aReplaceList) {
+ foreach ($aReplaceList as $sResource => $aConflict) {
+ if (isset($aAllInherits[$sResource])) {
+ $iConflict += count($aAllInherits[$sResource]['items']);
+ foreach ($aAllInherits[$sResource]['items'] as $aItem) {
+ $this->Message_AddError(
+ $this->Lang_Get('admin.plugins.notices.activation_overlap_inherit', array(
+ 'resource' => $sResource,
+ 'plugin' => $aItem['sign']
+ )),
+ $this->Lang_Get('common.error.error'), true
+ );
+ }
+ }
+ }
+ }
+ if ($iConflict) {
+ return false;
+ }
+ /**
+ * Кастомный функционал активации плагина
+ */
+ if ($bResult = $oPlugin->Activate()) {
+ /**
+ * Выполняем актулизацию БД плагина - миграции
+ */
+ $this->ApplyPluginUpdate($sPlugin);
+ /**
+ * Записываем в файл
+ */
+ $aPluginItemsActive[] = $sPlugin;
+ if (!$this->WriteActivePlugins($aPluginItemsActive)) {
+ return false;
+ }
+ }
+ return $bResult;
+ }
+
+ /**
+ * Выполняет деактивацию плагина
+ *
+ * @param $sPlugin
+ *
+ * @return bool
+ */
+ public function DeactivatePlugin($sPlugin)
+ {
+ if (!$this->CheckPluginsFileWritable()) {
+ $this->Message_AddError($this->Lang_Get('admin.plugins.notices.activation_file_write_error'),
+ $this->Lang_Get('common.error.error'), true);
+ return false;
+ }
+ $sPlugin = strtolower($sPlugin);
+ /**
+ * Получаем xml информацию
+ */
+ if (!$oXml = $this->GetPluginXmlInfo($sPlugin)) {
+ return false;
+ }
+ $sClassPlugin = 'Plugin' . func_camelize($sPlugin);
+ if (!class_exists($sClassPlugin)) {
+ return false;
+ }
+ $aPluginItemsActive = $this->GetPluginsActive();
+ $oPlugin = new $sClassPlugin;
+
+ if (!in_array($sPlugin, $aPluginItemsActive)) {
+ $this->Message_AddError($this->Lang_Get('admin.plugins.notices.deactivation_already_error'),
+ $this->Lang_Get('common.error.error'), true);
+ return false;
+ }
+ /**
+ * Проверяем на зависимость других плагинов через опцию requires
+ */
+ $aPluginItemsAll = $this->PluginManager_GetPluginsItems();
+ $iConflict = 0;
+
+ foreach ($aPluginItemsAll as $sPluginItemName => $oPluginItem) {
+ if (!$oPluginItem['is_active']) {
+ continue;
+ }
+
+ if ($oPluginItem['property']->requires->plugins) {
+ foreach ($oPluginItem['property']->requires->plugins->children() as $sReqPlugin) {
+ if ($sReqPlugin == $sPlugin) {
+ $iConflict++;
+ $this->Message_AddError(
+ $this->Lang_Get('admin.plugins.notices.deactivation_requires_error',
+ array('plugin' => func_camelize($oPluginItem['code']))),
+ $this->Lang_Get('common.error.error'),
+ true
+ );
+ }
+ }
+ }
+ }
+
+ if ($iConflict) {
+ return false;
+ }
+ /**
+ * Кастомный функционал деактивации плагина
+ */
+ if ($bResult = $oPlugin->Deactivate()) {
+ if (false !== ($iIndex = array_search($sPlugin, $aPluginItemsActive))) {
+ unset($aPluginItemsActive[$iIndex]);
+ if (!$this->WriteActivePlugins($aPluginItemsActive)) {
+ return false;
+ }
+ }
+ }
+ return $bResult;
+ }
+
+ /**
+ * Удаляет физически плагин с сервера
+ *
+ * @param $sPlugin
+ *
+ * @return bool
+ */
+ public function RemovePlugin($sPlugin)
+ {
+ $sPlugin = strtolower($sPlugin);
+ $aPluginItemsActive = $this->GetPluginsActive();
+ /**
+ * Если плагин активен, деактивируем его
+ */
+ if (in_array($sPlugin, $aPluginItemsActive)) {
+ if (!$this->DeactivatePlugin($sPlugin)) {
+ return false;
+ }
+ }
+ $sClassPlugin = 'Plugin' . func_camelize($sPlugin);
+ $oPlugin = new $sClassPlugin;
+ /**
+ * Сначала очищаем данные БД от плагина, а затем выполняем кастомный метод удаления плагина
+ */
+ if ($oPlugin->Remove()) {
+ /**
+ * Делаем откат изменений БД, которые делал плагин (откат миграций)
+ */
+ $this->PurgePluginUpdate($sPlugin);
+ /**
+ * Удаляем директорию с плагином
+ */
+ func_rmdir($this->sPluginsDir . $sPlugin);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает список плагинов с XML описанием
+ *
+ * @param array $aFilter
+ *
+ * @return array
+ */
+ public function GetPluginsItems($aFilter = array())
+ {
+ $aPluginItemsReturn = array();
+ $aPluginCodes = func_list_plugins(true);
+ $aPluginItemsActive = $this->GetPluginsActive();
+
+ /**
+ * Получаем версии из БД для всех плагинов
+ */
+ if ($aPluginCodes) {
+ $aVersionItems = $this->GetVersionItemsByFilter(array('code in' => $aPluginCodes, '#index-from' => 'code'));
+ } else {
+ $aVersionItems = array();
+ }
+
+ foreach ($aPluginCodes as $sPluginCode) {
+ /**
+ * Получаем из XML файла описания
+ */
+ if ($oXml = $this->GetPluginXmlInfo($sPluginCode)) {
+ if (isset($aVersionItems[$sPluginCode])) {
+ $sVersionDb = $aVersionItems[$sPluginCode]->getVersion();
+ } else {
+ $sVersionDb = null;
+ }
+ $aInfo = array(
+ 'code' => $sPluginCode,
+ 'is_active' => in_array($sPluginCode, $aPluginItemsActive),
+ 'property' => $oXml,
+ 'apply_update' => (is_null($sVersionDb) or version_compare($sVersionDb, (string)$oXml->version,
+ '<')) ? true : false,
+ );
+ $aPluginItemsReturn[$sPluginCode] = $aInfo;
+ }
+ }
+ /**
+ * Если нужно сортировать плагины
+ */
+ if (isset($aFilter['order'])) {
+ if ($aFilter['order'] == 'name') {
+ uasort($aPluginItemsReturn, function ($a, $b) {
+ if ((string)$a['property']->name->data == (string)$b['property']->name->data) {
+ return 0;
+ }
+ return ((string)$a['property']->name->data < (string)$b['property']->name->data) ? -1 : 1;
+ });
+ }
+ }
+ return $aPluginItemsReturn;
+ }
+
+ /**
+ * Возвращает XML объект описания плагина
+ *
+ * @param $sPlugin
+ *
+ * @return null|SimpleXMLElement
+ */
+ public function GetPluginXmlInfo($sPlugin)
+ {
+ /**
+ * Считываем данные из XML файла описания
+ */
+ $sPluginXML = $this->sPluginsDir . $sPlugin . '/plugin.xml';
+ if ($oXml = @simplexml_load_file($sPluginXML)) {
+ /**
+ * Обрабатываем данные, считанные из XML-описания
+ */
+ $sLang = $this->Lang_GetLang();
+
+ $this->Xlang($oXml, 'name', $sLang);
+ $this->Xlang($oXml, 'author', $sLang);
+ $this->Xlang($oXml, 'description', $sLang);
+ $oXml->homepage = $this->Text_Parser((string)$oXml->homepage);
+ $oXml->settings = preg_replace('/{([^}]+)}/', Router::GetPath('$1'), $oXml->settings);
+ return $oXml;
+ }
+ return null;
+ }
+
+ /**
+ * Выполняет установку плагина из магазина LS
+ *
+ * @param $sPlugin
+ *
+ * @return bool
+ */
+ public function InstallPluginFromCatalogLS($sPlugin)
+ {
+ var_dump('install from catalog - ' . $sPlugin);
+ return true;
+ }
+
+ /**
+ * Выполняет установку плагина из локальной директории
+ *
+ * @param $sPlugin
+ * @param $sDir
+ *
+ * @return bool
+ */
+ public function InstallPluginFromDir($sPlugin, $sDir)
+ {
+ var_dump('install from catalog - ' . $sPlugin);
+ return true;
+ }
+
+
+ /**
+ * Записывает список активных плагинов в файл PLUGINS.DAT
+ *
+ * @param array|string $aPlugins Список плагинов
+ * @return bool
+ */
+ public function WriteActivePlugins($aPlugins)
+ {
+ if (!$this->CheckPluginsFileWritable()) {
+ return false;
+ }
+ if (!is_array($aPlugins)) {
+ $aPlugins = array($aPlugins);
+ }
+ $aPlugins = array_unique(array_map('trim', $aPlugins));
+ /**
+ * Записываем данные в файл PLUGINS.DAT
+ */
+ if (@file_put_contents($this->sPluginsDir . Config::Get('sys.plugins.activation_file'),
+ implode(PHP_EOL, $aPlugins)) !== false
+ ) {
+ /**
+ * Сбрасываем весь кеш, т.к. могут быть закешированы унаследованые плагинами сущности
+ */
+ $this->Cache_Clean();
+ /**
+ * Сбрасываем отдельный кеш ORM
+ */
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_ALL, array(), 'file_orm', true);
+ /**
+ * Очищаем компиленые шаблоны от Smarty
+ */
+ $this->Viewer_ClearCompiledTemplates();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет доступность файла plugins.dat на запись
+ *
+ * @return bool
+ */
+ public function CheckPluginsFileWritable()
+ {
+ if (@is_writable($this->sPluginsDir . Config::Get('sys.plugins.activation_file'))) {
+ return true;
+ }
+ /**
+ * Возможно файла еще не существует
+ */
+ if (!file_exists($this->sPluginsDir . Config::Get('sys.plugins.activation_file'))) {
+ if (false !== @file_put_contents($this->sPluginsDir . Config::Get('sys.plugins.activation_file'), '')) {
+ @chmod($this->sPluginsDir . Config::Get('sys.plugins.activation_file'), 0666);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Возвращает список активных плагинов
+ *
+ * @return array
+ */
+ public function GetPluginsActive()
+ {
+ return array_keys(Engine::getInstance()->GetPlugins());
+ }
+
+ /**
+ * Получает значение параметра из XML на основе языковой разметки
+ *
+ * @param SimpleXMLElement $oXml XML узел
+ * @param string $sProperty Свойство, которое нужно вернуть
+ * @param string $sLang Название языка
+ */
+ protected function Xlang($oXml, $sProperty, $sLang)
+ {
+ $sProperty = trim($sProperty);
+
+ if (!count($data = $oXml->xpath("{$sProperty}/lang[@name='{$sLang}']"))) {
+ /**
+ * Пробуем получить язык в старом полном формате (ru -> russian)
+ */
+ $sLangOld = Config::Get('module.lang.i18n_mapping.' . $sLang);
+ if (!$sLangOld or !count($data = $oXml->xpath("{$sProperty}/lang[@name='{$sLangOld}']"))) {
+ $data = $oXml->xpath("{$sProperty}/lang[@name='default']");
+ }
+ }
+ $oXml->$sProperty->data = $this->Text_Parser(trim((string)array_shift($data)));
+ }
+
+ /**
+ * Выполняет актулизацию данных БД для плагина (применение миграций)
+ * Обратный по действию метод - PurgePluginUpdate (@see PurgePluginUpdate)
+ *
+ * @param $sPlugin
+ */
+ public function ApplyPluginUpdate($sPlugin)
+ {
+ $sPlugin = strtolower($sPlugin);
+ /**
+ * Получаем текущую версию плагина из XML описания
+ */
+ if (!$oXml = $this->GetPluginXmlInfo($sPlugin)) {
+ return;
+ }
+ $sVersionByFile = (string)$oXml->version;
+ /**
+ * Получаем текущую версию плагина из БД
+ */
+ if ($oVersion = $this->GetVersionByCode($sPlugin)) {
+ $sVersionByDb = $oVersion->getVersion();
+ } else {
+ $sVersionByDb = null;
+ }
+
+ if ($sVersionByFile == $sVersionByDb) {
+ return;
+ }
+ if (!$oVersion) {
+ $oVersion = Engine::GetEntity('ModulePluginManager_EntityVersion');
+ $oVersion->setCode($sPlugin);
+ }
+ /**
+ * Получаем новые файлы обновлений
+ */
+ $aVersionFiles = $this->GetUpdateNewFiles($sPlugin, $sVersionByDb);
+ foreach ($aVersionFiles as $sVersion => $aFiles) {
+ /**
+ * Выполняем файлы
+ */
+ if ($aFiles) {
+ foreach ($aFiles as $aFile) {
+ require_once($aFile['path']);
+ $sClass = 'Plugin' . func_camelize($sPlugin) . '_Update_' . $aFile['name'];
+ $oUpdate = new $sClass;
+ $oUpdate->up();
+ /**
+ * Сохраняем в БД
+ */
+ if (!$this->GetMigrationByCodeAndFile($sPlugin, $aFile['file'])) {
+ $oMigration = Engine::GetEntity('ModulePluginManager_EntityMigration');
+ $oMigration->setCode($sPlugin);
+ $oMigration->setVersion($sVersion);
+ $oMigration->setFile($aFile['file']);
+ $oMigration->Add();
+ }
+ }
+ }
+ }
+ /**
+ * Проставляем версию из описания плагина
+ */
+ $oVersion->setVersion($sVersionByFile);
+ $oVersion->Save();
+ }
+
+ /**
+ * Выполняет откат изменений плагина к БД
+ * Обратный по действию метод - ApplyPluginUpdate (@see ApplyPluginUpdate)
+ *
+ * @param $sPlugin
+ */
+ protected function PurgePluginUpdate($sPlugin)
+ {
+ $sPlugin = strtolower($sPlugin);
+ $sPluginDir = Plugin::GetPath($sPlugin) . 'update/';
+ /**
+ * Получаем список выполненых миграций из БД
+ */
+ $aMigrationItemsGroup = $this->GetMigrationItemsByFilter(array(
+ 'code' => $sPlugin,
+ '#order' => array('file' => 'asc'),
+ '#index-group' => 'version'
+ ));
+ $aMigrationItemsGroup = array_reverse($this->SortVersions($aMigrationItemsGroup, true), true);
+ foreach ($aMigrationItemsGroup as $sVersion => $aMigrationItems) {
+ foreach ($aMigrationItems as $oMigration) {
+ $sPath = $sPluginDir . $sVersion . '/' . $oMigration->getFile();
+ if (file_exists($sPath)) {
+ require_once($sPath);
+ $sClass = 'Plugin' . func_camelize($sPlugin) . '_Update_' . basename($oMigration->getFile(),
+ '.php');
+ $oUpdate = new $sClass;
+ $oUpdate->down();
+ }
+ /**
+ * Удаляем запись из БД
+ */
+ $oMigration->Delete();
+ }
+ }
+ /**
+ * Удаляем версию
+ */
+ if ($oVersion = $this->GetVersionByCode($sPlugin)) {
+ $oVersion->Delete();
+ }
+ /**
+ * Удаляем данные плагина из хранилища настроек
+ */
+ $this->Storage_RemoveAll('Plugin' . func_camelize($sPlugin));
+ $this->Storage_Remove('__config__', 'Plugin' . func_camelize($sPlugin)); // хардим удаление конфига админки
+ }
+
+ /**
+ * Возврашает список файлов миграций по версиям
+ *
+ * @param $sPlugin
+ * @param null $sVersionFrom
+ *
+ * @return array
+ */
+ public function GetUpdateNewFiles($sPlugin, $sVersionFrom = null)
+ {
+ $sPluginDir = Plugin::GetPath($sPlugin) . 'update/';
+ /**
+ * Получаем список каталогов-версий в /update/
+ */
+ $aVersions = array();
+ $aPaths = glob($sPluginDir . '*', GLOB_ONLYDIR);
+ if ($aPaths) {
+ foreach ($aPaths as $sPath) {
+ $aVersions[] = basename($sPath);
+ }
+ }
+ $aVersions = $this->SortVersions($aVersions);
+ /**
+ * Оставляем только новые версии
+ */
+ if ($sVersionFrom and false !== ($iPos = array_search($sVersionFrom, $aVersions))) {
+ $aVersions = array_slice($aVersions, ++$iPos);
+ }
+ /**
+ * Получаем список файлов для каждой версии
+ */
+ $aResultFiles = array();
+ foreach ($aVersions as $sVersion) {
+ $aResultFiles[$sVersion] = array();
+ $aFiles = glob($sPluginDir . "{$sVersion}/*.php");
+ if ($aFiles) {
+ foreach ($aFiles as $sFile) {
+ $aResultFiles[$sVersion][] = array(
+ 'name' => basename($sFile, '.php'),
+ 'file' => basename($sFile),
+ 'path' => $sFile,
+ );
+ }
+ }
+ }
+ return $aResultFiles;
+ }
+
+ /**
+ * Выполняет сортировку массива версий
+ *
+ * @param $aVersions
+ * @param bool $bUseKeys
+ *
+ * @return mixed
+ */
+ protected function SortVersions($aVersions, $bUseKeys = false)
+ {
+ $funcSort = function ($a, $b) {
+ if ($a == $b) {
+ return 0;
+ }
+ return version_compare($a, $b);
+ };
+ if ($bUseKeys) {
+ uksort($aVersions, $funcSort);
+ } else {
+ usort($aVersions, $funcSort);
+ }
+ return $aVersions;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/plugin_manager/entity/Migration.entity.class.php b/framework/classes/modules/plugin_manager/entity/Migration.entity.class.php
new file mode 100644
index 0000000..716722b
--- /dev/null
+++ b/framework/classes/modules/plugin_manager/entity/Migration.entity.class.php
@@ -0,0 +1,40 @@
+
+ *
+ */
+
+/**
+ * Сущность миграции (обновления плагина)
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModulePluginManager_EntityMigration extends EntityORM
+{
+
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ if ($this->_isNew()) {
+ $this->setDateCreate(date("Y-m-d H:i:s"));
+ }
+ }
+ return $bResult;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/plugin_manager/entity/Update.entity.class.php b/framework/classes/modules/plugin_manager/entity/Update.entity.class.php
new file mode 100644
index 0000000..fb67ca8
--- /dev/null
+++ b/framework/classes/modules/plugin_manager/entity/Update.entity.class.php
@@ -0,0 +1,110 @@
+
+ *
+ */
+
+/**
+ * От этого класса необходимо наследовать классы апдейтов/миграций плагина
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+abstract class ModulePluginManager_EntityUpdate extends EntityORM
+{
+ /**
+ * Выполняется при обновлении версии
+ */
+ public function up()
+ {
+
+ }
+
+ /**
+ * Выполняется при откате версии
+ */
+ public function down()
+ {
+
+ }
+
+ /**
+ * Транслирует на базу данных запросы из указанного файла
+ * @see ModuleDatabase::ExportSQL
+ *
+ * @param string $sFilePath Полный путь до файла с SQL
+ * @return array
+ */
+ protected function exportSQL($sFilePath)
+ {
+ return $this->Database_ExportSQL($sFilePath);
+ }
+
+ /**
+ * Выполняет SQL
+ * @see ModuleDatabase::ExportSQLQuery
+ *
+ * @param string $sSql Строка SQL запроса
+ * @return array
+ */
+ protected function exportSQLQuery($sSql)
+ {
+ return $this->Database_ExportSQLQuery($sSql);
+ }
+
+ /**
+ * Проверяет наличие таблицы в БД
+ * @see ModuleDatabase::isTableExists
+ *
+ * @param string $sTableName Название таблицы, необходимо перед именем таблицы добавлять "prefix_", это позволит учитывать произвольный префикс таблиц у пользователя
+ *
+ * prefix_topic
+ *
+ * @return bool
+ */
+ protected function isTableExists($sTableName)
+ {
+ return $this->Database_isTableExists($sTableName);
+ }
+
+ /**
+ * Проверяет наличие поля в таблице
+ * @see ModuleDatabase::isFieldExists
+ *
+ * @param string $sTableName Название таблицы, необходимо перед именем таблицы добавлять "prefix_", это позволит учитывать произвольный префикс таблиц у пользователя
+ * @param string $sFieldName Название поля в таблице
+ * @return bool
+ */
+ protected function isFieldExists($sTableName, $sFieldName)
+ {
+ return $this->Database_isFieldExists($sTableName, $sFieldName);
+ }
+
+ /**
+ * Добавляет новый тип в поле enum(перечисление)
+ * @see ModuleDatabase::addEnumType
+ *
+ * @param string $sTableName Название таблицы, необходимо перед именем таблицы добавлять "prefix_", это позволит учитывать произвольный префикс таблиц у пользователя
+ * @param string $sFieldName Название поля в таблице
+ * @param string $sType Название типа
+ */
+ protected function addEnumType($sTableName, $sFieldName, $sType)
+ {
+ $this->Database_addEnumType($sTableName, $sFieldName, $sType);
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/plugin_manager/entity/Version.entity.class.php b/framework/classes/modules/plugin_manager/entity/Version.entity.class.php
new file mode 100644
index 0000000..ae9c176
--- /dev/null
+++ b/framework/classes/modules/plugin_manager/entity/Version.entity.class.php
@@ -0,0 +1,39 @@
+
+ *
+ */
+
+/**
+ * Сущность версии плагина
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModulePluginManager_EntityVersion extends EntityORM
+{
+
+ protected function beforeSave()
+ {
+ if ($bResult = parent::beforeSave()) {
+ $this->setDateUpdate(date("Y-m-d H:i:s"));
+ }
+ return $bResult;
+ }
+
+}
\ No newline at end of file
diff --git a/framework/classes/modules/security/Security.class.php b/framework/classes/modules/security/Security.class.php
new file mode 100644
index 0000000..37d304e
--- /dev/null
+++ b/framework/classes/modules/security/Security.class.php
@@ -0,0 +1,129 @@
+
+ *
+ */
+
+/**
+ * Модуль безопасности
+ * Необходимо использовать перед обработкой отправленной формы:
+ *
+ * if (getRequest('submit_add')) {
+ * $this->Security_ValidateSendForm();
+ * // далее код обработки формы
+ * ......
+ * }
+ *
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModuleSecurity extends Module
+{
+ /**
+ * Инициализируем модуль
+ *
+ */
+ public function Init()
+ {
+
+ }
+
+ /**
+ * Производит валидацию отправки формы/запроса от пользователя, позволяет избежать атаки CSRF
+ *
+ * @param bool $bDie Определяет завершать работу скрипта или нет
+ * @param string|null $sCode Код для проверки в ValidateSecurityKey, если нет то берется из реквеста
+ *
+ * @return bool
+ */
+ public function ValidateSendForm($bDie = true, $sCode = null)
+ {
+ if (!$this->ValidateSecurityKey($sCode)) {
+ if ($bDie) {
+ die("Hacking attemp!");
+ } else {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Проверка на соотвествие реферала
+ *
+ * @return bool
+ */
+ public function ValidateReferer()
+ {
+ if (isset($_SERVER['HTTP_REFERER'])) {
+ $aUrl = parse_url($_SERVER['HTTP_REFERER']);
+ if (isset($aUrl['host'])) {
+ $aRoot = parse_url(Config::Get('path.root.web'));
+ if (isset($aRoot['host'])) {
+ if (strcasecmp($aUrl['host'], $aRoot['host']) == 0) {
+ return true;
+ } elseif (preg_match("/\." . quotemeta($aRoot['host']) . "$/i", $aUrl['host'])) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет наличие security-ключа в сессии
+ *
+ * @param null|string $sCode Код для проверки, если нет то берется из реквеста
+ * @return bool
+ */
+ public function ValidateSecurityKey($sCode = null)
+ {
+ if (!$sCode) {
+ $sCode = getRequestStr('security_ls_key');
+ }
+ return ($sCode == $this->GetSecurityKey());
+ }
+
+ /**
+ * Возвращает текущий security-ключ
+ */
+ public function GetSecurityKey()
+ {
+ return $this->GenerateSecurityKey();
+ }
+
+ /**
+ * Генерирует и возвращает security-ключ
+ *
+ * @return string
+ */
+ protected function GenerateSecurityKey()
+ {
+ /**
+ * Сначала получаем уникальные данные пользователя по его браузеру
+ */
+ $sDataForHash = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
+ /**
+ * Далее добавляем ID сессии и уникальный ключ из конфига
+ */
+ $sDataForHash .= $this->Session_GetId() . Config::Get('module.security.hash');
+ return md5($sDataForHash);
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/session/Session.class.php b/framework/classes/modules/session/Session.class.php
new file mode 100644
index 0000000..0f99b39
--- /dev/null
+++ b/framework/classes/modules/session/Session.class.php
@@ -0,0 +1,217 @@
+
+ *
+ */
+
+/**
+ * Модуль для работы с сессиями
+ * Выступает в качестве врапера для стандартного механизма сессий
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModuleSession extends Module
+{
+ /**
+ * Список user-agent'ов для флеш плеера
+ * Используется для передачи ID сессии при обращениии к сайту через flash, например, загрузка файлов через flash
+ *
+ * @var array
+ */
+ protected $aFlashUserAgent = array(
+ 'Shockwave Flash'
+ );
+
+ /**
+ * Инициализация модуля
+ *
+ */
+ public function Init()
+ {
+ /**
+ * Стартуем сессию
+ */
+ $this->Start();
+ }
+
+ /**
+ * Старт сессии
+ *
+ */
+ protected function Start()
+ {
+ session_name(Config::Get('sys.session.name'));
+ session_set_cookie_params(
+ Config::Get('sys.session.timeout'),
+ Config::Get('sys.session.path'),
+ Config::Get('sys.session.host'),
+ Config::Get('sys.session.secure'),
+ Config::Get('sys.session.httponly')
+ );
+ if (!session_id()) {
+ /**
+ * Попытка подменить идентификатор имени сессии через куку
+ */
+ if (isset($_COOKIE[Config::Get('sys.session.name')])) {
+ if (!is_string($_COOKIE[Config::Get('sys.session.name')])) {
+ die("Hacking attempt! Please check cookie PHP session name.");
+ }
+ if (!preg_match('#^[a-z0-9,-]{1,40}$#i', $_COOKIE[Config::Get('sys.session.name')])) {
+ die("Hacking attempt! Please check cookie PHP session id.");
+ }
+ }
+ /**
+ * Попытка подменить идентификатор имени сессии в реквесте
+ */
+ $aRequest = array_merge($_GET, $_POST); // Исключаем попадаение $_COOKIE в реквест
+ if (@ini_get('session.use_only_cookies') === "0" and isset($aRequest[Config::Get('sys.session.name')]) and !is_string($aRequest[Config::Get('sys.session.name')])) {
+ die("Hacking attempt! Please check cookie PHP session name.");
+ }
+ /**
+ * Даем возможность флешу задавать id сессии
+ */
+ $sUserAgent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : null;
+ if ($sUserAgent and (in_array($sUserAgent, $this->aFlashUserAgent) or strpos($sUserAgent,
+ "Adobe Flash Player") === 0) and is_string(getRequest('SSID')) and preg_match("/^[\w\d]{5,40}$/",
+ getRequest('SSID'))
+ ) {
+ session_id(getRequest('SSID'));
+ } else {
+ if (session_status() == PHP_SESSION_ACTIVE) {
+ session_regenerate_id();
+ }
+ }
+
+ @session_start();
+ }
+ }
+
+ /**
+ * Получает идентификатор текущей сессии
+ *
+ */
+ public function GetId()
+ {
+ return session_id();
+ }
+
+ /**
+ * Гинерирует уникальный идентификатор
+ *
+ * @return string
+ */
+ protected function GenerateId()
+ {
+ return md5(func_generator() . time());
+ }
+
+ /**
+ * Получает значение из сессии
+ *
+ * @param string $sName Имя параметра
+ * @return mixed|null
+ */
+ public function Get($sName)
+ {
+ return isset($_SESSION[$sName]) ? $_SESSION[$sName] : null;
+ }
+
+ /**
+ * Записывает значение в сессию
+ *
+ * @param string $sName Имя параметра
+ * @param mixed $data Данные
+ */
+ public function Set($sName, $data)
+ {
+ $_SESSION[$sName] = $data;
+ }
+
+ /**
+ * Удаляет значение из сессии
+ *
+ * @param string $sName Имя параметра
+ */
+ public function Drop($sName)
+ {
+ unset($_SESSION[$sName]);
+ }
+
+ /**
+ * Получает разом все данные сессии
+ *
+ * @return array
+ */
+ public function GetData()
+ {
+ return $_SESSION;
+ }
+
+ /**
+ * Завершает сессию, дропая все данные
+ *
+ */
+ public function DropSession()
+ {
+ unset($_SESSION);
+ session_destroy();
+ }
+
+ /**
+ * Устанавливает куку
+ *
+ * @param string $sName
+ * @param string $sValue
+ * @param int|null $iTime
+ * @param bool $bSecure
+ * @param bool $bHttpOnly
+ */
+ public function SetCookie($sName, $sValue, $iTime = null, $bSecure = false, $bHttpOnly = false)
+ {
+ $_COOKIE[$sName] = $sValue;
+ setcookie($sName, $sValue, $iTime, Config::Get('sys.cookie.path'), Config::Get('sys.cookie.host'), $bSecure,
+ $bHttpOnly);
+ }
+
+ /**
+ * Читает куку
+ *
+ * @param string $sName
+ * @param mixed $mDefault
+ * @return string|mixed
+ */
+ public function GetCookie($sName, $mDefault = null)
+ {
+ if (isset($_COOKIE[$sName])) {
+ return $_COOKIE[$sName];
+ }
+ return $mDefault;
+ }
+
+ /**
+ * Удаляет куку
+ *
+ * @param $sName
+ */
+ public function DropCookie($sName)
+ {
+ setcookie($sName, null, -1, Config::Get('sys.cookie.path'), Config::Get('sys.cookie.host'));
+ unset($_COOKIE[$sName]);
+ }
+}
diff --git a/framework/classes/modules/sitemap/Sitemap.class.php b/framework/classes/modules/sitemap/Sitemap.class.php
new file mode 100644
index 0000000..20d6ede
--- /dev/null
+++ b/framework/classes/modules/sitemap/Sitemap.class.php
@@ -0,0 +1,203 @@
+ array(
+ 'callback_counters' => null, // коллбэк для возврата числа страниц
+ 'callback_links' => null, // коллбэк для возврата списка ссылок для индексного файла
+ 'callback_data' => null, // коллбэк для возврата ссылок для конкретной страницы, в параметрах передается номер страницы
+ 'cache_lifetime' => 60*60*24 // время кеширования данных, при false кеширования не будет
+ )
+ */
+ );
+
+ public function Init()
+ {
+
+ }
+
+ /**
+ * Возвращает типов объектов
+ *
+ * @param bool|false $bOnlyTypes
+ * @return array
+ */
+ public function GetTargetTypes($bOnlyTypes = false)
+ {
+ return $bOnlyTypes ? array_keys($this->aTargetTypes) : $this->aTargetTypes;
+ }
+
+ /**
+ * Добавляет в разрешенные новый тип
+ *
+ * @param string $sTargetType Тип
+ * @param array $aParams Параметры
+ * @param bool|false $bRewrite
+ * @return bool
+ */
+ public function AddTargetType($sTargetType, $aParams = array(), $bRewrite = false)
+ {
+ if ($bRewrite or !array_key_exists($sTargetType, $this->aTargetTypes)) {
+ $this->aTargetTypes[$sTargetType] = $aParams;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Проверяет разрешен ли данный тип
+ *
+ * @param string $sTargetType Тип
+ * @return bool
+ */
+ public function IsAllowTargetType($sTargetType)
+ {
+ return is_string($sTargetType) && array_key_exists($sTargetType, $this->aTargetTypes);
+ }
+
+ /**
+ * Возвращает параметры нужного типа
+ *
+ * @param string $sTargetType
+ *
+ * @return array|null
+ */
+ public function GetTargetTypeParams($sTargetType)
+ {
+ if ($this->IsAllowTargetType($sTargetType)) {
+ return $this->aTargetTypes[$sTargetType];
+ }
+ return null;
+ }
+
+ /**
+ * Возвращает конкретный параметр нужного типа
+ *
+ * @param string $sTargetType
+ * @param string $sName
+ *
+ * @return mixed|null
+ */
+ public function GetTargetTypeParam($sTargetType, $sName)
+ {
+ $aParams = $this->GetTargetTypeParams($sTargetType);
+ if ($aParams and array_key_exists($sName, $aParams)) {
+ return $aParams[$sName];
+ }
+ return null;
+ }
+
+ /**
+ * Формирует sitemap
+ *
+ * @return string|null
+ */
+ public function ShowSitemap()
+ {
+ header("Content-type: application/xml");
+ $sTarget = Router::GetActionEvent();
+
+ $sTemplateDir = Config::Get('path.framework.server') . '/frontend/common/sitemap/';
+ $sTemplateDirWeb = Config::Get('path.framework.web') . '/frontend/common/sitemap/';
+ if ($sTarget and $this->IsAllowTargetType($sTarget)) {
+ /**
+ * Показываем ссылки для типа
+ */
+ $iPage = (int)Router::GetParam(0) ?: 1;
+ $iPage = $iPage > 0 ? $iPage : 1;
+
+ $aData = array();
+ $fCallbackData = $this->GetTargetTypeParam($sTarget, 'callback_data');
+ if (is_callable($fCallbackData)) {
+ $sCacheKey = "sitemap_type_{$sTarget}_data_{$iPage}";
+ $iLifetime = $this->GetTargetTypeParam($sTarget, 'cache_lifetime');
+ if (is_null($iLifetime)) {
+ $iLifetime = 60 * 60 * 1;
+ }
+
+ if ($iLifetime === false or false === ($aData = $this->Cache_Get($sCacheKey))) {
+ $aData = call_user_func($fCallbackData, $iPage);
+ if ($iLifetime !== false) {
+ $this->Cache_Set($aData, $sCacheKey, array("sitemap_data"), $iLifetime);
+ }
+ }
+ }
+ $this->Viewer_Assign('aData', $aData);
+ $this->Viewer_Assign('sTemplateDirWeb', $sTemplateDirWeb);
+ return $this->Viewer_Fetch($sTemplateDir . 'sitemap.tpl');
+ } else {
+ /**
+ * Выводим индексный файл
+ */
+ $aData = array();
+ $aTypes = $this->GetTargetTypes(true);
+ foreach ($aTypes as $sType) {
+ $fCallback = $this->GetTargetTypeParam($sType, 'callback_counters');
+ if (is_callable($fCallback) and $iPage = call_user_func($fCallback)) {
+ for ($i = 1; $i <= $iPage; ++$i) {
+ $aData[] = array(
+ 'loc' => Router::GetPath('/') . 'sitemap_' . $sType . '_' . $i . '.xml'
+ );
+ }
+ }
+ $fCallback = $this->GetTargetTypeParam($sType, 'callback_links');
+ if (is_callable($fCallback) and $aUrls = call_user_func($fCallback)) {
+ foreach ($aUrls as $sUrl) {
+ $aData[] = array(
+ 'loc' => $sUrl
+ );
+ }
+ }
+ }
+ $this->Viewer_Assign('aData', $aData);
+ $this->Viewer_Assign('sTemplateDirWeb', $sTemplateDirWeb);
+ return $this->Viewer_Fetch($sTemplateDir . 'index.tpl');
+ }
+ return null;
+ }
+
+ /**
+ * Конвертирует дату в формат W3C Datetime
+ *
+ * @param mixed $mDate - UNIX timestamp или дата в формате понимаемом функцией strtotime()
+ * @return string - дата в формате W3C Datetime (http://www.w3.org/TR/NOTE-datetime)
+ */
+ private function ConvertDateToLastMod($mDate = null)
+ {
+ if (is_null($mDate)) {
+ return null;
+ }
+
+ $mDate = is_int($mDate) ? $mDate : strtotime($mDate);
+ return date('Y-m-d\TH:i:s+00:00', $mDate);
+ }
+
+ /**
+ * Возвращает массив с данными для генерации sitemap'а
+ *
+ * @param string $sUrl
+ * @param mixed $sLastMod
+ * @param mixed $sChangeFreq
+ * @param mixed $sPriority
+ * @return array
+ */
+ public function GetDataForSitemapRow($sUrl, $sLastMod = null, $sChangeFreq = null, $sPriority = null)
+ {
+ return array(
+ 'loc' => $sUrl,
+ 'lastmod' => $this->ConvertDateToLastMod($sLastMod),
+ 'priority' => $sChangeFreq,
+ 'changefreq' => $sPriority,
+ );
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/storage/Storage.class.php b/framework/classes/modules/storage/Storage.class.php
new file mode 100644
index 0000000..6b98a0e
--- /dev/null
+++ b/framework/classes/modules/storage/Storage.class.php
@@ -0,0 +1,701 @@
+
+ *
+ */
+
+/**
+ * Хранилище "ключ => значение"
+ *
+ * Позволяет легко и быстро работать с небольшими объемами данных, CRUD операции с которыми теперь занимают всего одну строку кода.
+ *
+ * Например:
+ * $this->Storage_Set('keyname', 'some_mixed_value', $this); // сохранить 'some_mixed_value' под имененем 'keyname' для вашего плагина
+ * $this->Storage_Get('keyname', $this); // получить данные по ключу 'keyname' для вашего плагина
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleStorage extends Module
+{
+
+ protected $oMapperStorage = null;
+
+ /*
+ * Группа настроек по-умолчанию (инстанция)
+ */
+ const DEFAULT_INSTANCE = 'default';
+
+ /*
+ * Префикс ключей для кеша
+ */
+ const CACHE_FIELD_DATA_PREFIX = 'storage_field_data_';
+
+ /*
+ * Имя ключа для ядра
+ */
+ const DEFAULT_KEY_NAME = '__default__';
+
+ /*
+ * Префикс для плагина в таблице
+ */
+ const PLUGIN_PREFIX = 'plugin_';
+
+ /*
+ * Кеширование параметров на время работы сессии
+ * структура: array('instance' => array('key' => array('param1' => 'value1', 'param2' => 'value2')))
+ */
+ protected $aSessionCache = array();
+
+
+ public function Init()
+ {
+ $this->Setup();
+ }
+
+
+ /**
+ * Настройка
+ */
+ protected function Setup()
+ {
+ $this->oMapperStorage = Engine::GetMapper(__CLASS__);
+ }
+
+
+ /*
+ *
+ * --- Низкоуровневые обертки для работы с БД ---
+ *
+ * tip: для highload проектов эти обертки можно переопределить через плагин чтобы подключить не РСУБД хранилища, такие как, например, Redis
+ *
+ */
+
+ /**
+ * Записать в БД строку одного ключа
+ *
+ * @param $sKey ключ
+ * @param $sValue значение
+ * @param string $sInstance инстанция
+ * @return mixed
+ */
+ protected function SetFieldOne($sKey, $sValue, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ /*
+ * низкоуровневая обработка данных происходит только со строковыми значениями
+ */
+ $sKey = (string)$sKey;
+ $sValue = (string)$sValue;
+ $sInstance = (string)$sInstance;
+ /*
+ * сбросить кеш по тегу
+ */
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('storage_field_data'));
+ /*
+ * добавить запись в хранилище для ключа
+ */
+ return $this->oMapperStorage->SetData($sKey, $sValue, $sInstance);
+ }
+
+
+ /**
+ * Получить из БД строковое значение одного ключа
+ *
+ * @param $sKey ключ
+ * @param string $sInstance инстанция
+ * @return mixed
+ */
+ protected function GetFieldOne($sKey, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ $sKey = (string)$sKey;
+ $sInstance = (string)$sInstance;
+ /*
+ * построить ключ для кеша
+ */
+ $sCacheKey = self::CACHE_FIELD_DATA_PREFIX . $sKey . '_' . $sInstance;
+ /*
+ * есть ли такие данные в кеше
+ */
+ if (($mData = $this->Cache_Get($sCacheKey)) === false) {
+ /*
+ * построить строку части WHERE запроса
+ */
+ $sWhere = $this->oMapperStorage->BuildFilter(array(
+ 'key' => $sKey,
+ 'instance' => $sInstance
+ ));
+ $mData = null;
+ /*
+ * получить данные
+ */
+ $aResult = $this->oMapperStorage->GetData($sWhere, 1, 1);
+ /*
+ * есть ли данные
+ */
+ if ($aResult['count'] != 0) {
+ $mData = $aResult['collection']['value'];
+ $this->Cache_Set($mData, $sCacheKey, array('storage_field_data'), 60 * 60 * 24 * 365); // 1 год
+ }
+ }
+ return $mData;
+ }
+
+
+ /**
+ * Удалить из БД ключ
+ *
+ * @param $sKey ключ
+ * @param string $sInstance инстанция
+ * @return mixed
+ */
+ protected function DeleteFieldOne($sKey, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ $sKey = (string)$sKey;
+ $sInstance = (string)$sInstance;
+ /*
+ * сбросить кеш по тегу
+ */
+ $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('storage_field_data'));
+ /*
+ * построить строку части WHERE запроса
+ */
+ $sWhere = $this->oMapperStorage->BuildFilter(array(
+ 'key' => $sKey,
+ 'instance' => $sInstance
+ ));
+ /*
+ * удалить данные
+ */
+ return $this->oMapperStorage->DeleteData($sWhere, 1);
+ }
+
+
+ /**
+ * Получить из БД все ключи в "сыром" виде
+ *
+ * @param string $sInstance инстанция
+ * @return mixed
+ */
+ protected function GetFieldsAll($sInstance = self::DEFAULT_INSTANCE)
+ {
+ $sInstance = (string)$sInstance;
+ /*
+ * построить ключ для кеша
+ */
+ $sCacheKey = self::CACHE_FIELD_DATA_PREFIX . '_fields_all_' . $sInstance;
+ /*
+ * есть ли такие данные в кеше
+ */
+ if (($mData = $this->Cache_Get($sCacheKey)) === false) {
+ /*
+ * построить строку части WHERE запроса
+ */
+ $sWhere = $this->oMapperStorage->BuildFilter(array(
+ 'instance' => $sInstance
+ ));
+ /*
+ * получить данные
+ */
+ $mData = $this->oMapperStorage->GetData($sWhere);
+ $this->Cache_Set($mData, $sCacheKey, array('storage_field_data'), 60 * 60 * 24 * 365); // 1 год
+ }
+ return $mData;
+ }
+
+
+ /*
+ *
+ * --- Обработка значений параметров ---
+ *
+ */
+
+ /**
+ * Подготовка значения параметра перед сохранением
+ *
+ * @param $mValue значение
+ * @return string
+ * @throws Exception если тип данных - ресурсы
+ */
+ protected function PrepareParamValueBeforeSaving($mValue)
+ {
+ if (is_resource($mValue)) {
+ throw new Exception('Storage: your data must be scalar value, not resource!');
+ }
+ return $mValue;
+ }
+
+
+ /**
+ * Восстановление значения параметра
+ *
+ * @param $mValue значение
+ * @return mixed|null
+ */
+ protected function RetrieveParamValueFromSavedValue($mValue)
+ {
+ return $mValue;
+ }
+
+
+ /**
+ * Перевести данные в строковый вид (сериализировать)
+ *
+ * @param $mValue значение
+ * @return string
+ */
+ protected function PackValue($mValue)
+ {
+ return serialize($mValue);
+ }
+
+
+ /**
+ * Восстановить данные из строкового вида (десериализировать)
+ *
+ * @param $mValue значение
+ * @return mixed|null
+ */
+ protected function UnpackValue($mValue)
+ {
+ if (($mData = @unserialize($mValue)) !== false) {
+ return $mData;
+ }
+ return null;
+ }
+
+
+ /**
+ * Получить массив используемых значений параметров ключа по "сырым" данным из БД
+ *
+ * @param $sKey ключ
+ * @param $sFieldData "сырые" (серилизированные) данные ключа
+ * @param $sInstance инстанция
+ * @return array данные
+ */
+ protected function GetParamsValuesFromRawData($sKey, $sFieldData, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ if ($aData = $this->UnpackValue($sFieldData) and is_array($aData)) {
+ /*
+ * Восстановить значения параметров ключа
+ */
+ $aData = array_map(array($this, 'RetrieveParamValueFromSavedValue'), $aData);
+ /*
+ * Сохранить в кеше сессии распакованные значения
+ */
+ $this->aSessionCache[$sInstance][$sKey] = $aData;
+ return $aData;
+ }
+ return array();
+ }
+
+
+ /*
+ *
+ * --- Высокоуровневые обертки для работы непосредственно с параметрами каждого ключа ---
+ *
+ */
+
+ /**
+ * Получить список всех параметров ключа
+ *
+ * @param $sKey ключ
+ * @param string $sInstance инстанция
+ * @return array
+ */
+ protected function GetParamsAll($sKey, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ /*
+ * Если значение есть в кеше сессии - получить его
+ */
+ if (isset($this->aSessionCache[$sInstance][$sKey])) {
+ return $this->aSessionCache[$sInstance][$sKey];
+ }
+ /*
+ * Если есть запись для ключа и она не повреждена и корректна
+ */
+ if ($sFieldData = $this->GetFieldOne($sKey, $sInstance)) {
+ return $this->GetParamsValuesFromRawData($sKey, $sFieldData, $sInstance);
+ }
+ return array();
+ }
+
+
+ /**
+ * Сохранить значение параметра для ключа
+ *
+ * @param $sKey ключ
+ * @param $sParamName параметр
+ * @param $mValue значение
+ * @param string $sInstance инстанция
+ * @return mixed
+ */
+ protected function SetOneParam($sKey, $sParamName, $mValue, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ /*
+ * Подготовить значение перед сохранением
+ */
+ $mValueChecked = $this->PrepareParamValueBeforeSaving($mValue);
+ /*
+ * Объеденить с остальными параметрами ключа
+ */
+ $aParamsContainer = $this->GetParamsAll($sKey, $sInstance);
+ $aParamsContainer[$sParamName] = $mValueChecked;
+ /*
+ * Сохранить в кеше сессии оригинальное значение
+ */
+ $this->aSessionCache[$sInstance][$sKey][$sParamName] = $mValue;
+ /*
+ * записать упакованные данные в строку
+ */
+ return $this->SetFieldOne($sKey, $this->PackValue($aParamsContainer), $sInstance);
+ }
+
+
+ /**
+ * Получить значение параметра для ключа
+ *
+ * @param $sKey ключ
+ * @param $sParamName параметр
+ * @param string $sInstance инстанция
+ * @return null
+ */
+ protected function GetOneParam($sKey, $sParamName, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ /*
+ * Если значение есть в кеше сессии - получить его
+ */
+ if (isset($this->aSessionCache[$sInstance][$sKey][$sParamName])) {
+ return $this->aSessionCache[$sInstance][$sKey][$sParamName];
+ }
+ /*
+ * Получить одно значение
+ */
+ if ($aFieldData = $this->GetParamsAll($sKey, $sInstance) and isset($aFieldData[$sParamName])) {
+ return $aFieldData[$sParamName];
+ }
+ return null;
+ }
+
+
+ /**
+ * Удалить значение параметра для ключа
+ *
+ * @param $sKey ключ
+ * @param $sParamName параметр
+ * @param string $sInstance инстанция
+ * @return mixed
+ */
+ protected function RemoveOneParam($sKey, $sParamName, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ /*
+ * Удалить значение из кеша сессии
+ */
+ unset($this->aSessionCache[$sInstance][$sKey][$sParamName]);
+ /*
+ * Удалить параметр
+ */
+ $aParamsContainer = $this->GetParamsAll($sKey, $sInstance);
+ unset($aParamsContainer[$sParamName]);
+ /*
+ * записать упакованные данные в строку
+ */
+ return $this->SetFieldOne($sKey, $this->PackValue($aParamsContainer), $sInstance);
+ }
+
+
+ /**
+ * Удалить все параметры ключа
+ *
+ * @param $sKey ключ
+ * @param string $sInstance инстанция
+ * @return mixed
+ */
+ protected function RemoveAllParams($sKey, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ /*
+ * Удалить все значения из кеша сессии
+ */
+ unset($this->aSessionCache[$sInstance][$sKey]);
+ return $this->DeleteFieldOne($sKey, $sInstance);
+ }
+
+
+ /**
+ * Сохранить значение параметра для ключа на время сессии (без записи в хранилище)
+ *
+ * @param $sKey ключ
+ * @param $sParamName параметр
+ * @param $mValue значение
+ * @param string $sInstance инстанция
+ */
+ protected function SetSmartParam($sKey, $sParamName, $mValue, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ /*
+ * tip: В первый запрос все данные будут загружены в сессионное хранилище и при повторном вызове они не будут затираться
+ */
+ $this->GetParamsAll($sKey, $sInstance);
+ /*
+ * Сохранить в кеше сессии
+ */
+ $this->aSessionCache[$sInstance][$sKey][$sParamName] = $mValue;
+ }
+
+
+ /**
+ * Удалить значение параметра для ключа на время сессии (без записи в хранилище)
+ *
+ * @param $sKey ключ
+ * @param $sParamName параметр
+ * @param string $sInstance инстанция
+ */
+ protected function RemoveSmartParam($sKey, $sParamName, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ /*
+ * tip: В первый запрос все данные будут загружены в сессионное хранилище и при повторном вызове они не будут затираться
+ */
+ $this->GetParamsAll($sKey, $sInstance);
+ /*
+ * Удалить в кеше сессии
+ */
+ unset($this->aSessionCache[$sInstance][$sKey][$sParamName]);
+ }
+
+
+ /**
+ * Записать в хранилище значения параметров для ключа из кеша сессии
+ *
+ * @param $sKey ключ
+ * @param string $sInstance инстанция
+ * @return mixed
+ */
+ protected function StoreParams($sKey, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ return $this->SetFieldOne($sKey, $this->PackValue($this->aSessionCache[$sInstance][$sKey]), $sInstance);
+ }
+
+
+ /**
+ * Сбросить кеш сессии (без записи в хранилище)
+ *
+ * @param null $sKey ключ
+ * @param string $sInstance инстанция
+ */
+ protected function ResetSessionCache($sKey = null, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ if (!is_null($sKey)) {
+ unset($this->aSessionCache[$sInstance][$sKey]);
+ } else {
+ unset($this->aSessionCache[$sInstance]);
+ }
+ }
+
+
+ /*
+ *
+ * --- Хелперы ---
+ *
+ */
+
+ /**
+ * Получить имя ключа из текущего, вызывающего метод, контекста
+ *
+ * @param $oCaller контекст, вызывающий метод (для движка можно указывать null)
+ * @return string
+ */
+ protected function GetKeyForCaller($oCaller = null)
+ {
+ $this->CheckCaller($oCaller);
+ /*
+ * Получаем имя плагина, если возможно
+ */
+ if (!$sCaller = strtolower(Engine::GetPluginName($oCaller))) {
+ /*
+ * Если имени нет - значит это вызов ядра
+ */
+ return self::DEFAULT_KEY_NAME;
+ }
+ return self::PLUGIN_PREFIX . $sCaller;
+ }
+
+
+ /**
+ * Проверить корректность указания контекста
+ *
+ * @param $oCaller контекст, вызывающий метод
+ * @throws Exception если не объект
+ */
+ protected function CheckCaller($oCaller)
+ {
+ /**
+ * Возможность указывать имя класса плагина строкой
+ */
+ if (is_string($oCaller) and $oCaller) {
+ return true;
+ }
+ /*
+ * контекст должен быть указан или нулл для движка
+ */
+ if (!is_object($oCaller) and !is_null($oCaller)) {
+ throw new Exception('Storage: caller is not correct. Always use "$this" for caller value. Also it can be set to NULL for engine calls');
+ }
+ }
+
+
+ /*
+ *
+ * --- Конечные методы для использования в движке и плагинах ---
+ *
+ */
+
+ /**
+ * Установить значение
+ *
+ * @param $sParamName параметр
+ * @param $mValue значение
+ * @param $oCaller контекст, вызывающий метод
+ * @param string $sInstance инстанция
+ * @return mixed
+ */
+ public function Set($sParamName, $mValue, $oCaller = null, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ $sCallerName = $this->GetKeyForCaller($oCaller);
+ return $this->SetOneParam($sCallerName, $sParamName, $mValue, $sInstance);
+ }
+
+
+ /**
+ * Получить значение
+ *
+ * @param $sParamName параметр
+ * @param $oCaller контекст, вызывающий метод
+ * @param string $sInstance инстанция
+ * @return null
+ */
+ public function Get($sParamName, $oCaller = null, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ $sCallerName = $this->GetKeyForCaller($oCaller);
+ return $this->GetOneParam($sCallerName, $sParamName, $sInstance);
+ }
+
+
+ /**
+ * Получить все значения
+ *
+ * @param $oCaller контекст, вызывающий метод
+ * @param string $sInstance инстанция
+ * @return array
+ */
+ public function GetAll($oCaller = null, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ $sCallerName = $this->GetKeyForCaller($oCaller);
+ return $this->GetParamsAll($sCallerName, $sInstance);
+ }
+
+
+ /**
+ * Удалить значение
+ *
+ * @param $sParamName параметр
+ * @param $oCaller контекст, вызывающий метод
+ * @param string $sInstance инстанция
+ * @return mixed
+ */
+ public function Remove($sParamName, $oCaller = null, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ $sCallerName = $this->GetKeyForCaller($oCaller);
+ return $this->RemoveOneParam($sCallerName, $sParamName, $sInstance);
+ }
+
+
+ /**
+ * Удалить все значения
+ *
+ * @param $oCaller контекст, вызывающий метод
+ * @param string $sInstance инстанция
+ * @return mixed
+ */
+ public function RemoveAll($oCaller = null, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ $sCallerName = $this->GetKeyForCaller($oCaller);
+ return $this->RemoveAllParams($sCallerName, $sInstance);
+ }
+
+
+ /*
+ *
+ * --- Работа с параметрами только на момент сессии ---
+ *
+ */
+
+ /**
+ * Сохранить значение параметра на время сессии (без записи в хранилище)
+ *
+ * @param $sParamName параметр
+ * @param $mValue значение
+ * @param $oCaller контекст, вызывающий метод
+ * @param string $sInstance инстанция
+ */
+ public function SetSmart($sParamName, $mValue, $oCaller = null, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ $sCallerName = $this->GetKeyForCaller($oCaller);
+ $this->SetSmartParam($sCallerName, $sParamName, $mValue, $sInstance);
+ }
+
+
+ /**
+ * Удалить параметр кеша сессии (без записи в хранилище)
+ *
+ * @param $sParamName параметр
+ * @param $oCaller контекст, вызывающий метод
+ * @param string $sInstance инстанция
+ */
+ public function RemoveSmart($sParamName, $oCaller = null, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ $sCallerName = $this->GetKeyForCaller($oCaller);
+ $this->RemoveSmartParam($sCallerName, $sParamName, $sInstance);
+ }
+
+
+ /**
+ * Записать в хранилище значения параметров из кеша сессии
+ *
+ * @param $oCaller контекст, вызывающий метод
+ * @param string $sInstance инстанция
+ * @return mixed
+ */
+ public function Store($oCaller = null, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ $sCallerName = $this->GetKeyForCaller($oCaller);
+ return $this->StoreParams($sCallerName, $sInstance);
+ }
+
+
+ /**
+ * Сбросить кеш сессии (без записи в хранилище)
+ *
+ * @param $oCaller контекст, вызывающий метод
+ * @param string $sInstance инстанция
+ */
+ public function Reset($oCaller = null, $sInstance = self::DEFAULT_INSTANCE)
+ {
+ $sCallerName = $this->GetKeyForCaller($oCaller);
+ $this->ResetSessionCache($sCallerName, $sInstance);
+ }
+
+
+}
\ No newline at end of file
diff --git a/framework/classes/modules/storage/mapper/Storage.mapper.class.php b/framework/classes/modules/storage/mapper/Storage.mapper.class.php
new file mode 100644
index 0000000..5978ebc
--- /dev/null
+++ b/framework/classes/modules/storage/mapper/Storage.mapper.class.php
@@ -0,0 +1,164 @@
+
+ *
+ */
+
+/**
+ * Маппер хранилища настроек
+ *
+ * @package framework.modules
+ * @since 2.0
+ */
+class ModuleStorage_MapperStorage extends Mapper
+{
+
+ /**
+ * Получить данные из хранилища по фильтру
+ *
+ * @param null $sWhere фильтр
+ * @param int $iPage страница
+ * @param int $iPerPage результатов на страницу
+ * @return array
+ */
+ public function GetData($sWhere = null, $iPage = 1, $iPerPage = PHP_INT_MAX)
+ {
+ $sSql = 'SELECT *
+ FROM
+ ?#
+ WHERE
+ 1 = 1
+ ' . $sWhere . '
+ ORDER BY
+ `id` ASC
+ LIMIT ?d, ?d
+ ';
+ $iTotalCount = 0;
+ $aCollection = array();
+
+ if ($aData = $this->oDb->selectPage(
+ $iTotalCount,
+ $sSql,
+
+ Config::Get('db.table.storage'),
+
+ ($iPage - 1) * $iPerPage,
+ $iPerPage
+ )
+ ) {
+ /*
+ * Если нужен только один элемент
+ */
+ $aCollection = $iPerPage == 1 ? array_shift($aData) : $aData;
+ }
+ return array(
+ 'collection' => $aCollection,
+ 'count' => $iTotalCount
+ );
+ }
+
+
+ /**
+ * Записать данные
+ *
+ * @param $sKey ключ
+ * @param $sValue значение
+ * @param $sInstance инстанция хранилища
+ * @return array|null
+ */
+ public function SetData($sKey, $sValue, $sInstance)
+ {
+ $sSql = 'INSERT INTO
+ ?#
+ (
+ `key`,
+ `value`,
+ `instance`
+ )
+ VALUES
+ (
+ ?,
+ ?,
+ ?
+ )
+ ON DUPLICATE KEY UPDATE
+ `value` = ?
+ ';
+
+ return $this->oDb->query(
+ $sSql,
+
+ Config::Get('db.table.storage'),
+
+ $sKey,
+ $sValue,
+ $sInstance,
+
+ $sValue
+ );
+ }
+
+
+ /**
+ * Удалить данные из хранилища
+ *
+ * @param null $sWhere фильтр
+ * @param int $iLimit лимит запроса
+ * @return array|null
+ */
+ public function DeleteData($sWhere = null, $iLimit = 1)
+ {
+ $sSql = 'DELETE
+ FROM
+ ?#
+ WHERE
+ 1 = 1
+ ' . $sWhere . '
+ LIMIT ?d
+ ';
+
+ return $this->oDb->query(
+ $sSql,
+
+ Config::Get('db.table.storage'),
+
+ $iLimit
+ );
+ }
+
+
+ /**
+ * Построить строку части WHERE условия из набора параметров фильтра
+ *
+ * @param array $aFilter фильтр
+ * @return string часть WHERE условия sql запроса
+ */
+ public function BuildFilter($aFilter = array())
+ {
+ $sWhere = '';
+ /*
+ * для всех значение добавить условие "ключ = значение"
+ */
+ foreach ($aFilter as $sKey => $mValue) {
+ $sWhere .= '
+ AND ' . $this->oDb->escape($sKey, true) . ' = ' . $this->oDb->escape($mValue);
+ }
+ return $sWhere;
+ }
+
+}
\ No newline at end of file
diff --git a/framework/classes/modules/text/Text.class.php b/framework/classes/modules/text/Text.class.php
new file mode 100644
index 0000000..764f3dc
--- /dev/null
+++ b/framework/classes/modules/text/Text.class.php
@@ -0,0 +1,319 @@
+
+ *
+ */
+
+require_once(Config::Get('path.framework.libs_vendor.server') . '/Jevix/jevix.class.php');
+
+/**
+ * Модуль обработки текста на основе типографа Jevix
+ * Позволяет вырезать из текста лишние HTML теги и предотвращает различные попытки внедрить в текст JavaScript
+ *
+ * $sText=$this->Text_Parser($sTestSource);
+ *
+ * Настройки парсинга находятся в конфиге /config/jevix.php
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModuleText extends Module
+{
+ /**
+ * Объект типографа
+ *
+ * @var Jevix
+ */
+ protected $oJevix;
+ /**
+ * Дополнительные параметры, которые необходимо учитывать при обработке текста
+ * Можно задавать произвольные параметры, главное чтобы логика обработки текста их учитывала
+ * Желеательно после установки параметров и выполнения обработки эти параметры сбросить методом
+ * @see ModuleText::AddExecHook
+ *
+ * @var array
+ */
+ protected $aParams = array();
+
+ /**
+ * Инициализация модуля
+ *
+ * @param $bLocal
+ */
+ public function Init($bLocal = false)
+ {
+ /**
+ * Создаем объект типографа и запускаем его конфигурацию
+ */
+ $this->oJevix = new Jevix();
+ $this->JevixConfig();
+ }
+
+ /**
+ * Конфигурирует типограф
+ *
+ */
+ protected function JevixConfig()
+ {
+ // загружаем конфиг
+ $this->LoadJevixConfig();
+ }
+
+ /**
+ * Загружает конфиг Jevix'а
+ *
+ * @param string $sType Тип конфига
+ * @param bool $bClear Очищать предыдущий конфиг или нет
+ */
+ public function LoadJevixConfig($sType = 'default', $bClear = true)
+ {
+ if ($bClear) {
+ $this->oJevix->tagsRules = array();
+ }
+ $aConfig = Config::Get('jevix.' . $sType);
+ if (is_array($aConfig)) {
+ foreach ($aConfig as $sMethod => $aExec) {
+ foreach ($aExec as $aParams) {
+ if (in_array(strtolower($sMethod),
+ array_map("strtolower", array('cfgSetTagCallbackFull', 'cfgSetTagCallback')))) {
+ if (isset($aParams[1][0]) and $aParams[1][0] == '_this_') {
+ $aParams[1][0] = $this;
+ }
+ }
+ call_user_func_array(array($this->oJevix, $sMethod), $aParams);
+ }
+ }
+ /**
+ * Хардкодим некоторые параметры
+ */
+ unset($this->oJevix->entities1['&']); // разрешаем в параметрах символ &
+ if (Config::Get('view.noindex') and isset($this->oJevix->tagsRules['a'])) {
+ $this->oJevix->cfgSetTagParamDefault('a', 'rel', 'nofollow noreferrer noopener', true);
+ }
+ }
+ }
+
+ /**
+ * Возвращает объект Jevix
+ *
+ * @return Jevix
+ */
+ public function GetJevix()
+ {
+ return $this->oJevix;
+ }
+
+ /**
+ * Добавляет параметры
+ *
+ * @param $aParams
+ */
+ public function AddParams($aParams)
+ {
+ $this->aParams = array_merge($this->aParams, $aParams);
+ }
+
+ /**
+ * Возвращает параметр по имени или сразу все параметры
+ *
+ * @param string|null $sName Если null, то вернет массив всех параметров
+ *
+ * @return array|mixed
+ */
+ public function GetParam($sName = null)
+ {
+ if (is_null($sName)) {
+ return $this->aParams;
+ }
+ if (isset($this->aParams[$sName])) {
+ return $this->aParams[$sName];
+ }
+ return null;
+ }
+
+ /**
+ * Удаляет параметры
+ *
+ * @param array|string|null $aNames Название параметра или список названий параметров. Если null, то удалятся все параметры
+ */
+ public function RemoveParams($aNames = null)
+ {
+ if (is_null($aNames)) {
+ $this->aParams = array();
+ }
+ if (!is_array($aNames)) {
+ $aNames = array($aNames);
+ }
+ foreach ($aNames as $sName) {
+ unset($this->aParams[$sName]);
+ }
+ }
+
+ /**
+ * Парсинг текста с помощью Jevix
+ *
+ * @param string $sText Исходный текст
+ * @param array $aError Возвращает список возникших ошибок
+ * @return string
+ */
+ public function JevixParser($sText, &$aError = null)
+ {
+ // Если конфиг пустой, то загружаем его
+ if (!count($this->oJevix->tagsRules)) {
+ $this->LoadJevixConfig();
+ }
+ $sResult = $this->oJevix->parse($sText, $aError);
+ return $sResult;
+ }
+
+ /**
+ * Парсит текст, применя все парсеры
+ *
+ * @param string $sText Исходный текст
+ * @return string
+ */
+ public function Parser($sText)
+ {
+ if (!is_string($sText)) {
+ return '';
+ }
+ $sResult = $this->FlashParamParser($sText);
+ $sResult = $this->JevixParser($sResult);
+ return $sResult;
+ }
+
+ /**
+ * Заменяет все вхождения короткого тега на длиную версию
+ * Заменяет все вхождения короткого тега на длиную версию
+ *
+ * @param string $sText Исходный текст
+ * @return string
+ */
+ protected function FlashParamParser($sText)
+ {
+ if (preg_match_all("@(<\s*param\s*name\s*=\s*(?:\"|').*(?:\"|')\s*value\s*=\s*(?:\"|').*(?:\"|'))\s*/?\s*>(?!)@Ui",
+ $sText, $aMatch)) {
+ foreach ($aMatch[1] as $key => $str) {
+ $str_new = $str . '>';
+ $sText = str_replace($aMatch[0][$key], $str_new, $sText);
+ }
+ }
+ if (preg_match_all("@(<\s*embed\s*.*)\s*/?\s*>(?!)@Ui", $sText, $aMatch)) {
+ foreach ($aMatch[1] as $key => $str) {
+ $str_new = $str . '>';
+ $sText = str_replace($aMatch[0][$key], $str_new, $sText);
+ }
+ }
+ /**
+ * Удаляем все
+ */
+ if (preg_match_all("@( \s*)@Ui", $sText, $aMatch)) {
+ foreach ($aMatch[1] as $key => $str) {
+ $sText = str_replace($aMatch[0][$key], '', $sText);
+ }
+ }
+ /**
+ * А теперь после добавляем
+ * Решение не фантан, но главное работает :)
+ */
+ if (preg_match_all("@()@Ui", $sText, $aMatch)) {
+ foreach ($aMatch[1] as $key => $str) {
+ $sText = str_replace($aMatch[0][$key], $aMatch[0][$key] . ' ',
+ $sText);
+ }
+ }
+ return $sText;
+ }
+
+ /**
+ * Производить резрезание текста по тегу cut.
+ * Возвращаем массив вида:
+ *
+ * array(
+ * $sTextShort - текст до тега
+ * $sTextNew - весь текст за исключением удаленного тега
+ * $sTextCut - именованное значение
+ * )
+ *
+ *
+ * @param string $sText Исходный текст
+ * @return array
+ */
+ public function Cut($sText)
+ {
+ $sTextShort = $sText;
+ $sTextNew = $sText;
+ $sTextCut = null;
+
+ if (preg_match("#^(.*)]*+)>(.*)$#Usi", $sText, $aMatch)) {
+ $sTextShort = $aMatch[1];
+ $sTextNew = $aMatch[1] . ' ' . $aMatch[3];
+ if (preg_match('#^\s++name\s*+=\s*+"([^"]++)"\s*+\/?$#i', $aMatch[2], $aMatchCut)) {
+ $sTextCut = trim($aMatchCut[1]);
+ }
+ }
+
+ return array($sTextShort, $sTextNew, $sTextCut ? htmlspecialchars($sTextCut) : null);
+ }
+
+ /**
+ * Выполняет транслитерацию текста
+ *
+ * @param $sText
+ * @param bool $bLower
+ * @return mixed|string
+ */
+ public function Transliteration($sText, $bLower = true)
+ {
+ $aConverter = (array)Config::Get('module.text.transliteration_map');
+ $sRes = strtr(trim($sText), $aConverter);
+ if ($sResIconv = @iconv("UTF-8", "ISO-8859-1//IGNORE//TRANSLIT", $sRes)) {
+ $sRes = $sResIconv;
+ }
+ $sRes = preg_replace('/[^A-Za-z0-9\-]/', '', $sRes);
+ $sRes = preg_replace('/\-{2,}/', '-', $sRes);
+ if ($bLower) {
+ $sRes = strtolower($sRes);
+ }
+ return $sRes;
+ }
+
+ public function CallbackParserTag($sTag, $aParams, $sContent)
+ {
+ $sParserClass = 'ModuleText_EntityParserTag' . func_camelize($sTag);
+ if (class_exists($sParserClass)) {
+ $oParser = Engine::GetEntity($sParserClass);
+ return $oParser->parse($sContent, $aParams);
+ }
+ return '';
+ }
+
+ /**
+ * Получает локальную копию модуля
+ *
+ * @return ModuleText
+ */
+ public function GetLocal()
+ {
+ $sClass = $this->Plugin_GetDelegate('module', __CLASS__);
+
+ $oModuleLocal = new $sClass(Engine::getInstance());
+ $oModuleLocal->Init(true);
+ return $oModuleLocal;
+ }
+}
diff --git a/framework/classes/modules/text/entity/ParserTagCode.entity.class.php b/framework/classes/modules/text/entity/ParserTagCode.entity.class.php
new file mode 100644
index 0000000..aec3220
--- /dev/null
+++ b/framework/classes/modules/text/entity/ParserTagCode.entity.class.php
@@ -0,0 +1,41 @@
+
+ *
+ */
+
+/**
+ * Сущность для парсинга тега
+ *
+ * @package framework.modules.text
+ * @since 2.0
+ */
+class ModuleText_EntityParserTagCode extends Entity
+{
+ /**
+ * Запускает обработку контента тега
+ *
+ * @param $sContent
+ * @param array $aParams
+ * @return bool|string
+ */
+ public function parse($sContent, $aParams = array())
+ {
+ return "{$sContent}
";
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/text/entity/ParserTagCodeline.entity.class.php b/framework/classes/modules/text/entity/ParserTagCodeline.entity.class.php
new file mode 100644
index 0000000..b661a39
--- /dev/null
+++ b/framework/classes/modules/text/entity/ParserTagCodeline.entity.class.php
@@ -0,0 +1,41 @@
+
+ *
+ */
+
+/**
+ * Сущность для парсинга тега
+ *
+ * @package framework.modules.text
+ * @since 2.0
+ */
+class ModuleText_EntityParserTagCodeline extends Entity
+{
+ /**
+ * Запускает обработку контента тега
+ *
+ * @param $sContent
+ * @param array $aParams
+ * @return bool|string
+ */
+ public function parse($sContent, $aParams = array())
+ {
+ return "{$sContent}
";
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/text/entity/ParserTagVideo.entity.class.php b/framework/classes/modules/text/entity/ParserTagVideo.entity.class.php
new file mode 100644
index 0000000..5c87f02
--- /dev/null
+++ b/framework/classes/modules/text/entity/ParserTagVideo.entity.class.php
@@ -0,0 +1,76 @@
+
+ *
+ */
+
+/**
+ * Сущность для парсинга тега
+ *
+ * @package framework.modules.text
+ * @since 2.0
+ */
+class ModuleText_EntityParserTagVideo extends Entity
+{
+ /**
+ * Запускает парсинг контента тега
+ *
+ * @param $sContent
+ * @param array $aParams
+ * @return bool|string
+ */
+ public function parse($sContent, $aParams = array())
+ {
+ if ($sReturn = $this->parseYoutube($sContent)) {
+ return $sReturn;
+ }
+ if ($sReturn = $this->parseVimeo($sContent)) {
+ return $sReturn;
+ }
+ if ($sReturn = $this->parseYandex($sContent)) {
+ return $sReturn;
+ }
+
+ return false;
+ }
+
+ protected function parseYoutube($sContent)
+ {
+ if (preg_match('%(?:youtube(?:-nocookie)?\.com/(?:[\w\-?&!#=,;]+/[\w\-?&!#=/,;]+/|(?:v|e(?:mbed)?)/|[\w\-?&!#=,;]*[?&]v=)|youtu\.be/)([\w-]{11})(?:[^\w-]|\Z)%i',
+ $sContent, $aMatch)) {
+ return 'VIDEO ';
+ }
+ return false;
+ }
+
+ protected function parseVimeo($sContent)
+ {
+ if (preg_match('#(?:www\.|)vimeo\.com\/(\d+).*#i', $sContent, $aMatch)) {
+ return '';
+ }
+ return false;
+ }
+
+ protected function parseYandex($sContent)
+ {
+ if (preg_match('#video\.yandex\.ru\/users\/([a-zA-Z0-9_\-]+)\/view\/(\d+).*#i', $sContent, $aMatch)) {
+ return '';
+ }
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/Validate.class.php b/framework/classes/modules/validate/Validate.class.php
new file mode 100644
index 0000000..4daecd1
--- /dev/null
+++ b/framework/classes/modules/validate/Validate.class.php
@@ -0,0 +1,240 @@
+
+ *
+ */
+
+/**
+ * Модуль Validate
+ * Выполняет валидацию данных по определенным правилам. Поддерживает как обычную валидацию данных:
+ *
+ * if (!$this->Validate_Validate('url','http://livestreet.ru')) {
+ * var_dump($this->Validate_GetErrors());
+ * }
+ *
+ * так и валидацию данных сущности:
+ *
+ * class PluginTest_ModuleMain_EntityTest extends Entity {
+ * // Определяем правила валидации
+ * protected $aValidateRules=array(
+ * array('login, name','string','max'=>7,'min'=>'3'),
+ * array('title','my','on'=>'register'),
+ * );
+ *
+ * public function ValidateMy($sValue,$aParams) {
+ * if ($sValue!='Мега заголовок') {
+ * return 'Ошибочный заголовок';
+ * }
+ * return true;
+ * }
+ * }
+ *
+ * // Валидация
+ * $oObject=Engine::GetEntity('PluginTest_ModuleMain_EntityTest');
+ * $oObject->setLogin('bolshoi login');
+ * $oObject->setTitle('zagolovok');
+ *
+ * if ($oObject->_Validate()) {
+ * var_dump("OK");
+ * } else {
+ * var_dump($oObject->_getValidateErrors());
+ * }
+ *
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate extends Module
+{
+ /**
+ * Список ошибок при валидации, заполняется только если использовать валидацию напрямую без сущности
+ *
+ * @var array
+ */
+ protected $aErrors = array();
+
+ /**
+ * Инициализируем модуль
+ *
+ */
+ public function Init()
+ {
+
+ }
+
+ /**
+ * Запускает валидацию данных
+ *
+ * @param string $sNameValidator Имя валидатора или метода при использовании параметра $oObject
+ * @param mixed $mValue Валидируемое значение
+ * @param array $aParams Параметры валидации
+ * @param null $oObject Объект в котором необходимо вызвать метод валидации
+ *
+ * @return bool
+ */
+ public function Validate($sNameValidator, $mValue, $aParams = array(), $oObject = null)
+ {
+ if (is_null($oObject)) {
+ $oObject = $this;
+ }
+ $oValidator = $this->CreateValidator($sNameValidator, $oObject, null, $aParams);
+
+ if (($sMsg = $oValidator->validate($mValue)) !== true) {
+ $sMsg = str_replace('%%field%%', is_null($oValidator->label) ? '' : '«' . $oValidator->label . '»', $sMsg);
+ $this->AddError($sMsg);
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Создает и возвращает объект валидатора
+ *
+ * @param string $sName Имя валидатора или метода при использовании параметра $oObject
+ * @param LsObject $oObject Объект в котором необходимо вызвать метод валидации
+ * @param null|array $aFields Список полей сущности для которых необходимо провести валидацию
+ * @param array $aParams Параметры
+ *
+ * @return mixed
+ */
+ public function CreateValidator($sName, $oObject, $aFields = null, $aParams = array())
+ {
+ if (is_string($aFields)) {
+ $aFields = preg_split('/[\s,]+/', $aFields, -1, PREG_SPLIT_NO_EMPTY);
+ }
+ /**
+ * Определяем список сценариев валидации
+ */
+ if (isset($aParams['on'])) {
+ if (is_array($aParams['on'])) {
+ $aOn = $aParams['on'];
+ } else {
+ $aOn = preg_split('/[\s,]+/', $aParams['on'], -1, PREG_SPLIT_NO_EMPTY);
+ }
+ } else {
+ $aOn = array();
+ }
+ /**
+ * Если в качестве имени валидатора указан метод объекта, то создаем специальный валидатор
+ */
+ $sMethod = 'validate' . func_camelize($sName);
+ if (method_exists($oObject, $sMethod)) {
+ $oValidator = Engine::GetEntity('ModuleValidate_EntityValidatorInline');
+ if (!is_null($aFields)) {
+ $oValidator->fields = $aFields;
+ }
+ $oValidator->object = $oObject;
+ $oValidator->method = $sMethod;
+ $oValidator->params = $aParams;
+ if (isset($aParams['skipOnError'])) {
+ $oValidator->skipOnError = $aParams['skipOnError'];
+ }
+ } else {
+ /**
+ * Иначе создаем валидатор по имени
+ */
+ if (!is_null($aFields)) {
+ $aParams['fields'] = $aFields;
+ }
+ $sValidateName = 'Validator' . func_camelize($sName);
+ $oValidator = Engine::GetEntity('ModuleValidate_Entity' . $sValidateName);
+ foreach ($aParams as $sNameParam => $sValue) {
+ $oValidator->$sNameParam = $sValue;
+ }
+ }
+ $oValidator->on = empty($aOn) ? array() : array_combine($aOn, $aOn);
+ return $oValidator;
+ }
+
+ /**
+ * Возвращает факт наличия ошибки после валидации
+ *
+ * @return bool
+ */
+ public function HasErrors()
+ {
+ return count($this->aErrors) ? true : false;
+ }
+
+ /**
+ * Возвращает список ошибок после валидации
+ *
+ * @return array
+ */
+ public function GetErrors()
+ {
+ return $this->aErrors;
+ }
+
+ /**
+ * Возвращает последнюю ошибку после валидации
+ *
+ * @param bool $bRemove Удалять или нет ошибку из списка ошибок
+ * @return bool|string
+ */
+ public function GetErrorLast($bRemove = false)
+ {
+ if (!$this->HasErrors()) {
+ return false;
+ }
+ if ($bRemove) {
+ return array_pop($this->aErrors);
+ } else {
+ return $this->aErrors[count($this->aErrors) - 1];
+ }
+ }
+
+ /**
+ * Добавляет ошибку в список
+ *
+ * @param string $sError Текст ошибки
+ */
+ public function AddError($sError)
+ {
+ $this->aErrors[] = $sError;
+ }
+
+ /**
+ * Очищает список ошибок
+ */
+ public function ClearErrors()
+ {
+ $this->aErrors = array();
+ }
+
+ /**
+ * Хелпер для подготовки списка ошибок при передаче в js
+ *
+ * @param $aErrors
+ * @param null $sWrapField
+ * @param array $aSkipWrapFields
+ * @return array
+ */
+ public function PrepareValidateErrors($aErrors, $sWrapField = null, $aSkipWrapFields = array())
+ {
+ $aResult = array();
+ foreach ($aErrors as $sField => $aErrorsItem) {
+ if ($sWrapField and !in_array($sField, $aSkipWrapFields)) {
+ $sField = str_replace('*', $sField, $sWrapField);
+ }
+ $aResult[$sField] = $aErrorsItem;
+ }
+ return $aResult;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/Validator.entity.class.php b/framework/classes/modules/validate/entity/Validator.entity.class.php
new file mode 100644
index 0000000..919dc42
--- /dev/null
+++ b/framework/classes/modules/validate/entity/Validator.entity.class.php
@@ -0,0 +1,247 @@
+
+ *
+ */
+
+/**
+ * Базовый класс валидатора
+ * От этого класса наследуются все валидаторы
+ * Public свойства используются в качестве параметров валидатора, котрый можно задавать в правилах
+ * @see Entity::aValidateRules
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+abstract class ModuleValidate_EntityValidator extends Entity
+{
+ /**
+ * Пропускать или нет ошибку
+ *
+ * @var bool
+ */
+ public $bSkipOnError = false;
+ /**
+ * Список полей сущности для валидации
+ *
+ * @var array
+ */
+ public $fields = array();
+ /**
+ * Название поля сущности для отображения в тексте ошибки
+ *
+ * @var null|string
+ */
+ public $label = null;
+ /**
+ * Текст ошибки валидации, переопределяет текст валидатора
+ *
+ * @var null|string
+ */
+ public $msg = null;
+ /**
+ * Список сценариев в которых участвует валидатор
+ *
+ * @var null|array
+ */
+ public $on = null;
+ /**
+ * Условие срабатывания валидации
+ * Поддерживается только для валидации сущности, значение соответствует методу сущности, который будет вызван перед валидацией, если метод вернет false, то валидация будет пропущена
+ *
+ * @var null|string
+ */
+ public $condition = null;
+ /**
+ * Объект текущей сущности, которая проходит валидацию
+ *
+ * @var null|Entity
+ */
+ protected $oEntityCurrent = null;
+ /**
+ * Объект текущей сущности, которая проходит валидацию
+ *
+ * @var null|string
+ */
+ protected $sFieldCurrent = null;
+
+ /**
+ * Основной метод валидации
+ *
+ * @abstract
+ * @param $sValue
+ */
+ abstract public function validate($sValue);
+
+ /**
+ * Проверяет данные на пустое значение
+ *
+ * @param mixed $mValue Данные
+ * @param bool $bTrim Не учитывать пробелы
+ *
+ * @return bool
+ */
+ protected function isEmpty($mValue, $bTrim = false)
+ {
+ return $mValue === null || $mValue === array() || $mValue === '' || $bTrim && is_scalar($mValue) && trim($mValue) === '';
+ }
+
+ /**
+ * Применять или нет сценарий к текущему валидатору
+ * Для сценария учитываются только те правила, где явно прописан необходимый сценарий
+ * Если в правиле не прописан сценарий, то он принимает значение '' (пустая строка)
+ *
+ * @param string $sScenario Сценарий валидации
+ *
+ * @return bool
+ */
+ public function applyTo($sScenario)
+ {
+ return (empty($this->on) && !$sScenario) || isset($this->on[$sScenario]);
+ }
+
+ /**
+ * Возвращает сообщение, используется для получения сообщения об ошибке валидатора
+ *
+ * @param string $sMsgDefault Дефолтное сообщение
+ * @param null|string $sMsgFieldCustom Поле/параметр в котором может храниться кастомное сообщение. В поле $sMsgFieldCustom."Id" можно хранить ключ текстовки из языкового файла
+ * @param array $aReplace Список параметров для замены в сообщении (плейсхолдеры)
+ *
+ * @return string
+ */
+ protected function getMessage($sMsgDefault, $sMsgFieldCustom = null, $aReplace = array())
+ {
+ if (!is_null($sMsgFieldCustom)) {
+ if (property_exists($this, $sMsgFieldCustom) and !is_null($this->$sMsgFieldCustom)) {
+ $sMsgDefault = $this->$sMsgFieldCustom;
+ } else {
+ $sMsgFieldCustomId = $sMsgFieldCustom . 'Id';
+ if (property_exists($this, $sMsgFieldCustomId) and !is_null($this->$sMsgFieldCustomId)) {
+ $sMsgDefault = $this->Lang_Get($this->$sMsgFieldCustomId, array(), false);
+ }
+ }
+ }
+ if ($aReplace) {
+ $aReplacePairs = array();
+ foreach ($aReplace as $sFrom => $sTo) {
+ $aReplacePairs["%%{$sFrom}%%"] = $sTo;
+ }
+ $sMsgDefault = strtr($sMsgDefault, $aReplacePairs);
+ }
+ return $sMsgDefault;
+ }
+
+ /**
+ * Запускает валидацию полей сущности
+ *
+ * @param Entity $oEntity Объект сущности
+ * @param null $aFields Список полей для валидации, если пуст то валидируются все поля указанные в правиле
+ */
+ public function validateEntity($oEntity, $aFields = null)
+ {
+ if (is_array($aFields)) {
+ $aFields = array_intersect($this->fields, $aFields);
+ } else {
+ $aFields = $this->fields;
+ }
+ $this->oEntityCurrent = $oEntity;
+ /**
+ * Запускаем валидацию для каждого поля
+ */
+ foreach ($aFields as $sField) {
+ if (!$this->bSkipOnError || !$oEntity->_hasValidateErrors($sField)) {
+ $this->validateEntityField($oEntity, $sField);
+ }
+ }
+ }
+
+ /**
+ * Запускает валидацию конкретного поля сущности
+ *
+ * @param Entity $oEntity Объект сущности
+ * @param string $sField Поле сущности
+ *
+ * @return bool
+ */
+ public function validateEntityField($oEntity, $sField)
+ {
+ $this->sFieldCurrent = $sField;
+ /**
+ * Получаем значение поля у сущности через геттер
+ */
+ $sValue = call_user_func_array(array($oEntity, 'get' . func_camelize($sField)), array());
+ /**
+ * Если условие валидации возвращает false, то пропускаем валидацию
+ */
+ if ($this->condition and method_exists($oEntity, $this->condition) and !call_user_func_array(array(
+ $oEntity,
+ $this->condition
+ ), array())
+ ) {
+ return true;
+ }
+ if (($sMsg = $this->validate($sValue)) !== true) {
+ /**
+ * Подставляем имя поля в сообщение об ошибке валидации
+ */
+ $sFieldName = is_null($this->label) ? $sField : $this->label;
+ $sMsg = str_replace('%%field%%', $sFieldName ? '«' . $sFieldName . '»' : '', $sMsg);
+ $oEntity->_addValidateError($sField, $sMsg);
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Возвращает значение поля текущей сущности
+ *
+ * @param string $sField
+ * @return mixed|null
+ */
+ protected function getValueOfCurrentEntity($sField)
+ {
+ if ($this->oEntityCurrent) {
+ return call_user_func_array(array($this->oEntityCurrent, 'get' . func_camelize($sField)), array());
+ }
+ return null;
+ }
+
+ /**
+ * Устанавливает значение поля текущей сущности
+ *
+ * @param string $sField
+ * @param string|mixed $sValue
+ */
+ protected function setValueOfCurrentEntity($sField, $sValue)
+ {
+ if ($this->oEntityCurrent) {
+ call_user_func_array(array($this->oEntityCurrent, 'set' . func_camelize($sField)), array($sValue));
+ }
+ }
+
+ /**
+ * Возвращает тип валидатора
+ *
+ * @return string
+ */
+ public function getTypeValidator()
+ {
+ return func_underscore(str_ireplace('ModuleValidate_EntityValidator', '', get_class($this)));
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorBoolean.entity.class.php b/framework/classes/modules/validate/entity/ValidatorBoolean.entity.class.php
new file mode 100644
index 0000000..6a2ca1b
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorBoolean.entity.class.php
@@ -0,0 +1,81 @@
+
+ *
+ */
+/**
+ * CBooleanValidator class file.
+ *
+ * @author Qiang Xue
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright © 2008-2011 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+/**
+ * Валидатор булевых значений
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorBoolean extends ModuleValidate_EntityValidator
+{
+ /**
+ * Значение true
+ *
+ * @var mixed
+ */
+ public $trueValue = '1';
+ /**
+ * Значение false
+ *
+ * @var mixed
+ */
+ public $falseValue = '0';
+ /**
+ * Строгое сравнение с учетом типов
+ *
+ * @var bool
+ */
+ public $strict = false;
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = true;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+ if (!$this->strict && $sValue != $this->trueValue && $sValue != $this->falseValue || $this->strict && $sValue !== $this->trueValue && $sValue !== $this->falseValue) {
+ return $this->getMessage($this->Lang_Get('validate.boolean.invalid', null, false), 'msg',
+ array('true' => $this->trueValue, 'false' => $this->falseValue));
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorCaptchaKcaptcha.entity.class.php b/framework/classes/modules/validate/entity/ValidatorCaptchaKcaptcha.entity.class.php
new file mode 100644
index 0000000..35d4e1b
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorCaptchaKcaptcha.entity.class.php
@@ -0,0 +1,66 @@
+
+ *
+ */
+
+/**
+ * Валидатор каптчи (число с картинки)
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorCaptchaKcaptcha extends ModuleValidate_EntityValidator
+{
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = false;
+ /**
+ * Название каптчи для возможности создавать несколько независимых каптч на странице
+ *
+ * @var string
+ */
+ public $name = '';
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if (is_array($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.captcha.not_valid', null, false), 'msg');
+ }
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+
+ $sSessionName = 'captcha_keystring' . ($this->name ? '_' . $this->name : '');
+ $sSessionValue = $this->Session_Get($sSessionName);
+ if (!$sSessionValue or $sSessionValue != strtolower($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.captcha.not_valid', null, false), 'msg');
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorCaptchaRecaptcha.entity.class.php b/framework/classes/modules/validate/entity/ValidatorCaptchaRecaptcha.entity.class.php
new file mode 100644
index 0000000..839ef0a
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorCaptchaRecaptcha.entity.class.php
@@ -0,0 +1,72 @@
+
+ *
+ */
+
+/**
+ * Валидатор google re-каптчи
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorCaptchaRecaptcha extends ModuleValidate_EntityValidator
+{
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = false;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if (is_array($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.captcha.not_valid', null, false), 'msg');
+ }
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+ $sSecret = Config::Get('module.validate.recaptcha.secret_key');
+ $sUrl = "https://www.google.com/recaptcha/api/siteverify?secret={$sSecret}&response={$sValue}";
+ if (Config::Get('module.validate.recaptcha.use_ip')) {
+ $sUrl .= '&remoteip=' . func_getIp();
+ }
+ if ($sData = file_get_contents($sUrl)) {
+ if ($aData = @json_decode($sData, true)) {
+ if (isset($aData['success']) and $aData['success']) {
+ return true;
+ }
+ } else {
+ $this->Logger_Warning('ReCaptcha: error json decode', array('url' => $sUrl));
+ }
+ } else {
+ $aError = error_get_last();
+ $this->Logger_Warning('ReCaptcha: ' . ($aError ? $aError['message'] : 'error server request'),
+ array('url' => $sUrl));
+ }
+ return $this->getMessage($this->Lang_Get('validate.captcha.not_valid', null, false), 'msg');
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorCompare.entity.class.php b/framework/classes/modules/validate/entity/ValidatorCompare.entity.class.php
new file mode 100644
index 0000000..e5952a0
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorCompare.entity.class.php
@@ -0,0 +1,143 @@
+
+ *
+ */
+/**
+ * CCompareValidator class file.
+ *
+ * @author Qiang Xue
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright © 2008-2011 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+/**
+ * Валидатор сравнения значений
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorCompare extends ModuleValidate_EntityValidator
+{
+ /**
+ * Имя поля для сравнения
+ *
+ * @var string
+ */
+ public $compareField;
+ /**
+ * Значение для сравнения
+ *
+ * @var string
+ */
+ public $compareValue;
+ /**
+ * Название поля сущности для сравнения, используется в сообщениях об ошибках
+ *
+ * @var string
+ */
+ public $compareLabel;
+ /**
+ * Строгое сравнение
+ *
+ * @var bool
+ */
+ public $strict = false;
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = false;
+ /**
+ * Оператор для сравнения
+ * Доступны: '=' или '==', '!=', '>', '>=', '<', '<='
+ *
+ * @var string
+ */
+ public $operator = '=';
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+ /**
+ * Определяем значение для сравнения
+ */
+ if ($this->compareValue !== null or !$this->oEntityCurrent) {
+ $sCompareLabel = $sCompareValue = $this->compareValue;
+ } else {
+ $sCompareField = $this->compareField === null ? $this->sFieldCurrent . '_repeat' : $this->compareField;
+ $sCompareValue = $this->getValueOfCurrentEntity($sCompareField);
+ $sCompareLabel = is_null($this->compareLabel) ? $sCompareField : $this->compareLabel;
+ }
+
+ switch ($this->operator) {
+ case '=':
+ case '==':
+ if (($this->strict && $sValue !== $sCompareValue) || (!$this->strict && $sValue != $sCompareValue)) {
+ return $this->getMessage($this->Lang_Get('validate.compare.must_repeated', null, false), 'msg',
+ array('compare_field' => $sCompareLabel));
+ }
+ break;
+ case '!=':
+ if (($this->strict && $sValue === $sCompareValue) || (!$this->strict && $sValue == $sCompareValue)) {
+ return $this->getMessage($this->Lang_Get('validate.compare.must_not_equal', null, false), 'msg',
+ array('compare_field' => $sCompareLabel, 'compare_value' => htmlspecialchars($sCompareValue)));
+ }
+ break;
+ case '>':
+ if ($sValue <= $sCompareValue) {
+ return $this->getMessage($this->Lang_Get('validate.compare.must_greater', null, false), 'msg',
+ array('compare_field' => $sCompareLabel, 'compare_value' => htmlspecialchars($sCompareValue)));
+ }
+ break;
+ case '>=':
+ if ($sValue < $sCompareValue) {
+ return $this->getMessage($this->Lang_Get('validate.compare.must_greater_equal', null, false), 'msg',
+ array('compare_field' => $sCompareLabel, 'compare_value' => htmlspecialchars($sCompareValue)));
+ }
+ break;
+ case '<':
+ if ($sValue >= $sCompareValue) {
+ return $this->getMessage($this->Lang_Get('validate.compare.must_less', null, false), 'msg',
+ array('compare_field' => $sCompareLabel, 'compare_value' => htmlspecialchars($sCompareValue)));
+ }
+ break;
+ case '<=':
+ if ($sValue > $sCompareValue) {
+ return $this->getMessage($this->Lang_Get('validate.compare.must_less_equal', null, false), 'msg',
+ array('compare_field' => $sCompareLabel, 'compare_value' => htmlspecialchars($sCompareValue)));
+ }
+ break;
+ default:
+ return $this->getMessage($this->Lang_Get('validate.compare.invalid_operator', null, false), 'msg',
+ array('operator' => $this->operator));
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorDate.entity.class.php b/framework/classes/modules/validate/entity/ValidatorDate.entity.class.php
new file mode 100644
index 0000000..96d6134
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorDate.entity.class.php
@@ -0,0 +1,85 @@
+
+ *
+ */
+/**
+ * CDateValidator class file.
+ *
+ * @author Qiang Xue
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright © 2008-2011 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+/**
+ * Валидатор даты
+ * Валидатор использует внешний класс DateTimeParser
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorDate extends ModuleValidate_EntityValidator
+{
+ /**
+ * Формат допустимой даты, может содержать список форматов в массиве
+ *
+ * @var string|array
+ */
+ public $format = 'yyyy-MM-dd';
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = true;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if (is_array($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.date.format_invalid', null, false), 'msg');
+ }
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+
+ require_once(Config::Get('path.framework.libs_vendor.server') . '/DateTime/DateTimeParser.php');
+
+ $aFormats = is_string($this->format) ? array($this->format) : $this->format;
+ $bValid = false;
+ foreach ($aFormats as $sFormat) {
+ $iTimestamp = DateTimeParser::parse($sValue, $sFormat);
+ if ($iTimestamp !== false) {
+ $bValid = true;
+ break;
+ }
+ }
+
+ if (!$bValid) {
+ return $this->getMessage($this->Lang_Get('validate.date.format_invalid', null, false), 'msg');
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorDefault.entity.class.php b/framework/classes/modules/validate/entity/ValidatorDefault.entity.class.php
new file mode 100644
index 0000000..b41b01d
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorDefault.entity.class.php
@@ -0,0 +1,58 @@
+
+ *
+ */
+
+/**
+ * Если значение не установлено, то присваивает дефолтное значение
+ * Данный метод валидации применим только к валидации сущностей (Entity)
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorDefault extends ModuleValidate_EntityValidator
+{
+ /**
+ * Дефолтное значение
+ *
+ * @var mixed
+ */
+ public $value = null;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ /**
+ * Выставляем дефолтное значение
+ */
+ if ($this->isEmpty($sValue)) {
+ $mValue = $this->value;
+ if (is_callable($this->value)) {
+ $mValue = call_user_func($this->value, $this->sFieldCurrent, $this->oEntityCurrent);
+ }
+ $this->setValueOfCurrentEntity($this->sFieldCurrent, $mValue);
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorEmail.entity.class.php b/framework/classes/modules/validate/entity/ValidatorEmail.entity.class.php
new file mode 100644
index 0000000..b2c0fc0
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorEmail.entity.class.php
@@ -0,0 +1,122 @@
+
+ *
+ */
+/**
+ * CEmailValidator class file.
+ *
+ * @author Qiang Xue
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright © 2008-2011 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+/**
+ * Валидатор емайл адресов
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorEmail extends ModuleValidate_EntityValidator
+{
+ /**
+ * Регулярное выражение для проверки емайла
+ *
+ * @var string
+ * @see http://www.regular-expressions.info/email.html
+ */
+ public $pattern = '/^[a-zA-Z0-9!#$%&\'*+\\/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&\'*+\\/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/';
+ /**
+ * Регулярное выражение для проверки емайла с именем отправителя.
+ * Используется только при allowName = true
+ *
+ * @var string
+ * @see allowName
+ */
+ public $fullPattern = '/^[^@]*<[a-zA-Z0-9!#$%&\'*+\\/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&\'*+\\/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?>$/';
+ /**
+ * Учитывать при проверке имя отправителя, например, "Ivanov "
+ *
+ * @var bool
+ * @see fullPattern
+ */
+ public $allowName = false;
+ /**
+ * Производить проверку MX записи для емайла
+ *
+ * @var bool
+ */
+ public $checkMX = false;
+ /**
+ * Проверять 25 порт для емайла
+ *
+ * @var bool
+ */
+ public $checkPort = false;
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = true;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if (is_array($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.email.not_valid', null, false), 'msg');
+ }
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+ if (!$this->validateValue($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.email.not_valid', null, false), 'msg');
+ }
+ return true;
+ }
+
+ /**
+ * Проверка емайла на корректность
+ *
+ * @param string $sValue Данные для валидации
+ *
+ * @return bool
+ */
+ public function validateValue($sValue)
+ {
+ $bValid = is_string($sValue) && strlen($sValue) <= 254 && (preg_match($this->pattern,
+ $sValue) || $this->allowName && preg_match($this->fullPattern, $sValue));
+ if ($bValid) {
+ $sDomain = rtrim(substr($sValue, strpos($sValue, '@') + 1), '>');
+ }
+ if ($bValid && $this->checkMX && function_exists('checkdnsrr')) {
+ $bValid = checkdnsrr($sDomain, 'MX');
+ }
+ if ($bValid && $this->checkPort && function_exists('fsockopen')) {
+ $bValid = fsockopen($sDomain, 25) !== false;
+ }
+ return $bValid;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorEnum.entity.class.php b/framework/classes/modules/validate/entity/ValidatorEnum.entity.class.php
new file mode 100644
index 0000000..ef22183
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorEnum.entity.class.php
@@ -0,0 +1,75 @@
+
+ *
+ */
+
+/**
+ * Валидатор перечислений
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorEnum extends ModuleValidate_EntityValidator
+{
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = true;
+ /**
+ * Массив разрешенных элементов
+ *
+ * @var array
+ */
+ public $enum = array();
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ /**
+ * Проверка типа значения
+ */
+ if (!is_scalar($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.enum.invalid', null, false), 'msg');
+ }
+ /**
+ * Разрешение на пустое значение
+ */
+ if ($this->allowEmpty and $this->isEmpty($sValue)) {
+ return true;
+ }
+ /**
+ * Проверка на вхождение в перечисление
+ */
+ if (!in_array($sValue, $this->enum)) {
+ return $this->getMessage($this->Lang_Get('validate.enum.not_allowed', null, false), 'msg',
+ array('value' => htmlspecialchars($sValue)));
+ }
+ /**
+ * Значение корректно
+ */
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorFilterEscape.entity.class.php b/framework/classes/modules/validate/entity/ValidatorFilterEscape.entity.class.php
new file mode 100644
index 0000000..9bda468
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorFilterEscape.entity.class.php
@@ -0,0 +1,71 @@
+
+ *
+ */
+
+/**
+ * Фильтр для экранирования строк
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorFilterEscape extends ModuleValidate_EntityValidator
+{
+ /**
+ * Функция для экранирования, может быть коллбэком
+ *
+ * @var string
+ */
+ public $function = 'htmlspecialchars';
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = true;
+ /**
+ * Пропускать или нет ошибку
+ *
+ * @var bool
+ */
+ public $bSkipOnError = true;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+ if (!is_scalar($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.string.not_valid', null, false), 'msg');
+ }
+
+ if ($this->oEntityCurrent) {
+ $sValueEscape = call_user_func($this->function, $sValue);
+ $this->setValueOfCurrentEntity($this->sFieldCurrent, $sValueEscape);
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorInline.entity.class.php b/framework/classes/modules/validate/entity/ValidatorInline.entity.class.php
new file mode 100644
index 0000000..0423793
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorInline.entity.class.php
@@ -0,0 +1,61 @@
+
+ *
+ */
+
+/**
+ * Валидатор для кастомных методов объектов
+ * Валидация происходит через метод внешнего объекта
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorInline extends ModuleValidate_EntityValidator
+{
+ /**
+ * Метод объекта для валидации, в него передаются параметры: $sValue и $aParam
+ *
+ * @var string
+ */
+ public $method;
+ /**
+ * Объект у которого будет вызван метод валидации, дляя сущности - это сам объект сущности
+ *
+ * @var LsObject object
+ */
+ public $object;
+ /**
+ * Список параметров для передачи в метод валидации
+ *
+ * @var array
+ */
+ public $params;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ $sMethod = $this->method;
+ return $this->object->$sMethod($sValue, $this->params);
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorMethod.entity.class.php b/framework/classes/modules/validate/entity/ValidatorMethod.entity.class.php
new file mode 100644
index 0000000..48628d4
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorMethod.entity.class.php
@@ -0,0 +1,73 @@
+
+ *
+ */
+
+/**
+ * Валидатор значений через вызов внешнего метода
+ * В аргументах вызова метода передается один параметр - значение для валидации
+ * Проверка происходит на нестрогое соответвие !=false результата выполнения метода
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorMethod extends ModuleValidate_EntityValidator
+{
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = true;
+ /**
+ * Полное название метода для проверки, метод будет вызваться через объект $this
+ *
+ * @var string
+ */
+ public $method = null;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ /**
+ * Проверка типа значения
+ */
+ if (!$this->method) {
+ return $this->getMessage($this->Lang_Get('validate.method.invalid', null, false), 'msg');
+ }
+ /**
+ * Разрешение на пустое значение
+ */
+ if ($this->allowEmpty and $this->isEmpty($sValue)) {
+ return true;
+ }
+ /**
+ * Проверяем значение внешнего метода
+ */
+ if (!call_user_func(array($this, $this->method), $sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.method.error', null, false), 'msg');
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorNumber.entity.class.php b/framework/classes/modules/validate/entity/ValidatorNumber.entity.class.php
new file mode 100644
index 0000000..67518be
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorNumber.entity.class.php
@@ -0,0 +1,124 @@
+
+ *
+ */
+/**
+ * CNumberValidator class file.
+ *
+ * @author Qiang Xue
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright © 2008-2011 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+/**
+ * Валидатор числовых значений
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorNumber extends ModuleValidate_EntityValidator
+{
+ /**
+ * Допускать только целое число
+ *
+ * @var bool
+ */
+ public $integerOnly = false;
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = true;
+ /**
+ * Максимально допустимое значение
+ *
+ * @var null|integer|float
+ */
+ public $max;
+ /**
+ * Минимально допустимое значение
+ *
+ * @var null|integer|float
+ */
+ public $min;
+ /**
+ * Кастомное сообщение об ошибке при слишком большом числе
+ *
+ * @var string
+ */
+ public $msgTooBig;
+ /**
+ * Кастомное сообщение об ошибке при слишком маленьком числе
+ *
+ * @var string
+ */
+ public $msgTooSmall;
+ /**
+ * Регулярное выражение для целого числа
+ *
+ * @var string
+ */
+ public $integerPattern = '/^\s*[+-]?\d+\s*$/';
+ /**
+ * Регулярное выражение для числа, допускается дробное
+ *
+ * @var string
+ */
+ public $numberPattern = '/^\s*[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?\s*$/';
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if (is_array($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.number.must_number', null, false), 'msg');
+ }
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+ if ($this->integerOnly) {
+ if (!preg_match($this->integerPattern, $sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.number.must_integer', null, false), 'msg');
+ }
+ } else {
+ if (!preg_match($this->numberPattern, $sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.number.must_number', null, false), 'msg');
+ }
+ }
+ if ($this->min !== null && $sValue < $this->min) {
+ return $this->getMessage($this->Lang_Get('validate.number.too_small', null, false), 'msgTooSmall',
+ array('min' => $this->min));
+ }
+ if ($this->max !== null && $sValue > $this->max) {
+ return $this->getMessage($this->Lang_Get('validate.number.too_big', null, false), 'msgTooBig',
+ array('max' => $this->max));
+ }
+ if (!$this->allowEmpty && $this->isEmpty($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.empty_error', null, false), 'msg');
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorRegexp.entity.class.php b/framework/classes/modules/validate/entity/ValidatorRegexp.entity.class.php
new file mode 100644
index 0000000..3b6c6ee
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorRegexp.entity.class.php
@@ -0,0 +1,83 @@
+
+ *
+ */
+/**
+ * CRegularExpressionValidator class file.
+ *
+ * @author Qiang Xue
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright © 2008-2011 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+/**
+ * Валидатор текстовых данных на регулярное выражение
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorRegexp extends ModuleValidate_EntityValidator
+{
+ /**
+ * Проверяющее регулярное выражение
+ *
+ * @var string
+ */
+ public $pattern;
+ /**
+ * Инвертировать логику проверки на регулярное выражение
+ *
+ * @var bool
+ **/
+ public $not = false;
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = true;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if (is_array($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.regexp.invalid_pattern', null, false), 'msg');
+ }
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+
+ if ($this->pattern === null) {
+ return $this->getMessage($this->Lang_Get('validate.regexp.invalid_pattern', null, false), 'msg');
+ }
+ if ((!$this->not && !preg_match($this->pattern, $sValue)) || ($this->not && preg_match($this->pattern,
+ $sValue))
+ ) {
+ return $this->getMessage($this->Lang_Get('validate.regexp.not_valid', null, false), 'msg');
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorRequired.entity.class.php b/framework/classes/modules/validate/entity/ValidatorRequired.entity.class.php
new file mode 100644
index 0000000..c3e1f6b
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorRequired.entity.class.php
@@ -0,0 +1,72 @@
+
+ *
+ */
+/**
+ * CRequiredValidator class file.
+ *
+ * @author Qiang Xue
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright © 2008-2011 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+/**
+ * Валидатор на пустое значение или точное совпадение
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorRequired extends ModuleValidate_EntityValidator
+{
+ /**
+ * Требуемое значение для точного совпадения
+ *
+ * @var mixed
+ */
+ public $requiredValue;
+ /**
+ * Строгое сравнение с учетом типов, актуально при использовании requiredValue
+ *
+ * @var bool
+ */
+ public $strict = false;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if ($this->requiredValue !== null) {
+ if (!$this->strict && $sValue != $this->requiredValue || $this->strict && $sValue !== $this->requiredValue) {
+ return $this->getMessage($this->Lang_Get('validate.required.must_be', null, false), 'msg',
+ array('value' => $this->requiredValue));
+ }
+ } else {
+ if ($this->isEmpty($sValue, true)) {
+ return $this->getMessage($this->Lang_Get('validate.required.cannot_blank', null, false), 'msg');
+ }
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorSafe.entity.class.php b/framework/classes/modules/validate/entity/ValidatorSafe.entity.class.php
new file mode 100644
index 0000000..9d0153b
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorSafe.entity.class.php
@@ -0,0 +1,41 @@
+
+ *
+ */
+
+/**
+ * Помечает поле для возможности использования его в методе Entity::_setDataSafe()
+ *
+ * @package framework.modules.validate
+ * @since 2.0
+ */
+class ModuleValidate_EntityValidatorSafe extends ModuleValidate_EntityValidator
+{
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorString.entity.class.php b/framework/classes/modules/validate/entity/ValidatorString.entity.class.php
new file mode 100644
index 0000000..e4940ed
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorString.entity.class.php
@@ -0,0 +1,110 @@
+
+ *
+ */
+/**
+ * CStringValidator class file.
+ *
+ * @author Qiang Xue
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright © 2008-2011 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+/**
+ * Валидатор текстовых данных на длину
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorString extends ModuleValidate_EntityValidator
+{
+ /**
+ * Максимальня длина строки
+ *
+ * @var int
+ */
+ public $max;
+ /**
+ * Минимальная длина строки
+ *
+ * @var int
+ */
+ public $min;
+ /**
+ * Конкретное значение длины строки
+ *
+ * @var int
+ */
+ public $is;
+ /**
+ * Кастомное сообщение об ошибке при короткой строке
+ *
+ * @var string
+ */
+ public $msgTooShort;
+ /**
+ * Кастомное сообщение об ошибке при слишком длинной строке
+ *
+ * @var string
+ */
+ public $msgTooLong;
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = true;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if (is_array($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.string.not_valid', null, false), 'msg');
+ }
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+
+ $iLength = mb_strlen($sValue, 'UTF-8');
+
+ if ($this->min !== null && $iLength < $this->min) {
+ return $this->getMessage($this->Lang_Get('validate.string.too_short', null, false), 'msgTooShort',
+ array('count' => $this->Lang_Pluralize($this->min, $this->Lang_Get('validate.symbols', array('count' => $this->min)))));
+ }
+ if ($this->max !== null && $iLength > $this->max) {
+ return $this->getMessage($this->Lang_Get('validate.string.too_long', null, false), 'msgTooLong',
+ array('count' => $this->Lang_Pluralize($this->max, $this->Lang_Get('validate.symbols', array('count' => $this->max)))));
+ }
+ if ($this->is !== null && $iLength !== $this->is) {
+ return $this->getMessage($this->Lang_Get('validate.string.no_length', null, false), 'msg',
+ array('count' => $this->Lang_Pluralize($this->is, $this->Lang_Get('validate.symbols', array('count' => $this->is)))));
+ }
+ if (!$this->allowEmpty && $this->isEmpty($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.empty_error', null, false), 'msg');
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorTags.entity.class.php b/framework/classes/modules/validate/entity/ValidatorTags.entity.class.php
new file mode 100644
index 0000000..2661690
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorTags.entity.class.php
@@ -0,0 +1,112 @@
+
+ *
+ */
+
+/**
+ * Валидатор тегов - строка с перечислением тегов
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorTags extends ModuleValidate_EntityValidator
+{
+ /**
+ * Максимальня длина тега
+ *
+ * @var int
+ */
+ public $max = 50;
+ /**
+ * Минимальня длина тега
+ *
+ * @var int
+ */
+ public $min = 2;
+ /**
+ * Максимальное количество тегов
+ *
+ * @var int
+ */
+ public $countMax = 15;
+ /**
+ * Минимальное количество тегов
+ *
+ * @var int
+ */
+ public $countMin = 1;
+ /**
+ * Разделитель тегов
+ *
+ * @var string
+ */
+ public $sep = ',';
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = false;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if (is_array($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.tags.not_valid', null, false), 'msg');
+ }
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+
+ $aTags = explode($this->sep, trim($sValue, "\r\n\t\0\x0B ."));
+ $aTagsNew = array();
+ $aTagsNewLow = array();
+ foreach ($aTags as $sTag) {
+ $sTag = trim($sTag, "\r\n\t\0\x0B .");
+ $iLength = mb_strlen($sTag, 'UTF-8');
+ if ($iLength >= $this->min and $iLength <= $this->max and !in_array(mb_strtolower($sTag, 'UTF-8'),
+ $aTagsNewLow)
+ ) {
+ $aTagsNew[] = $sTag;
+ $aTagsNewLow[] = mb_strtolower($sTag, 'UTF-8');
+ }
+ }
+ $iCount = count($aTagsNew);
+ if (!$iCount) {
+ return $this->getMessage($this->Lang_Get('validate.tags.empty', null, false), 'msg',
+ array('min' => $this->min, 'max' => $this->max));
+ } elseif ($iCount > $this->countMax or $iCount < $this->countMin) {
+ return $this->getMessage($this->Lang_Get('validate.tags.count', null, false), 'msg',
+ array('max' => $this->countMax, 'min' => $this->countMin));
+ }
+ /**
+ * Если проверка от сущности, то возвращаем обновленное значение
+ */
+ if ($this->oEntityCurrent) {
+ $this->setValueOfCurrentEntity($this->sFieldCurrent, join($this->sep, $aTagsNew));
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorType.entity.class.php b/framework/classes/modules/validate/entity/ValidatorType.entity.class.php
new file mode 100644
index 0000000..8f26e59
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorType.entity.class.php
@@ -0,0 +1,125 @@
+
+ *
+ */
+/**
+ * CTypeValidator class file.
+ *
+ * @author Qiang Xue
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright © 2008-2011 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+/**
+ * Валидатор типа данных
+ * Для типа дата/время используется внешний валидатор DateTimeParser
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorType extends ModuleValidate_EntityValidator
+{
+ /**
+ * Допустимый тип данных.
+ * Допустимые значения: 'string', 'integer', 'float', 'array', 'date', 'time' и 'datetime'.
+ *
+ * @var string
+ */
+ public $type = 'string';
+ /**
+ * Допустимый формат даты, актуально при type = date
+ *
+ * @var string
+ */
+ public $dateFormat = 'dd-MM-yyyy';
+ /**
+ * Допустимый формат времени, актуально при type = time
+ *
+ * @var string
+ */
+ public $timeFormat = 'hh:mm';
+ /**
+ * Допустимый формат даты со временем, актуально при type = datetime
+ *
+ * @var string
+ */
+ public $datetimeFormat = 'dd-MM-yyyy hh:mm';
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = true;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+
+ require_once(Config::Get('path.framework.libs_vendor.server') . '/DateTime/DateTimeParser.php');
+
+ if ($this->type === 'integer') {
+ $bValid = preg_match('/^[-+]?[0-9]+$/', trim($sValue));
+ } else {
+ if ($this->type === 'float') {
+ $bValid = preg_match('/^[-+]?([0-9]*\.)?[0-9]+([eE][-+]?[0-9]+)?$/', trim($sValue));
+ } else {
+ if ($this->type === 'date') {
+ $bValid = DateTimeParser::parse($sValue, $this->dateFormat,
+ array('month' => 1, 'day' => 1, 'hour' => 0, 'minute' => 0, 'second' => 0)) !== false;
+ } else {
+ if ($this->type === 'time') {
+ $bValid = DateTimeParser::parse($sValue, $this->timeFormat) !== false;
+ } else {
+ if ($this->type === 'datetime') {
+ $bValid = DateTimeParser::parse($sValue, $this->datetimeFormat, array(
+ 'month' => 1,
+ 'day' => 1,
+ 'hour' => 0,
+ 'minute' => 0,
+ 'second' => 0
+ )) !== false;
+ } else {
+ if ($this->type === 'array') {
+ $bValid = is_array($sValue);
+ } else {
+ return true;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (!$bValid) {
+ return $this->getMessage($this->Lang_Get('validate.type.error', null, false), 'msg',
+ array('type' => $this->type));
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/validate/entity/ValidatorUrl.entity.class.php b/framework/classes/modules/validate/entity/ValidatorUrl.entity.class.php
new file mode 100644
index 0000000..01ef2f8
--- /dev/null
+++ b/framework/classes/modules/validate/entity/ValidatorUrl.entity.class.php
@@ -0,0 +1,117 @@
+
+ *
+ */
+/**
+ * CUrlValidator class file.
+ *
+ * @author Qiang Xue
+ * @link http://www.yiiframework.com/
+ * @copyright Copyright © 2008-2011 Yii Software LLC
+ * @license http://www.yiiframework.com/license/
+ */
+
+/**
+ * Валидатор URL адресов
+ *
+ * @package framework.modules.validate
+ * @since 1.0
+ */
+class ModuleValidate_EntityValidatorUrl extends ModuleValidate_EntityValidator
+{
+ /**
+ * Патерн проверки URL с учетом схемы
+ *
+ * @var string
+ */
+ public $pattern = '/^{schemes}:\/\/(([A-Z0-9][A-Z0-9_-]*)(\.[A-Z0-9][A-Z0-9_-]*)+)/i';
+ /**
+ * Список допустимых схем
+ *
+ * @var array
+ **/
+ public $validSchemes = array('http', 'https');
+ /**
+ * Дефолтная схема, которая добавляется к URL при ее отсутствии.
+ * Если null, то URL должен уже содержать схему
+ *
+ * @var null|string
+ **/
+ public $defaultScheme;
+ /**
+ * Допускать или нет пустое значение
+ *
+ * @var bool
+ */
+ public $allowEmpty = true;
+
+ /**
+ * Запуск валидации
+ *
+ * @param mixed $sValue Данные для валидации
+ *
+ * @return bool|string
+ */
+ public function validate($sValue)
+ {
+ if (is_array($sValue)) {
+ return $this->getMessage($this->Lang_Get('validate.url.not_valid', null, false), 'msg');
+ }
+ if ($this->allowEmpty && $this->isEmpty($sValue)) {
+ return true;
+ }
+
+ if (($sValue = $this->validateValue($sValue)) !== false) {
+ /**
+ * Если проверка от сущности, то возвращаем обновленное значение
+ */
+ if ($this->oEntityCurrent) {
+ $this->setValueOfCurrentEntity($this->sFieldCurrent, $sValue);
+ }
+ } else {
+ return $this->getMessage($this->Lang_Get('validate.url.not_valid', null, false), 'msg');
+ }
+ return true;
+ }
+
+ /**
+ * Проверка URL на корректность
+ *
+ * @param string $sValue Данные для валидации
+ *
+ * @return bool
+ */
+ public function validateValue($sValue)
+ {
+ if (is_string($sValue) && strlen($sValue) < 2000) {
+ if ($this->defaultScheme !== null && strpos($sValue, '://') === false) {
+ $sValue = $this->defaultScheme . '://' . $sValue;
+ }
+ if (strpos($this->pattern, '{schemes}') !== false) {
+ $sPattern = str_replace('{schemes}', '(' . implode('|', $this->validSchemes) . ')', $this->pattern);
+ } else {
+ $sPattern = $this->pattern;
+ }
+ if (preg_match($sPattern, $sValue)) {
+ return $sValue;
+ }
+ }
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/framework/classes/modules/viewer/Viewer.class.php b/framework/classes/modules/viewer/Viewer.class.php
new file mode 100644
index 0000000..5576bc7
--- /dev/null
+++ b/framework/classes/modules/viewer/Viewer.class.php
@@ -0,0 +1,1515 @@
+
+ *
+ */
+
+require_once(Config::Get('path.framework.libs_vendor.server') . '/Smarty/libs/Smarty.class.php');
+require_once(Config::Get('path.framework.libs_application.server') . '/SmartyLS/SmartyLS.class.php');
+
+/**
+ * Модуль обработки шаблонов используя шаблонизатор Smarty
+ *
+ * @package framework.modules
+ * @since 1.0
+ */
+class ModuleViewer extends Module
+{
+ /**
+ * Объект Smarty
+ *
+ * @var Smarty
+ */
+ protected $oSmarty;
+ /**
+ * Коллекция(массив) блоков
+ *
+ * @var array
+ */
+ protected $aBlocks = array();
+ /**
+ * Массив правил организации блоков
+ *
+ * @var array
+ */
+ protected $aBlockRules = array();
+ /**
+ * SEO ключевые слова страницы
+ *
+ * @var string
+ */
+ protected $sHtmlKeywords;
+ /**
+ * SEO описание страницы
+ *
+ * @var string
+ */
+ protected $sHtmlDescription;
+ /**
+ * Разделитель заголовка HTML страницы
+ *
+ * @var string
+ */
+ protected $sHtmlTitleSeparation = ' / ';
+ /**
+ * Список элементов/частей из которых строится заголовок страницы
+ *
+ * @var array
+ */
+ protected $aHtmlTitleParts = array();
+ /**
+ * Альтернативный адрес страницы по RSS
+ *
+ * @var array
+ */
+ protected $aHtmlRssAlternate = null;
+ /**
+ * Указание поисковику основного URL страницы, для борьбы с дублями
+ *
+ * @var string
+ */
+ protected $sHtmlCanonical;
+ /**
+ * Указание поисковику директив для индексирования страницы
+ *
+ * @var string
+ */
+ protected $sHtmlRobots = 'index,follow';
+ /**
+ * Html код для подключения js,css
+ *
+ * @var array
+ */
+ protected $aHtmlHeadFiles = array(
+ 'js' => '',
+ 'css' => ''
+ );
+ /**
+ * Переменные для отдачи при ajax запросе
+ *
+ * @var array
+ */
+ protected $aVarsAjax = array();
+ /**
+ * Переменные для загрузки в JS (используется ls.registry)
+ *
+ * @var array
+ */
+ protected $aVarsJs = array();
+ /**
+ * Определяет тип ответа при ajax запросе
+ *
+ * @var string
+ */
+ protected $sResponseAjax = null;
+ /**
+ * Отправляет специфичный для ответа header
+ *
+ * @var bool
+ */
+ protected $bResponseSpecificHeader = true;
+ /**
+ * Список меню для рендеринга
+ *
+ * @var array
+ */
+ protected $aMenu = array();
+ /**
+ * Скомпилированные меню
+ *
+ * @var array
+ */
+ protected $aMenuFetch = array();
+ /**
+ * Объект Open Graph
+ *
+ * @var ModuleViewer_EntityOpenGraph
+ */
+ protected $oOpenGraph = array();
+
+ /**
+ * Инициализация модуля
+ *
+ */
+ public function Init($bLocal = false)
+ {
+ $this->Hook_Run('viewer_init_start', compact('bLocal'));
+ /**
+ * Load template config
+ */
+ if (!$bLocal) {
+ if (file_exists($sFile = Config::Get('path.smarty.template') . '/settings/config/config.php')) {
+ Config::LoadFromFile($sFile, false);
+ }
+ }
+ /**
+ * Разделитель заголовков страниц
+ */
+ $this->SetHtmlTitleSeparation(Config::Get('view.title_separator'));
+ /**
+ * Заголовок HTML страницы
+ */
+ $this->AddHtmlTitle(Config::Get('view.name'));
+ /**
+ * SEO ключевые слова страницы
+ */
+ $this->sHtmlKeywords = Config::Get('view.keywords');
+ /**
+ * SEO описание страницы
+ */
+ $this->sHtmlDescription = Config::Get('view.description');
+ /**
+ * Объект Open Graph
+ */
+ $this->oOpenGraph = Engine::GetEntity('ModuleViewer_EntityOpenGraph');
+
+ /**
+ * Создаём объект Smarty и устанавливаем необходимые параметры
+ */
+ $this->oSmarty = $this->CreateSmartyObject();
+ $this->oSmarty->error_reporting = error_reporting() & ~E_NOTICE; // подавляем NOTICE ошибки - в этом вся прелесть смарти )
+ $this->oSmarty->setTemplateDir(Config::Get('path.smarty.template'));
+ $this->oSmarty->compile_check = Config::Get('smarty.compile_check');
+ $this->oSmarty->force_compile = Config::Get('smarty.force_compile');
+ /**
+ * Для каждого скина устанавливаем свою директорию компиляции шаблонов
+ */
+ $sCompilePath = Config::Get('path.smarty.compiled') . '/' . Config::Get('view.skin');
+ if (!is_dir($sCompilePath)) {
+ @mkdir($sCompilePath, 0777, true);
+ }
+ $this->oSmarty->setCompileDir($sCompilePath);
+ $this->oSmarty->addPluginsDir(Config::Get('path.smarty.plug'));
+ $this->oSmarty->default_template_handler_func = array($this, 'SmartyDefaultTemplateHandler');
+ }
+
+ /**
+ * Получает локальную копию модуля
+ *
+ * @return ModuleViewer
+ */
+ public function GetLocalViewer()
+ {
+ $sClass = $this->Plugin_GetDelegate('module', __CLASS__);
+
+ $oViewerLocal = new $sClass(Engine::getInstance());
+ $oViewerLocal->Init(true);
+ $oViewerLocal->VarAssign();
+ return $oViewerLocal;
+ }
+
+ /**
+ * Выполняет загрузку необходимых (возможно даже системных :)) переменных в шаблон
+ *
+ */
+ public function VarAssign()
+ {
+ $this->Hook_Run('viewer_init_assign');
+ /**
+ * Загружаем весь $_REQUEST, предварительно обработав его функцией func_htmlspecialchars()
+ */
+ $aRequest = $_REQUEST;
+ func_htmlspecialchars($aRequest);
+ $this->Assign("_aRequest", $aRequest);
+ /**
+ * Параметры стандартной сессии
+ */
+ $this->Assign("_sPhpSessionName", session_name());
+ $this->Assign("_sPhpSessionId", session_id());
+ /**
+ * Short Engine aliases
+ */
+ $this->Assign("LS", LS::getInstance());
+ /**
+ * Загружаем объект доступа к конфигурации
+ */
+ $this->Assign("oConfig", Config::getInstance());
+ /**
+ * Загружаем роутинг с учетом правил rewrite
+ */
+ $aRouter = array();
+ if ($aPages = Config::Get('router.page')) {
+ foreach ($aPages as $sPage => $aAction) {
+ $aRouter[$sPage] = Router::GetPath($sPage);
+ }
+ }
+
+ $this->Assign("aRouter", $aRouter);
+ /**
+ * Загружаем в шаблон блоки
+ */
+ $this->Assign("aBlocks", $this->aBlocks);
+ /**
+ * Загружаем HTML заголовки
+ */
+ $this->Assign("sHtmlTitle", htmlspecialchars($this->GetHtmlTitle(Config::Get('view.title_sort_reverse'))));
+ $this->Assign("sHtmlKeywords", htmlspecialchars($this->sHtmlKeywords));
+ $this->Assign("sHtmlDescription", htmlspecialchars($this->sHtmlDescription));
+ $this->Assign("aHtmlHeadFiles", $this->aHtmlHeadFiles);
+ $this->Assign("aHtmlRssAlternate", $this->aHtmlRssAlternate);
+ $this->Assign("sHtmlCanonical", func_urlspecialchars($this->sHtmlCanonical));
+ $this->Assign("sHtmlRobots", $this->sHtmlRobots);
+ /**
+ * Загружаем список активных плагинов
+ */
+ $aPlugins = Engine::getInstance()->GetPlugins();
+ $this->Assign("aPluginActive", array_fill_keys(array_keys($aPlugins), true));
+ /**
+ * Загружаем пути до шаблонов плагинов
+ */
+ $aTemplateWebPathPlugin = array();
+ $aTemplatePathPlugin = array();
+ foreach ($aPlugins as $k => $oPlugin) {
+ $aTemplateWebPathPlugin[$k] = Plugin::GetTemplateWebPath(get_class($oPlugin));
+ $aTemplatePathPlugin[$k] = Plugin::GetTemplatePath(get_class($oPlugin));
+ }
+ $this->Assign("aTemplateWebPathPlugin", $aTemplateWebPathPlugin);
+ $this->Assign("aTemplatePathPlugin", $aTemplatePathPlugin);
+ /**
+ * Загружаем security-ключ
+ */
+ $this->Assign("LIVESTREET_SECURITY_KEY", $this->Security_GetSecurityKey());
+ /**
+ * Текстовки
+ */
+ $oModuleLang = Engine::getInstance()->GetModuleObject('Lang');
+ $aLang =& $oModuleLang->GetLangMsgRef();
+ $this->Assign('aLang', array(&$aLang), false, true);
+ }
+
+ /**
+ * Загружаем содержимое menu-контейнеров
+ */
+ protected function MenuVarAssign()
+ {
+ $this->Assign("aMenuFetch", $this->aMenuFetch);
+ $this->Assign("aMenuContainers", array_keys($this->aMenu));
+ }
+
+ /**
+ * Выводит на экран(браузер) обработанный шаблон
+ *
+ * @param string $sTemplate Шаблон для вывода
+ */
+ public function Display($sTemplate)
+ {
+ if ($this->sResponseAjax) {
+ $this->DisplayAjax($this->sResponseAjax);
+ }
+ /**
+ * Если шаблон найден то выводим, иначе ошибка
+ * Предварительно проверяем наличие делегата
+ */
+ if ($sTemplate) {
+ $sTemplate = $this->Plugin_GetDelegate('template', $sTemplate);
+ if ($this->TemplateExists($sTemplate)) {
+ $this->oSmarty->display($sTemplate);
+ } else {
+ throw new Exception('Can not find the template: ' . $sTemplate);
+ }
+ }
+ }
+
+ /**
+ * Возвращает объект Smarty
+ *
+ * @return Smarty
+ */
+ public function GetSmartyObject()
+ {
+ return $this->oSmarty;
+ }
+
+ /**
+ * Создает и возвращает объект Smarty
+ *
+ * @return Smarty
+ */
+ public function CreateSmartyObject()
+ {
+ return new SmartyLS();
+ }
+
+ /**
+ * Очищает кеш компиленных шаблонов
+ */
+ public function ClearCompiledTemplates()
+ {
+ $this->oSmarty->clearCompiledTemplate();
+ }
+
+ /**
+ * Ответ на ajax запрос
+ *
+ * @param string $sType Варианты: json, jsonIframe, jsonp
+ */
+ public function DisplayAjax($sType = 'json')
+ {
+ /**
+ * Загружаем статус ответа и сообщение
+ */
+ $bStateError = false;
+ $sMsgTitle = '';
+ $sMsg = '';
+ $aMsgError = $this->Message_GetError();
+ $aMsgNotice = $this->Message_GetNotice();
+ if (count($aMsgError) > 0) {
+ $bStateError = true;
+ $sMsgTitle = $aMsgError[0]['title'];
+ $sMsg = $aMsgError[0]['msg'];
+ } elseif (count($aMsgNotice) > 0) {
+ $sMsgTitle = $aMsgNotice[0]['title'];
+ $sMsg = $aMsgNotice[0]['msg'];
+ }
+ $this->AssignAjax('sMsgTitle', $sMsgTitle);
+ $this->AssignAjax('sMsg', $sMsg);
+ $this->AssignAjax('bStateError', $bStateError);
+ if ($sType == 'json') {
+ if ($this->bResponseSpecificHeader and !headers_sent()) {
+ header('Content-type: application/json');
+ }
+ echo json_encode($this->aVarsAjax);
+ } elseif ($sType == 'jsonIframe') {
+ // Оборачивает json в тег