API Implement polymorphic sitetree link tracking (#2123)

* WIP Implement polymorphic sitetree link tracking

* Update unit tests
Merge SiteTreeTrackedPage into SiteTree directly

* Fix bugs and issues

* Fix support for file link tracking

* Add missing use

* Add back deprecated extension

* Remove obsolete belongs_many_many

* Update deprecations

* BUG Ensure non-SiteTree records support link tracking

* Safer changed check

* Shift file tracking test to assets module

* Better check for live stage on versioning

* Deprecate method

* Cleanup virtualpage

* Clear records on delete

* Ensure upgrade task occurs on draft

* fix linting
This commit is contained in:
Damian Mooyman 2018-04-06 15:53:57 +12:00 committed by Aaron Carlino
parent 48f53a522a
commit 6c616f5f7a
24 changed files with 480 additions and 952 deletions

View File

@ -4,9 +4,6 @@ Name: cmsextensions
SilverStripe\Admin\LeftAndMain: SilverStripe\Admin\LeftAndMain:
extensions: extensions:
- SilverStripe\CMS\Controllers\LeftAndMainPageIconsExtension - SilverStripe\CMS\Controllers\LeftAndMainPageIconsExtension
SilverStripe\Assets\File:
extensions:
- SilverStripe\CMS\Model\SiteTreeFileExtension
--- ---
Name: cmsmodals Name: cmsmodals
--- ---

6
_config/linktracking.yml Normal file
View File

@ -0,0 +1,6 @@
---
Name: cmslinktracking
---
SilverStripe\ORM\DataObject:
extensions:
- SilverStripe\CMS\Model\SiteTreeLinkTracking

View File

@ -4,6 +4,7 @@ namespace SilverStripe\CMS\Model;
use Page; use Page;
use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\CacheInterface;
use SilverStripe\Assets\Shortcodes\FileLinkTracking;
use SilverStripe\CampaignAdmin\AddToCampaignHandler_FormAction; use SilverStripe\CampaignAdmin\AddToCampaignHandler_FormAction;
use SilverStripe\CMS\Controllers\CMSPageEditController; use SilverStripe\CMS\Controllers\CMSPageEditController;
use SilverStripe\CMS\Controllers\ContentController; use SilverStripe\CMS\Controllers\ContentController;
@ -48,6 +49,7 @@ use SilverStripe\ORM\CMSPreviewable;
use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB; use SilverStripe\ORM\DB;
use SilverStripe\ORM\HasManyList;
use SilverStripe\ORM\HiddenClass; use SilverStripe\ORM\HiddenClass;
use SilverStripe\ORM\Hierarchy\Hierarchy; use SilverStripe\ORM\Hierarchy\Hierarchy;
use SilverStripe\ORM\ManyManyList; use SilverStripe\ORM\ManyManyList;
@ -82,25 +84,29 @@ use Subsite;
* {@link AbsoluteLink()}. You can allow these segments to contain multibyte characters through * {@link AbsoluteLink()}. You can allow these segments to contain multibyte characters through
* {@link URLSegmentFilter::$default_allow_multibyte}. * {@link URLSegmentFilter::$default_allow_multibyte}.
* *
* @property string URLSegment * @property string $URLSegment
* @property string Title * @property string $Title
* @property string MenuTitle * @property string $MenuTitle
* @property string Content HTML content of the page. * @property string $Content HTML content of the page.
* @property string MetaDescription * @property string $MetaDescription
* @property string ExtraMeta * @property string $ExtraMeta
* @property string ShowInMenus * @property string $ShowInMenus
* @property string ShowInSearch * @property string $ShowInSearch
* @property string Sort Integer value denoting the sort order. * @property string $Sort Integer value denoting the sort order.
* @property string ReportClass * @property string $ReportClass
* @property bool $HasBrokenFile True if this page has a broken file shortcode
* @property bool $HasBrokenLink True if this page has a broken page shortcode
* *
* @method ManyManyList ViewerGroups() List of groups that can view this object. * @method ManyManyList ViewerGroups() List of groups that can view this object.
* @method ManyManyList EditorGroups() List of groups that can edit this object. * @method ManyManyList EditorGroups() List of groups that can edit this object.
* @method SiteTree Parent() * @method SiteTree Parent()
* @method HasManyList|SiteTreeLink[] BackLinks() List of SiteTreeLink objects attached to this page
* *
* @mixin Hierarchy * @mixin Hierarchy
* @mixin Versioned * @mixin Versioned
* @mixin RecursivePublishable * @mixin RecursivePublishable
* @mixin SiteTreeLinkTracking * @mixin SiteTreeLinkTracking Added via linktracking.yml to DataObject directly
* @mixin FileLinkTracking Added via filetracking.yml in silverstripe/assets
* @mixin InheritedPermissionsExtension * @mixin InheritedPermissionsExtension
*/ */
class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvider, CMSPreviewable, Resettable, Flushable, MemberCacheFlusher class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvider, CMSPreviewable, Resettable, Flushable, MemberCacheFlusher
@ -205,9 +211,10 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
"URLSegment" => true, "URLSegment" => true,
); );
private static $has_many = array( private static $has_many = [
"VirtualPages" => VirtualPage::class . '.CopyContentFrom' "VirtualPages" => VirtualPage::class . '.CopyContentFrom',
); 'BackLinks' => SiteTreeLink::class . '.Linked',
];
private static $owned_by = array( private static $owned_by = array(
"VirtualPages" "VirtualPages"
@ -262,7 +269,6 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
private static $extensions = [ private static $extensions = [
Hierarchy::class, Hierarchy::class,
Versioned::class, Versioned::class,
SiteTreeLinkTracking::class,
InheritedPermissionsExtension::class, InheritedPermissionsExtension::class,
]; ];
@ -384,7 +390,7 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
// Grab the initial root level page to traverse down from. // Grab the initial root level page to traverse down from.
$URLSegment = array_shift($parts); $URLSegment = array_shift($parts);
$conditions = array('"SiteTree"."URLSegment"' => rawurlencode($URLSegment)); $conditions = array('"SiteTree"."URLSegment"' => rawurlencode($URLSegment));
if (self::config()->nested_urls) { if (self::config()->get('nested_urls')) {
$conditions[] = array('"SiteTree"."ParentID"' => 0); $conditions[] = array('"SiteTree"."ParentID"' => 0);
} }
/** @var SiteTree $sitetree */ /** @var SiteTree $sitetree */
@ -392,7 +398,7 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
/// Fall back on a unique URLSegment for b/c. /// Fall back on a unique URLSegment for b/c.
if (!$sitetree if (!$sitetree
&& self::config()->nested_urls && self::config()->get('nested_urls')
&& $sitetree = DataObject::get_one(self::class, array( && $sitetree = DataObject::get_one(self::class, array(
'"SiteTree"."URLSegment"' => $URLSegment '"SiteTree"."URLSegment"' => $URLSegment
), $cache) ), $cache)
@ -402,7 +408,7 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
// Attempt to grab an alternative page from extensions. // Attempt to grab an alternative page from extensions.
if (!$sitetree) { if (!$sitetree) {
$parentID = self::config()->nested_urls ? 0 : null; $parentID = self::config()->get('nested_urls') ? 0 : null;
if ($alternatives = static::singleton()->extend('alternateGetByLink', $URLSegment, $parentID)) { if ($alternatives = static::singleton()->extend('alternateGetByLink', $URLSegment, $parentID)) {
foreach ($alternatives as $alternative) { foreach ($alternatives as $alternative) {
@ -418,7 +424,7 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
} }
// Check if we have any more URL parts to parse. // Check if we have any more URL parts to parse.
if (!self::config()->nested_urls || !count($parts)) { if (!self::config()->get('nested_urls') || !count($parts)) {
return $sitetree; return $sitetree;
} }
@ -600,7 +606,7 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
*/ */
public function RelativeLink($action = null) public function RelativeLink($action = null)
{ {
if ($this->ParentID && self::config()->nested_urls) { if ($this->ParentID && self::config()->get('nested_urls')) {
$parent = $this->Parent(); $parent = $this->Parent();
// If page is removed select parent from version history (for archive page view) // If page is removed select parent from version history (for archive page view)
if ((!$parent || !$parent->exists()) && !$this->isOnDraft()) { if ((!$parent || !$parent->exists()) && !$this->isOnDraft()) {
@ -843,6 +849,7 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
* @param boolean $unlinked Whether to link page titles. * @param boolean $unlinked Whether to link page titles.
* @param boolean|string $stopAtPageType ClassName of a page to stop the upwards traversal. * @param boolean|string $stopAtPageType ClassName of a page to stop the upwards traversal.
* @param boolean $showHidden Include pages marked with the attribute ShowInMenus = 0 * @param boolean $showHidden Include pages marked with the attribute ShowInMenus = 0
* @param string $delimiter Delimiter character (raw html)
* @return string The breadcrumb trail. * @return string The breadcrumb trail.
*/ */
public function Breadcrumbs($maxDepth = 20, $unlinked = false, $stopAtPageType = false, $showHidden = false, $delimiter = '»') public function Breadcrumbs($maxDepth = 20, $unlinked = false, $stopAtPageType = false, $showHidden = false, $delimiter = '»')
@ -913,8 +920,9 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
*/ */
public function getParent() public function getParent()
{ {
if ($parentID = $this->getField("ParentID")) { $parentID = $this->getField("ParentID");
return DataObject::get_by_id(self::class, $parentID); if ($parentID) {
return SiteTree::get_by_id(self::class, $parentID);
} }
return null; return null;
} }
@ -1377,14 +1385,14 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
)); ));
} }
$tags = implode("\n", $tags); $tagString = implode("\n", $tags);
if ($this->ExtraMeta) { if ($this->ExtraMeta) {
$tags .= $this->obj('ExtraMeta')->forTemplate(); $tagString .= $this->obj('ExtraMeta')->forTemplate();
} }
$this->extend('MetaTags', $tags); $this->extend('MetaTags', $tagString);
return $tags; return $tagString;
} }
/** /**
@ -1411,12 +1419,13 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
parent::requireDefaultRecords(); parent::requireDefaultRecords();
// default pages // default pages
if (static::class == self::class && $this->config()->create_default_pages) { if (static::class === self::class && $this->config()->get('create_default_pages')) {
if (!SiteTree::get_by_link(RootURLController::config()->default_homepage_link)) { $defaultHomepage = RootURLController::config()->get('default_homepage_link');
if (!SiteTree::get_by_link($defaultHomepage)) {
$homepage = new Page(); $homepage = new Page();
$homepage->Title = _t(__CLASS__.'.DEFAULTHOMETITLE', 'Home'); $homepage->Title = _t(__CLASS__.'.DEFAULTHOMETITLE', 'Home');
$homepage->Content = _t(__CLASS__.'.DEFAULTHOMECONTENT', '<p>Welcome to SilverStripe! This is the default homepage. You can edit this page by opening <a href="admin/">the CMS</a>.</p><p>You can now access the <a href="http://docs.silverstripe.org">developer documentation</a>, or begin the <a href="http://www.silverstripe.org/learn/lessons">SilverStripe lessons</a>.</p>'); $homepage->Content = _t(__CLASS__.'.DEFAULTHOMECONTENT', '<p>Welcome to SilverStripe! This is the default homepage. You can edit this page by opening <a href="admin/">the CMS</a>.</p><p>You can now access the <a href="http://docs.silverstripe.org">developer documentation</a>, or begin the <a href="http://www.silverstripe.org/learn/lessons">SilverStripe lessons</a>.</p>');
$homepage->URLSegment = RootURLController::config()->default_homepage_link; $homepage->URLSegment = $defaultHomepage;
$homepage->Sort = 1; $homepage->Sort = 1;
$homepage->write(); $homepage->write();
$homepage->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); $homepage->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE);
@ -1491,8 +1500,6 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
$count++; $count++;
} }
$this->syncLinkTracking();
// Check to see if we've only altered fields that shouldn't affect versioning // Check to see if we've only altered fields that shouldn't affect versioning
$fieldsIgnoredByVersioning = array('HasBrokenLink', 'Status', 'HasBrokenFile', 'ToDo', 'VersionID', 'SaveCount'); $fieldsIgnoredByVersioning = array('HasBrokenLink', 'Status', 'HasBrokenFile', 'ToDo', 'VersionID', 'SaveCount');
$changedFields = array_keys($this->getChangedFields(true, 2)); $changedFields = array_keys($this->getChangedFields(true, 2));
@ -1521,8 +1528,8 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
parent::onBeforeDelete(); parent::onBeforeDelete();
// If deleting this page, delete all its children. // If deleting this page, delete all its children.
if ($this->isInDB() && SiteTree::config()->enforce_strict_hierarchy && $children = $this->AllChildren()) { if ($this->isInDB() && SiteTree::config()->get('enforce_strict_hierarchy')) {
foreach ($children as $child) { foreach ($this->AllChildren() as $child) {
/** @var SiteTree $child */ /** @var SiteTree $child */
$child->delete(); $child->delete();
} }
@ -1615,7 +1622,7 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
public function validURLSegment() public function validURLSegment()
{ {
// Check known urlsegment blacklists // Check known urlsegment blacklists
if (self::config()->nested_urls && $this->ParentID) { if (self::config()->get('nested_urls') && $this->ParentID) {
// Guard against url segments for sub-pages // Guard against url segments for sub-pages
$parent = $this->Parent(); $parent = $this->Parent();
if ($controller = ModelAsController::controller_for($parent)) { if ($controller = ModelAsController::controller_for($parent)) {
@ -1645,7 +1652,7 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
if ($this->ID) { if ($this->ID) {
$source = $source->exclude('ID', $this->ID); $source = $source->exclude('ID', $this->ID);
} }
if (self::config()->nested_urls) { if (self::config()->get('nested_urls')) {
$source = $source->filter('ParentID', $this->ParentID ? $this->ParentID : 0); $source = $source->filter('ParentID', $this->ParentID ? $this->ParentID : 0);
} }
return !$source->exists(); return !$source->exists();
@ -1685,9 +1692,10 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
*/ */
public function getStageURLSegment() public function getStageURLSegment()
{ {
$stageRecord = Versioned::get_one_by_stage(self::class, Versioned::DRAFT, array( /** @var SiteTree $stageRecord */
$stageRecord = Versioned::get_one_by_stage(self::class, Versioned::DRAFT, [
'"SiteTree"."ID"' => $this->ID '"SiteTree"."ID"' => $this->ID
)); ]);
return ($stageRecord) ? $stageRecord->URLSegment : null; return ($stageRecord) ? $stageRecord->URLSegment : null;
} }
@ -1698,17 +1706,37 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
*/ */
public function getLiveURLSegment() public function getLiveURLSegment()
{ {
$liveRecord = Versioned::get_one_by_stage(self::class, Versioned::LIVE, array( /** @var SiteTree $liveRecord */
$liveRecord = Versioned::get_one_by_stage(self::class, Versioned::LIVE, [
'"SiteTree"."ID"' => $this->ID '"SiteTree"."ID"' => $this->ID
)); ]);
return ($liveRecord) ? $liveRecord->URLSegment : null; return ($liveRecord) ? $liveRecord->URLSegment : null;
} }
/**
* Get the back-link tracking objects that link to this page
*
* @retun ArrayList|DataObject[]
*/
public function BackLinkTracking()
{
// @todo - Implement PolymorphicManyManyList to replace this
$list = ArrayList::create();
foreach ($this->BackLinks() as $link) {
// Ensure parent record exists
$item = $link->Parent();
if ($item && $item->isInDB()) {
$list->push($item);
}
}
return $list;
}
/** /**
* Returns the pages that depend on this page. This includes virtual pages, pages that link to it, etc. * Returns the pages that depend on this page. This includes virtual pages, pages that link to it, etc.
* *
* @param bool $includeVirtuals Set to false to exlcude virtual pages. * @param bool $includeVirtuals Set to false to exlcude virtual pages.
* @return ArrayList * @return ArrayList|SiteTree[]
*/ */
public function DependentPages($includeVirtuals = true) public function DependentPages($includeVirtuals = true)
{ {
@ -1881,7 +1909,7 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
$baseLink = Controller::join_links( $baseLink = Controller::join_links(
Director::absoluteBaseURL(), Director::absoluteBaseURL(),
(self::config()->nested_urls && $this->ParentID ? $this->Parent()->RelativeLink(true) : null) (self::config()->get('nested_urls') && $this->ParentID ? $this->Parent()->RelativeLink(true) : null)
); );
$urlsegment = SiteTreeURLSegmentField::create("URLSegment", $this->fieldLabel('URLSegment')) $urlsegment = SiteTreeURLSegmentField::create("URLSegment", $this->fieldLabel('URLSegment'))
@ -1891,7 +1919,7 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
'New {pagetype}', 'New {pagetype}',
array('pagetype' => $this->i18n_singular_name()) array('pagetype' => $this->i18n_singular_name())
))); )));
$helpText = (self::config()->nested_urls && $this->numChildren()) $helpText = (self::config()->get('nested_urls') && $this->numChildren())
? $this->fieldLabel('LinkChangeNote') ? $this->fieldLabel('LinkChangeNote')
: ''; : '';
if (!Config::inst()->get('SilverStripe\\View\\Parsers\\URLSegmentFilter', 'default_allow_multibyte')) { if (!Config::inst()->get('SilverStripe\\View\\Parsers\\URLSegmentFilter', 'default_allow_multibyte')) {
@ -2154,14 +2182,14 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
$labels['Comments'] = _t(__CLASS__.'.Comments', 'Comments'); $labels['Comments'] = _t(__CLASS__.'.Comments', 'Comments');
$labels['Visibility'] = _t(__CLASS__.'.Visibility', 'Visibility'); $labels['Visibility'] = _t(__CLASS__.'.Visibility', 'Visibility');
$labels['LinkChangeNote'] = _t( $labels['LinkChangeNote'] = _t(
'SilverStripe\\CMS\\Model\\SiteTree.LINKCHANGENOTE', __CLASS__ . '.LINKCHANGENOTE',
'Changing this page\'s link will also affect the links of all child pages.' 'Changing this page\'s link will also affect the links of all child pages.'
); );
if ($includerelations) { if ($includerelations) {
$labels['Parent'] = _t(__CLASS__.'.has_one_Parent', 'Parent Page', 'The parent page in the site hierarchy'); $labels['Parent'] = _t(__CLASS__.'.has_one_Parent', 'Parent Page', 'The parent page in the site hierarchy');
$labels['LinkTracking'] = _t(__CLASS__.'.many_many_LinkTracking', 'Link Tracking'); $labels['LinkTracking'] = _t(__CLASS__.'.many_many_LinkTracking', 'Link Tracking');
$labels['ImageTracking'] = _t(__CLASS__.'.many_many_ImageTracking', 'Image Tracking'); $labels['FileTracking'] = _t(__CLASS__.'.many_many_ImageTracking', 'Image Tracking');
$labels['BackLinkTracking'] = _t(__CLASS__.'.many_many_BackLinkTracking', 'Backlink Tracking'); $labels['BackLinkTracking'] = _t(__CLASS__.'.many_many_BackLinkTracking', 'Backlink Tracking');
} }
@ -2188,7 +2216,7 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
// Get status of page // Get status of page
$isOnDraft = $this->isOnDraft(); $isOnDraft = $this->isOnDraft();
$isPublished = $this->isPublished(); $isPublished = $this->isPublished();
$stagesDiffer = $this->stagesDiffer(Versioned::DRAFT, Versioned::LIVE); $stagesDiffer = $this->stagesDiffer();
// Check permissions // Check permissions
$canPublish = $this->canPublish(); $canPublish = $this->canPublish();
@ -2232,6 +2260,7 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
} }
// "readonly"/viewing version that isn't the current version of the record // "readonly"/viewing version that isn't the current version of the record
/** @var SiteTree $stageRecord */
$stageRecord = Versioned::get_by_stage(static::class, Versioned::DRAFT)->byID($this->ID); $stageRecord = Versioned::get_by_stage(static::class, Versioned::DRAFT)->byID($this->ID);
/** @skipUpgrade */ /** @skipUpgrade */
if ($stageRecord && $stageRecord->Version != $this->Version) { if ($stageRecord && $stageRecord->Version != $this->Version) {
@ -3005,14 +3034,20 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi
*/ */
protected function updateDependentPages() protected function updateDependentPages()
{ {
// Skip live stage
if (Versioned::get_stage() === Versioned::LIVE) {
return;
}
// Need to flush cache to avoid outdated versionnumber references // Need to flush cache to avoid outdated versionnumber references
$this->flushCache(); $this->flushCache();
// Need to mark pages depending to this one as broken // Need to mark pages depending to this one as broken
$dependentPages = $this->DependentPages(); /** @var Page $page */
if ($dependentPages) { foreach ($this->DependentPages() as $page) {
foreach ($dependentPages as $page) { // Update sync link tracking
// $page->write() calls syncLinkTracking, which does all the hard work for us. $page->syncLinkTracking();
if ($page->isChanged()) {
$page->write(); $page->write();
} }
} }

View File

@ -3,43 +3,18 @@
namespace SilverStripe\CMS\Model; namespace SilverStripe\CMS\Model;
use SilverStripe\Assets\File; use SilverStripe\Assets\File;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\ReadonlyField;
use SilverStripe\ORM\DataExtension; use SilverStripe\ORM\DataExtension;
use SilverStripe\ORM\ManyManyList;
use SilverStripe\Subsites\Model\Subsite;
use SilverStripe\Versioned\Versioned;
use SilverStripe\View\SSViewer; use SilverStripe\View\SSViewer;
/** /**
* Extension applied to {@see File} object to track links to {@see SiteTree} records. * @deprecated 4.2..5.0 Link tracking is baked into File class now
*
* {@see SiteTreeLinkTracking} for the extension applied to {@see SiteTree}
*
* Note that since both SiteTree and File are versioned, LinkTracking and ImageTracking will
* only be enabled for the Stage record.
*
* @property File $owner * @property File $owner
*/ */
class SiteTreeFileExtension extends DataExtension class SiteTreeFileExtension extends DataExtension
{ {
private static $belongs_many_many = array( private static $casting = [
'BackLinkTracking' => SiteTree::class . '.ImageTracking' // {@see SiteTreeLinkTracking}
);
/**
* Images tracked by pages are owned by those pages
*
* @config
* @var array
*/
private static $owned_by = array(
'BackLinkTracking'
);
private static $casting = array(
'BackLinkHTMLList' => 'HTMLFragment' 'BackLinkHTMLList' => 'HTMLFragment'
); ];
/** /**
* Generate an HTML list which provides links to where a file is used. * Generate an HTML list which provides links to where a file is used.
@ -49,67 +24,6 @@ class SiteTreeFileExtension extends DataExtension
public function BackLinkHTMLList() public function BackLinkHTMLList()
{ {
$viewer = SSViewer::create(["type" => "Includes", self::class . "_description"]); $viewer = SSViewer::create(["type" => "Includes", self::class . "_description"]);
return $viewer->process($this->owner); return $viewer->process($this->owner);
} }
/**
* Extend through {@link updateBackLinkTracking()} in your own {@link Extension}.
*
* @return ManyManyList
*/
public function BackLinkTracking()
{
// @todo remove coupling with Subsites
if (class_exists(Subsite::class)) {
$rememberSubsiteFilter = Subsite::$disable_subsite_filter;
Subsite::disable_subsite_filter(true);
}
$links = $this->owner->getManyManyComponents('BackLinkTracking');
$this->owner->extend('updateBackLinkTracking', $links);
if (class_exists(Subsite::class)) {
Subsite::disable_subsite_filter($rememberSubsiteFilter);
}
return $links;
}
/**
* @todo Unnecessary shortcut for AssetTableField, coupled with cms module.
*
* @return int
*/
public function BackLinkTrackingCount()
{
$pages = $this->owner->BackLinkTracking();
if ($pages) {
return $pages->count();
} else {
return 0;
}
}
/**
* Updates link tracking in the current stage.
*/
public function onAfterDelete()
{
// Skip live stage
if (Versioned::get_stage() === Versioned::LIVE) {
return;
}
// We query the explicit ID list, because BackLinkTracking will get modified after the stage
// site does its thing
$brokenPageIDs = $this->owner->BackLinkTracking()->column("ID");
if ($brokenPageIDs) {
// This will syncLinkTracking on the same stage as this file
$brokenPages = SiteTree::get()->byIDs($brokenPageIDs);
foreach ($brokenPages as $brokenPage) {
$brokenPage->write();
}
}
}
} }

View File

@ -32,10 +32,8 @@ class SiteTreeFileFormFactoryExtension extends DataExtension
$class = UsedOnTable::class; $class = UsedOnTable::class;
Deprecation::notice('5.0', "Use the $class to show this table"); Deprecation::notice('5.0', "Use the $class to show this table");
/** @var File|SiteTreeFileExtension|RecursivePublishable $record */
$record = $context['Record'];
$usedOnField = UsedOnTable::create('UsedOnTableReplacement'); $usedOnField = UsedOnTable::create('UsedOnTableReplacement');
$usedOnField->setRecord($context['Record']);
// Add field to new tab // Add field to new tab
/** @var Tab $tab */ /** @var Tab $tab */

View File

@ -4,26 +4,40 @@ namespace SilverStripe\CMS\Model;
use SilverStripe\Assets\File; use SilverStripe\Assets\File;
use SilverStripe\Assets\Folder; use SilverStripe\Assets\Folder;
use SilverStripe\Assets\Shortcodes\FileLink;
use SilverStripe\Core\ClassInfo; use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Convert; use SilverStripe\Core\Convert;
use SilverStripe\Dev\Deprecation;
use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataExtension; use SilverStripe\ORM\DataExtension;
use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
/**
* @deprecated 4.2..5.0 Will be removed in cms 5.0
*/
class SiteTreeFolderExtension extends DataExtension class SiteTreeFolderExtension extends DataExtension
{ {
public function __construct()
{
parent::__construct();
Deprecation::notice('5.0', 'Will be removed in 5.0');
}
/** /**
* Looks for files used in system and create where clause which contains all ID's of files. * Looks for files used in system and create where clause which contains all ID's of files.
* *
* @deprecated 4.2..5.0
* @returns string where clause which will work as filter. * @returns string where clause which will work as filter.
*/ */
public function getUnusedFilesListFilter() public function getUnusedFilesListFilter()
{ {
Deprecation::notice('5.0', 'Will be removed in 5.0');
// Add all records in link tracking // Add all records in link tracking
$usedFiles = DB::query("SELECT DISTINCT \"FileID\" FROM \"SiteTree_ImageTracking\"")->column('FileID'); $usedFiles = FileLink::get()->column('LinkedID');
// Get all classes that aren't folder // Get all classes that aren't folder
$fileClasses = array_diff_key( $fileClasses = array_diff_key(

View File

@ -0,0 +1,23 @@
<?php
namespace SilverStripe\CMS\Model;
use SilverStripe\ORM\DataObject;
/**
* Represents a link between a dataobject parent and a page in a HTML content area
*
* @method DataObject Parent() Parent object
* @method SiteTree Linked() Page being linked to
*
* Run `MigrateSiteTreeLinkingTask` to migrate from old table to this.
*/
class SiteTreeLink extends DataObject
{
private static $table_name = 'SiteTreeLink';
private static $has_one = [
'Parent' => DataObject::class,
'Linked' => SiteTree::class,
];
}

View File

@ -3,11 +3,11 @@
namespace SilverStripe\CMS\Model; namespace SilverStripe\CMS\Model;
use DOMElement; use DOMElement;
use SilverStripe\Assets\File; use SilverStripe\Assets\Shortcodes\FileLinkTracking;
use SilverStripe\ORM\DataExtension; use SilverStripe\ORM\DataExtension;
use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\FieldType\DBHTMLText; use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\ORM\ManyManyList; use SilverStripe\ORM\ManyManyThroughList;
use SilverStripe\Versioned\Versioned; use SilverStripe\Versioned\Versioned;
use SilverStripe\View\Parsers\HTMLValue; use SilverStripe\View\Parsers\HTMLValue;
@ -18,23 +18,17 @@ use SilverStripe\View\Parsers\HTMLValue;
* referenced in any HTMLText fields, and two booleans to indicate if there are any broken links. Call * referenced in any HTMLText fields, and two booleans to indicate if there are any broken links. Call
* augmentSyncLinkTracking to update those fields with any changes to those fields. * augmentSyncLinkTracking to update those fields with any changes to those fields.
* *
* Note that since both SiteTree and File are versioned, LinkTracking and ImageTracking will * Note that since both SiteTree and File are versioned, LinkTracking and FileTracking will
* only be enabled for the Stage record. * only be enabled for the Stage record.
* *
* {@see SiteTreeFileExtension} for the extension applied to {@see File} * Note: To support `HasBrokenLink` for non-SiteTree classes, add a boolean `HasBrokenLink`
* field to your `db` config and this extension will ensure it's flagged appropriately.
* *
* @property SiteTree $owner * @property DataObject|SiteTreeLinkTracking $owner
* * @method ManyManyThroughList LinkTracking() List of site pages linked on this dataobject
* @property bool $HasBrokenFile
* @property bool $HasBrokenLink
*
* @method ManyManyList LinkTracking() List of site pages linked on this page.
* @method ManyManyList ImageTracking() List of Images linked on this page.
* @method ManyManyList BackLinkTracking List of site pages that link to this page.
*/ */
class SiteTreeLinkTracking extends DataExtension class SiteTreeLinkTracking extends DataExtension
{ {
/** /**
* @var SiteTreeLinkTracking_Parser * @var SiteTreeLinkTracking_Parser
*/ */
@ -50,6 +44,14 @@ class SiteTreeLinkTracking extends DataExtension
'Parser' => '%$' . SiteTreeLinkTracking_Parser::class 'Parser' => '%$' . SiteTreeLinkTracking_Parser::class
]; ];
private static $many_many = [
"LinkTracking" => [
'through' => SiteTreeLink::class,
'from' => 'Parent',
'to' => 'Linked',
],
];
/** /**
* Parser for link tracking * Parser for link tracking
* *
@ -70,172 +72,126 @@ class SiteTreeLinkTracking extends DataExtension
return $this; return $this;
} }
private static $db = array( public function onBeforeWrite()
"HasBrokenFile" => "Boolean", {
"HasBrokenLink" => "Boolean" // Trigger link tracking (unless this would also be triggered by FileLinkTracking)
); if (!$this->owner->hasExtension(FileLinkTracking::class)) {
$this->owner->syncLinkTracking();
private static $many_many = array( }
"LinkTracking" => SiteTree::class, }
"ImageTracking" => File::class, // {@see SiteTreeFileExtension}
);
private static $belongs_many_many = array(
"BackLinkTracking" => SiteTree::class . '.LinkTracking',
);
/** /**
* Tracked images are considered owned by this page * Public method to call when triggering symlink extension. Can be called externally,
* or overridden by class implementations.
* *
* @config * {@see SiteTreeLinkTracking::augmentSyncLinkTracking}
* @var array
*/ */
private static $owns = array( public function syncLinkTracking()
"ImageTracking" {
); $this->owner->extend('augmentSyncLinkTracking');
}
private static $many_many_extraFields = array( /**
"LinkTracking" => array("FieldName" => "Varchar"), * Find HTMLText fields on {@link owner} to scrape for links that need tracking
"ImageTracking" => array("FieldName" => "Varchar") */
); public function augmentSyncLinkTracking()
{
// If owner is versioned, skip tracking on live
if (Versioned::get_stage() == Versioned::LIVE && $this->owner->hasExtension(Versioned::class)) {
return;
}
// Build a list of HTMLText fields, merging all linked pages together.
$allFields = DataObject::getSchema()->fieldSpecs($this->owner);
$linkedPages = [];
$anyBroken = false;
foreach ($allFields as $field => $fieldSpec) {
$fieldObj = $this->owner->dbObject($field);
if ($fieldObj instanceof DBHTMLText) {
// Merge links in this field with global list.
$linksInField = $this->trackLinksInField($field, $anyBroken);
$linkedPages = array_merge($linkedPages, $linksInField);
}
}
// Soft support for HasBrokenLink db field (e.g. SiteTree)
if ($this->owner->hasField('HasBrokenLink')) {
$this->owner->HasBrokenLink = $anyBroken;
}
// Update the "LinkTracking" many_many.
$this->owner->LinkTracking()->setByIDList($linkedPages);
}
public function onAfterDelete()
{
// If owner is versioned, skip tracking on live
if (Versioned::get_stage() == Versioned::LIVE && $this->owner->hasExtension(Versioned::class)) {
return;
}
$this->owner->LinkTracking()->removeAll();
}
/** /**
* Scrape the content of a field to detect anly links to local SiteTree pages or files * Scrape the content of a field to detect anly links to local SiteTree pages or files
* *
* @param string $fieldName The name of the field on {@link @owner} to scrape * @param string $fieldName The name of the field on {@link @owner} to scrape
* @param bool &$anyBroken Will be flagged to true (by reference) if a link is broken.
* @return int[] Array of page IDs found (associative array)
*/ */
public function trackLinksInField($fieldName) public function trackLinksInField($fieldName, &$anyBroken = false)
{ {
$record = $this->owner; // Pull down current field content
$htmlValue = HTMLValue::create($this->owner->$fieldName);
// Process all links
$linkedPages = []; $linkedPages = [];
$linkedFiles = [];
$htmlValue = HTMLValue::create($record->$fieldName);
$links = $this->parser->process($htmlValue); $links = $this->parser->process($htmlValue);
// Highlight broken links in the content.
foreach ($links as $link) { foreach ($links as $link) {
// Skip links without domelements // Toggle highlight class to element
if (!isset($link['DOMReference'])) { $this->toggleElementClass($link['DOMReference'], 'ss-broken', $link['Broken']);
continue;
}
/** @var DOMElement $domReference */ // Flag broken
$domReference = $link['DOMReference'];
$classStr = trim($domReference->getAttribute('class'));
if (!$classStr) {
$classes = [];
} else {
$classes = explode(' ', $classStr);
}
// Add or remove the broken class from the link, depending on the link status.
if ($link['Broken']) { if ($link['Broken']) {
$classes = array_unique(array_merge($classes, array('ss-broken'))); $anyBroken = true;
} else {
$classes = array_diff($classes, array('ss-broken'));
} }
if (!empty($classes)) { // Collect page ids
$domReference->setAttribute('class', implode(' ', $classes)); if ($link['Type'] === 'sitetree' && $link['Target']) {
} else { $pageID = (int)$link['Target'];
$domReference->removeAttribute('class'); $linkedPages[$pageID] = $pageID;
}
}
$record->$fieldName = $htmlValue->getContent();
// Populate link tracking for internal links & links to asset files.
foreach ($links as $link) {
switch ($link['Type']) {
case 'sitetree':
if ($link['Broken']) {
$record->HasBrokenLink = true;
} else {
$linkedPages[] = $link['Target'];
}
break;
case 'file':
case 'image':
if ($link['Broken']) {
$record->HasBrokenFile = true;
} else {
$linkedFiles[] = $link['Target'];
}
break;
default:
if ($link['Broken']) {
$record->HasBrokenLink = true;
}
break;
} }
} }
// Update the "LinkTracking" many_many // Update any changed content
if ($record->getSchema()->manyManyComponent(get_class($record), 'LinkTracking') $this->owner->$fieldName = $htmlValue->getContent();
&& ($tracker = $record->LinkTracking()) return $linkedPages;
) {
// If already saved, clear existing records
if ($record->isInDB()) {
$tracker->removeByFilter(array(
sprintf('"FieldName" = ? AND "%s" = ?', $tracker->getForeignKey())
=> array($fieldName, $record->ID)
));
}
foreach ($linkedPages as $item) {
$tracker->add($item, array('FieldName' => $fieldName));
}
}
// Update the "ImageTracking" many_many
if ($record->getSchema()->manyManyComponent(get_class($record), 'ImageTracking')
&& ($tracker = $record->ImageTracking())
) {
// If already saved, clear existing records
if ($record->isInDB()) {
$tracker->removeByFilter(array(
sprintf('"FieldName" = ? AND "%s" = ?', $tracker->getForeignKey())
=> array($fieldName, $record->ID)
));
}
foreach ($linkedFiles as $item) {
$tracker->add($item, array('FieldName' => $fieldName));
}
}
} }
/** /**
* Find HTMLText fields on {@link owner} to scrape for links that need tracking * Add the given css class to the DOM element.
* *
* @todo Support versioned many_many for per-stage page link tracking * @param DOMElement $domReference Element to modify.
* @param string $class Class name to toggle.
* @param bool $toggle On or off.
*/ */
public function augmentSyncLinkTracking() protected function toggleElementClass(DOMElement $domReference, $class, $toggle)
{ {
// Skip live tracking // Get all existing classes.
if (Versioned::get_stage() === Versioned::LIVE) { $classes = array_filter(explode(' ', trim($domReference->getAttribute('class'))));
return;
// Add or remove the broken class from the link, depending on the link status.
if ($toggle) {
$classes = array_unique(array_merge($classes, [$class]));
} else {
$classes = array_diff($classes, [$class]);
} }
// Reset boolean broken flags if (!empty($classes)) {
$this->owner->HasBrokenLink = false; $domReference->setAttribute('class', implode(' ', $classes));
$this->owner->HasBrokenFile = false; } else {
$domReference->removeAttribute('class');
// Build a list of HTMLText fields
$allFields = DataObject::getSchema()->fieldSpecs($this->owner);
$htmlFields = array();
foreach ($allFields as $field => $fieldSpec) {
$fieldObj = $this->owner->dbObject($field);
if ($fieldObj instanceof DBHTMLText) {
$htmlFields[] = $field;
}
}
foreach ($htmlFields as $field) {
$this->trackLinksInField($field);
} }
} }
} }

View File

@ -28,7 +28,6 @@ class SiteTreeLinkTracking_Parser
{ {
$results = array(); $results = array();
// @todo - Should be calling getElementsByTagName on DOMDocument?
$links = $htmlValue->getElementsByTagName('a'); $links = $htmlValue->getElementsByTagName('a');
if (!$links) { if (!$links) {
return $results; return $results;
@ -61,21 +60,18 @@ class SiteTreeLinkTracking_Parser
// Link to a page on this site. // Link to a page on this site.
$matches = array(); $matches = array();
if (preg_match('/\[sitetree_link(?:\s*|%20|,)?id=(?<id>[0-9]+)\](#(?<anchor>.*))?/i', $href, $matches)) { if (preg_match('/\[sitetree_link(?:\s*|%20|,)?id=(?<id>[0-9]+)\](#(?<anchor>.*))?/i', $href, $matches)) {
// Check if page link is broken
/** @var SiteTree $page */
$page = DataObject::get_by_id(SiteTree::class, $matches['id']); $page = DataObject::get_by_id(SiteTree::class, $matches['id']);
$broken = false;
if (!$page) { if (!$page) {
// Page doesn't exist. // Page doesn't exist.
$broken = true; $broken = true;
} elseif (!empty($matches['anchor'])) {
// Ensure anchor isn't broken on target page
$anchor = preg_quote($matches['anchor'], '/');
$broken = !preg_match("/(name|id)=\"{$anchor}\"/", $page->Content);
} else { } else {
if (!empty($matches['anchor'])) { $broken = false;
$anchor = preg_quote($matches['anchor'], '/');
if (!preg_match("/(name|id)=\"{$anchor}\"/", $page->Content)) {
// Broken anchor on the target page.
$broken = true;
}
}
} }
$results[] = array( $results[] = array(
@ -89,22 +85,7 @@ class SiteTreeLinkTracking_Parser
continue; continue;
} }
// Link to a file on this site.
$matches = array();
if (preg_match('/\[file_link(?:\s*|%20|,)?id=(?<id>[0-9]+)/i', $href, $matches)) {
$results[] = array(
'Type' => 'file',
'Target' => $matches['id'],
'Anchor' => null,
'DOMReference' => $link,
'Broken' => !DataObject::get_by_id('SilverStripe\\Assets\\File', $matches['id'])
);
continue;
}
// Local anchor. // Local anchor.
$matches = array();
if (preg_match('/^#(.*)/i', $href, $matches)) { if (preg_match('/^#(.*)/i', $href, $matches)) {
$anchor = preg_quote($matches[1], '#'); $anchor = preg_quote($matches[1], '#');
$results[] = array( $results[] = array(
@ -118,20 +99,6 @@ class SiteTreeLinkTracking_Parser
continue; continue;
} }
} }
// Find all [image ] shortcodes (will be inline, not inside attributes)
$content = $htmlValue->getContent();
if (preg_match_all('/\[image([^\]]+)\bid=(["])?(?<id>\d+)\D/i', $content, $matches)) {
foreach ($matches['id'] as $id) {
$results[] = array(
'Type' => 'image',
'Target' => (int)$id,
'Anchor' => null,
'DOMReference' => null,
'Broken' => !DataObject::get_by_id('SilverStripe\\Assets\\Image', (int)$id)
);
}
}
return $results; return $results;
} }
} }

View File

@ -4,6 +4,7 @@ namespace SilverStripe\CMS\Model;
use Page; use Page;
use SilverStripe\Core\Convert; use SilverStripe\Core\Convert;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Forms\FieldList; use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\LiteralField; use SilverStripe\Forms\LiteralField;
use SilverStripe\Forms\ReadonlyTransformation; use SilverStripe\Forms\ReadonlyTransformation;
@ -12,6 +13,7 @@ use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\ValidationResult; use SilverStripe\ORM\ValidationResult;
use SilverStripe\Security\Member; use SilverStripe\Security\Member;
use SilverStripe\Versioned\Versioned; use SilverStripe\Versioned\Versioned;
use SilverStripe\View\HTML;
/** /**
* Virtual Page creates an instance of a page, with the same fields that the original page had, but readonly. * Virtual Page creates an instance of a page, with the same fields that the original page had, but readonly.
@ -104,9 +106,10 @@ class VirtualPage extends Page
*/ */
public function getNonVirtualisedFields() public function getNonVirtualisedFields()
{ {
$config = self::config();
return array_merge( return array_merge(
self::config()->non_virtual_fields, $config->get('non_virtual_fields'),
self::config()->initially_copied_fields $config->get('initially_copied_fields')
); );
} }
@ -140,8 +143,11 @@ class VirtualPage extends Page
$tags = parent::MetaTags($includeTitle); $tags = parent::MetaTags($includeTitle);
$copied = $this->CopyContentFrom(); $copied = $this->CopyContentFrom();
if ($copied && $copied->exists()) { if ($copied && $copied->exists()) {
$link = Convert::raw2att($copied->Link()); $tags .= HTML::createTag('link', [
$tags .= "<link rel=\"canonical\" href=\"{$link}\" />\n"; 'rel' => 'canonical',
'href' => $copied->Link()
]);
$tags .= "\n";
} }
return $tags; return $tags;
} }
@ -159,8 +165,8 @@ class VirtualPage extends Page
{ {
if ($this->CopyContentFromID) { if ($this->CopyContentFromID) {
$this->HasBrokenLink = Versioned::get_by_stage(SiteTree::class, Versioned::DRAFT) $this->HasBrokenLink = Versioned::get_by_stage(SiteTree::class, Versioned::DRAFT)
->filter('ID', $this->CopyContentFromID) ->filter('ID', $this->CopyContentFromID)
->count() === 0; ->count() === 0;
} else { } else {
$this->HasBrokenLink = true; $this->HasBrokenLink = true;
} }
@ -212,8 +218,8 @@ class VirtualPage extends Page
// Setup the linking to the original page. // Setup the linking to the original page.
$copyContentFromField = TreeDropdownField::create( $copyContentFromField = TreeDropdownField::create(
'CopyContentFromID', 'CopyContentFromID',
_t('SilverStripe\\CMS\\Model\\VirtualPage.CHOOSE', "Linked Page"), _t(self::class . '.CHOOSE', "Linked Page"),
"SilverStripe\\CMS\\Model\\SiteTree" SiteTree::class
); );
// Setup virtual fields // Setup virtual fields
@ -235,20 +241,24 @@ class VirtualPage extends Page
// Create links back to the original object in the CMS // Create links back to the original object in the CMS
if ($this->CopyContentFrom()->exists()) { if ($this->CopyContentFrom()->exists()) {
$link = "<a class=\"cmsEditlink\" href=\"admin/pages/edit/show/$this->CopyContentFromID\">" . _t( $link = HTML::createTag(
'SilverStripe\\CMS\\Model\\VirtualPage.EditLink', 'a',
'edit' [
) . "</a>"; 'class' => 'cmsEditlink',
'href' => 'admin/pages/edit/show/' . $this->CopyContentFromID,
],
_t(self::class . '.EditLink', 'edit')
);
$msgs[] = _t( $msgs[] = _t(
'SilverStripe\\CMS\\Model\\VirtualPage.HEADERWITHLINK', self::class . '.HEADERWITHLINK',
"This is a virtual page copying content from \"{title}\" ({link})", "This is a virtual page copying content from \"{title}\" ({link})",
array( [
'title' => $this->CopyContentFrom()->obj('Title'), 'title' => $this->CopyContentFrom()->obj('Title'),
'link' => $link, 'link' => $link,
) ]
); );
} else { } else {
$msgs[] = _t('SilverStripe\\CMS\\Model\\VirtualPage.HEADER', "This is a virtual page"); $msgs[] = _t(self::class . '.HEADER', "This is a virtual page");
$msgs[] = _t( $msgs[] = _t(
'SilverStripe\\CMS\\Model\\SiteTree.VIRTUALPAGEWARNING', 'SilverStripe\\CMS\\Model\\SiteTree.VIRTUALPAGEWARNING',
'Please choose a linked page and save first in order to publish this page' 'Please choose a linked page and save first in order to publish this page'
@ -258,8 +268,7 @@ class VirtualPage extends Page
SiteTree::class, SiteTree::class,
Versioned::LIVE, Versioned::LIVE,
$this->CopyContentFromID $this->CopyContentFromID
) )) {
) {
$msgs[] = _t( $msgs[] = _t(
'SilverStripe\\CMS\\Model\\SiteTree.VIRTUALPAGEDRAFTWARNING', 'SilverStripe\\CMS\\Model\\SiteTree.VIRTUALPAGEDRAFTWARNING',
'Please publish the linked page in order to publish the virtual page' 'Please publish the linked page in order to publish the virtual page'
@ -295,7 +304,7 @@ class VirtualPage extends Page
// We also want to copy certain, but only if we're copying the source page for the first // 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. // time. After this point, the user is free to customise these for the virtual page themselves.
if ($this->isChanged('CopyContentFromID', 2) && $this->CopyContentFromID) { if ($this->isChanged('CopyContentFromID', 2) && $this->CopyContentFromID) {
foreach (self::config()->initially_copied_fields as $fieldName) { foreach (self::config()->get('initially_copied_fields') as $fieldName) {
$this->$fieldName = $source->$fieldName; $this->$fieldName = $source->$fieldName;
} }
} }
@ -337,7 +346,7 @@ class VirtualPage extends Page
if ($orig && $orig->exists() && !$orig->config()->get('can_be_root') && !$this->ParentID) { if ($orig && $orig->exists() && !$orig->config()->get('can_be_root') && !$this->ParentID) {
$result->addError( $result->addError(
_t( _t(
'SilverStripe\\CMS\\Model\\VirtualPage.PageTypNotAllowedOnRoot', self::class . '.PageTypNotAllowedOnRoot',
'Original page type "{type}" is not allowed on the root level for this virtual page', 'Original page type "{type}" is not allowed on the root level for this virtual page',
array('type' => $orig->i18n_singular_name()) array('type' => $orig->i18n_singular_name())
), ),
@ -349,10 +358,15 @@ class VirtualPage extends Page
return $result; return $result;
} }
/**
* @deprecated 4.2..5.0
*/
public function updateImageTracking() public function updateImageTracking()
{ {
Deprecation::notice('5.0', 'This will be removed in 5.0');
// Doesn't work on unsaved records // Doesn't work on unsaved records
if (!$this->ID) { if (!$this->isInDB()) {
return; return;
} }
@ -360,7 +374,12 @@ class VirtualPage extends Page
unset($this->components['CopyContentFrom']); unset($this->components['CopyContentFrom']);
// Update ImageTracking // Update ImageTracking
$this->ImageTracking()->setByIDList($this->CopyContentFrom()->ImageTracking()->column('ID')); $copyContentFrom = $this->CopyContentFrom();
if (!$copyContentFrom || !$copyContentFrom->isInDB()) {
return;
}
$this->FileTracking()->setByIDList($copyContentFrom->FileTracking()->column('ID'));
} }
public function CMSTreeClasses() public function CMSTreeClasses()

View File

@ -4,58 +4,52 @@ namespace SilverStripe\CMS\Tasks;
use SilverStripe\CMS\Model\SiteTree; use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Dev\BuildTask; use SilverStripe\Dev\BuildTask;
use SilverStripe\ORM\DataList; use SilverStripe\Dev\Debug;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB; use SilverStripe\ORM\DB;
use SilverStripe\Versioned\Versioned;
/** /**
* Rewrites plain internal HTML links into shortcode form, using existing link tracking information. * Updates legacy SiteTree link tracking into new polymorphic many_many relation.
* This should be done for any site upgrading to 4.2.0
*/ */
class MigrateSiteTreeLinkingTask extends BuildTask class MigrateSiteTreeLinkingTask extends BuildTask
{ {
private static $segment = 'MigrateSiteTreeLinkingTask'; private static $segment = 'MigrateSiteTreeLinkingTask';
protected $title = 'Migrate SiteTree Linking Task'; protected $title = 'Migrate SiteTree Linking Task';
protected $description = 'Rewrites plain internal HTML links into shortcode form, using existing link tracking information.'; protected $description = 'Updates legacy SiteTree link tracking into new polymorphic many_many relation';
public function run($request) public function run($request)
{ {
$pages = 0; // Ensure legacy table exists
$links = 0; $exists = DB::get_conn()->getSchemaManager()->hasTable('SiteTree_LinkTracking');
if (!$exists) {
$linkedPages = new DataList(SiteTree::class); DB::alteration_message("Table SiteTree_LinkTracking has already been migrated, or doesn't exist");
$linkedPages = $linkedPages->innerJoin('SiteTree_LinkTracking', '"SiteTree_LinkTracking"."SiteTreeID" = "SiteTree"."ID"'); return;
if ($linkedPages) {
foreach ($linkedPages as $page) {
$tracking = DB::prepared_query(
'SELECT "ChildID", "FieldName" FROM "SiteTree_LinkTracking" WHERE "SiteTreeID" = ?',
array($page->ID)
)->map();
foreach ($tracking as $childID => $fieldName) {
$linked = DataObject::get_by_id(SiteTree::class, $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"; $pages = 0;
// Ensure sync occurs on draft
Versioned::withVersionedMode(function () use (&$pages) {
Versioned::set_stage(Versioned::DRAFT);
/** @var SiteTree[] $linkedPages */
$linkedPages = SiteTree::get()
->innerJoin(
'SiteTree_LinkTracking',
'"SiteTree_LinkTracking"."SiteTreeID" = "SiteTree"."ID"'
);
foreach ($linkedPages as $page) {
// Command page to update symlink tracking
$page->syncLinkTracking();
$pages++;
}
});
DB::alteration_message("Migrated page links on " . SiteTree::singleton()->i18n_pluralise($pages));
// Disable table to prevent double-migration
DB::dont_require_table('SiteTree_LinkTracking');
} }
} }

View File

@ -138,13 +138,13 @@ class ContentControllerTest extends FunctionalTest
$linkedPage = new SiteTree(); $linkedPage = new SiteTree();
$linkedPage->URLSegment = 'linked-page'; $linkedPage->URLSegment = 'linked-page';
$linkedPage->write(); $linkedPage->write();
$linkedPage->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); $linkedPage->publishSingle();
$page = new SiteTree(); $page = new SiteTree();
$page->URLSegment = 'linking-page'; $page->URLSegment = 'linking-page';
$page->Content = sprintf('<a href="[sitetree_link,id=%s]">Testlink</a>', $linkedPage->ID); $page->Content = sprintf('<a href="[sitetree_link,id=%s]">Testlink</a>', $linkedPage->ID);
$page->write(); $page->write();
$page->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); $page->publishSingle();
$link = $page->RelativeLink(); $link = $page->RelativeLink();
$response = $this->get($link); $response = $this->get($link);

View File

@ -1,216 +0,0 @@
<?php
namespace SilverStripe\CMS\Tests\Model;
use SilverStripe\Assets\Folder;
use SilverStripe\Assets\Image;
use SilverStripe\Versioned\Versioned;
use SilverStripe\ORM\DataObject;
use SilverStripe\CMS\Model\VirtualPage;
use SilverStripe\Assets\File;
use SilverStripe\Assets\Filesystem;
use SilverStripe\Dev\SapphireTest;
use Silverstripe\Assets\Dev\TestAssetStore;
use Page;
class FileLinkTrackingTest extends SapphireTest
{
protected static $fixture_file = "FileLinkTrackingTest.yml";
public function setUp()
{
parent::setUp();
Versioned::set_stage(Versioned::DRAFT);
TestAssetStore::activate('FileLinkTrackingTest');
$this->logInWithPermission('ADMIN');
// Write file contents
$files = File::get()->exclude('ClassName', Folder::class);
foreach ($files as $file) {
$destPath = TestAssetStore::getLocalPath($file);
Filesystem::makeFolder(dirname($destPath));
file_put_contents($destPath, str_repeat('x', 1000000));
// Ensure files are published, thus have public urls
$file->publishRecursive();
}
// Since we can't hard-code IDs, manually inject image tracking shortcode
$imageID = $this->idFromFixture(Image::class, 'file1');
$page = $this->objFromFixture('Page', 'page1');
$page->Content = sprintf(
'<p>[image src="/assets/FileLinkTrackingTest/55b443b601/testscript-test-file.jpg" id="%d"]</p>',
$imageID
);
$page->write();
}
public function tearDown()
{
TestAssetStore::reset();
parent::tearDown();
}
/**
* Test uses global state through Versioned::set_reading_mode() since
* the shortcode parser doesn't pass along the underlying DataObject
* context, hence we can't call getSourceQueryParams().
*/
public function testFileRenameUpdatesDraftAndPublishedPages()
{
$page = $this->objFromFixture('Page', 'page1');
$page->publishRecursive();
// Live and stage pages both have link to public file
Versioned::set_stage(Versioned::DRAFT);
$this->assertContains(
'<img src="/assets/FileLinkTrackingTest/55b443b601/testscript-test-file.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
Versioned::set_stage(Versioned::LIVE);
$this->assertContains(
'<img src="/assets/FileLinkTrackingTest/55b443b601/testscript-test-file.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
Versioned::set_stage(Versioned::DRAFT);
$file = $this->objFromFixture(Image::class, 'file1');
$file->Name = 'renamed-test-file.jpg';
$file->write();
// Staged record now points to secure URL of renamed file, live record remains unchanged
// Note that the "secure" url doesn't have the "FileLinkTrackingTest" component because
// the mocked test location disappears for secure files.
Versioned::set_stage(Versioned::DRAFT);
$this->assertContains(
'<img src="/assets/55b443b601/renamed-test-file.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
Versioned::set_stage(Versioned::LIVE);
$this->assertContains(
'<img src="/assets/FileLinkTrackingTest/55b443b601/testscript-test-file.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
// Publishing the file should result in a direct public link (indicated by "FileLinkTrackingTest")
// Although the old live page will still point to the old record.
// @todo - Ensure shortcodes are used with all images to prevent live records having broken links
$file->publishRecursive();
Versioned::set_stage(Versioned::DRAFT);
$this->assertContains(
'<img src="/assets/FileLinkTrackingTest/55b443b601/renamed-test-file.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
Versioned::set_stage(Versioned::LIVE);
$this->assertContains(
'<img src="/assets/FileLinkTrackingTest/55b443b601/renamed-test-file.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
// Publishing the page after publishing the asset should retain linking
$page->publishRecursive();
Versioned::set_stage(Versioned::DRAFT);
$this->assertContains(
'<img src="/assets/FileLinkTrackingTest/55b443b601/renamed-test-file.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
Versioned::set_stage(Versioned::LIVE);
$this->assertContains(
'<img src="/assets/FileLinkTrackingTest/55b443b601/renamed-test-file.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
}
public function testFileLinkRewritingOnVirtualPages()
{
// Publish the source page
$page = $this->objFromFixture('Page', 'page1');
$this->assertTrue($page->publishRecursive());
// Create a virtual page from it, and publish that
$svp = new VirtualPage();
$svp->CopyContentFromID = $page->ID;
$svp->write();
$svp->publishRecursive();
// Rename the file
$file = $this->objFromFixture(Image::class, 'file1');
$file->Name = 'renamed-test-file.jpg';
$file->write();
// Verify that the draft virtual pages have the correct content
Versioned::set_stage(Versioned::DRAFT);
$this->assertContains(
'<img src="/assets/55b443b601/renamed-test-file.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
// Publishing both file and page will update the live record
$file->publishRecursive();
$page->publishRecursive();
Versioned::set_stage(Versioned::LIVE);
$this->assertContains(
'<img src="/assets/FileLinkTrackingTest/55b443b601/renamed-test-file.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
}
public function testLinkRewritingOnAPublishedPageDoesntMakeItEditedOnDraft()
{
// Publish the source page
/** @var Page $page */
$page = $this->objFromFixture('Page', 'page1');
$this->assertTrue($page->publishRecursive());
$this->assertFalse($page->isModifiedOnDraft());
// Rename the file
$file = $this->objFromFixture(Image::class, 'file1');
$file->Name = 'renamed-test-file.jpg';
$file->write();
// Confirm that the page hasn't gone green.
$this->assertFalse($page->isModifiedOnDraft());
}
public function testTwoFileRenamesInARowWork()
{
$page = $this->objFromFixture('Page', 'page1');
$this->assertTrue($page->publishRecursive());
Versioned::set_stage(Versioned::LIVE);
$this->assertContains(
'<img src="/assets/FileLinkTrackingTest/55b443b601/testscript-test-file.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
// Rename the file twice
Versioned::set_stage(Versioned::DRAFT);
$file = $this->objFromFixture(Image::class, 'file1');
$file->Name = 'renamed-test-file.jpg';
$file->write();
// TODO Workaround for bug in DataObject->getChangedFields(), which returns stale data,
// and influences File->updateFilesystem()
$file = DataObject::get_by_id('SilverStripe\\Assets\\File', $file->ID);
$file->Name = 'renamed-test-file-second-time.jpg';
$file->write();
$file->publishRecursive();
// Confirm that the correct image is shown in both the draft and live site
Versioned::set_stage(Versioned::DRAFT);
$this->assertContains(
'<img src="/assets/FileLinkTrackingTest/55b443b601/renamed-test-file-second-time.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
// Publishing this record also updates live record
$page->publishRecursive();
Versioned::set_stage(Versioned::LIVE);
$this->assertContains(
'<img src="/assets/FileLinkTrackingTest/55b443b601/renamed-test-file-second-time.jpg"',
Page::get()->byID($page->ID)->dbObject('Content')->forTemplate()
);
}
}

View File

@ -1,12 +0,0 @@
# These need to come first so that SiteTree has the link meta-data written.
SilverStripe\Assets\Image:
file1:
FileFilename: testscript-test-file.jpg
FileHash: 55b443b60176235ef09801153cca4e6da7494a0c
Name: testscript-test-file.jpg
Page:
page1:
Title: page1
URLSegment: page1
# Content is set via test setup

View File

@ -2,16 +2,17 @@
namespace SilverStripe\CMS\Tests\Model; namespace SilverStripe\CMS\Tests\Model;
use Page;
use Silverstripe\Assets\Dev\TestAssetStore;
use SilverStripe\CMS\Model\RedirectorPage;
use SilverStripe\CMS\Model\SiteTree; use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Versioned\Versioned; use SilverStripe\CMS\Model\SiteTreeLink;
use SilverStripe\CMS\Model\VirtualPage;
use SilverStripe\CMS\Tests\Model\SiteTreeBrokenLinksTest\NotPageObject;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB; use SilverStripe\ORM\DB;
use SilverStripe\CMS\Model\VirtualPage; use SilverStripe\Versioned\Versioned;
use SilverStripe\CMS\Model\RedirectorPage;
use SilverStripe\Assets\File;
use SilverStripe\Dev\SapphireTest;
use Silverstripe\Assets\Dev\TestAssetStore;
use Page;
/** /**
* Tests {@see SiteTreeLinkTracking} broken links feature: LinkTracking * Tests {@see SiteTreeLinkTracking} broken links feature: LinkTracking
@ -20,6 +21,10 @@ class SiteTreeBrokenLinksTest extends SapphireTest
{ {
protected static $fixture_file = 'SiteTreeBrokenLinksTest.yml'; protected static $fixture_file = 'SiteTreeBrokenLinksTest.yml';
protected static $extra_dataobjects = [
NotPageObject::class,
];
public function setUp() public function setUp()
{ {
parent::setUp(); parent::setUp();
@ -44,11 +49,57 @@ class SiteTreeBrokenLinksTest extends SapphireTest
$obj->syncLinkTracking(); $obj->syncLinkTracking();
$this->assertTrue($obj->HasBrokenLink, 'Page has a broken link'); $this->assertTrue($obj->HasBrokenLink, 'Page has a broken link');
$obj->Content = '<a href="[sitetree_link,id=' . $this->idFromFixture('Page', 'about') .']">this is not a broken link</a>'; $obj->Content = '<a href="[sitetree_link,id=' . $this->idFromFixture(
'Page',
'about'
) . ']">this is not a broken link</a>';
$obj->syncLinkTracking(); $obj->syncLinkTracking();
$this->assertFalse($obj->HasBrokenLink, 'Page does NOT have a broken link'); $this->assertFalse($obj->HasBrokenLink, 'Page does NOT have a broken link');
} }
/**
* Ensure broken links can be tracked between non-page objects
*/
public function testBrokenLinksNonPage()
{
/** @var Page $aboutPage */
$aboutPage = $this->objFromFixture('Page', 'about');
/** @var NotPageObject $obj */
$obj = $this->objFromFixture(NotPageObject::class, 'object1');
$obj->Content = '<a href="[sitetree_link,id=3423423]">this is a broken link</a>';
$obj->AnotherContent = '<a href="[sitetree_link,id=' . $aboutPage->ID . ']">this is not a broken link</a>';
$obj->write();
// Two links created for this record
$this->assertListEquals(
[
['LinkedID' => 3423423],
['LinkedID' => $aboutPage->ID],
],
SiteTreeLink::get()->filter([
'ParentClass' => NotPageObject::class,
'ParentID' => $obj->ID,
])
);
// ManyManyThrough relation only links to unbroken pages
$this->assertListEquals(
[
['Title' => 'About'],
],
$obj->LinkTracking()
);
// About-page backlinks contains this object
$this->assertListEquals(
[
['ID' => $obj->ID]
],
$aboutPage->BackLinkTracking()
);
}
public function testBrokenAnchorBetweenPages() public function testBrokenAnchorBetweenPages()
{ {
/** @var Page $obj */ /** @var Page $obj */
@ -94,47 +145,12 @@ class SiteTreeBrokenLinksTest extends SapphireTest
$this->assertTrue($rp->HasBrokenLink, 'Broken redirector page IS marked as such'); $this->assertTrue($rp->HasBrokenLink, 'Broken redirector page IS marked as such');
} }
public function testDeletingFileMarksBackedPagesAsBroken()
{
// Test entry
$file = new File();
$file->setFromString('test', 'test-file.txt');
$file->write();
/** @var Page $obj */
$obj = $this->objFromFixture('Page', 'content');
$obj->Content = sprintf(
'<p><a href="[file_link,id=%d]">Working Link</a></p>',
$file->ID
);
$obj->write();
$this->assertTrue($obj->publishRecursive());
// Confirm that it isn't marked as broken to begin with
$obj = SiteTree::get()->byID($obj->ID);
$this->assertEquals(0, $obj->HasBrokenFile);
$liveObj = Versioned::get_one_by_stage(SiteTree::class, Versioned::LIVE, "\"SiteTree\".\"ID\" = $obj->ID");
$this->assertEquals(0, $liveObj->HasBrokenFile);
// Delete the file
$file->delete();
// Confirm that it is marked as broken in stage
$obj = SiteTree::get()->byID($obj->ID);
$this->assertEquals(1, $obj->HasBrokenFile);
// Publishing this page marks it as broken on live too
$obj->publishRecursive();
$liveObj = Versioned::get_one_by_stage(SiteTree::class, Versioned::LIVE, "\"SiteTree\".\"ID\" = $obj->ID");
$this->assertEquals(1, $liveObj->HasBrokenFile);
}
public function testDeletingMarksBackLinkedPagesAsBroken() public function testDeletingMarksBackLinkedPagesAsBroken()
{ {
// Set up two published pages with a link from content -> about // Set up two published pages with a link from content -> about
$linkDest = $this->objFromFixture('Page', 'about'); $linkDest = $this->objFromFixture('Page', 'about');
/** @var Page $linkSrc */
$linkSrc = $this->objFromFixture('Page', 'content'); $linkSrc = $this->objFromFixture('Page', 'content');
$linkSrc->Content = "<p><a href=\"[sitetree_link,id=$linkDest->ID]\">about us</a></p>"; $linkSrc->Content = "<p><a href=\"[sitetree_link,id=$linkDest->ID]\">about us</a></p>";
$linkSrc->write(); $linkSrc->write();
@ -207,7 +223,9 @@ class SiteTreeBrokenLinksTest extends SapphireTest
$this->assertFalse($rp->HasBrokenLink); $this->assertFalse($rp->HasBrokenLink);
// Unpublishing doesn't affect broken state on live (draft is source of truth) // Unpublishing doesn't affect broken state on live (draft is source of truth)
/** @var SiteTree $p2Live */
$p2Live = Versioned::get_by_stage(SiteTree::class, Versioned::LIVE)->byID($p2->ID); $p2Live = Versioned::get_by_stage(SiteTree::class, Versioned::LIVE)->byID($p2->ID);
/** @var SiteTree $rpLive */
$rpLive = Versioned::get_by_stage(SiteTree::class, Versioned::LIVE)->byID($rp->ID); $rpLive = Versioned::get_by_stage(SiteTree::class, Versioned::LIVE)->byID($rp->ID);
$this->assertEquals(0, $p2Live->HasBrokenLink); $this->assertEquals(0, $p2Live->HasBrokenLink);
$this->assertEquals(0, $rpLive->HasBrokenLink); $this->assertEquals(0, $rpLive->HasBrokenLink);
@ -215,8 +233,10 @@ class SiteTreeBrokenLinksTest extends SapphireTest
// Delete the source page, confirm that the VP, RP and page 2 have broken links on draft // Delete the source page, confirm that the VP, RP and page 2 have broken links on draft
$p->delete(); $p->delete();
$p2->flushCache(); $p2->flushCache();
/** @var SiteTree $p2 */
$p2 = DataObject::get_by_id(SiteTree::class, $p2->ID); $p2 = DataObject::get_by_id(SiteTree::class, $p2->ID);
$rp->flushCache(); $rp->flushCache();
/** @var RedirectorPage $rp */
$rp = DataObject::get_by_id(SiteTree::class, $rp->ID); $rp = DataObject::get_by_id(SiteTree::class, $rp->ID);
$this->assertEquals(1, $p2->HasBrokenLink); $this->assertEquals(1, $p2->HasBrokenLink);
$this->assertEquals(1, $rp->HasBrokenLink); $this->assertEquals(1, $rp->HasBrokenLink);
@ -260,7 +280,7 @@ class SiteTreeBrokenLinksTest extends SapphireTest
// Redirector links are a third // Redirector links are a third
$redirectorPage = new RedirectorPage(); $redirectorPage = new RedirectorPage();
$redirectorPage->Title = "redirector"; $redirectorPage->Title = "redirector";
$redirectorPage->LinkType = 'Internal'; $redirectorPage->RedirectionType = 'Internal';
$redirectorPage->LinkToID = $page->ID; $redirectorPage->LinkToID = $page->ID;
$redirectorPage->write(); $redirectorPage->write();
$this->assertTrue($redirectorPage->publishRecursive()); $this->assertTrue($redirectorPage->publishRecursive());
@ -273,8 +293,10 @@ class SiteTreeBrokenLinksTest extends SapphireTest
$page->delete(); $page->delete();
$page2->flushCache(); $page2->flushCache();
/** @var SiteTree $page2 */
$page2 = DataObject::get_by_id(SiteTree::class, $page2->ID); $page2 = DataObject::get_by_id(SiteTree::class, $page2->ID);
$redirectorPage->flushCache(); $redirectorPage->flushCache();
/** @var RedirectorPage $redirectorPage */
$redirectorPage = DataObject::get_by_id(SiteTree::class, $redirectorPage->ID); $redirectorPage = DataObject::get_by_id(SiteTree::class, $redirectorPage->ID);
$this->assertEquals(1, $page2->HasBrokenLink); $this->assertEquals(1, $page2->HasBrokenLink);
$this->assertEquals(1, $redirectorPage->HasBrokenLink); $this->assertEquals(1, $redirectorPage->HasBrokenLink);

View File

@ -14,3 +14,6 @@ Page:
RedirectionType: Internal RedirectionType: Internal
Title: RedirectorPageToBrokenInteralPage Title: RedirectorPageToBrokenInteralPage
LinkTo: =>Page.content LinkTo: =>Page.content
SilverStripe\CMS\Tests\Model\SiteTreeBrokenLinksTest\NotPageObject:
object1:
Content: 'Everything will be ok'

View File

@ -0,0 +1,22 @@
<?php
namespace SilverStripe\CMS\Tests\Model\SiteTreeBrokenLinksTest;
use SilverStripe\CMS\Model\SiteTreeLinkTracking;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
/**
* @mixin SiteTreeLinkTracking
* @property string $Content
* @property string $AnotherContent
*/
class NotPageObject extends DataObject implements TestOnly
{
private static $table_name = 'SiteTreeLinkTrackingTest_NotPageObject';
private static $db = [
'Content' => 'HTMLText',
'AnotherContent' => 'HTMLText',
];
}

View File

@ -1,62 +0,0 @@
<?php
namespace SilverStripe\CMS\Tests\Model;
use SilverStripe\Assets\File;
use SilverStripe\Assets\Image;
use Silverstripe\Assets\Dev\TestAssetStore;
use SilverStripe\CMS\Tests\Model\SiteTreeFolderExtensionTest\PageWithFile;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Versioned\Versioned;
class SiteTreeFolderExtensionTest extends SapphireTest
{
protected static $extra_dataobjects = [
PageWithFile::class,
];
protected static $fixture_file = 'SiteTreeFolderExtensionTest.yml';
public function setUp()
{
parent::setUp();
Versioned::set_stage(Versioned::DRAFT);
TestAssetStore::activate('SiteTreeFolderExtensionTest');
$this->logInWithPermission('ADMIN');
// Since we can't hard-code IDs, manually inject image tracking shortcode
$imageID = $this->idFromFixture(Image::class, 'image1');
$page = $this->objFromFixture(PageWithFile::class, 'page1');
$page->Content = sprintf(
'<p>[image id="%d"]</p>',
$imageID
);
$page->write();
}
public function tearDown()
{
TestAssetStore::reset();
parent::tearDown();
}
public function testFindsFiles()
{
/** @var PageWithFile $page */
$page = $this->objFromFixture(PageWithFile::class, 'page1');
$query = $page->getUnusedFilesListFilter();
$this->assertContains('"ID" NOT IN', $query);
$this->assertContains('"ClassName" IN (', $query);
$files = File::get()->where($query);
$this->assertDOSEquals(
[
['Name' => 'file2.txt'],
['Name' => 'image2.jpg'],
],
$files
);
}
}

View File

@ -1,28 +0,0 @@
SilverStripe\Assets\Folder:
folder1:
Name: myfolder
folder2:
Name: other
SilverStripe\Assets\File:
file1:
Name: file1.txt
FileFilename: myfolder/file1.txt
Parent: =>SilverStripe\Assets\Folder.folder1
file2:
Name: file2.txt
FileFilename: myfolder/file2.txt
Parent: =>SilverStripe\Assets\Folder.folder1
SilverStripe\Assets\Image:
image1:
Name: image1.jpg
FileFilename: other/image1.jpg
Parent: =>SilverStripe\Assets\Folder.folder2
image2:
Name: image2.jpg
FileFilename: other/image2.jpg
Parent: =>SilverStripe\Assets\Folder.folder2
SilverStripe\CMS\Tests\Model\SiteTreeFolderExtensionTest\PageWithFile:
page1:
Title: mypage
URLSegment: mypage
LinkedFile: =>SilverStripe\Assets\File.file1

View File

@ -1,24 +0,0 @@
<?php
namespace SilverStripe\CMS\Tests\Model\SiteTreeFolderExtensionTest;
use Page;
use SilverStripe\Assets\File;
use SilverStripe\CMS\Model\SiteTreeFolderExtension;
use SilverStripe\Dev\TestOnly;
/**
* @mixin SiteTreeFolderExtension
*/
class PageWithFile extends Page implements TestOnly
{
private static $table_name = 'SiteTreeFolderExtensionTest_PageWithFile';
private static $has_one = [
'LinkedFile' => File::class,
];
private static $extensions = [
SiteTreeFolderExtension::class,
];
}

View File

@ -3,11 +3,10 @@
namespace SilverStripe\CMS\Tests\Model; namespace SilverStripe\CMS\Tests\Model;
use Page; use Page;
use Silverstripe\Assets\Dev\TestAssetStore; use SilverStripe\Assets\Dev\TestAssetStore;
use SilverStripe\Assets\File; use SilverStripe\Assets\File;
use SilverStripe\Assets\Filesystem; use SilverStripe\Assets\Filesystem;
use SilverStripe\Assets\Folder; use SilverStripe\Assets\Folder;
use SilverStripe\Assets\Image;
use SilverStripe\CMS\Model\SiteTree; use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Dev\CSSContentParser; use SilverStripe\Dev\CSSContentParser;
use SilverStripe\Dev\FunctionalTest; use SilverStripe\Dev\FunctionalTest;
@ -46,6 +45,7 @@ class SiteTreeHTMLEditorFieldTest extends FunctionalTest
public function testLinkTracking() public function testLinkTracking()
{ {
/** @var SiteTree $sitetree */
$sitetree = $this->objFromFixture(SiteTree::class, 'home'); $sitetree = $this->objFromFixture(SiteTree::class, 'home');
$editor = new HTMLEditorField('Content'); $editor = new HTMLEditorField('Content');
@ -84,43 +84,6 @@ class SiteTreeHTMLEditorFieldTest extends FunctionalTest
); );
} }
public function testFileLinkTracking()
{
$sitetree = $this->objFromFixture(SiteTree::class, 'home');
$editor = new HTMLEditorField('Content');
$fileID = $this->idFromFixture(File::class, 'example_file');
$editor->setValue(sprintf(
'<p><a href="[file_link,id=%d]">Example File</a></p>',
$fileID
));
$editor->saveInto($sitetree);
$sitetree->write();
$this->assertEquals(
array($fileID => $fileID),
$sitetree->ImageTracking()->getIDList(),
'Links to assets are tracked.'
);
$editor->setValue(null);
$editor->saveInto($sitetree);
$sitetree->write();
$this->assertEquals(array(), $sitetree->ImageTracking()->getIdList(), 'Asset tracking is removed with links.');
// Legacy support - old CMS versions added link shortcodes with spaces instead of commas
$editor->setValue(sprintf(
'<p><a href="[file_link id=%d]">Example File</a></p>',
$fileID
));
$editor->saveInto($sitetree);
$sitetree->write();
$this->assertEquals(
array($fileID => $fileID),
$sitetree->ImageTracking()->getIDList(),
'Link tracking with space instead of comma in shortcode works.'
);
}
public function testImageInsertion() public function testImageInsertion()
{ {
$sitetree = new SiteTree(); $sitetree = new SiteTree();
@ -145,30 +108,6 @@ class SiteTreeHTMLEditorFieldTest extends FunctionalTest
$this->assertEquals('bar', (string)$xml[0]['title'], 'Title tags are preserved.'); $this->assertEquals('bar', (string)$xml[0]['title'], 'Title tags are preserved.');
} }
public function testImageTracking()
{
$sitetree = $this->objFromFixture(SiteTree::class, 'home');
$editor = new HTMLEditorField('Content');
$file = $this->objFromFixture(Image::class, 'example_image');
$editor->setValue(sprintf('[image src="%s" id="%d"]', $file->getURL(), $file->ID));
$editor->saveInto($sitetree);
$sitetree->write();
$this->assertEquals(
array($file->ID => $file->ID),
$sitetree->ImageTracking()->getIDList(),
'Inserted images are tracked.'
);
$editor->setValue(null);
$editor->saveInto($sitetree);
$sitetree->write();
$this->assertEmpty(
$sitetree->ImageTracking()->getIDList(),
'Tracked images are deleted when removed.'
);
}
public function testBrokenSiteTreeLinkTracking() public function testBrokenSiteTreeLinkTracking()
{ {
$sitetree = new SiteTree(); $sitetree = new SiteTree();
@ -192,28 +131,4 @@ class SiteTreeHTMLEditorFieldTest extends FunctionalTest
$this->assertFalse((bool) $sitetree->HasBrokenLink); $this->assertFalse((bool) $sitetree->HasBrokenLink);
} }
public function testBrokenFileLinkTracking()
{
$sitetree = new SiteTree();
$editor = new HTMLEditorField('Content');
$this->assertFalse((bool) $sitetree->HasBrokenFile);
$editor->setValue('<p><a href="[file_link,id=0]">Broken Link</a></p>');
$editor->saveInto($sitetree);
$sitetree->write();
$this->assertTrue($sitetree->HasBrokenFile);
$editor->setValue(sprintf(
'<p><a href="[file_link,id=%d]">Working Link</a></p>',
$this->idFromFixture(File::class, 'example_file')
));
$sitetree->HasBrokenFile = false;
$editor->saveInto($sitetree);
$sitetree->write();
$this->assertFalse((bool) $sitetree->HasBrokenFile);
}
} }

View File

@ -2,15 +2,15 @@
namespace SilverStripe\CMS\Tests\Model; namespace SilverStripe\CMS\Tests\Model;
use Page;
use SilverStripe\CMS\Model\SiteTreeLinkTracking_Parser; use SilverStripe\CMS\Model\SiteTreeLinkTracking_Parser;
use SilverStripe\Assets\File;
use SilverStripe\Control\Director; use SilverStripe\Control\Director;
use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\SapphireTest;
use SilverStripe\View\Parsers\HTMLValue; use SilverStripe\View\Parsers\HTMLValue;
use Page;
class SiteTreeLinkTrackingTest extends SapphireTest class SiteTreeLinkTrackingTest extends SapphireTest
{ {
protected function setUp() protected function setUp()
{ {
parent::setUp(); parent::setUp();
@ -34,7 +34,6 @@ class SiteTreeLinkTrackingTest extends SapphireTest
// Shortcodes // Shortcodes
$this->assertTrue($this->isBroken('<a href="[sitetree_link,id=123]">link</a>')); $this->assertTrue($this->isBroken('<a href="[sitetree_link,id=123]">link</a>'));
$this->assertTrue($this->isBroken('<a href="[sitetree_link,id=123]#no-such-anchor">link</a>')); $this->assertTrue($this->isBroken('<a href="[sitetree_link,id=123]#no-such-anchor">link</a>'));
$this->assertTrue($this->isBroken('<a href="[file_link,id=123]">link</a>'));
// Relative urls // Relative urls
$this->assertTrue($this->isBroken('<a href="">link</a>')); $this->assertTrue($this->isBroken('<a href="">link</a>'));
@ -57,13 +56,9 @@ class SiteTreeLinkTrackingTest extends SapphireTest
$page->Content = '<a name="yes-name-anchor">name</a><a id="yes-id-anchor">id</a>'; $page->Content = '<a name="yes-name-anchor">name</a><a id="yes-id-anchor">id</a>';
$page->write(); $page->write();
$file = new File();
$file->write();
$this->assertFalse($this->isBroken("<a href=\"[sitetree_link,id=$page->ID]\">link</a>")); $this->assertFalse($this->isBroken("<a href=\"[sitetree_link,id=$page->ID]\">link</a>"));
$this->assertFalse($this->isBroken("<a href=\"[sitetree_link,id=$page->ID]#yes-name-anchor\">link</a>")); $this->assertFalse($this->isBroken("<a href=\"[sitetree_link,id=$page->ID]#yes-name-anchor\">link</a>"));
$this->assertFalse($this->isBroken("<a href=\"[sitetree_link,id=$page->ID]#yes-id-anchor\">link</a>")); $this->assertFalse($this->isBroken("<a href=\"[sitetree_link,id=$page->ID]#yes-id-anchor\">link</a>"));
$this->assertFalse($this->isBroken("<a href=\"[file_link,id=$file->ID]\">link</a>"));
$this->assertTrue($this->isBroken("<a href=\"[sitetree_link,id=$page->ID]#http://invalid-anchor.com\"></a>")); $this->assertTrue($this->isBroken("<a href=\"[sitetree_link,id=$page->ID]#http://invalid-anchor.com\"></a>"));
} }
@ -94,18 +89,4 @@ class SiteTreeLinkTrackingTest extends SapphireTest
$this->assertEquals(substr_count($content, 'ss-broken'), 0, 'All ss-broken classes are removed from good link'); $this->assertEquals(substr_count($content, 'ss-broken'), 0, 'All ss-broken classes are removed from good link');
$this->assertEquals(substr_count($content, 'existing-class'), 1, 'Existing class is not removed.'); $this->assertEquals(substr_count($content, 'existing-class'), 1, 'Existing class is not removed.');
} }
public function testHasBrokenFile()
{
$this->assertTrue($this->pageIsBrokenFile('[image src="someurl.jpg" id="99999999"]'));
$this->assertFalse($this->pageIsBrokenFile('[image src="someurl.jpg"]'));
}
protected function pageIsBrokenFile($content)
{
$page = new Page();
$page->Content = $content;
$page->write();
return $page->HasBrokenFile;
}
} }

View File

@ -6,6 +6,7 @@ use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\CMS\Tasks\MigrateSiteTreeLinkingTask; use SilverStripe\CMS\Tasks\MigrateSiteTreeLinkingTask;
use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
class MigrateSiteTreeLinkingTaskTest extends SapphireTest class MigrateSiteTreeLinkingTaskTest extends SapphireTest
{ {
@ -13,73 +14,76 @@ class MigrateSiteTreeLinkingTaskTest extends SapphireTest
protected static $use_draft_site = true; protected static $use_draft_site = true;
public static function setUpBeforeClass()
{
parent::setUpBeforeClass();
// Cover db reset in case parent did not start
if (!static::getExtraDataObjects()) {
DataObject::reset();
static::resetDBSchema(true, true);
}
// Ensure legacy SiteTree_LinkTracking table exists
DB::get_schema()->schemaUpdate(function () {
DB::require_table('SiteTree_LinkTracking', [
'SiteTreeID' => 'Int',
'ChildID' => 'Int',
'FieldName' => 'Varchar',
]);
});
}
protected function setUp()
{
parent::setUp();
// Manually bootstrap all Content blocks with soft coded IDs (raw sql to avoid save hooks)
$replacements = [
'$$ABOUTID$$' => $this->idFromFixture(SiteTree::class, 'about'),
'$$HOMEID$$' => $this->idFromFixture(SiteTree::class, 'home'),
'$$STAFFID$$' => $this->idFromFixture(SiteTree::class, 'staff'),
];
foreach (DB::query('SELECT "ID", "Content" FROM "SiteTree"') as $row) {
$id = (int)$row['ID'];
$content = str_replace(array_keys($replacements), array_values($replacements), $row['Content']);
DB::prepared_query('UPDATE "SiteTree" SET "Content" = ? WHERE "ID" = ?', [$content, $id]);
}
DataObject::reset();
}
public function testLinkingMigration() public function testLinkingMigration()
{ {
ob_start(); ob_start();
DB::quiet(false);
$task = new MigrateSiteTreeLinkingTask(); $task = new MigrateSiteTreeLinkingTask();
$task->run(null); $task->run(null);
$this->assertContains(
$this->assertEquals( "Migrated page links on 5 Pages",
"Rewrote 9 link(s) on 5 page(s) to use shortcodes.\n",
ob_get_contents(), ob_get_contents(),
'Rewritten links are correctly reported' 'Rewritten links are correctly reported'
); );
DB::quiet(true);
ob_end_clean(); ob_end_clean();
$homeID = $this->idFromFixture(SiteTree::class, 'home'); // Query links for pages
$aboutID = $this->idFromFixture(SiteTree::class, 'about'); /** @var SiteTree $home */
$staffID = $this->idFromFixture(SiteTree::class, 'staff'); $home = $this->objFromFixture(SiteTree::class, 'home');
$actionID = $this->idFromFixture(SiteTree::class, 'action'); /** @var SiteTree $about */
$hashID = $this->idFromFixture(SiteTree::class, 'hash_link'); $about = $this->objFromFixture(SiteTree::class, 'about');
/** @var SiteTree $staff */
$staff = $this->objFromFixture(SiteTree::class, 'staff');
/** @var SiteTree $action */
$action = $this->objFromFixture(SiteTree::class, 'action');
/** @var SiteTree $hash */
$hash = $this->objFromFixture(SiteTree::class, 'hash_link');
$homeContent = sprintf( // Ensure all links are created
'<a href="[sitetree_link,id=%d]">About</a><a href="[sitetree_link,id=%d]">Staff</a><a href="http://silverstripe.org/">External Link</a><a name="anchor"></a>', $this->assertListEquals([$about->toMap(), $staff->toMap()], $home->LinkTracking());
$aboutID, $this->assertListEquals([$home->toMap(), $staff->toMap()], $about->LinkTracking());
$staffID $this->assertListEquals([$home->toMap(), $about->toMap()], $staff->LinkTracking());
); $this->assertListEquals([$home->toMap()], $action->LinkTracking());
$aboutContent = sprintf( $this->assertListEquals([$home->toMap(), $about->toMap()], $hash->LinkTracking());
'<a href="[sitetree_link,id=%d]">Home</a><a href="[sitetree_link,id=%d]">Staff</a><a name="second-anchor"></a>',
$homeID,
$staffID
);
$staffContent = sprintf(
'<a href="[sitetree_link,id=%d]">Home</a><a href="[sitetree_link,id=%d]">About</a>',
$homeID,
$aboutID
);
$actionContent = sprintf(
'<a href="[sitetree_link,id=%d]SearchForm">Search Form</a>',
$homeID
);
$hashLinkContent = sprintf(
'<a href="[sitetree_link,id=%d]#anchor">Home</a><a href="[sitetree_link,id=%d]#second-anchor">About</a>',
$homeID,
$aboutID
);
$this->assertEquals(
$homeContent,
DataObject::get_by_id(SiteTree::class, $homeID)->Content,
'HTML URLSegment links are rewritten.'
);
$this->assertEquals(
$aboutContent,
DataObject::get_by_id(SiteTree::class, $aboutID)->Content
);
$this->assertEquals(
$staffContent,
DataObject::get_by_id(SiteTree::class, $staffID)->Content
);
$this->assertEquals(
$actionContent,
DataObject::get_by_id(SiteTree::class, $actionID)->Content,
'Links to actions on pages are rewritten correctly.'
);
$this->assertEquals(
$hashLinkContent,
DataObject::get_by_id(SiteTree::class, $hashID)->Content,
'Hash/anchor links are correctly handled.'
);
} }
} }

View File

@ -2,24 +2,24 @@ SilverStripe\CMS\Model\SiteTree:
home: home:
Title: Home Page Title: Home Page
URLSegment: home URLSegment: home
Content: '<a href="about/">About</a><a href="staff">Staff</a><a href="http://silverstripe.org/">External Link</a><a name="anchor"></a>' Content: '<a href="[sitetree_link,id=$$ABOUTID$$]">About</a><a href="[sitetree_link,id=$$STAFFID$$]">Staff</a><a href="http://silverstripe.org/">External Link</a><a name="anchor"></a>'
about: about:
Title: About Us Title: About Us
URLSegment: about URLSegment: about
Content: '<a href="home">Home</a><a href="staff/">Staff</a><a name="second-anchor"></a>' Content: '<a href="[sitetree_link,id=$$HOMEID$$]">Home</a><a href="[sitetree_link,id=$$STAFFID$$]">Staff</a><a name="second-anchor"></a>'
staff: staff:
Title: Staff Title: Staff
URLSegment: staff URLSegment: staff
Content: '<a href="home/">Home</a><a href="about">About</a>' Content: '<a href="[sitetree_link,id=$$HOMEID$$]">Home</a><a href="[sitetree_link,id=$$ABOUTID$$]">About</a>'
Parent: =>SilverStripe\CMS\Model\SiteTree.about Parent: =>SilverStripe\CMS\Model\SiteTree.about
action: action:
Title: Action Link Title: Action Link
URLSegment: action URLSegment: action
Content: '<a href="home/SearchForm">Search Form</a>' Content: '<a href="[sitetree_link,id=$$HOMEID$$]SearchForm">Search Form</a>'
hash_link: hash_link:
Title: Hash Link Title: Hash Link
URLSegment: hash-link URLSegment: hash-link
Content: '<a href="home/#anchor">Home</a><a href="about/#second-anchor">About</a>' Content: '<a href="[sitetree_link,id=$$HOMEID$$]#anchor">Home</a><a href="[sitetree_link,id=$$ABOUTID$$]#second-anchor">About</a>'
admin_link: admin_link:
Title: Admin Link Title: Admin Link
URLSegment: admin-link URLSegment: admin-link