'EditForm', ]; private static $casting = array( 'TreeIsFiltered' => 'Boolean', 'AddForm' => 'HTMLFragment', 'LinkPages' => 'Text', 'Link' => 'Text', 'ListViewForm' => 'HTMLFragment', 'ExtraTreeTools' => 'HTMLFragment', 'PageList' => 'HTMLFragment', 'PageListSidebar' => 'HTMLFragment', 'SiteTreeHints' => 'HTMLFragment', 'SecurityID' => 'Text', 'SiteTreeAsUL' => 'HTMLFragment', ); protected function init() { // set reading lang if (SiteTree::has_extension('Translatable') && !$this->getRequest()->isAjax()) { Translatable::choose_site_locale(array_keys(Translatable::get_existing_content_languages('SilverStripe\\CMS\\Model\\SiteTree'))); } parent::init(); Requirements::javascript('silverstripe/cms: client/dist/js/bundle.js'); Requirements::javascript('silverstripe/cms: client/dist/js/SilverStripeNavigator.js'); Requirements::css('silverstripe/cms: client/dist/styles/bundle.css'); Requirements::customCSS($this->generatePageIconsCss()); $module = ModuleLoader::getModule('silverstripe/cms'); Requirements::add_i18n_javascript($module->getRelativeResourcePath('client/lang'), false, true); CMSBatchActionHandler::register('restore', CMSBatchAction_Restore::class); CMSBatchActionHandler::register('archive', CMSBatchAction_Archive::class); CMSBatchActionHandler::register('unpublish', CMSBatchAction_Unpublish::class); CMSBatchActionHandler::register('publish', CMSBatchAction_Publish::class); } public function index($request) { // In case we're not showing a specific record, explicitly remove any session state, // to avoid it being highlighted in the tree, and causing an edit form to show. if (!$request->param('Action')) { $this->setCurrentPageID(null); } return parent::index($request); } public function getResponseNegotiator() { $negotiator = parent::getResponseNegotiator(); // ListViewForm $negotiator->setCallback('ListViewForm', function () { return $this->ListViewForm()->forTemplate(); }); // PageList view $negotiator->setCallback('Content-PageList', function () { return $this->PageList()->forTemplate(); }); // PageList view for edit controller $negotiator->setCallback('Content-PageList-Sidebar', function () { return $this->PageListSidebar()->forTemplate(); }); return $negotiator; } /** * Get pages listing area * * @return DBHTMLText */ public function PageList() { return $this->renderWith($this->getTemplatesWithSuffix('_PageList')); } /** * Page list view for edit-form * * @return DBHTMLText */ public function PageListSidebar() { return $this->renderWith($this->getTemplatesWithSuffix('_PageList_Sidebar')); } /** * If this is set to true, the "switchView" context in the * template is shown, with links to the staging and publish site. * * @return boolean */ public function ShowSwitchView() { return true; } /** * Overloads the LeftAndMain::ShowView. Allows to pass a page as a parameter, so we are able * to switch view also for archived versions. * * @param SiteTree $page * @return array */ public function SwitchView($page = null) { if (!$page) { $page = $this->currentPage(); } if ($page) { $nav = SilverStripeNavigator::get_for_record($page); return $nav['items']; } } //------------------------------------------------------------------------------------------// // Main controllers //------------------------------------------------------------------------------------------// // Main UI components /** * Override {@link LeftAndMain} Link to allow blank URL segment for CMSMain. * * @param string|null $action Action to link to. * @return string */ public function Link($action = null) { $link = Controller::join_links( AdminRootController::admin_url(), $this->stat('url_segment'), // in case we want to change the segment '/', // trailing slash needed if $action is null! "$action" ); $this->extend('updateLink', $link); return $link; } public function LinkPages() { return CMSPagesController::singleton()->Link(); } public function LinkPagesWithSearch() { return $this->LinkWithSearch($this->LinkPages()); } /** * Get link to tree view * * @return string */ public function LinkTreeView() { // Tree view is just default link to main pages section (no /treeview suffix) return $this->LinkWithSearch(CMSMain::singleton()->Link()); } /** * Get link to list view * * @return string */ public function LinkListView() { // Note : Force redirect to top level page controller return $this->LinkWithSearch(CMSMain::singleton()->Link('listview')); } public function LinkPageEdit($id = null) { if (!$id) { $id = $this->currentPageID(); } return $this->LinkWithSearch( Controller::join_links(CMSPageEditController::singleton()->Link('show'), $id) ); } public function LinkPageSettings() { if ($id = $this->currentPageID()) { return $this->LinkWithSearch( Controller::join_links(CMSPageSettingsController::singleton()->Link('show'), $id) ); } else { return null; } } public function LinkPageHistory() { if ($id = $this->currentPageID()) { return $this->LinkWithSearch( Controller::join_links(CMSPageHistoryController::singleton()->Link('show'), $id) ); } else { return null; } } /** * Return the active tab identifier for the CMS. Used by templates to decide which tab to give the active state. * The default value is "edit", as the primary content tab. Child controllers will override this. * * @return string */ public function getTabIdentifier() { return 'edit'; } public function LinkWithSearch($link) { // Whitelist to avoid side effects $params = array( 'q' => (array)$this->getRequest()->getVar('q'), 'ParentID' => $this->getRequest()->getVar('ParentID') ); $link = Controller::join_links( $link, array_filter(array_values($params)) ? '?' . http_build_query($params) : null ); $this->extend('updateLinkWithSearch', $link); return $link; } public function LinkPageAdd($extra = null, $placeholders = null) { $link = CMSPageAddController::singleton()->Link(); $this->extend('updateLinkPageAdd', $link); if ($extra) { $link = Controller::join_links($link, $extra); } if ($placeholders) { $link .= (strpos($link, '?') === false ? "?$placeholders" : "&$placeholders"); } return $link; } /** * @return string */ public function LinkPreview() { $record = $this->getRecord($this->currentPageID()); $baseLink = Director::absoluteBaseURL(); if ($record && $record instanceof SiteTree) { // if we are an external redirector don't show a link if ($record instanceof RedirectorPage && $record->RedirectionType == 'External') { $baseLink = false; } else { $baseLink = $record->Link('?stage=Stage'); } } return $baseLink; } /** * Return the entire site tree as a nested set of ULs */ public function SiteTreeAsUL() { // Pre-cache sitetree version numbers for querying efficiency Versioned::prepopulate_versionnumber_cache(SiteTree::class, Versioned::DRAFT); Versioned::prepopulate_versionnumber_cache(SiteTree::class, Versioned::LIVE); $html = $this->getSiteTreeFor($this->stat('tree_class')); $this->extend('updateSiteTreeAsUL', $html); return $html; } /** * Get a site tree HTML listing which displays the nodes under the given criteria. * * @param string $className The class of the root object * @param string $rootID The ID of the root object. If this is null then a complete tree will be * shown * @param string $childrenMethod The method to call to get the children of the tree. For example, * Children, AllChildrenIncludingDeleted, or AllHistoricalChildren * @param string $numChildrenMethod * @param callable $filterFunction * @param int $nodeCountThreshold * @return string Nested unordered list with links to each page */ public function getSiteTreeFor( $className, $rootID = null, $childrenMethod = null, $numChildrenMethod = null, $filterFunction = null, $nodeCountThreshold = 30 ) { // Provide better defaults from filter $filter = $this->getSearchFilter(); if ($filter) { if (!$childrenMethod) { $childrenMethod = $filter->getChildrenMethod(); } if (!$numChildrenMethod) { $numChildrenMethod = $filter->getNumChildrenMethod(); } if (!$filterFunction) { $filterFunction = function ($node) use ($filter) { return $filter->isPageIncluded($node); }; } } // Build set from node and begin marking $record = ($rootID) ? $this->getRecord($rootID) : null; $rootNode = $record ? $record : DataObject::singleton($className); $markingSet = MarkedSet::create($rootNode, $childrenMethod, $numChildrenMethod, $nodeCountThreshold); // Set filter function if ($filterFunction) { $markingSet->setMarkingFilterFunction($filterFunction); } // Mark tree from this node $markingSet->markPartialTree(); // Ensure current page is exposed $currentPage = $this->currentPage(); if ($currentPage) { $markingSet->markToExpose($currentPage); } // Pre-cache permissions $checker = SiteTree::getPermissionChecker(); if ($checker instanceof InheritedPermissions) { $checker->prePopulatePermissionCache( InheritedPermissions::EDIT, $markingSet->markedNodeIDs() ); } // Render using full-subtree template return $markingSet->renderChildren( [ self::class . '_SubTree', 'type' => 'Includes' ], $this->getTreeNodeCustomisations() ); } /** * Get callback to determine template customisations for nodes * * @return callable */ protected function getTreeNodeCustomisations() { $rootTitle = $this->getCMSTreeTitle(); $linkWithSearch = $this->LinkWithSearch($this->Link()); return function (SiteTree $node) use ($linkWithSearch, $rootTitle) { return [ 'listViewLink' => Controller::join_links( $linkWithSearch, '?view=listview&ParentID=' . $node->ID ), 'rootTitle' => $rootTitle, 'extraClass' => $this->getTreeNodeClasses($node), ]; }; } /** * Get extra CSS classes for a page's tree node * * @param SiteTree $node * @return string */ public function getTreeNodeClasses(SiteTree $node) { // Get classes from object $classes = $node->CMSTreeClasses(); // Flag as current if ($this->isCurrentPage($node)) { $classes .= ' current'; } // Get status flag classes $flags = $node->getStatusFlags(); if ($flags) { $statuses = array_keys($flags); foreach ($statuses as $s) { $classes .= ' status-' . $s; } } // Get additional filter classes $filter = $this->getSearchFilter(); if ($filter && ($filterClasses = $filter->getPageClasses($node))) { if (is_array($filterClasses)) { $filterClasses = implode(' ', $filterClasses); } $classes .= ' ' . $filterClasses; } return trim($classes); } /** * Get a subtree underneath the request param 'ID'. * If ID = 0, then get the whole tree. * * @param HTTPRequest $request * @return string */ public function getsubtree($request) { $html = $this->getSiteTreeFor( $this->stat('tree_class'), $request->getVar('ID'), null, null, null, $request->getVar('minNodeCount') ); // Trim off the outer tag $html = preg_replace('/^[\s\t\r\n]*]*>/', '', $html); $html = preg_replace('/<\/ul[^>]*>[\s\t\r\n]*$/', '', $html); return $html; } /** * Allows requesting a view update on specific tree nodes. * Similar to {@link getsubtree()}, but doesn't enforce loading * all children with the node. Useful to refresh views after * state modifications, e.g. saving a form. * * @param HTTPRequest $request * @return HTTPResponse */ public function updatetreenodes($request) { $data = array(); $ids = explode(',', $request->getVar('ids')); foreach ($ids as $id) { if ($id === "") { continue; // $id may be a blank string, which is invalid and should be skipped over } $record = $this->getRecord($id); if (!$record) { continue; // In case a page is no longer available } // Create marking set with sole marked root $markingSet = MarkedSet::create($record); $markingSet->setMarkingFilterFunction(function () { return false; }); $markingSet->markUnexpanded($record); // Find the next & previous nodes, for proper positioning (Sort isn't good enough - it's not a raw offset) // TODO: These methods should really be in hierarchy - for a start it assumes Sort exists $prev = null; $className = $this->stat('tree_class'); $next = DataObject::get($className) ->filter('ParentID', $record->ParentID) ->filter('Sort:GreaterThan', $record->Sort) ->first(); if (!$next) { $prev = DataObject::get($className) ->filter('ParentID', $record->ParentID) ->filter('Sort:LessThan', $record->Sort) ->reverse() ->first(); } // Render using single node template $html = $markingSet->renderChildren( [ self::class . '_TreeNode', 'type' => 'Includes'], $this->getTreeNodeCustomisations() ); $data[$id] = array( 'html' => $html, 'ParentID' => $record->ParentID, 'NextID' => $next ? $next->ID : null, 'PrevID' => $prev ? $prev->ID : null ); } return $this ->getResponse() ->addHeader('Content-Type', 'application/json') ->setBody(Convert::raw2json($data)); } /** * Update the position and parent of a tree node. * Only saves the node if changes were made. * * Required data: * - 'ID': The moved node * - 'ParentID': New parent relation of the moved node (0 for root) * - 'SiblingIDs': Array of all sibling nodes to the moved node (incl. the node itself). * In case of a 'ParentID' change, relates to the new siblings under the new parent. * * @param HTTPRequest $request * @return HTTPResponse JSON string with a * @throws HTTPResponse_Exception */ public function savetreenode($request) { if (!SecurityToken::inst()->checkRequest($request)) { return $this->httpError(400); } if (!Permission::check('SITETREE_REORGANISE') && !Permission::check('ADMIN')) { return $this->httpError( 403, _t( __CLASS__.'.CANT_REORGANISE', "You do not have permission to rearange the site tree. Your change was not saved." ) ); } $className = $this->stat('tree_class'); $id = $request->requestVar('ID'); $parentID = $request->requestVar('ParentID'); if (!is_numeric($id) || !is_numeric($parentID)) { return $this->httpError(400); } // Check record exists in the DB /** @var SiteTree $node */ $node = DataObject::get_by_id($className, $id); if (!$node) { return $this->httpError( 500, _t( __CLASS__.'.PLEASESAVE', "Please Save Page: This page could not be updated because it hasn't been saved yet." ) ); } // Check top level permissions $root = $node->getParentType(); if (($parentID == '0' || $root == 'root') && !SiteConfig::current_site_config()->canCreateTopLevel()) { return $this->httpError( 403, _t( __CLASS__.'.CANT_REORGANISE', "You do not have permission to alter Top level pages. Your change was not saved." ) ); } $siblingIDs = $request->requestVar('SiblingIDs'); $statusUpdates = array('modified'=>array()); if (!$node->canEdit()) { return Security::permissionFailure($this); } // Update hierarchy (only if ParentID changed) if ($node->ParentID != $parentID) { $node->ParentID = (int)$parentID; $node->write(); $statusUpdates['modified'][$node->ID] = array( 'TreeTitle' => $node->TreeTitle ); // Update all dependent pages $virtualPages = VirtualPage::get()->filter("CopyContentFromID", $node->ID); foreach ($virtualPages as $virtualPage) { $statusUpdates['modified'][$virtualPage->ID] = array( 'TreeTitle' => $virtualPage->TreeTitle() ); } $this->getResponse()->addHeader( 'X-Status', rawurlencode(_t(__CLASS__.'.REORGANISATIONSUCCESSFUL', 'Reorganised the site tree successfully.')) ); } // Update sorting if (is_array($siblingIDs)) { $counter = 0; foreach ($siblingIDs as $id) { if ($id == $node->ID) { $node->Sort = ++$counter; $node->write(); $statusUpdates['modified'][$node->ID] = array( 'TreeTitle' => $node->TreeTitle ); } elseif (is_numeric($id)) { // Nodes that weren't "actually moved" shouldn't be registered as // having been edited; do a direct SQL update instead ++$counter; $table = DataObject::getSchema()->baseDataTable($className); DB::prepared_query( "UPDATE \"$table\" SET \"Sort\" = ? WHERE \"ID\" = ?", array($counter, $id) ); } } $this->getResponse()->addHeader( 'X-Status', rawurlencode(_t(__CLASS__.'.REORGANISATIONSUCCESSFUL', 'Reorganised the site tree successfully.')) ); } return $this ->getResponse() ->addHeader('Content-Type', 'application/json') ->setBody(Convert::raw2json($statusUpdates)); } public function CanOrganiseSitetree() { return !Permission::check('SITETREE_REORGANISE') && !Permission::check('ADMIN') ? false : true; } /** * @return boolean */ public function TreeIsFiltered() { $query = $this->getRequest()->getVar('q'); if (!$query || (count($query) === 1 && isset($query['FilterClass']) && $query['FilterClass'] === 'SilverStripe\\CMS\\Controllers\\CMSSiteTreeFilter_Search')) { return false; } return true; } public function ExtraTreeTools() { $html = ''; $this->extend('updateExtraTreeTools', $html); return $html; } /** * Returns a Form for page searching for use in templates. * * Can be modified from a decorator by a 'updateSearchForm' method * * @return Form */ public function SearchForm() { // Create the fields $content = new TextField('q[Term]', _t('SilverStripe\\CMS\\Search\\SearchForm.FILTERLABELTEXT', 'Search')); $dateFrom = new DateField( 'q[LastEditedFrom]', _t('SilverStripe\\CMS\\Search\\SearchForm.FILTERDATEFROM', 'From') ); $dateTo = new DateField( 'q[LastEditedTo]', _t('SilverStripe\\CMS\\Search\\SearchForm.FILTERDATETO', 'To') ); $pageFilter = new DropdownField( 'q[FilterClass]', _t('SilverStripe\\CMS\\Controllers\\CMSMain.PAGES', 'Page status'), CMSSiteTreeFilter::get_all_filters() ); $pageClasses = new DropdownField( 'q[ClassName]', _t('SilverStripe\\CMS\\Controllers\\CMSMain.PAGETYPEOPT', 'Page type', 'Dropdown for limiting search to a page type'), $this->getPageTypes() ); $pageClasses->setEmptyString(_t('SilverStripe\\CMS\\Controllers\\CMSMain.PAGETYPEANYOPT', 'Any')); // Group the Datefields $dateGroup = new FieldGroup( $dateFrom, $dateTo ); $dateGroup->setTitle(_t('SilverStripe\\CMS\\Search\\SearchForm.PAGEFILTERDATEHEADING', 'Last edited')); // view mode $viewMode = HiddenField::create('view', false, $this->ViewState()); // Create the Field list $fields = new FieldList( $content, $pageFilter, $pageClasses, $dateGroup, $viewMode ); // Create the Search and Reset action $actions = new FieldList( FormAction::create('doSearch', _t('SilverStripe\\CMS\\Controllers\\CMSMain.APPLY_FILTER', 'Search')) ->addExtraClass('btn btn-primary'), FormAction::create('clear', _t('SilverStripe\\CMS\\Controllers\\CMSMain.CLEAR_FILTER', 'Clear')) ->setAttribute('type', 'reset') ->addExtraClass('btn btn-secondary') ); // Use