Index: core/admin_templates/config/custom_variables.tpl =================================================================== --- core/admin_templates/config/custom_variables.tpl +++ core/admin_templates/config/custom_variables.tpl @@ -81,6 +81,10 @@ - + + seconds + + @@ -143,4 +147,4 @@ - \ No newline at end of file + Index: core/admin_templates/incs/form_blocks.tpl =================================================================== --- core/admin_templates/incs/form_blocks.tpl +++ core/admin_templates/incs/form_blocks.tpl @@ -41,6 +41,32 @@ + + + + Index: core/install/english.lang =================================================================== --- core/install/english.lang +++ core/install/english.lang @@ -166,6 +166,7 @@ RGVmYXVsdCAiUGVyIFBhZ2UiIHNldHRpbmcgaW4gR3JpZHM= RGVmYXVsdCBSZWdpc3RyYXRpb24gQ291bnRyeQ== RGVmYXVsdCBBbmFseXRpY3MgVHJhY2tpbmcgQ29kZQ== + RWRpdGluZyBXaW5kb3cgTWFuYWdlciBQaW5nIEludGVydmFs RW1haWwgRGVsaXZlcnk= S2VlcCAiRS1tYWlsIExvZyIgZm9y RW5hYmxlICJFLW1haWwgTG9nIg== Index: core/install/install_data.sql =================================================================== --- core/install/install_data.sql +++ core/install/install_data.sql @@ -134,6 +134,7 @@ INSERT INTO SystemSettings VALUES(DEFAULT, 'User_Default_Registration_Country', '', 'In-Portal:Users', 'in-portal:configure_users', 'la_title_General', 'la_config_DefaultRegistrationCountry', 'select', NULL, '=+||SELECT l%3$s_Name AS OptionName, CountryStateId AS OptionValue FROM CountryStates WHERE Type = 1 ORDER BY OptionName', 10.13, 0, 0, NULL); INSERT INTO SystemSettings VALUES(DEFAULT, 'AllowSelectGroupOnFront', '0', 'In-Portal:Users', 'in-portal:configure_users', 'la_title_General', 'la_config_AllowSelectGroupOnFront', 'checkbox', NULL, NULL, 10.14, 0, 0, NULL); INSERT INTO SystemSettings VALUES(DEFAULT, 'DefaultSettingsUserId', '-1', 'In-Portal:Users', 'in-portal:configure_users', 'la_title_General', 'la_prompt_DefaultUserId', 'text', NULL, NULL, 10.15, 0, 0, NULL); +INSERT INTO SystemSettings VALUES(DEFAULT, 'EditingWindowManagerPingInterval', '30', 'In-Portal:Users', 'in-portal:configure_users', 'la_title_General', 'la_config_EditingWindowManagerPingInterval', 'text', '', 'style=\"width: 50px;\"', 10.16, 0, 1, NULL); INSERT INTO SystemSettings VALUES(DEFAULT, 'u_MaxImageCount', '5', 'In-Portal:Users', 'in-portal:configure_users', 'la_section_ImageSettings', 'la_config_MaxImageCount', 'text', '', '', 30.01, 0, 0, NULL); INSERT INTO SystemSettings VALUES(DEFAULT, 'u_ThumbnailImageWidth', '120', 'In-Portal:Users', 'in-portal:configure_users', 'la_section_ImageSettings', 'la_config_ThumbnailImageWidth', 'text', '', '', 30.02, 0, 0, NULL); INSERT INTO SystemSettings VALUES(DEFAULT, 'u_ThumbnailImageHeight', '120', 'In-Portal:Users', 'in-portal:configure_users', 'la_section_ImageSettings', 'la_config_ThumbnailImageHeight', 'text', '', '', 30.03, 0, 0, NULL); Index: core/install/upgrades.sql =================================================================== --- core/install/upgrades.sql +++ core/install/upgrades.sql @@ -2956,3 +2956,4 @@ # ===== v 5.2.2-B3 ===== INSERT INTO SearchConfig VALUES ('Categories', 'PageContent', 1, 1, 'lu_fielddesc_category_PageContent', 'lc_field_PageContent', 'In-Portal', 'la_text_category', 22, DEFAULT, 1, 'text', 'MULTI:PageRevisions.PageContent', '{ForeignTable}.PageId = {LocalTable}.CategoryId AND {ForeignTable}.RevisionNumber = {LocalTable}.LiveRevisionNumber', NULL, NULL, NULL, NULL, NULL); ALTER TABLE Semaphores CHANGE MainIDs MainIDs TEXT NULL; +INSERT INTO SystemSettings VALUES(DEFAULT, 'EditingWindowManagerPingInterval', '30', 'In-Portal:Users', 'in-portal:configure_users', 'la_title_General', 'la_config_EditingWindowManagerPingInterval', 'text', '', 'style=\"width: 50px;\"', 10.16, 0, 1, NULL); Index: core/kernel/db/db_event_handler.php =================================================================== --- core/kernel/db/db_event_handler.php +++ core/kernel/db/db_event_handler.php @@ -167,6 +167,7 @@ 'OnViewFile' => Array ('self' => true, 'subitem' => true), 'OnSaveWidths' => Array ('self' => 'admin', 'subitem' => 'admin'), + 'OnTrackEditingWindowAJAX' => Array ('self' => 'admin', 'subitem' => 'admin'), 'OnValidateMInputFields' => Array ('self' => 'view'), 'OnValidateField' => Array ('self' => true, 'subitem' => true), @@ -359,6 +360,7 @@ * @param bool $from_session return ids from session (written, when editing was started) * @return Array * @access protected + * @see EditingWindowManager::getSelectedIDs */ protected function getSelectedIDs(kEvent $event, $from_session = false) { @@ -1915,6 +1917,10 @@ // all temp tables are deleted here => all after hooks should think, that it's live mode now $this->Application->SetVar($event->Prefix . '_mode', ''); + + /** @var EditingWindowManager $editing_window_manager */ + $editing_window_manager = $this->Application->recallObject('EditingWindowManager'); + $editing_window_manager->forgetWindows(EditingWindowManager::WINDOW_TYPE_CURRENT); } /** @@ -2037,6 +2043,10 @@ $this->Application->RemoveVar($changes_var_name); $event->SetRedirectParam('opener', 'u'); + + /** @var EditingWindowManager $editing_window_manager */ + $editing_window_manager = $this->Application->recallObject('EditingWindowManager'); + $editing_window_manager->forgetWindows(EditingWindowManager::WINDOW_TYPE_CURRENT); } /** @@ -3480,4 +3490,29 @@ { $event->setEventParam('constrain_info', Array ('', '')); } + + /** + * Tracking an editing window. + * + * @param kEvent $event Event. + * + * @return void + */ + protected function OnTrackEditingWindowAJAX(kEvent $event) + { + $event->status = kEvent::erSTOP; + + $window_id = (int)$this->Application->GetVar('m_wid'); + $id = (int)$this->Application->GetVar($event->getPrefixSpecial() . '_id'); + + if ( !$window_id || $id <= 0 ) { + return; + } + + /** @var EditingWindowManager $editing_window_manager */ + $editing_window_manager = $this->Application->recallObject('EditingWindowManager'); + + echo $editing_window_manager->trackWindow($event->Prefix); + } + } Index: core/kernel/db/db_tag_processor.php =================================================================== --- core/kernel/db/db_tag_processor.php +++ core/kernel/db/db_tag_processor.php @@ -3100,4 +3100,29 @@ return ''; } + + /** + * Tracks an editing window. + * + * @param array $params Tag params. + * + * @return string + */ + protected function TrackEditingWindow(array $params) + { + $window_id = (int)$this->Application->GetVar('m_wid'); + $id = (int)$this->Application->GetVar($this->getPrefixSpecial() . '_id'); + + // Not a popup OR creating new record. + if ( !$window_id || $id <= 0 ) { + return 'ignore'; + } + + /** @var EditingWindowManager $editing_window_manager */ + $editing_window_manager = $this->Application->recallObject('EditingWindowManager'); + + // Failure to track a window (e.g. when it's a duplicate would prevent AJAX pings as well). + return $editing_window_manager->trackWindow($this->Prefix); + } + } Index: core/kernel/utility/temp_handler.php =================================================================== --- core/kernel/utility/temp_handler.php +++ core/kernel/utility/temp_handler.php @@ -1018,70 +1018,44 @@ */ function CheckSimultaniousEdit($ids = null) { - $tables = $this->Conn->GetCol('SHOW TABLES'); - $mask_edit_table = '/' . TABLE_PREFIX . 'ses_(.*)_edit_' . $this->MasterTable . '$/'; + $ids = isset($ids) ? $ids : $this->Tables['IDs']; - $my_sid = $this->Application->GetSID(); - $my_wid = $this->Application->GetVar('m_wid'); - $ids = implode(',', isset($ids) ? $ids : $this->Tables['IDs']); - $sids = Array (); - if (!$ids) { + if ( implode(',', $ids) === '' ) { return true; } - foreach ($tables as $table) { - if ( preg_match($mask_edit_table, $table, $rets) ) { - $sid = preg_replace('/(.*)_(.*)/', '\\1', $rets[1]); // remove popup's wid from sid - if ($sid == $my_sid) { - if ($my_wid) { - // using popups for editing - if (preg_replace('/(.*)_(.*)/', '\\2', $rets[1]) == $my_wid) { - // don't count window, that is being opened right now - continue; - } - } - else { - // not using popups for editing -> don't count my session tables - continue; - } - } - - $sql = 'SELECT COUNT(' . $this->Tables['IdField'] . ') - FROM ' . $table . ' - WHERE ' . $this->Tables['IdField'] . ' IN (' . $ids . ')'; - $found = $this->Conn->GetOne($sql); - - if (!$found || in_array($sid, $sids)) { - continue; - } + /** @var EditingWindowManager $editing_window_manager */ + $editing_window_manager = $this->Application->recallObject('EditingWindowManager'); + $sids = $editing_window_manager->isDuplicateWindow($this->Tables['Prefix'], $ids); - $sids[] = $sid; - } + if ( !$sids ) { + return true; } - if ($sids) { - // detect who is it - $sql = 'SELECT - CONCAT(IF (s.PortalUserId = ' . USER_ROOT . ', \'root\', - IF (s.PortalUserId = ' . USER_GUEST . ', \'Guest\', - CONCAT(u.FirstName, \' \', u.LastName, \' (\', u.Username, \')\') - ) - ), \' IP: \', s.IpAddress, \'\') FROM ' . TABLE_PREFIX . 'UserSessions AS s - LEFT JOIN ' . TABLE_PREFIX . 'Users AS u - ON u.PortalUserId = s.PortalUserId - WHERE s.SessionKey IN (' . implode(',', $sids) . ')'; - $users = $this->Conn->GetCol($sql); - - if ($users) { - $this->Application->SetVar('_simultaneous_edit_message', - sprintf($this->Application->Phrase('la_record_being_edited_by'), join(",\n", $users)) - ); + $sids = array_unique($sids); - return false; - } + // Detect who is it. + $sql = 'SELECT + CONCAT(IF (s.PortalUserId = ' . USER_ROOT . ', \'root\', + IF (s.PortalUserId = ' . USER_GUEST . ', \'Guest\', + CONCAT(u.FirstName, \' \', u.LastName, \' (\', u.Username, \')\') + ) + ), \' IP: \', s.IpAddress, \'\') FROM ' . TABLE_PREFIX . 'UserSessions AS s + LEFT JOIN ' . TABLE_PREFIX . 'Users AS u + ON u.PortalUserId = s.PortalUserId + WHERE s.SessionKey IN (' . implode(',', $sids) . ')'; + $users = $this->Conn->GetCol($sql); + + if ( !$users ) { + return true; } - return true; + $this->Application->SetVar( + '_simultaneous_edit_message', + sprintf($this->Application->Phrase('la_record_being_edited_by'), implode(",\n", $users)) + ); + + return false; } } Index: core/units/admin/admin_config.php =================================================================== --- core/units/admin/admin_config.php +++ core/units/admin/admin_config.php @@ -32,6 +32,7 @@ 'optimize_performance' => Array ('EventName' => 'OnOptimizePerformance', 'RunSchedule' => '0 0 * * *'), 'purge_expired_database_cache' => Array ('EventName' => 'OnPurgeExpiredDatabaseCacheScheduledTask', 'RunSchedule' => '0 0,12 * * *'), 'populate_url_unit_cache' => array('EventName' => 'OnPopulateUrlUnitCacheScheduledTask', 'RunSchedule' => '0 * * * *'), + 'forget_closed_windows' => array('EventName' => 'OnForgetClosedWindowsScheduledTask', 'RunSchedule' => '*/5 * * * *'), ), 'TitlePresets' => Array ( Index: core/units/admin/admin_events_handler.php =================================================================== --- core/units/admin/admin_events_handler.php +++ core/units/admin/admin_events_handler.php @@ -753,21 +753,27 @@ */ protected function OnDropTempTablesByWID(kEvent $event) { - $sid = $this->Application->GetSID(); - $wid = $this->Application->GetVar('m_wid'); - $tables = $this->Conn->GetCol('SHOW TABLES'); - $mask_edit_table = '/' . TABLE_PREFIX . 'ses_' . $sid . '_' . $wid . '_edit_(.*)$/'; - - foreach ($tables as $table) { - if ( preg_match($mask_edit_table, $table, $rets) ) { - $this->Conn->Query('DROP TABLE IF EXISTS ' . $table); - } - } - echo 'OK'; $event->status = kEvent::erSTOP; + + /** @var EditingWindowManager $live_editing_tracker */ + $live_editing_tracker = $this->Application->recallObject('EditingWindowManager'); + $live_editing_tracker->forgetWindows(EditingWindowManager::WINDOW_TYPE_CURRENT); } + /** + * Forgets closed windows. + * + * @param kEvent $event Event. + * + * @return void + */ + protected function OnForgetClosedWindowsScheduledTask(kEvent $event) + { + /** @var EditingWindowManager $live_editing_tracker */ + $live_editing_tracker = $this->Application->recallObject('EditingWindowManager'); + $live_editing_tracker->forgetWindows(EditingWindowManager::WINDOW_TYPE_CLOSED); + } /** * Backup all data Index: core/units/helpers/EditingWindowManager.php =================================================================== --- /dev/null +++ core/units/helpers/EditingWindowManager.php @@ -0,0 +1,326 @@ +tableName = TABLE_PREFIX . 'EditingWindowManager'; + $this->pingInterval = $this->Application->ConfigValue('EditingWindowManagerPingInterval'); + + $this->ensureStorage(); + } + + /** + * Ensures, that storage is available. + * + * @return void + */ + protected function ensureStorage() + { + $sql = 'SHOW TABLES LIKE ' . $this->Conn->qstr($this->tableName); + + if ( $this->Conn->GetCol($sql) ) { + return; + } + + $sql = 'CREATE TABLE ' . $this->tableName . ' ( + `Id` int(11) NOT NULL AUTO_INCREMENT, + `SessionKey` int(10) unsigned DEFAULT NULL, + `WindowId` int(11) unsigned DEFAULT NULL, + `UserId` int(11) DEFAULT NULL, + `ItemPrefix` VARCHAR(255) NOT NULL DEFAULT "", + `ItemIds` VARCHAR(' . self::IDS_STORAGE_SIZE . ') NOT NULL DEFAULT "", + `LastPingOn` int(11) unsigned DEFAULT NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `IDX_INTEGRITY` (`SessionKey`,`WindowId`,`UserId`,`ItemPrefix`,`ItemIds`), + KEY `IDX_SEARCH` (`ItemPrefix`,`ItemIds`,`LastPingOn`), + KEY `IDX_DELETE` (`LastPingOn`) + ) ENGINE = MEMORY'; + $this->Conn->Query($sql); + } + + /** + * Returns condition for closed window detection. + * + * @return string + */ + protected function getClosedWindowWhereClause() + { + return 'LastPingOn < ' . strtotime('-' . (2 * $this->pingInterval) . ' seconds'); + } + + /** + * Forgets closed windows. + * + * @param integer $window_type Window type. + * + * @return void + * @throws InvalidArgumentException When unsupported $window_type is given. + */ + public function forgetWindows($window_type) + { + if ( $window_type === self::WINDOW_TYPE_CURRENT ) { + $windows = $this->getCurrentWindows(); + } + elseif ( $window_type === self::WINDOW_TYPE_CLOSED ) { + $windows = $this->getClosedWindows(); + } + else { + throw new InvalidArgumentException('The "' . $window_type . '" window type isn\'t supported.'); + } + + if ( !$windows ) { + return; + } + + // Sample table name: "inp_ses_806561653_1_edit_inp_TableName". + $regexp = $this->getDatabaseTableRegExp($windows); + $editing_tables = $this->Conn->GetCol('SHOW TABLES LIKE "' . TABLE_PREFIX . 'ses_%"'); + + foreach ( $editing_tables as $table ) { + if ( !preg_match($regexp, $table) ) { + continue; + } + + $this->Conn->Query('DROP TABLE IF EXISTS ' . $table); + } + + $sql = 'DELETE FROM ' . $this->tableName . ' + WHERE Id IN (' . implode(',', array_keys($windows)) . ')'; + $this->Conn->Query($sql); + } + + /** + * Returns current windows. + * + * @return array + */ + protected function getCurrentWindows() + { + // Using real (not topmost) Window ID guarantees, that sub-item popup close won't cause any harm. + $where_clause = array( + 'SessionKey = ' . $this->Application->GetSID(), + 'WindowId = ' . $this->Application->GetVar('m_wid'), + ); + + $sql = 'SELECT SessionKey, WindowId, Id + FROM ' . $this->tableName . ' + WHERE (' . implode(') AND (', $where_clause) . ')'; + + return $this->Conn->Query($sql, 'Id'); + } + + /** + * Returns closed window information. + * + * @return array + */ + protected function getClosedWindows() + { + $sql = 'SELECT SessionKey, WindowId, Id + FROM ' . $this->tableName . ' + WHERE ' . $this->getClosedWindowWhereClause(); + + return $this->Conn->Query($sql, 'Id'); + } + + /** + * Returns regular expression for locating window-based database tables. + * + * @param array $windows Windows. + * + * @return string + */ + protected function getDatabaseTableRegExp(array $windows) + { + $parts = array(); + + foreach ( $windows as $window_data ) { + $parts[] = $window_data['SessionKey'] . '_' . $window_data['WindowId']; + } + + return '/^' . TABLE_PREFIX . 'ses_(' . implode('|', $parts) . ')_edit_.*$/'; + } + + /** + * Checks if it's a duplicate window. + * + * @param string $prefix Prefix. + * @param array $ids IDs. + * + * @return integer[] + */ + public function isDuplicateWindow($prefix, array $ids) + { + $ids_where_clause = array(); + + foreach ( $ids as $id ) { + $ids_where_clause[] = 'ItemIds LIKE ' . $this->Conn->qstr('%|' . $id . '|%'); + } + + $where_clause = array( + 'ItemPrefix = ' . $this->Conn->qstr($prefix), + '(' . implode(') OR (', $ids_where_clause) . ')', + '!(' . $this->getClosedWindowWhereClause() . ')', + ); + + $sql = 'SELECT * + FROM ' . $this->tableName . ' + WHERE (' . implode(') AND (', $where_clause) . ')'; + $windows = $this->Conn->Query($sql); + + // Record isn't being edited. + if ( !$windows ) { + return array(); + } + + // Duplicate editing window records aren't inserted into the database, so check only 1st window. + $first_window_data = reset($windows); + + // Record being edited by another user. + if ( $first_window_data['UserId'] != $this->Application->RecallVar('user_id') ) { + return array($first_window_data['SessionKey']); + } + + // Record being edited by current user, but in another window. + if ( $first_window_data['WindowId'] != $this->Application->GetTopmostWid($prefix) ) { + return array($first_window_data['SessionKey']); + } + + // Record being edited once by current user in current window. + return array(); + } + + /** + * Track new/existing editing window. + * + * @param string $prefix Prefix. + * @param array $ids IDs. + * + * @return string + */ + public function trackWindow($prefix, array $ids = array()) + { + // Only track editing windows using temp tables. + if ( !$this->Application->IsTempMode($prefix) ) { + return 'ignore'; + } + + if ( !$ids ) { + $ids = $this->getSelectedIDs($prefix); + } + + // The "\kTempTablesHandler::CheckSimultaniousEdit" method handles this. Ignore here. + if ( $this->isDuplicateWindow($prefix, $ids) ) { + return 'duplicate'; + } + + $ids_storage = $this->prepareItemIdsForStorage($ids); + + $where_clause = array( + 'SessionKey = ' . $this->Application->GetSID(), + 'WindowId = ' . $this->Application->GetTopmostWid($prefix), + 'UserId = ' . $this->Application->RecallVar('user_id'), + 'ItemPrefix = ' . $this->Conn->qstr($prefix), + 'ItemIds = ' . $this->Conn->qstr($ids_storage), + ); + + $sql = 'SELECT Id + FROM ' . $this->tableName . ' + WHERE (' . implode(') AND (', $where_clause) . ')'; + $tacking_id = $this->Conn->GetOne($sql); + + if ( $tacking_id === false ) { + // Add the new window. + $this->Conn->doInsert( + array( + 'SessionKey' => $this->Application->GetSID(), + 'WindowId' => $this->Application->GetTopmostWid($prefix), + 'UserId' => $this->Application->RecallVar('user_id'), + 'ItemPrefix' => $prefix, + 'ItemIds' => $ids_storage, + 'LastPingOn' => time(), + ), + $this->tableName + ); + } + else { + // Extend lifetime of an existing window. + $this->Conn->doUpdate(array('LastPingOn' => time()), $this->tableName, 'Id = ' . $tacking_id); + } + + return 'tracked'; + } + + /** + * Prepares Item IDs for stage. + * + * @param array $ids IDs. + * + * @return string + */ + protected function prepareItemIdsForStorage(array $ids) + { + $ret = '|' . implode('|', $ids) . '|'; + + // Already has correct size. + if ( strlen($ret) < self::IDS_STORAGE_SIZE ) { + return $ret; + } + + $ret = substr($ret, 0, self::IDS_STORAGE_SIZE); + + // Cut exactly to the ID. + if ( substr($ret, -1) === '|' ) { + return $ret; + } + + // Cut in the middle of the ID. + return substr($ret, 0, strrpos($ret, '|') + 1); + } + + /** + * Returns stored selected ids as an array + * + * @param string $prefix Prefix. + * + * @return array + * @see kDBEventHandler::getSelectedIDs + */ + protected function getSelectedIDs($prefix) + { + $wid = $this->Application->GetTopmostWid($prefix); + $var_name = rtrim($prefix . '_selected_ids_' . $wid, '_'); + $ret = $this->Application->RecallVar($var_name); + + return explode(',', $ret); + } + +} Index: core/units/helpers/helpers_config.php =================================================================== --- core/units/helpers/helpers_config.php +++ core/units/helpers/helpers_config.php @@ -78,5 +78,6 @@ Array ('pseudo' => 'AjaxFormHelper', 'class' => 'AjaxFormHelper', 'file' => 'ajax_form_helper.php', 'build_event' => ''), Array ('pseudo' => 'kCronHelper', 'class' => 'kCronHelper', 'file' => 'cron_helper.php', 'build_event' => ''), Array ('pseudo' => 'kUploadHelper', 'class' => 'kUploadHelper', 'file' => 'upload_helper.php', 'build_event' => ''), + Array ('pseudo' => 'EditingWindowManager', 'class' => 'EditingWindowManager', 'file' => 'EditingWindowManager.php', 'build_event' => ''), ), );