Index: core/install/install_data.sql =================================================================== --- core/install/install_data.sql +++ core/install/install_data.sql @@ -12,7 +12,7 @@ INSERT INTO SystemSettings VALUES(DEFAULT, 'Catalog_PreselectModuleTab', '1', 'In-Portal', 'in-portal:configure_categories', 'la_title_General', 'la_config_CatalogPreselectModuleTab', 'checkbox', NULL, NULL, 10.09, 0, 0, NULL); INSERT INTO SystemSettings VALUES(DEFAULT, 'RecycleBinFolder', '', 'In-Portal', 'in-portal:configure_categories', 'la_title_General', 'la_config_RecycleBinFolder', 'text', NULL, NULL, 10.10, 0, 0, NULL); INSERT INTO SystemSettings VALUES(DEFAULT, 'CheckViewPermissionsInCatalog', '0', 'In-Portal', 'in-portal:configure_categories', 'la_title_General', 'la_config_CheckViewPermissionsInCatalog', 'radio', NULL, '1=la_Yes||0=la_No', 10.11, 0, 1, 'hint:la_config_CheckViewPermissionsInCatalog'); -INSERT INTO SystemSettings VALUES(DEFAULT, 'CategoryPermissionRebuildMode', '3', 'In-Portal', 'in-portal:configure_categories', 'la_title_General', 'la_config_CategoryPermissionRebuildMode', 'select', NULL, '1=la_opt_Manual||2=la_opt_Silent||3=la_opt_Automatic', 10.12, 0, 0, 'hint:la_config_CategoryPermissionRebuildMode'); +INSERT INTO SystemSettings VALUES(DEFAULT, 'CategoryPermissionRebuildMode', '1', 'In-Portal', 'in-portal:configure_categories', 'la_title_General', 'la_config_CategoryPermissionRebuildMode', 'select', NULL, '1=la_opt_Manual||2=la_opt_Silent||3=la_opt_Automatic', 10.12, 0, 0, 'hint:la_config_CategoryPermissionRebuildMode'); INSERT INTO SystemSettings VALUES(DEFAULT, 'FilenameSpecialCharReplacement', '-', 'In-Portal', 'in-portal:configure_categories', 'la_title_General', 'la_config_FilenameSpecialCharReplacement', 'select', NULL, '_=+_||-=+-', 10.13, 0, 0, NULL); INSERT INTO SystemSettings VALUES(DEFAULT, 'Search_MinKeyword_Length', '3', 'In-Portal', 'in-portal:configure_categories', 'la_title_General', 'la_config_Search_MinKeyword_Length', 'text', NULL, NULL, 10.14, 0, 0, NULL); INSERT INTO SystemSettings VALUES(DEFAULT, 'ExcludeTemplateSectionsFromSearch', '0', 'In-Portal', 'in-portal:configure_categories', 'la_title_General', 'la_config_ExcludeTemplateSectionsFromSearch', 'checkbox', '', '', 10.15, 0, 0, NULL); Index: core/install/install_schema.sql =================================================================== --- core/install/install_schema.sql +++ core/install/install_schema.sql @@ -518,6 +518,7 @@ ParentPath text, TreeLeft bigint(20) NOT NULL DEFAULT '0', TreeRight bigint(20) NOT NULL DEFAULT '0', + TreeLocked tinyint(4) NOT NULL DEFAULT '0', NamedParentPath text, NamedParentPathHash int(10) unsigned NOT NULL DEFAULT '0', MetaDescription text, @@ -592,7 +593,8 @@ KEY LiveRevisionNumber (LiveRevisionNumber), KEY PromoBlockGroupId (PromoBlockGroupId), KEY NamedParentPathHash (NamedParentPathHash), - KEY CachedTemplateHash (CachedTemplateHash) + KEY CachedTemplateHash (CachedTemplateHash), + KEY IDX_TREELOCKED (TreeLocked) ); CREATE TABLE CategoryCustomData ( Index: core/install/upgrades.sql =================================================================== --- core/install/upgrades.sql +++ core/install/upgrades.sql @@ -2934,3 +2934,7 @@ WHERE TemplateName LIKE 'USER%'; UPDATE SystemSettings SET VariableValue = 1 WHERE VariableName = 'CSVExportEncoding'; + +ALTER TABLE Categories ADD TreeLocked TINYINT(4) NOT NULL DEFAULT '0' AFTER TreeRight; +ALTER TABLE Categories ADD INDEX IDX_TREELOCKED (TreeLocked); +UPDATE SystemSettings SET VariableValue = 1 WHERE VariableName = 'CategoryPermissionRebuildMode'; Index: core/units/admin/admin_tag_processor.php =================================================================== --- core/units/admin/admin_tag_processor.php +++ core/units/admin/admin_tag_processor.php @@ -582,7 +582,10 @@ if ( $global_mark || $local_mark ) { $this->Application->RemoveVar('PermCache_UpdateRequired'); - $rebuild_mode = $this->Application->ConfigValue('CategoryPermissionRebuildMode'); + + /** @var CategoryHelper $category_helper */ + $category_helper = $this->Application->recallObject('CategoryHelper'); + $rebuild_mode = $category_helper->getCategoryPermissionRebuildMode(); if ( $rebuild_mode == CategoryPermissionRebuild::SILENT ) { $updater = $this->Application->makeClass('kPermCacheUpdater'); Index: core/units/categories/cache_updater.php =================================================================== --- core/units/categories/cache_updater.php +++ core/units/categories/cache_updater.php @@ -206,12 +206,20 @@ var $StrictPath = false; /** + * Previous parent of strict category. + * + * @var integer + */ + protected $StrictPathOldParentId; + + /** * Returns instance of perm cache updater * - * @param int $continuing - * @param mixed $strict_path + * @param integer $continuing Continuing previous operation. + * @param string $strict_path Strict path limitation. + * @param integer $strict_path_old_parent_id Previous parent of strict category. */ - public function __construct($continuing = null, $strict_path = null) + public function __construct($continuing = null, $strict_path = null, $strict_path_old_parent_id = null) { parent::__construct(); @@ -221,6 +229,7 @@ } $this->StrictPath = $strict_path; + $this->StrictPathOldParentId = $strict_path_old_parent_id; } // cache widely used values to speed up process @@ -343,6 +352,11 @@ $this->Conn->Query($sql); $this->clearData(); + // Don't delete override, that is set exactly, during strict path processing. + if ( !$this->StrictPath ) { + $this->Application->RemoveVar('CategoryPermissionRebuildModeOverride'); + } + $this->Application->incrementCacheSerial('c'); } @@ -381,19 +395,22 @@ $this->iteration++; } - // start with first child if we haven't started yet - if (!isset($data['current_child'])) $data['current_child'] = 0; + // Start with first child if we haven't started yet. + if ( !isset($data['current_child']) ) { + $data['current_child'] = 0; + } - // if we have more children on CURRENT LEVEL - if (isset($data['children'][$data['current_child']])) { - if ($this->StrictPath) { - while ( isset($data['children'][ $data['current_child'] ]) && !in_array($data['children'][ $data['current_child'] ], $this->StrictPath) ) { - $data['current_child']++; - continue; - } - if (!isset($data['children'][ $data['current_child'] ])) return false; //error + // Skip all children on CURRENT LEVEL that are not within StrictPath. + if ( $this->StrictPath && isset($data['children'][$data['current_child']]) ) { + while ( isset($data['children'][$data['current_child']]) && !in_array($data['children'][$data['current_child']], $this->StrictPath) ) { + $data['current_child']++; + continue; } - $next_data = Array(); + } + + // If we have more children on CURRENT LEVEL. + if ( isset($data['children'][$data['current_child']]) ) { + $next_data = array(); $next_data['titles'] = $data['titles']; $next_data['parent_path'] = $data['parent_path']; $next_data['named_path'] = $data['named_path']; @@ -444,11 +461,77 @@ 'CachedTemplate' => $data['template'], // actual template to use when category is visited 'CachedTemplateHash' => kUtil::crc32(mb_strtolower($data['template'])), 'CachedDescendantCatsQty' => $data['children_count'], - 'TreeLeft' => $data['left'], - 'TreeRight' => $data['right'], ); - foreach ($this->languages as $language_id) { + if ( $this->StrictPath ) { + // Only do once per whole strict path. + $category_id = $data['current_id']; + + if ( $category_id == end($this->StrictPath) + && is_numeric($this->StrictPathOldParentId) + && count($this->StrictPath) > 1 + ) { + $this->Conn->doUpdate(array('TreeLocked' => 0), TABLE_PREFIX . 'Categories', 'TRUE'); + + $new_parent_id = $this->StrictPath[count($this->StrictPath) - 2]; + + if ( $this->StrictPathOldParentId == 0 ) { + // New category. + $this->Conn->doUpdate( + array('TreeLocked' => 1), + TABLE_PREFIX . 'Categories', + 'CategoryId = ' . $category_id + ); + $parent_tree_indexes = $this->Application->getTreeIndex($new_parent_id); + $this->addCreateTreeWindow($parent_tree_indexes['TreeLeft'], 1); + } + elseif ( $new_parent_id != $this->StrictPathOldParentId ) { + // Moved categories. + $tree_indexes = $this->Application->getTreeIndex($category_id); + $this->Conn->doUpdate( + array('TreeLocked' => 1), + TABLE_PREFIX . 'Categories', + 'TreeLeft BETWEEN ' . $tree_indexes['TreeLeft'] . ' AND ' . $tree_indexes['TreeRight'] + ); + $parent_tree_indexes = $this->Application->getTreeIndex($new_parent_id); + $this->addCreateTreeWindow($parent_tree_indexes['TreeLeft'], $data['children_count'] + 1); + + // Increase/decrease moved tree indexes to fit within new parent tree indexes. + $tree_diff = ($parent_tree_indexes['TreeLeft'] + 1) - $tree_indexes['TreeLeft']; + $tree_diff_clause = ($tree_diff >= 0 ? '+' : '-') . ' ' . abs($tree_diff); + + $sql = 'UPDATE ' . TABLE_PREFIX . 'Categories + SET TreeLeft = TreeLeft ' . $tree_diff_clause . ', + TreeRight = TreeRight ' . $tree_diff_clause . ' + WHERE TreeLocked = 1'; + $this->Conn->Query($sql); + + $sql = 'SELECT CategoryId + FROM ' . TABLE_PREFIX . 'Categories + WHERE TreeLocked = 1'; + $moved_categories = $this->Conn->GetColIterator($sql); + + foreach ( $moved_categories as $moved_category_id ) { + $this->Application->incrementCacheSerial('c', $moved_category_id); + } + + // Moved category has sub-categories. + if ( count($moved_categories) > 1 ) { + $this->Application->StoreVar( + 'CategoryPermissionRebuildModeOverride', + CategoryPermissionRebuild::AUTOMATIC + ); + } + } + } + } + else { + // Is calculated correctly, only when doing full cache rebuild. + $fields_hash['TreeLeft'] = $data['left']; + $fields_hash['TreeRight'] = $data['right']; + } + + foreach ( $this->languages as $language_id ) { $fields_hash['l' . $language_id . '_CachedNavbar'] = implode('&|&', $data['titles'][$language_id]); } @@ -459,6 +542,43 @@ } } + /** + * Creates window in TreeLeft/TreeRight for X categories. + * + * @param integer $tree_left Tree left. + * @param integer $sub_category_count Sub category count. + * + * @return void + */ + protected function addCreateTreeWindow($tree_left, $sub_category_count) + { + // Use ">" in WHERE to avoid affecting direct parent. + $sql = 'SELECT CategoryId + FROM ' . TABLE_PREFIX . 'Categories + WHERE TreeLeft > ' . $tree_left . ' AND TreeLocked = 0'; + $updated_categories = $this->Conn->GetCol($sql); + + $sql = 'UPDATE ' . TABLE_PREFIX . 'Categories + SET TreeLeft = TreeLeft + ' . ($sub_category_count * 2) . ' + WHERE TreeLeft > ' . $tree_left . ' AND TreeLocked = 0'; + $this->Conn->Query($sql); + + // Use ">=" in WHERE to affect direct parent. + $sql = 'SELECT CategoryId + FROM ' . TABLE_PREFIX . 'Categories + WHERE TreeRight >= ' . $tree_left . ' AND TreeLocked = 0'; + $updated_categories = array_unique(array_merge($updated_categories, $this->Conn->GetCol($sql))); + + $sql = 'UPDATE ' . TABLE_PREFIX . 'Categories + SET TreeRight = TreeRight + ' . ($sub_category_count * 2) . ' + WHERE TreeRight >= ' . $tree_left . ' AND TreeLocked = 0'; + $this->Conn->Query($sql); + + foreach ( $updated_categories as $updated_category_id ) { + $this->Application->incrementCacheSerial('c', $updated_category_id); + } + } + function QueryTitle(&$data) { $category_id = $data['current_id']; Index: core/units/categories/categories_event_handler.php =================================================================== --- core/units/categories/categories_event_handler.php +++ core/units/categories/categories_event_handler.php @@ -739,24 +739,34 @@ $parent_path = false; $object->Load($event->getEventParam('id')); + $old_parent_id = $object->GetDBField('ParentId'); + $old_parent_path = $object->GetDBField('ParentPath'); if ( $event->getEventParam('temp_id') == 0 ) { + // Update path only for real categories (not including "Home" root category). if ( $object->isLoaded() ) { - // update path only for real categories (not including "Home" root category) - $fields_hash = $object->buildParentBasedFields(); - $this->Conn->doUpdate($fields_hash, $object->TableName, 'CategoryId = ' . $object->GetID()); - $parent_path = $fields_hash['ParentPath']; + $old_parent_id = 0; + $this->refreshHierarchyFields($object, true); + $parent_path = $object->GetDBField('ParentPath'); } } else { + $parent_changed = $this->Application->GetVar($event->Prefix . '_parent_changed', array()); + + if ( isset($parent_changed[$object->GetID()]) ) { + $this->refreshHierarchyFields($object); + $old_parent_id = $parent_changed[$object->GetID()]; + } + $parent_path = $object->GetDBField('ParentPath'); } if ( $parent_path ) { - $cache_updater = $this->Application->makeClass('kPermCacheUpdater', Array (null, $parent_path)); - /* @var $cache_updater kPermCacheUpdater */ + $this->quickCategoryCacheRebuild($parent_path, $old_parent_id); - $cache_updater->OneStepRun(); + if ( strlen($old_parent_path) && $old_parent_path != $parent_path ) { + $this->quickCategoryCacheRebuild($this->removeLastFromPath($old_parent_path), null); + } } } @@ -804,7 +814,18 @@ } } - // remember category filename change between temp and live records + // Change in these fields require full category cache rebuilding. + foreach ( array('Filename', 'Template') as $cached_field ) { + if ( $live_object->GetDBField($cached_field) != $temp_object->GetDBField($cached_field) ) { + $this->Application->StoreVar( + 'CategoryPermissionRebuildModeOverride', + CategoryPermissionRebuild::AUTOMATIC + ); + break; + } + } + + // Remember category filename change between temp and live records. if ( $temp_object->GetDBField('Filename') != $live_object->GetDBField('Filename') ) { $filename_changes = $this->Application->GetVar($event->Prefix . '_filename_changes', Array ()); @@ -815,6 +836,13 @@ $this->Application->SetVar($event->Prefix . '_filename_changes', $filename_changes); } + + // Remember category parent change between temp and live records. + if ( $temp_object->GetDBField('ParentId') != $live_object->GetDBField('ParentId') ) { + $parent_changed = $this->Application->GetVar($event->Prefix . '_parent_changed', array()); + $parent_changed[$live_object->GetID()] = $live_object->GetDBField('ParentId'); + $this->Application->SetVar($event->Prefix . '_parent_changed', $parent_changed); + } } /** @@ -989,6 +1017,8 @@ $temp_handler->DeleteItems('system-event-subscription', '', $ids); } + + $this->quickCategoryCacheRebuild($this->removeLastFromPath($object->GetDBField('ParentPath')), null); } /** @@ -1284,6 +1314,7 @@ { $this->Application->StoreVar('PermCache_UpdateRequired', 1); $this->Application->StoreVar('RefreshStructureTree', 1); + $this->_resetMenuCache(); } /** @@ -1487,11 +1518,6 @@ $object = $event->getObject(); /* @var $object kDBItem */ - $cache_updater = $this->Application->makeClass('kPermCacheUpdater', Array (null, $object->GetDBField('ParentPath'))); - /* @var $cache_updater kPermCacheUpdater */ - - $cache_updater->OneStepRun(); - $is_active = ($object->GetDBField('Status') == STATUS_ACTIVE); $next_template = $is_active ? 'suggest_confirm_template' : 'suggest_pending_confirm_template'; @@ -2335,13 +2361,93 @@ { parent::OnAfterItemCreate($event); + /** @var CategoriesItem $object */ $object = $event->getObject(); - /* @var $object CategoriesItem */ - // need to update path after category is created, so category is included in that path - $fields_hash = $object->buildParentBasedFields(); - $this->Conn->doUpdate($fields_hash, $object->TableName, $object->IDField . ' = ' . $object->GetID()); - $object->SetDBFieldsFromHash($fields_hash); + // The hierarchy fields would be updated in "OnAfterCopyToLive" event. + if ( $object->IsTempTable() ) { + return; + } + + $this->refreshHierarchyFields($object, true); + $this->quickCategoryCacheRebuild($object->GetDBField('ParentPath'), 0); + } + + /** + * Occurs after updating item + * + * @param kEvent $event Event. + * + * @return void + */ + protected function OnAfterItemUpdate(kEvent $event) + { + parent::OnAfterItemUpdate($event); + + /** @var CategoriesItem $object */ + $object = $event->getObject(); + + // The hierarchy fields would be updated in "OnAfterCopyToLive" event. + if ( $object->IsTempTable() || $object->GetDBField('ParentId') == $object->GetOriginalField('ParentId') ) { + return; + } + + $this->refreshHierarchyFields($object); + + $this->quickCategoryCacheRebuild($object->GetDBField('ParentPath'), $object->GetOriginalField('ParentId')); + $this->quickCategoryCacheRebuild($this->removeLastFromPath($object->GetOriginalField('ParentPath')), null); + } + + /** + * Refreshes category hierarchy fields. + * + * @param CategoriesItem $category Category. + * @param boolean $including_tree_indexes Also calculate values for tree indexes. + * + * @return void + */ + protected function refreshHierarchyFields(CategoriesItem $category, $including_tree_indexes = false) + { + $fields_hash = $category->buildParentBasedFields($including_tree_indexes); + $this->Conn->doUpdate( + $fields_hash, + $category->TableName, + $category->IDField . ' = ' . $category->GetID() + ); + $category->SetDBFieldsFromHash($fields_hash); + } + + /** + * Quickly rebuilds category cache for given path only. + * + * @param string $strict_path Strict path limitation. + * @param integer $strict_path_old_parent_id Previous parent of strict category. + * + * @return void + */ + protected function quickCategoryCacheRebuild($strict_path, $strict_path_old_parent_id) + { + /** @var kPermCacheUpdater $cache_updater */ + $cache_updater = $this->Application->makeClass( + 'kPermCacheUpdater', + array(null, $strict_path, $strict_path_old_parent_id) + ); + $cache_updater->OneStepRun(); + } + + /** + * Removes last category from path. + * + * @param string $parent_path Parent path. + * + * @return string + */ + protected function removeLastFromPath($parent_path) + { + $parent_path = explode('|', trim($parent_path, '|')); + array_pop($parent_path); + + return '|' . implode('|', $parent_path) . '|'; } /** @@ -2379,7 +2485,10 @@ } } - if ( $this->Application->ConfigValue('CategoryPermissionRebuildMode') == CategoryPermissionRebuild::SILENT ) { + /** @var CategoryHelper $category_helper */ + $category_helper = $this->Application->recallObject('CategoryHelper'); + + if ( $category_helper->getCategoryPermissionRebuildMode() == CategoryPermissionRebuild::SILENT ) { $updater = $this->Application->makeClass('kPermCacheUpdater'); /* @var $updater kPermCacheUpdater */ Index: core/units/categories/categories_item.php =================================================================== --- core/units/categories/categories_item.php +++ core/units/categories/categories_item.php @@ -16,13 +16,15 @@ class CategoriesItem extends kDBItem { + /** * Builds parent path for this category * - * @return Array - * @access public + * @param boolean $including_tree_indexes Also calculate values for tree indexes. + * + * @return array */ - public function buildParentBasedFields() + public function buildParentBasedFields($including_tree_indexes = false) { static $parent_cache = Array ( 0 => Array ('ParentPath' => '|', 'NamedParentPath' => '', 'CachedTemplate' => ''), @@ -60,6 +62,12 @@ 'CachedTemplateHash' => kUtil::crc32(mb_strtolower($cached_template)), ); + if ( $including_tree_indexes ) { + $tree_indexes = $this->Application->getTreeIndex($parent_id); + $ret['TreeLeft'] = $tree_indexes['TreeLeft'] + 1; + $ret['TreeRight'] = $tree_indexes['TreeLeft'] + 2; + } + $primary_language = $this->Application->GetDefaultLanguageId(); foreach ($languages as $language_id) { @@ -286,4 +294,4 @@ } while ($res !== false); $this->SetDBField($title_field, $new_name); } - } \ No newline at end of file + } Index: core/units/categories/categories_tag_processor.php =================================================================== --- core/units/categories/categories_tag_processor.php +++ core/units/categories/categories_tag_processor.php @@ -512,10 +512,13 @@ $total_cats = (int)$this->Conn->GetOne('SELECT COUNT(*) FROM ' . TABLE_PREFIX . 'Categories'); if ( $continue === false ) { - $rebuild_mode = $this->Application->ConfigValue('CategoryPermissionRebuildMode'); + /** @var CategoryHelper $category_helper */ + $category_helper = $this->Application->recallObject('CategoryHelper'); - if ( $rebuild_mode == CategoryPermissionRebuild::AUTOMATIC && $total_cats > CACHE_PERM_CHUNK_SIZE ) { - // first step, if category count > CACHE_PERM_CHUNK_SIZE, then ask for cache update + if ( $category_helper->getCategoryPermissionRebuildMode() == CategoryPermissionRebuild::AUTOMATIC + && $total_cats > CACHE_PERM_CHUNK_SIZE + ) { + // First step, if category count > CACHE_PERM_CHUNK_SIZE, then ask for cache update. return true; } Index: core/units/helpers/category_helper.php =================================================================== --- core/units/helpers/category_helper.php +++ core/units/helpers/category_helper.php @@ -358,4 +358,21 @@ return $text; } + + /** + * Tells how category cache needs to be rebuilt. + * + * @return integer + */ + public function getCategoryPermissionRebuildMode() + { + $rebuild_mode = $this->Application->RecallVar('CategoryPermissionRebuildModeOverride'); + + if ( $rebuild_mode !== false ) { + return $rebuild_mode; + } + + return $this->Application->ConfigValue('CategoryPermissionRebuildMode'); + } + } Index: core/units/helpers/recursive_helper.php =================================================================== --- core/units/helpers/recursive_helper.php +++ core/units/helpers/recursive_helper.php @@ -98,11 +98,18 @@ $child_categories = array_intersect($dest_parent_path, $category_ids); // get categories, then can't be moved $category_ids = array_diff($category_ids, $child_categories); // remove them from movable categories list - if ($category_ids) { - $sql = 'UPDATE '.$table_name.' - SET ParentId = '.$dest_category_id.' - WHERE '.$id_field.' IN ('.implode(',', $category_ids).')'; - $this->Conn->Query($sql); + if ( $category_ids ) { + /** @var CategoriesItem $move_category */ + $move_category = $this->Application->recallObject('c.move', null, array('skip_autoload' => true)); + + foreach ( $category_ids as $move_category_id ) { + $move_category->Load($move_category_id); + + if ( $move_category->GetDBField('ParentId') != $dest_category_id ) { + $move_category->SetDBField('ParentId', $dest_category_id); + $move_category->Update(); + } + } } } @@ -224,4 +231,4 @@ return $cache[$category_id]; } - } \ No newline at end of file + }