]+|["\']))' . // 3:"href" attribute and optional scheme+hostname
'([-_./a-z0-9]+)' . // 4:"path" in PW page name format
'([^<>]*>)' . // 5:"end" which includes everything else and closing ">", i.e. query string, other attrs, etc.
'!i';
if(!preg_match_all($re, $value, $matches)) return array();
$replacements = array();
$languages = $this->wire('languages');
$rootURL = $this->wire('config')->urls->root;
$adminURL = $this->wire('config')->urls->admin;
$adminPath = $rootURL === '/' ? $adminURL : str_replace($rootURL, '/', $adminURL);
$debug = $this->debug();
foreach($matches[2] as $key => $pwid) {
if(strpos($pwid, '/')) {
list($pwid, $urlSegmentStr) = explode('/', $pwid, 2);
} else {
$urlSegmentStr = '';
}
if(strpos($pwid, '-')) {
list($pageID, $languageID) = explode('-', $pwid);
} else {
$pageID = $pwid;
$languageID = 0;
}
$pageID = (int) $pageID;
$full = $matches[0][$key];
$start = $matches[1][$key];
$href = $matches[3][$key];
$path = $matches[4][$key];
$end = $matches[5][$key];
if($languages) {
$language = $languageID ? $languages->get((int) $languageID) : $languages->getDefault();
} else {
$language = null;
}
$livePath = $this->wire('pages')->getPath($pageID, array(
'language' => $language
));
if($urlSegmentStr) {
$livePath = rtrim($livePath, '/') . "/$urlSegmentStr";
if(substr($path, '-1') === '/') $livePath .= '/';
}
if(strlen($rootURL) > 1) {
$livePath = rtrim($rootURL, '/') . $livePath;
$href = ' ' . ltrim($href); // immunity to wakeupUrls(), replacing tab with space
}
$langName = $debug && $language ? $language->name : '';
if($livePath) {
$ignore = false;
foreach($this->ignorePaths() as $ignorePath) {
if(strpos($livePath, $ignorePath) !== 0) continue;
if($debug) $this->message("MarkupQA wakeupLinks path $livePath matches ignored path $ignorePath");
$ignore = true;
break;
}
if($path && substr($path, -1) != '/') {
// no trailing slash, retain the editors wishes here
$livePath = rtrim($livePath, '/');
}
if($ignore) {
// path should be ignored and left as-is
} else if(strpos($livePath, '/trash/') !== false) {
// linked page is in trash, we won't update it but we'll produce a warning
$this->linkWarning("$path => $livePath (" . $this->_('it is in the trash') . ')');
continue;
} else if(strpos($livePath, $adminPath) === 0) {
// do not update paths that point in admin
$this->linkWarning("$path => $livePath (" . $this->_('points to the admin') . ')');
continue;
} else if($livePath != $path) {
// path differs from what's in the markup and should be updated
if($debug) $this->warning(
"MarkupQA wakeupLinks PATH UPDATED (field=$this->field, page={$this->page->path}, " .
"language=$langName): $path => $livePath"
);
$path = $livePath;
} else if($debug) {
$this->message("MarkupQA wakeupLinks no changes (field=$this->field, language=$langName): $path => $livePath");
}
} else {
// did not resolve to a PW page
$this->linkWarning("wakeup: $path");
}
$replacements[$full] = "$start$href$path$end";
}
if(count($replacements)) {
$value = str_replace(array_keys($replacements), array_values($replacements), $value);
}
return $replacements;
}
/**
* Find pages linking to another
*
* @param Page $page Page to find links to, or omit to use page specified in constructor
* @param array $fieldNames Field names to look in or omit to use field specified in constructor
* @param string $selector Optional selector to use as a filter
* @param array $options Additional options
* - `getIDs` (bool): Return array of page IDs rather than Page instances. (default=false)
* - `getCount` (bool): Return a total count (int) of found pages rather than Page instances. (default=false)
* - `confirm` (bool): Confirm that the links are present by looking at the actual page field data. (default=true)
* You can specify false for this option to make it perform faster, but with a potentially less accurate result.
* @return PageArray|array|int
*
*/
public function findLinks(Page $page = null, $fieldNames = array(), $selector = '', array $options = array()) {
$defaults = array(
'getIDs' => false,
'getCount' => false,
'confirm' => true
);
$options = array_merge($defaults, $options);
if($options['getIDs']) {
$result = array();
} else if($options['getCount']) {
$result = 0;
} else {
$result = $this->wire('pages')->newPageArray();
}
if(!$page) $page = $this->page;
if(!$page) return $result;
if(empty($fieldNames)) {
if($this->field) $fieldNames[] = $this->field->name;
if(empty($fieldNames)) return $result;
}
if($selector === true) $selector = "include=all";
$op = strlen("$page->id") > 3 ? "~=" : "%=";
$selector = implode('|', $fieldNames) . "$op'$page->id', id!=$page->id, $selector";
$selector = trim($selector, ', ');
// find pages
if($options['getCount'] && !$options['confirm']) {
// just return a count
return $this->wire('pages')->count($selector);
} else {
// find the IDs
$checkIDs = array();
$foundIDs = $this->wire('pages')->findIDs($selector);
if(!count($foundIDs)) return $result;
if($options['confirm']) {
$checkIDs = array_flip($foundIDs);
$foundIDs = array();
}
}
// confirm results
foreach($fieldNames as $fieldName) {
if(!count($checkIDs)) break;
$field = $this->wire('fields')->get($fieldName);
if(!$field) continue;
$table = $field->getTable();
$ids = implode(',', array_keys($checkIDs));
$sql = "SELECT * FROM `$table` WHERE `pages_id` IN($ids)";
$query = $this->wire('database')->prepare($sql);
$query->execute();
while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
$pageID = (int) $row['pages_id'];
if(isset($foundIDs[$pageID])) continue;
$row = implode(' ', $row);
$find = "data-pwid=$page->id";
// first check if it might be there
if(!strpos($row, $find)) continue;
// then confirm with a more accurate check
if(!strpos($row, "$find ") && !strpos($row, "$find\t") && !strpos($row, "$find-")) continue;
// at this point we have confirmed that this item links to $page
unset($checkIDs[$pageID]);
$foundIDs[$pageID] = $pageID;
}
$query->closeCursor();
}
if(count($foundIDs)) {
if($options['getIDs']) {
$result = $foundIDs;
} else if($options['getCount']) {
$result = count($foundIDs);
} else {
$result = $this->wire('pages')->getById($foundIDs);
}
}
return $result;
}
/**
* Display and log a warning about a path that didn't resolve
*
* @param string $path
* @param bool $logWarning
*
*/
protected function linkWarning($path, $logWarning = true) {
if($this->wire('page')->template == 'admin' && $this->wire('process') == 'ProcessPageEdit') {
$this->warning(sprintf(
$this->_('Unable to resolve link on page %1$s in field "%2$s": %3$s'),
$this->page->path,
$this->field->getLabel(),
$path
));
}
if($this->verbose() || $logWarning) {
$this->error("Unable to resolve link: $path");
}
}
/**
* Quality assurance for tags
*
* @param string $value
* @param array $options What actions should be performed:
* - replaceBlankAlt (bool): Replace blank alt attributes with file description? (default=true)
* - removeNoExists (bool): Remove references to images that don't exist (or re-create images when possible) (default=true)
* - removeNoAccess (bool): Remove references to images user doesn't have view permission to (default=true)
*
*/
public function checkImgTags(&$value, array $options = array()) {
if(strpos($value, ']+>)}', $value, $matches)) {
foreach($matches[0] as $key => $img) {
$this->checkImgTag($value, $img, $options);
}
}
}
/**
* Quality assurance for one tag
*
* @param string $value Entire markup
* @param string $img Just the found tag
* @param array $options What actions should be performed:
* - replaceBlankAlt (bool): Replace blank alt attributes with file description? (default=true)
* - removeNoExists (bool): Remove references to images that don't exist (or re-create images when possible) (default=true)
* - removeNoAccess (bool): Remove references to images user doesn't have view permission to (default=true)
*
*/
protected function checkImgTag(&$value, $img, array $options = array()) {
$defaults = array(
'replaceBlankAlt' => true,
'removeNoExists' => true,
'removeNoAccess' => true,
);
$options = array_merge($defaults, $options);
$replaceAlt = ''; // exact text to replace for blank alt attribute, i.e. alt=""
$src = '';
$user = $this->wire()->user;
$attrStrings = explode(' ', $img); // array of strings like "key=value"
if($this->verbose()) {
$markupQA = $this->page->get('_markupQA');
if(!is_array($markupQA)) $markupQA = array();
if(!isset($markupQA[$this->field->name])) $markupQA[$this->field->name] = array();
$info =& $markupQA[$this->field->name];
} else {
$markupQA = null;
$info = array();
}
if(!isset($info['img_unresolved'])) $info['img_unresolved'] = 0;
if(!isset($info['img_fixed'])) $info['img_fixed'] = 0;
if(!isset($info['img_noalt'])) $info['img_noalt'] = 0; // blank alt
// determine current 'alt' and 'src' attributes
foreach($attrStrings as $n => $attr) {
if(!strpos($attr, '=')) continue;
list($name, $val) = explode('=', $attr);
$name = strtolower($name);
$val = trim($val, "\"'> ");
if($name == 'alt' && !strlen($val)) {
$replaceAlt = $attr;
} else if($name == 'src') {
$src = $val;
}
}
// if had no src attr, or if it was pointing to something outside of PW assets, skip it
if(!$src || strpos($src, $this->assetsURL) === false) return;
// recognized site image, make sure the file exists
/** @var Pageimage $pagefile */
$pagefile = $this->page->filesManager()->getFile($src);
// if this doesn't resolve to a known pagefile, stop now
if(!$pagefile) {
if($options['removeNoExists']) {
if(file_exists($this->page->filesManager()->path() . basename($src))) {
// file exists, but we just don't know what it is - leave it alone
} else {
$this->error("Image file no longer exists: $src");
if($this->page->of()) $value = str_replace($img, '', $value);
$info['img_unresolved']++;
}
}
return;
}
if($options['removeNoAccess']) {
if(($pagefile->page->id != $this->page->id && !$user->hasPermission('page-view', $pagefile->page))
|| ($pagefile->field && !$pagefile->page->viewable($pagefile->field))) {
// if the file resolves to another page that the user doesn't have access to view,
// OR user doesn't have permission to view the field that $pagefile is in,
// then we will simply remove the image
$this->error("Image referenced that user does not have view access to: $src");
if($this->page->of()) $value = str_replace($img, '', $value);
return;
}
}
/*
* @todo potential replacement for 'removeNoAccess' block above
* Regarding: https://github.com/processwire/processwire-issues/issues/1548
*
// if(($pagefile->page->id != $this->page->id && !$user->hasPermission('page-view', $pagefile->page))
if($options['removeNoAccess']) {
// if the file resolves to another page that the user doesn't have access to view,
// OR user doesn't have permission to view the field that $pagefile is in, remove image
$page = $pagefile->page;
$field = $pagefile->field;
$removeImage = false;
if(wireInstanceOf($page, 'RepeaterPage')) {
$page = $page->getForPageRoot();
$field = $page->getForFieldRoot();
}
if($page->id != $this->page->id && !$page->viewable(false)) {
$this->error("Image on page ($page->id) that user does not have view access to: $src");
$removeImage = true;
} else if($field && !$page->viewable($field)) {
$this->error("Image on page:field ($page->id:$field) that user does not have view access to: $src");
$removeImage = true;
}
if($removeImage) {
if($this->page->of()) $value = str_replace($img, '', $value);
return;
}
}
*/
if($options['replaceBlankAlt'] && $replaceAlt) {
// image has a blank alt tag, meaning, we will auto-populate it with current file description,
// if output formatting is on
if($this->page->of()) {
$alt = $pagefile->description;
if(strlen($alt)) {
$alt = $this->wire('sanitizer')->entities1($alt);
$_img = str_replace(" $replaceAlt", " alt=\"$alt\"", $img);
$value = str_replace($img, $_img, $value);
}
}
$info['img_noalt']++;
}
if($options['removeNoExists'] && $pagefile instanceof Pageimage) {
$result = $this->checkImgExists($pagefile, $img, $src, $value);
if($result < 0) $info['img_unresolved'] += abs($result);
if($result > 0) $info['img_fixed'] += $result;
}
if($markupQA) $this->page->setQuietly('_markupQA', $markupQA);
}
/**
* Attempt to re-create images that don't exist, when possible
*
* @param Pageimage $pagefile
* @param $img
* @param $src
* @param $value
* @return int Returns 0 on no change, negative count on broken, positive count on fixed
*
*/
protected function checkImgExists(Pageimage $pagefile, $img, $src, &$value) {
$basename = basename($src);
$pathname = $pagefile->pagefiles->path() . $basename;
if(file_exists($pathname)) return 0; // no action necessary
// file referenced in tag does not exist, and it is not a variation we can re-create
if($pagefile->basename == $basename) {
// original file no longer exists
$this->error("Original image file no longer exists, unable to create new variation ($basename)");
if($this->page->of()) $value = str_replace($img, '', $value); // remove reference to image, when output formatting is on
return -1;
}
// check if this is a variation that we might be able to re-create
$info = $pagefile->isVariation($basename);
if(!$info) {
// file is not a variation, so we apparently have no source to pull info from
$this->error("Unrecognized image that does not exist ($basename)");
if($this->page->of()) $value = str_replace($img, '', $value); // remove reference to image, when output formatting is on
return -1;
}
$info['targetName'] = $basename;
$variations = array($info);
while(!empty($info['parent'])) {
$variations[] = $info['parent'];
$info = $info['parent'];
}
$good = 0;
$bad = 0;
foreach(array_reverse($variations) as $info) {
// definitely a variation, attempt to re-create it
$options = array();
if($info['crop']) $options['cropping'] = $info['crop'];
if($info['suffix']) {
$options['suffix'] = $info['suffix'];
if(in_array('hidpi', $options['suffix'])) $options['hidpi'] = true;
}
/** @var Pageimage $newPagefile */
$newPagefile = $pagefile->size($info['width'], $info['height'], $options);
if($newPagefile && is_file($newPagefile->filename())) {
if(!empty($info['targetName']) && $newPagefile->basename != $info['targetName']) {
// new name differs from what is in text. Rename file to be consistent with text.
rename($newPagefile->filename(), $pathname);
}
if($this->debug() || $this->wire('config')->debug) {
$this->message($this->_('Re-created image variation') . " - $newPagefile->name");
}
$pagefile = $newPagefile; // for next iteration
$good++;
} else {
$this->error($this->_('Unable to re-create image variation') . " - $newPagefile->name");
$bad++;
}
}
if($good) return $good;
if($bad) return -1 * $bad;
return 0;
}
/**
* Record error message to image-errors log
*
* @param string $text
* @param int $flags
* @return $this
*
*/
public function error($text, $flags = 0) {
$logText = "$text (field={$this->field->name}, id={$this->page->id}, path={$this->page->path})";
$this->wire('log')->save(self::errorLogName, $logText);
/*
if($this->wire('modules')->isInstalled('SystemNotifications')) {
$user = $this->wire('modules')->get('SystemNotifications')->getSystemUser();
if($user && !$user->notifications->getBy('title', $text)) {
$no = $user->notifications()->getNew('error');
$no->title = $text;
$no->html = "Field: {$this->field->name}\n
Page: {$this->page->title}
";
$user->notifications->save();
}
}
*/
return $this;
}
/**
* Get or set a setting
*
* @param string $key Setting name to get or set, or omit to get all settings
* @param string|array|int|null $value Setting value to set, or omit when getting setting
* @return string|array|int|null|$this Returns value of $key
*
public function setting($key = null, $value = null) {
if($key === null) return $this->settings; // return all
if($value === null) return isset($this->settings[$key]) ? $this->settings[$key] : null; // return one
if($key === 'ignorePaths') return $this->ignorePaths($value); // set specific
$this->settings[$key] = $value; // set
return $value;
}
*/
/**
* Enable or disable verbose mode
*
* Sets whether or not to set/track verbose information to page, i.e.
* `$page->_markupQA = array('field_name' => array(counts))`
*
* #pw-internal
*
* @param bool $verbose
* @deprecated use verbose() method instead
*
*/
public function setVerbose($verbose) {
$this->settings['verbose'] = $verbose ? true : false;
}
}