initially_copied_fields} is implicitly included in this list. */ private static $non_virtual_fields = array( "SecurityTypeID", "OwnerID", "URLSegment", "Sort", "Status", 'ShowInMenus', // 'Locale' 'ShowInSearch', 'Version', "Embargo", "Expiry", ); /** * @var Array Define fields that are initially copied to virtual pages but left modifiable after that. */ private static $initially_copied_fields = array( 'ShowInMenus', 'ShowInSearch', 'URLSegment', ); private static $has_one = array( "CopyContentFrom" => "SiteTree", ); private static $db = array( "VersionID" => "Int", ); /** * Generates the array of fields required for the page type. */ public function getVirtualFields() { $nonVirtualFields = array_merge(self::config()->non_virtual_fields, self::config()->initially_copied_fields); $record = $this->CopyContentFrom(); $allFields = $record->db(); if($hasOne = $record->hasOne()) foreach($hasOne as $link) $allFields[$link . 'ID'] = "Int"; $virtualFields = array(); foreach($allFields as $field => $type) { if(!in_array($field, $nonVirtualFields)) $virtualFields[] = $field; } return $virtualFields; } /** * @return SiteTree Returns the linked page, or failing that, a new object. */ public function CopyContentFrom() { $copyContentFromID = $this->CopyContentFromID; if(!$copyContentFromID) return new SiteTree(); if(!isset($this->components['CopyContentFrom'])) { $this->components['CopyContentFrom'] = DataObject::get_by_id("SiteTree", $copyContentFromID); // Don't let VirtualPages point to other VirtualPages if($this->components['CopyContentFrom'] instanceof VirtualPage) { $this->components['CopyContentFrom'] = null; } // has_one component semantics incidate than an empty object should be returned if(!$this->components['CopyContentFrom']) { $this->components['CopyContentFrom'] = new SiteTree(); } } return $this->components['CopyContentFrom'] ? $this->components['CopyContentFrom'] : new SiteTree(); } public function setCopyContentFromID($val) { if($val && DataObject::get_by_id('SiteTree', $val) instanceof VirtualPage) $val = 0; return $this->setField("CopyContentFromID", $val); } public function ContentSource() { return $this->CopyContentFrom(); } /** * For VirtualPage, add a canonical link tag linking to the original page * See TRAC #6828 & http://support.google.com/webmasters/bin/answer.py?hl=en&answer=139394 * * @param boolean $includeTitle Show default -tag, set to false for custom templating * @return string The XHTML metatags */ public function MetaTags($includeTitle = true) { $tags = parent::MetaTags($includeTitle); if ($this->CopyContentFrom()->ID) { $tags .= "<link rel=\"canonical\" href=\"{$this->CopyContentFrom()->Link()}\" />\n"; } return $tags; } public function allowedChildren() { if($this->CopyContentFrom()) { return $this->CopyContentFrom()->allowedChildren(); } } public function syncLinkTracking() { if($this->CopyContentFromID) { $this->HasBrokenLink = !(bool) DataObject::get_by_id('SiteTree', $this->CopyContentFromID); } else { $this->HasBrokenLink = true; } } /** * We can only publish the page if there is a published source page */ public function canPublish($member = null) { return $this->isPublishable() && parent::canPublish($member); } /** * Return true if we can delete this page from the live site, which is different from can * we publish it. */ public function canDeleteFromLive($member = null) { return parent::canPublish($member); } /** * Returns true if is page is publishable by anyone at all * Return false if the source page isn't published yet. * * Note that isPublishable doesn't affect ete from live, only publish. */ public function isPublishable() { // No source if(!$this->CopyContentFrom() || !$this->CopyContentFrom()->ID) { return false; } // Unpublished source if(!Versioned::get_versionnumber_by_stage('SiteTree', 'Live', $this->CopyContentFromID)) { return false; } // Default - publishable return true; } /** * Generate the CMS fields from the fields from the original page. */ public function getCMSFields() { $fields = parent::getCMSFields(); // Setup the linking to the original page. $copyContentFromField = new TreeDropdownField( "CopyContentFromID", _t('VirtualPage.CHOOSE', "Linked Page"), "SiteTree" ); // filter doesn't let you select children of virtual pages as as source page //$copyContentFromField->setFilterFunction(create_function('$item', 'return !($item instanceof VirtualPage);')); // Setup virtual fields if($virtualFields = $this->getVirtualFields()) { $roTransformation = new ReadonlyTransformation(); foreach($virtualFields as $virtualField) { if($fields->dataFieldByName($virtualField)) $fields->replaceField($virtualField, $fields->dataFieldByName($virtualField)->transform($roTransformation)); } } $msgs = array(); $fields->addFieldToTab("Root.Main", $copyContentFromField, "Title"); // Create links back to the original object in the CMS if($this->CopyContentFrom()->exists()) { $link = "<a class=\"cmsEditlink\" href=\"admin/pages/edit/show/$this->CopyContentFromID\">" . _t('VirtualPage.EditLink', 'edit') . "</a>"; $msgs[] = _t( 'VirtualPage.HEADERWITHLINK', "This is a virtual page copying content from \"{title}\" ({link})", array( 'title' => $this->CopyContentFrom()->obj('Title'), 'link' => $link ) ); } else { $msgs[] = _t('VirtualPage.HEADER', "This is a virtual page"); $msgs[] = _t( 'SITETREE.VIRTUALPAGEWARNING', 'Please choose a linked page and save first in order to publish this page' ); } if( $this->CopyContentFromID && !Versioned::get_versionnumber_by_stage('SiteTree', 'Live', $this->CopyContentFromID) ) { $msgs[] = _t( 'SITETREE.VIRTUALPAGEDRAFTWARNING', 'Please publish the linked page in order to publish the virtual page' ); } $fields->addFieldToTab("Root.Main", new LiteralField( 'VirtualPageMessage', '<div class="message notice">' . implode('. ', $msgs) . '.</div>' ), 'CopyContentFromID' ); return $fields; } public function getSettingsFields() { $fields = parent::getSettingsFields(); if(!$this->CopyContentFrom()->exists()) { $fields->addFieldToTab("Root.Settings", new LiteralField( 'VirtualPageWarning', '<div class="message notice">' . _t( 'SITETREE.VIRTUALPAGEWARNINGSETTINGS', 'Please choose a linked page in the main content fields in order to publish' ) . '</div>' ), 'ClassName' ); } return $fields; } /** * We have to change it to copy all the content from the original page first. */ public function onBeforeWrite() { $performCopyFrom = null; // Determine if we need to copy values. if( $this->extension_instances['Versioned']->migratingVersion && Versioned::current_stage() == 'Live' && $this->CopyContentFromID ) { // On publication to live, copy from published source. $performCopyFrom = true; $stageSourceVersion = DB::prepared_query( 'SELECT "Version" FROM "SiteTree" WHERE "ID" = ?', array($this->CopyContentFromID) )->value(); $liveSourceVersion = DB::prepared_query( 'SELECT "Version" FROM "SiteTree_Live" WHERE "ID" = ?', array($this->CopyContentFromID) )->value(); // We're going to create a new VP record in SiteTree_versions because the published // version might not exist, unless we're publishing the latest version if($stageSourceVersion != $liveSourceVersion) { $this->extension_instances['Versioned']->migratingVersion = null; } } else { // On regular write, copy from draft source. This is only executed when the source page changes. $performCopyFrom = $this->isChanged('CopyContentFromID', 2) && $this->CopyContentFromID != 0; } if($performCopyFrom && $this instanceof VirtualPage) { // This flush is needed because the get_one cache doesn't respect site version :-( singleton('SiteTree')->flushCache(); // @todo Update get_one to support parameterised queries $source = DataObject::get_by_id("SiteTree", $this->CopyContentFromID); // Leave the updating of image tracking until after write, in case its a new record $this->copyFrom($source, false); } parent::onBeforeWrite(); } public function onAfterWrite() { parent::onAfterWrite(); // Don't do this stuff when we're publishing if(!$this->extension_instances['Versioned']->migratingVersion) { if( $this->isChanged('CopyContentFromID') && $this->CopyContentFromID != 0 && $this instanceof VirtualPage ) { $this->updateImageTracking(); } } // Check if page type has changed to a non-virtual page. // Caution: Relies on the fact that the current instance is still of the old page type. if($this->isChanged('ClassName', 2)) { $changed = $this->getChangedFields(); $classBefore = $changed['ClassName']['before']; $classAfter = $changed['ClassName']['after']; if($classBefore != $classAfter) { // Remove all database rows for the old page type to avoid inconsistent data retrieval. // TODO This should apply to all page type changes, not only on VirtualPage - but needs // more comprehensive testing as its a destructive operation $removedTables = array_diff(ClassInfo::dataClassesFor($classBefore), ClassInfo::dataClassesFor($classAfter)); if($removedTables) foreach($removedTables as $removedTable) { // Note: *_versions records are left intact foreach(array('', 'Live') as $stage) { if($stage) $removedTable = "{$removedTable}_{$stage}"; DB::prepared_query("DELETE FROM \"$removedTable\" WHERE \"ID\" = ?", array($this->ID)); } } // Also publish the change immediately to avoid inconsistent behaviour between // a non-virtual draft and a virtual live record (e.g. republishing the original record // shouldn't republish the - now unrelated - changes on the ex-VirtualPage draft). // Copies all stage fields to live as well. // @todo Update get_one to support parameterised queries $source = DataObject::get_by_id("SiteTree", $this->CopyContentFromID); $this->copyFrom($source); $this->publish('Stage', 'Live'); // Change reference on instance (as well as removing the underlying database tables) $this->CopyContentFromID = 0; } } } protected function validate() { $result = parent::validate(); // "Can be root" validation $orig = $this->CopyContentFrom(); if(!$orig->stat('can_be_root') && !$this->ParentID) { $result->error( _t( 'VirtualPage.PageTypNotAllowedOnRoot', 'Original page type "{type}" is not allowed on the root level for this virtual page', array('type' => $orig->i18n_singular_name()) ), 'CAN_BE_ROOT_VIRTUAL' ); } return $result; } /** * Ensure we have an up-to-date version of everything. */ public function copyFrom($source, $updateImageTracking = true) { if($source) { foreach($this->getVirtualFields() as $virtualField) { $this->$virtualField = $source->$virtualField; } // We also want to copy certain, but only if we're copying the source page for the first // time. After this point, the user is free to customise these for the virtual page themselves. if($this->isChanged('CopyContentFromID', 2) && $this->CopyContentFromID != 0) { foreach(self::config()->initially_copied_fields as $fieldName) { $this->$fieldName = $source->$fieldName; } } if($updateImageTracking) $this->updateImageTracking(); } } public function updateImageTracking() { // Doesn't work on unsaved records if(!$this->ID) return; // Remove CopyContentFrom() from the cache unset($this->components['CopyContentFrom']); // Update ImageTracking $this->ImageTracking()->setByIdList($this->CopyContentFrom()->ImageTracking()->column('ID')); } /** * @param string $numChildrenMethod * @return string */ public function CMSTreeClasses($numChildrenMethod="numChildren") { return parent::CMSTreeClasses($numChildrenMethod) . ' VirtualPage-' . $this->CopyContentFrom()->ClassName; } /** * Allow attributes on the master page to pass * through to the virtual page * * @param string $field * @return mixed */ public function __get($field) { if(parent::hasMethod($funcName = "get$field")) { return $this->$funcName(); } else if(parent::hasField($field) || ($field === 'ID' && !$this->exists())) { return $this->getField($field); } else { return $this->copyContentFrom()->$field; } } /** * Pass unrecognized method calls on to the original data object * * @param string $method * @param string $args * @return mixed */ public function __call($method, $args) { if(parent::hasMethod($method)) { return parent::__call($method, $args); } else { return call_user_func_array(array($this->copyContentFrom(), $method), $args); } } /** * @param string $field * @return bool */ public function hasField($field) { return ( array_key_exists($field, $this->record) || $this->hasDatabaseField($field) || array_key_exists($field, $this->db()) // Needed for composite fields || parent::hasMethod("get{$field}") || $this->CopyContentFrom()->hasField($field) ); } /** * Overwrite to also check for method on the original data object * * @param string $method * @return bool */ public function hasMethod($method) { if(parent::hasMethod($method)) return true; return $this->copyContentFrom()->hasMethod($method); } /** * Return the "casting helper" (a piece of PHP code that when evaluated creates a casted value object) for a field * on this object. * * @param string $field * @return string */ public function castingHelper($field) { if($this->copyContentFrom()) { return $this->copyContentFrom()->castingHelper($field); } else { return parent::castingHelper($field); } } } /** * Controller for the virtual page. * @package cms */ class VirtualPage_Controller extends Page_Controller { private static $allowed_actions = array( 'loadcontentall' => 'ADMIN', ); /** * Reloads the content if the version is different ;-) */ public function reloadContent() { $this->failover->copyFrom($this->failover->CopyContentFrom()); $this->failover->write(); return; } public function getViewer($action) { $object = $this->CopyContentFrom(); $controller = ModelAsController::controller_for($object, $action); return $controller->getViewer($action); } /** * When the virtualpage is loaded, check to see if the versions are the same * if not, reload the content. * NOTE: Virtual page must have a container object of subclass of sitetree. * We can't load the content without an ID or record to copy it from. */ public function init(){ if(isset($this->record) && $this->record->ID){ if($this->record->VersionID != $this->failover->CopyContentFrom()->Version){ $this->reloadContent(); $this->VersionID = $this->failover->CopyContentFrom()->VersionID; } } parent::init(); $this->__call('init', array()); } public function loadcontentall() { $pages = DataObject::get("VirtualPage"); foreach($pages as $page) { $page->copyFrom($page->CopyContentFrom()); $page->write(); $page->publish("Stage", "Live"); echo "<li>Published $page->URLSegment"; } } /** * Also check the original object's original controller for the method * * @param string $method * @return bool */ public function hasMethod($method) { $haveIt = parent::hasMethod($method); if (!$haveIt) { $controller = ModelAsController::controller_for($this->CopyContentFrom()); $haveIt = $controller->hasMethod($method); } return $haveIt; } /** * Pass unrecognized method calls on to the original controller * * @param string $method * @param string $args * @return mixed * * @throws Exception Any error other than a 'no method' error. */ public function __call($method, $args) { try { return parent::__call($method, $args); } catch (Exception $e) { // Hack... detect exception type. We really should use exception subclasses. // if the exception isn't a 'no method' error, rethrow it if ($e->getCode() !== 2175) { throw $e; } $original = $this->copyContentFrom(); $controller = ModelAsController::controller_for($original); // Ensure request/response data is available on virtual controller $controller->setRequest($this->getRequest()); $controller->response = $this->response; // @todo - replace with getter/setter in 3.3 return call_user_func_array(array($controller, $method), $args); } } }