<?php namespace SilverStripe\CMS\Model; use DOMElement; use SilverStripe\Assets\Shortcodes\FileLinkTracking; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\FormScaffolder; use SilverStripe\ORM\DataExtension; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\FieldType\DBHTMLText; use SilverStripe\ORM\ManyManyThroughList; use SilverStripe\Versioned\Versioned; use SilverStripe\View\Parsers\HTMLValue; /** * Adds tracking of links in any HTMLText fields which reference SiteTree or File items. * * Attaching this to any DataObject will add four fields which contain all links to SiteTree and File items * 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. * * Note that since both SiteTree and File are versioned, LinkTracking and FileTracking will * only be enabled for the Stage record. * * 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 DataObject|SiteTreeLinkTracking $owner * @method ManyManyThroughList LinkTracking() List of site pages linked on this dataobject */ class SiteTreeLinkTracking extends DataExtension { /** * @var SiteTreeLinkTracking_Parser */ protected $parser; /** * Inject parser for each page * * @var array * @config */ private static $dependencies = [ 'Parser' => '%$' . SiteTreeLinkTracking_Parser::class ]; private static $many_many = [ "LinkTracking" => [ 'through' => SiteTreeLink::class, 'from' => 'Parent', 'to' => 'Linked', ], ]; /** * Controls visibility of the Link Tracking tab * * @config * @see linktracking.yml * @var boolean */ private static $show_sitetree_link_tracking = false; /** * Parser for link tracking * * @return SiteTreeLinkTracking_Parser */ public function getParser() { return $this->parser; } /** * @param SiteTreeLinkTracking_Parser $parser * @return $this */ public function setParser(SiteTreeLinkTracking_Parser $parser = null) { $this->parser = $parser; return $this; } public function onBeforeWrite() { // Trigger link tracking (unless this would also be triggered by FileLinkTracking) if (!$this->owner->hasExtension(FileLinkTracking::class)) { $this->owner->syncLinkTracking(); } } /** * Public method to call when triggering symlink extension. Can be called externally, * or overridden by class implementations. * * {@see SiteTreeLinkTracking::augmentSyncLinkTracking} */ public function syncLinkTracking() { $this->owner->extend('augmentSyncLinkTracking'); } /** * Find HTMLText fields on {@link owner} to scrape for links that need tracking */ 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 * * @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, &$anyBroken = false) { // Pull down current field content $htmlValue = HTMLValue::create($this->owner->$fieldName); // Process all links $linkedPages = []; $links = $this->parser->process($htmlValue); foreach ($links as $link) { // Toggle highlight class to element $this->toggleElementClass($link['DOMReference'], 'ss-broken', $link['Broken']); // Flag broken if ($link['Broken']) { $anyBroken = true; } // Collect page ids if ($link['Type'] === 'sitetree' && $link['Target']) { $pageID = (int)$link['Target']; $linkedPages[$pageID] = $pageID; } } // Update any changed content $this->owner->$fieldName = $htmlValue->getContent(); return $linkedPages; } /** * Add the given css class to the DOM element. * * @param DOMElement $domReference Element to modify. * @param string $class Class name to toggle. * @param bool $toggle On or off. */ protected function toggleElementClass(DOMElement $domReference, $class, $toggle) { // Get all existing classes. $classes = array_filter(explode(' ', trim($domReference->getAttribute('class') ?? ''))); // 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]); } if (!empty($classes)) { $domReference->setAttribute('class', implode(' ', $classes)); } else { $domReference->removeAttribute('class'); } } public function updateCMSFields(FieldList $fields) { if (!$this->owner->config()->get('show_sitetree_link_tracking')) { $fields->removeByName('LinkTracking'); } elseif ($this->owner->ID && !$this->owner->getField('LinkTracking')) { FormScaffolder::addManyManyRelationshipFields($fields, 'LinkTracking', null, true, $this->owner); } } }