diff --git a/tasks/MigrateSiteTreeLinkingTask.php b/tasks/MigrateSiteTreeLinkingTask.php new file mode 100755 index 00000000..88c76e66 --- /dev/null +++ b/tasks/MigrateSiteTreeLinkingTask.php @@ -0,0 +1,52 @@ +ID))->map(); + + foreach($tracking as $childID => $fieldName) { + $linked = DataObject::get_by_id('SiteTree', $childID); + + // TOOD: Replace in all HTMLText fields + $page->Content = preg_replace ( + "/href *= *([\"']?){$linked->URLSegment}\/?/i", + "href=$1[sitetree_link id={$linked->ID}]", + $page->Content, + -1, + $replaced + ); + + if($replaced) { + $links += $replaced; + } + } + + $page->write(); + $pages++; + } + + echo "Rewrote $links link(s) on $pages page(s) to use shortcodes.\n"; + } + +} \ No newline at end of file diff --git a/tasks/RemoveOrphanedPagesTask.php b/tasks/RemoveOrphanedPagesTask.php new file mode 100644 index 00000000..30083d9f --- /dev/null +++ b/tasks/RemoveOrphanedPagesTask.php @@ -0,0 +1,354 @@ +@silverstripe.com), SilverStripe Ltd. + * + * @package cms + * @subpackage tasks + */ +//class RemoveOrphanedPagesTask extends BuildTask { +class RemoveOrphanedPagesTask extends Controller { + + static $allowed_actions = array( + 'index' => 'ADMIN', + 'Form' => 'ADMIN', + 'run' => 'ADMIN', + 'handleAction' => 'ADMIN', + ); + + protected $title = 'Removed orphaned pages without existing parents from both stage and live'; + + protected $description = " +

+Identify 'orphaned' pages which point to a parent +that no longer exists in a specific stage. +

+

+Caution: Pages also count as orphans if they don't +have parents in this stage, even if the parent has a representation +in the other stage:
+- A live child is orphaned if its parent was deleted from live, but still exists on stage
+- A stage child is orphaned if its parent was deleted from stage, but still exists on live +

+ "; + + protected $orphanedSearchClass = 'SiteTree'; + + function Link() { + return $this->class; + } + + function init() { + parent::init(); + + if(!Permission::check('ADMIN')) { + return Security::permissionFailure($this); + } + } + + function index() { + Requirements::javascript(SAPPHIRE_DIR . '/thirdparty/jquery/jquery.js'); + Requirements::customCSS('#OrphanIDs .middleColumn {width: auto;}'); + Requirements::customCSS('#OrphanIDs label {display: inline;}'); + + return $this->renderWith('BlankPage'); + } + + function Form() { + $fields = new FieldSet(); + $source = array(); + + $fields->push(new HeaderField( + 'Header', + _t('RemoveOrphanedPagesTask.HEADER', 'Remove all orphaned pages task') + )); + $fields->push(new LiteralField( + 'Description', + $this->description + )); + + $orphans = $this->getOrphanedPages($this->orphanedSearchClass); + if($orphans) foreach($orphans as $orphan) { + $latestVersion = Versioned::get_latest_version($this->orphanedSearchClass, $orphan->ID); + $latestAuthor = DataObject::get_by_id('Member', $latestVersion->AuthorID); + $stageRecord = Versioned::get_one_by_stage( + $this->orphanedSearchClass, + 'Stage', + sprintf("\"%s\".\"ID\" = %d", + ClassInfo::baseDataClass($this->orphanedSearchClass), + $orphan->ID + ) + ); + $liveRecord = Versioned::get_one_by_stage( + $this->orphanedSearchClass, + 'Live', + sprintf("\"%s\".\"ID\" = %d", + ClassInfo::baseDataClass($this->orphanedSearchClass), + $orphan->ID + ) + ); + $label = sprintf( + '%s (#%d, Last Modified Date: %s, Last Modifier: %s, %s)', + $orphan->ID, + $orphan->Title, + $orphan->ID, + DBField::create('Date', $orphan->LastEdited)->Nice(), + ($latestAuthor) ? $latestAuthor->Title : 'unknown', + ($liveRecord) ? 'is published' : 'not published' + ); + $source[$orphan->ID] = $label; + } + + if($orphans && $orphans->Count()) { + $fields->push(new CheckboxSetField('OrphanIDs', false, $source)); + $fields->push(new LiteralField( + 'SelectAllLiteral', + sprintf( + '

%s ', + _t('RemoveOrphanedPagesTask.SELECTALL', 'select all') + ) + )); + $fields->push(new LiteralField( + 'UnselectAllLiteral', + sprintf( + '%s

', + _t('RemoveOrphanedPagesTask.UNSELECTALL', 'unselect all') + ) + )); + $fields->push(new OptionSetField( + 'OrphanOperation', + _t('RemoveOrphanedPagesTask.CHOOSEOPERATION', 'Choose operation:'), + array( + 'rebase' => _t( + 'RemoveOrphanedPagesTask.OPERATION_REBASE', + sprintf( + 'Rebase selected to a new holder page "%s" and unpublish. None of these pages will show up for website visitors.', + $this->rebaseHolderTitle() + ) + ), + 'remove' => _t('RemoveOrphanedPagesTask.OPERATION_REMOVE', 'Remove selected from all stages (WARNING: Will destroy all selected pages from both stage and live)'), + ), + 'rebase' + )); + $fields->push(new LiteralField( + 'Warning', + sprintf('

%s

', + _t( + 'RemoveOrphanedPagesTask.DELETEWARNING', + 'Warning: These operations are not reversible. Please handle with care.' + ) + ) + )); + } else { + $fields->push(new LiteralField( + 'NotFoundLabel', + sprintf( + '

%s

', + _t('RemoveOrphanedPagesTask.NONEFOUND', 'No orphans found') + ) + )); + } + + $form = new Form( + $this, + 'Form', + $fields, + new FieldSet( + new FormAction('doSubmit', _t('RemoveOrphanedPagesTask.BUTTONRUN', 'Run')) + ) + ); + + if(!$orphans || !$orphans->Count()) { + $form->makeReadonly(); + } + + return $form; + } + + function run($request) { + // @todo Merge with BuildTask functionality + } + + function doSubmit($data, $form) { + set_time_limit(60*10); // 10 minutes + + if(!isset($data['OrphanIDs']) || !isset($data['OrphanOperation'])) return false; + + switch($data['OrphanOperation']) { + case 'remove': + $successIDs = $this->removeOrphans($data['OrphanIDs']); + break; + case 'rebase': + $successIDs = $this->rebaseOrphans($data['OrphanIDs']); + break; + default: + user_error(sprintf("Unknown operation: '%s'", $data['OrphanOperation']), E_USER_ERROR); + } + + $content = ''; + if($successIDs) { + $content .= ""; + } else { + $content = _t('RemoveOrphanedPagesTask.NONEREMOVED', 'None removed'); + } + + return $this->customise(array( + 'Content' => $content, + 'Form' => ' ' + ))->renderWith('BlankPage'); + } + + protected function removeOrphans($orphanIDs) { + $removedOrphans = array(); + foreach($orphanIDs as $id) { + $stageRecord = Versioned::get_one_by_stage( + $this->orphanedSearchClass, + 'Stage', + sprintf("\"%s\".\"ID\" = %d", + ClassInfo::baseDataClass($this->orphanedSearchClass), + $id + ) + ); + if($stageRecord) { + $removedOrphans[$stageRecord->ID] = sprintf('Removed %s (#%d) from Stage', $stageRecord->Title, $stageRecord->ID); + $stageRecord->delete(); + $stageRecord->destroy(); + unset($stageRecord); + } + $liveRecord = Versioned::get_one_by_stage( + $this->orphanedSearchClass, + 'Live', + sprintf("\"%s\".\"ID\" = %d", + ClassInfo::baseDataClass($this->orphanedSearchClass), + $id + ) + ); + if($liveRecord) { + $removedOrphans[$liveRecord->ID] = sprintf('Removed %s (#%d) from Live', $liveRecord->Title, $liveRecord->ID); + $liveRecord->doDeleteFromLive(); + $liveRecord->destroy(); + unset($liveRecord); + } + } + + return $removedOrphans; + } + + protected function rebaseHolderTitle() { + return sprintf('Rebased Orphans (%s)', date('d/m/Y g:ia', time())); + } + + protected function rebaseOrphans($orphanIDs) { + $holder = new SiteTree(); + $holder->ShowInMenus = 0; + $holder->ShowInSearch = 0; + $holder->ParentID = 0; + $holder->Title = $this->rebaseHolderTitle(); + $holder->write(); + + $removedOrphans = array(); + foreach($orphanIDs as $id) { + $stageRecord = Versioned::get_one_by_stage( + $this->orphanedSearchClass, + 'Stage', + sprintf("\"%s\".\"ID\" = %d", + ClassInfo::baseDataClass($this->orphanedSearchClass), + $id + ) + ); + if($stageRecord) { + $removedOrphans[$stageRecord->ID] = sprintf('Rebased %s (#%d)', $stageRecord->Title, $stageRecord->ID); + $stageRecord->ParentID = $holder->ID; + $stageRecord->ShowInMenus = 0; + $stageRecord->ShowInSearch = 0; + $stageRecord->write(); + $stageRecord->doUnpublish(); + $stageRecord->destroy(); + //unset($stageRecord); + } + $liveRecord = Versioned::get_one_by_stage( + $this->orphanedSearchClass, + 'Live', + sprintf("\"%s\".\"ID\" = %d", + ClassInfo::baseDataClass($this->orphanedSearchClass), + $id + ) + ); + if($liveRecord) { + $removedOrphans[$liveRecord->ID] = sprintf('Rebased %s (#%d)', $liveRecord->Title, $liveRecord->ID); + $liveRecord->ParentID = $holder->ID; + $liveRecord->ShowInMenus = 0; + $liveRecord->ShowInSearch = 0; + $liveRecord->write(); + if(!$stageRecord) $liveRecord->doRestoreToStage(); + $liveRecord->doUnpublish(); + $liveRecord->destroy(); + unset($liveRecord); + } + if($stageRecord) { + unset($stageRecord); + } + } + + return $removedOrphans; + } + + /** + * Gets all orphans from "Stage" and "Live" stages. + * + * @param string $class + * @param string $filter + * @param string $sort + * @param string $join + * @param int|array $limit + * @return DataObjectSet + */ + function getOrphanedPages($class = 'SiteTree', $filter = '', $sort = null, $join = null, $limit = null) { + $filter .= ($filter) ? ' AND ' : ''; + $filter .= sprintf("\"%s\".\"ParentID\" != 0 AND \"Parents\".\"ID\" IS NULL", $class); + + $orphans = new DataObjectSet(); + foreach(array('Stage', 'Live') as $stage) { + $joinByStage = $join; + $table = $class; + $table .= ($stage == 'Live') ? '_Live' : ''; + $joinByStage .= sprintf( + "LEFT JOIN \"%s\" AS \"Parents\" ON \"%s\".\"ParentID\" = \"Parents\".\"ID\"", + $table, + $table + ); + $stageOrphans = Versioned::get_by_stage( + $class, + $stage, + $filter, + $sort, + $joinByStage, + $limit + ); + $orphans->merge($stageOrphans); + } + + $orphans->removeDuplicates(); + + return $orphans; + } +} +?> \ No newline at end of file diff --git a/tasks/UpgradeSiteTreePermissionSchemaTask.php b/tasks/UpgradeSiteTreePermissionSchemaTask.php new file mode 100644 index 00000000..3e024b1a --- /dev/null +++ b/tasks/UpgradeSiteTreePermissionSchemaTask.php @@ -0,0 +1,51 @@ + 'ADMIN' + ); + + protected $title = 'Upgrade SiteTree Permissions Schema'; + + protected $description = "Move data from legacy columns to new schema introduced in SilverStripe 2.1.
+ SiteTree->Viewers to SiteTree->CanViewType
+ SiteTree->Editors to SiteTree->CanEditType
+ SiteTree->ViewersGroup to SiteTree->ViewerGroups (has_one to many_many)
+ SiteTree->Editorsroup to SiteTree->EditorGroups (has_one to many_many)
+ See http://open.silverstripe.com/ticket/2847 + "; + + function run($request) { + // transfer values for changed column name + foreach(array('SiteTree','SiteTree_Live','SiteTree_versions') as $table) { + DB::query("UPDATE \"{$table}\" SET \"CanViewType\" = 'Viewers';"); + DB::query("UPDATE \"{$table}\" SET \"CanEditType\" = 'Editors';"); + } + //Debug::message('Moved SiteTree->Viewers to SiteTree->CanViewType'); + //Debug::message('Moved SiteTree->Editors to SiteTree->CanEditType'); + + // convert has_many to many_many + $pageIDs = DB::query("SELECT ID FROM SiteTree")->column('ID'); + foreach($pageIDs as $pageID) { + $page = DataObject::get_by_id('SiteTree', $pageID); + if($page->ViewersGroup && DataObject::get_by_id("Group", $page->ViewersGroup)) $page->ViewerGroups()->add($page->ViewersGroup); + if($page->EditorsGroup && DataObject::get_by_id("Group", $page->EditorsGroup)) $page->EditorGroups()->add($page->EditorsGroup); + + $page->destroy(); + unset($page); + } + //Debug::message('SiteTree->ViewersGroup to SiteTree->ViewerGroups (has_one to many_many)'); + //Debug::message('SiteTree->EditorsGroup to SiteTree->EditorGroups (has_one to many_many)'); + + // rename legacy columns + foreach(array('SiteTree','SiteTree_Live','SiteTree_versions') as $table) { + foreach(array('Viewers','Editors','ViewersGroup','EditorsGroup') as $field) { + DB::getConn()->dontRequireField($table, $field); + } + } + } +} +?> \ No newline at end of file diff --git a/tests/MigrateSiteTreeLinkingTaskTest.php b/tests/MigrateSiteTreeLinkingTaskTest.php new file mode 100755 index 00000000..f27efbcc --- /dev/null +++ b/tests/MigrateSiteTreeLinkingTaskTest.php @@ -0,0 +1,80 @@ +run(null); + + $this->assertEquals ( + "Rewrote 9 link(s) on 5 page(s) to use shortcodes.\n", + ob_get_contents(), + 'Rewritten links are correctly reported' + ); + ob_end_clean(); + + $homeID = $this->idFromFixture('SiteTree', 'home'); + $aboutID = $this->idFromFixture('SiteTree', 'about'); + $staffID = $this->idFromFixture('SiteTree', 'staff'); + $actionID = $this->idFromFixture('SiteTree', 'action'); + $hashID = $this->idFromFixture('SiteTree', 'hash_link'); + + $homeContent = sprintf ( + 'AboutStaffExternal Link', + $aboutID, + $staffID + ); + $aboutContent = sprintf ( + 'HomeStaff', + $homeID, + $staffID + ); + $staffContent = sprintf ( + 'HomeAbout', + $homeID, + $aboutID + ); + $actionContent = sprintf ( + 'Search Form', $homeID + ); + $hashLinkContent = sprintf ( + 'HomeAbout', + $homeID, + $aboutID + ); + + $this->assertEquals ( + $homeContent, + DataObject::get_by_id('SiteTree', $homeID)->Content, + 'HTML URLSegment links are rewritten.' + ); + $this->assertEquals ( + $aboutContent, + DataObject::get_by_id('SiteTree', $aboutID)->Content + ); + $this->assertEquals ( + $staffContent, + DataObject::get_by_id('SiteTree', $staffID)->Content + ); + $this->assertEquals ( + $actionContent, + DataObject::get_by_id('SiteTree', $actionID)->Content, + 'Links to actions on pages are rewritten correctly.' + ); + $this->assertEquals ( + $hashLinkContent, + DataObject::get_by_id('SiteTree', $hashID)->Content, + 'Hash/anchor links are correctly handled.' + ); + } + +} \ No newline at end of file diff --git a/tests/MigrateSiteTreeLinkingTaskTest.yml b/tests/MigrateSiteTreeLinkingTaskTest.yml new file mode 100755 index 00000000..34faf015 --- /dev/null +++ b/tests/MigrateSiteTreeLinkingTaskTest.yml @@ -0,0 +1,67 @@ +SiteTree: + home: + Title: Home Page + URLSegment: home + Content: 'AboutStaffExternal Link' + about: + Title: About Us + URLSegment: about + Content: HomeStaff + staff: + Title: Staff + URLSegment: staff + Content: HomeAbout + Parent: =>SiteTree.about + action: + Title: Action Link + URLSegment: action + Content: Search Form + hash_link: + Title: Hash Link + URLSegment: hash-link + Content: 'HomeAbout' + admin_link: + Title: Admin Link + URLSegment: admin-link + Content: Admin + no_links: + Title: No Links + URLSegment: No Links + +SiteTree_LinkTracking: + home_about: + SiteTreeID: =>SiteTree.home + ChildID: =>SiteTree.about + FieldName: Content + home_staff: + SiteTreeID: =>SiteTree.home + ChildID: =>SiteTree.staff + FieldName: Content + about_home: + SiteTreeID: =>SiteTree.about + ChildID: =>SiteTree.home + FieldName: Content + about_staff: + SiteTreeID: =>SiteTree.about + ChildID: =>SiteTree.staff + FieldName: Content + staff_home: + SiteTreeID: =>SiteTree.staff + ChildID: =>SiteTree.home + FieldName: Content + staff_about: + SiteTreeID: =>SiteTree.staff + ChildID: =>SiteTree.about + FieldName: Content + action_home: + SiteTreeID: =>SiteTree.action + ChildID: =>SiteTree.home + FieldName: Content + hash_link_home: + SiteTreeID: =>SiteTree.hash_link + ChildID: =>SiteTree.home + FieldName: Content + hash_link_about: + SiteTreeID: =>SiteTree.hash_link + ChildID: =>SiteTree.about + FieldName: Content diff --git a/tests/RemoveOrphanedPagesTaskTest.php b/tests/RemoveOrphanedPagesTaskTest.php new file mode 100644 index 00000000..bd56308b --- /dev/null +++ b/tests/RemoveOrphanedPagesTaskTest.php @@ -0,0 +1,105 @@ +Fixture tree + * + * parent1_published + * child1_1_published + * grandchild1_1_1 + * grandchild1_1_2_published + * grandchild1_1_3_orphaned + * grandchild1_1_4_orphaned_published + * child1_2_published + * child1_3_orphaned + * child1_4_orphaned_published + * parent2 + * child2_1_published_orphaned // is orphaned because parent is not published + * + * + *

Cleaned up tree

+ * + * parent1_published + * child1_1_published + * grandchild1_1_1 + * grandchild1_1_2_published + * child2_1_published_orphaned + * parent2 + * + * + * @author Ingo Schommer (@silverstripe.com), SilverStripe Ltd. + * + * @package sapphire + * @subpackage tests + */ +class RemoveOrphanedPagesTaskTest extends FunctionalTest { + + static $fixture_file = 'sapphire/tests/tasks/RemoveOrphanedPagesTaskTest.yml'; + + static $use_draft_site = false; + + function setUp() { + parent::setUp(); + + $parent1_published = $this->objFromFixture('Page', 'parent1_published'); + $parent1_published->publish('Stage', 'Live'); + + $child1_1_published = $this->objFromFixture('Page', 'child1_1_published'); + $child1_1_published->publish('Stage', 'Live'); + + $child1_2_published = $this->objFromFixture('Page', 'child1_2_published'); + $child1_2_published->publish('Stage', 'Live'); + + $child1_3_orphaned = $this->objFromFixture('Page', 'child1_3_orphaned'); + $child1_3_orphaned->ParentID = 9999; + $child1_3_orphaned->write(); + + $child1_4_orphaned_published = $this->objFromFixture('Page', 'child1_4_orphaned_published'); + $child1_4_orphaned_published->ParentID = 9999; + $child1_4_orphaned_published->write(); + $child1_4_orphaned_published->publish('Stage', 'Live'); + + $grandchild1_1_2_published = $this->objFromFixture('Page', 'grandchild1_1_2_published'); + $grandchild1_1_2_published->publish('Stage', 'Live'); + + $grandchild1_1_3_orphaned = $this->objFromFixture('Page', 'grandchild1_1_3_orphaned'); + $grandchild1_1_3_orphaned->ParentID = 9999; + $grandchild1_1_3_orphaned->write(); + + $grandchild1_1_4_orphaned_published = $this->objFromFixture('Page', + 'grandchild1_1_4_orphaned_published' + ); + $grandchild1_1_4_orphaned_published->ParentID = 9999; + $grandchild1_1_4_orphaned_published->write(); + $grandchild1_1_4_orphaned_published->publish('Stage', 'Live'); + + $child2_1_published_orphaned = $this->objFromFixture('Page', 'child2_1_published_orphaned'); + $child2_1_published_orphaned->publish('Stage', 'Live'); + } + + function testGetOrphansByStage() { + // all orphans + $child1_3_orphaned = $this->objFromFixture('Page', 'child1_3_orphaned'); + $child1_4_orphaned_published = $this->objFromFixture('Page', 'child1_4_orphaned_published'); + $grandchild1_1_3_orphaned = $this->objFromFixture('Page', 'grandchild1_1_3_orphaned'); + $grandchild1_1_4_orphaned_published = $this->objFromFixture('Page', + 'grandchild1_1_4_orphaned_published' + ); + $child2_1_published_orphaned = $this->objFromFixture('Page', 'child2_1_published_orphaned'); + + $task = singleton('RemoveOrphanedPagesTask'); + $orphans = $task->getOrphanedPages(); + $orphanIDs = $orphans->column('ID'); + sort($orphanIDs); + $compareIDs = array( + $child1_3_orphaned->ID, + $child1_4_orphaned_published->ID, + $grandchild1_1_3_orphaned->ID, + $grandchild1_1_4_orphaned_published->ID, + $child2_1_published_orphaned->ID + ); + sort($compareIDs); + + $this->assertEquals($orphanIDs, $compareIDs); + } + +} +?> \ No newline at end of file diff --git a/tests/RemoveOrphanedPagesTaskTest.yml b/tests/RemoveOrphanedPagesTaskTest.yml new file mode 100644 index 00000000..47719b8d --- /dev/null +++ b/tests/RemoveOrphanedPagesTaskTest.yml @@ -0,0 +1,32 @@ +Page: + parent1_published: + Title: Parent1 + child1_1_published: + Title: Child1.1 + Parent: =>Page.parent1_published + child1_2_published: + Title: Child1.2 + Parent: =>Page.parent1_published + child1_3_orphaned: + Title: Child1.3 + Parent: =>Page.parent1_published + child1_4_orphaned_published: + Title: Child1.4 + Parent: =>Page.parent1_published + grandchild1_1_1: + Title: Grandchild1.1.1 + Parent: =>Page.child1_1_published + grandchild1_1_2_published: + Title: Grandchild1.1.2 + Parent: =>Page.child1_1_published + grandchild1_1_3_orphaned: + Title: Grandchild1.1.3 + Parent: =>Page.child1_1_published + grandchild1_1_4_orphaned_published: + Title: Grandchild1.1.4 + Parent: =>Page.child1_1_published + parent2: + Title: Parent2 + child2_1_published_orphaned: + Title: Child2.1 + Parent: =>Page.parent2 \ No newline at end of file