In my last article, we modified the code of our PlaceholderFinder class to make the snippet and plugin names into links to edit each object in the MODX Manager. In this one, we'll do the same thing with the file names.
Example Link
This is an example of what you'll see in the MODX Manager in the browser address line if you right-click on a file at core/test.php and select "Edit" to edit it:
http://localhost/addons/manager/?a=system/file/edit&file=core/test.php&source=1
In order to let the user click on a link to edit the file, we need to wrap the file name in an href that uses the form above. It's fairly straightforward, but notice that it ends with the ID of the Media Source that contains the file.
The Problem
The major difficulty with this task is that getting the ID of the file's Media Source turns out to be quite tricky.
We already have the full path to the file, and it's relatively easy to get the basePath of each Media Source (once you know how — see below). Unfortunately, that doesn't tell us the file's media source. The only way I could find to determine the Media Source for a given file is to loop through the Media Sources looking for the file. It's even more complicated because the search has to use file paths that are relative to the media source's base path. It's an unpleasant brute-force solution.
It would be nice if media source objects had a method that let us use the file name and ask, "is this your file?" Or even better, a generic method that let us call getMediaSource('filepath') Sadly, at present neither of those methods exist. That said, the method below beats having to manually search through each media source looking for the file.
Normalizing File Paths
The first modification to our PlaceholderFinder class is a method to "normalize" file paths. To look for our files under the various Media Sources, we need to perform str_replace() actions on the file paths (more on this in a bit). In order to do that, the file paths have to use the same character (slash or backslash) to separate the parts of the path. This is complicated by the fact that the characters may not be consistent. Some of the separators will come from your operating system and others will be provided by MODX. It's not unusual to see something like this:
path\to\file/filename.php
We need a method that will make them all use the same character. Here it is:
function normalizePath($path) {
return str_replace('\\', '/', $path);
}
The method above just replaces all backslashes in the path with forward slashes and returns the "normalized" path. I use this method lot when I have to manipulate file paths. You could also use DIRECTORY_SEPARATOR for the replacement code, but I think '/' is safer (since it's never an escape character), and it involves a lot less typing.
Getting the Media Sources
Since we need to get all the Media Sources of the site in order to loop through them looking for each file, we need a method for that. We don't need the entire MediaSource Object, just its base path and ID, so we create a simple associative array ($mediaSources) where the key is the base path and the value is the ID of the Media Source.
public function getMediaSources() {
$mediaSources = array();
$mediaSourcePrefix = $this->modx->getVersionData()['version'] >= 3
? 'MODX\Revolution\Sources\\'
: 'sources.';
$sources = $this->modx->getCollection($mediaSourcePrefix . 'modFileMediaSource');
foreach ($sources as $source) {
$source->initialize();
$basePath = $source->getBasePath();
$basePath = $this->normalizePath($basePath);
$id = $source->get('id');
$mediaSources [$basePath] = $id;
}
return $mediaSources;
}
Notice that we need a separate prefix for Media Sources so the code will run in both MODX 2 and MODX 3. This is different from the prefix we use for Resources and Elements.
The code above is fairly simple. We get the base path of each Media Source and its ID and add each one to our array. When we're finished, we return the array. Since we only need this array once, it's not worth creating a class variable for it.
This method took me a lot longer to write than you would guess from looking at the code. I started out with $mediaSource->get('basePath'), which returned nothing. Then I had an embarrassingly complex bit of code that delved into the Media Source's _fields array. Next, I used $mediaSource->getBases() and extracted the path from array of bases. Eventually I discovered that for getBasePath() to work, you have to call $mediaSource->initialize() first. Once you do that, getBasePath() calls getBases() for you, and Bob's your uncle.
Now that we're able to normalize a path, and we have the array of Media Sources, all that remains is to rewrite the file section of the report() method to display the filenames as links to edit each file.
New File Section
Here's the new code of the file section of the report() method:
/* Files */
if (!empty($this->files)) {
$modxBasePath = $this->normalizePath(MODX_BASE_PATH);
$managerUrl = MODX_SITE_URL . basename(MODX_MANAGER_URL) . '/';
$mediaSources = $this->getMediaSources();
$output .= "\n### Files containing placeholder\n";
foreach ($this->files as $file) {
$mediaSourceId = null;
$file = $this->normalizePath($file);
$fileRelative = str_replace($modxBasePath, '', $file);
foreach ($mediaSources as $basePath => $id) {
$fileToSearch = $basePath . $fileRelative;
if (file_exists($fileToSearch)) {
$mediaSourceId = $id;
break;
}
}
if ($mediaSourceId !== null) {
$output .= "\n * " . '<a href="' . $managerUrl . '?a=system/file/edit&file=' . $fileRelative . '&source=' . $mediaSourceId . '">' . $file . '</a>';
}
}
$output .= "\n";
} else {
$output .= "\nPlaceholder not found in files";
}
At the top of the code, we creat the variables we'll need:
$modxBasePath = $this->normalizePath(MODX_BASE_PATH);
$managerUrl = MODX_SITE_URL . basename(MODX_MANAGER_URL) . '/';
$mediaSources = $this->getMediaSources();
After normalizing the MODX base path, we create our own $managerUrl. As with the snippet and plugins code from my earlier article, we can't use MODX_MANAGER_URL by itself, because it reports a relative URL with no server protocol. We can't use MODX_BASE_URL, because it's also relative and even it if weren't, we can't just tack manager/ onto the end of it, because the manager directory may have been renamed.
Finally, we call the getMediaSources() we saw above.
New Files Loop
Finally, we rewrite our the files loop in the report() method to display the links to edit each file:
foreach ($this->files as $file) {
$mediaSourceId = null;
$file = $this->normalizePath($file);
$fileRelative = str_replace($modxBasePath, '', $file);
foreach ($mediaSources as $basePath => $id) {
$fileToSearch = $basePath . $fileRelative;
if (file_exists($fileToSearch)) {
$mediaSourceId = $id;
break;
}
}
if ($mediaSourceId !== null) {
$output .= "\n * " . '<a href="' . $managerUrl . '?a=system/file/edit&file=' . $fileRelative . '&source=' . $mediaSourceId . '">' . $file . '</a>';
}
}
In the code above, we normalize each file path and get the relative path of the file by removing the $modxBasePath we created earlier from the beginning of the file. Then, we loop through each Media Source in our $mediaSources array. We tack the relative file path onto the end of the Media Source's base path to get the actual path to the file (which may or may not exist in that Media Source).
If the file exists, we create the link to it, only if the Media Source ID is not null:
$output .= "\n * " . '<a href="' . $managerUrl . '?a=system/file/edit&file=' . $fileRelative . '&source=' . $mediaSourceId . '">' . $file . '</a>';
If the file exists in a Media Source, we break out of the Media Source search loop, since there's no need to search further. Files that exist in more than one Media Source will only be listed once, but we only need to edit them once.
Considering that we're searching for every file that contains our placeholder in every Media Source, the search is surprisingly fast, because our files array only contains files that have the placeholder.
The Full Code:
Here's the full code of this version of our placeholder finder:
class PlaceholderFinder {
public modX $modx;
public string $placeholder = '';
public array $snippets = array();
public array $plugins = array();
public array $files = array();
public string $prefix = '';
public array $errors = array();
public function __construct($modx) {
$this->modx = $modx;
}
public function init($scriptProperties): void {
/* Make this work in both MODX 2 and MODX 3 */
$this->prefix = $this->modx->getVersionData()['version'] >= 3
? 'MODX\Revolution\\'
: '';
$this->placeholder = $this->modx->getOption('placeholder', $scriptProperties);
if (empty($this->placeholder)) {
$this->addError('placeholder is empty');
}
}
public function addError($error): void {
$this->errors[] = $error;
}
public function normalizePath($path) {
return str_replace('\\', '/', $path);
}
public function checkContent($content): bool {
$found = 0;
if (strpos($content, $this->placeholder) !== false) {
$found = 1;
}
return $found;
}
public function checkIncludes($content) {
$cs = array(
'MODX_CORE_PATH' => MODX_CORE_PATH,
'XPDO_CORE_PATH' => MODX_CORE_PATH,
'MODX_ASSETS_PATH' => MODX_ASSETS_PATH,
'MODX_PROCESSORS_PATH' => MODX_PROCESSORS_PATH,
'MODX_CONNECTORS_PATH' => MODX_CONNECTORS_PATH,
'MODX_MANAGER_PATH' => MODX_MANAGER_PATH,
'MODX_BASE_PATH' => MODX_BASE_PATH,
);
$pattern = <<<EOT
/(?:include|require)(?:_once)?[^"']*['"]([^'"]+)['"]/im
EOT;
$success = preg_match_all($pattern, $content, $matches);
if (!empty($success)) {
$i = 0;
foreach ($matches[1] as $path) {
/* Skip includes that have a PHP variable in them */
if (strpos($path, '$') !== false) {
continue;
}
$fullMatch = $matches[0][$i];
foreach ($cs as $constName => $constValue) {
if (strpos($fullMatch, $constName) !== false) {
$path = $constValue . $path;
break;
}
}
/* Make sure the file exists */
if (file_exists($path)) {
/* Make sure it's not a directory */
if (is_dir($path)) {
continue;
}
$c = file_get_contents($path);
if (!empty($c)) {
if ($this->checkContent($c)) {
$this->files[] = $path;
};
}
}
$i++;
}
}
}
public function findInObjects(string $objectType): void {
$objects = $this->modx->getCollection($this->prefix . $objectType);
foreach ($objects as $object) {
$name = $object->get('name');
$id = $object->get('id');
$content = $object->getContent();
if ($this->checkContent($content)) {
if ($objectType == 'modSnippet') {
$this->snippets[$name] = $id;
}
if ($objectType == 'modPlugin') {
$this->plugins[$name] = $id;
}
};
$this->checkIncludes($content);
}
}
public function getMediaSources() {
$mediaSources = array();
$mediaSourcePrefix = $this->modx->getVersionData()['version'] >= 3
? 'MODX\Revolution\Sources\\'
: 'sources.';
$sources = $this->modx->getCollection($mediaSourcePrefix . 'modFileMediaSource');
foreach ($sources as $source) {
$source->initialize();
$basePath = $source->getBasePath();
$basePath = $this->normalizePath($basePath);
$id = $source->get('id');
$mediaSources [$basePath] = $id;
}
return $mediaSources;
}
public function report(): string {
$output = '';
$managerUrl = MODX_SITE_URL . basename(MODX_MANAGER_URL) . '/';
if (!empty($this->errors)) {
foreach ($this->errors as $error) {
$output .= "\nError: " . $error . '';
}
/* Uncomment the next line if you want to quit
if there's an error */
// return $output;
}
/* Snippets */
if (!empty($this->snippets)) {
$output .= "\n### Snippets containing placeholder\n";
foreach ($this->snippets as $snippetName => $snippetId) {
$output .= "\n * " . '<a href="' . $managerUrl .
'?a=element/snippet/update?&id=' . $snippetId . '">' . $snippetName . '</a>';
}
$output .= "\n";
} else {
$output .= "\nPlaceholder not found in snippets";
}
/* Plugins */
if (!empty($this->plugins)) {
$output .= "\n### Plugins containing placeholder\n";
foreach ($this->plugins as $pluginName => $pluginId) {
$output .= "\n * " . '<a href="' . $managerUrl .
'?a=element/plugin/update?&id=' . $pluginId . '">' . $pluginName . '</a>';
}
$output .= "\n";
} else {
$output .= "\nPlaceholder not found in plugins";
}
/* Files */
if (!empty($this->files)) {
$modxBasePath = $this->normalizePath(MODX_BASE_PATH);
$managerUrl = MODX_SITE_URL . basename(MODX_MANAGER_URL) . '/';
$mediaSources = $this->getMediaSources();
$output .= "\n### Files containing placeholder\n";
foreach ($this->files as $file) {
$mediaSourceId = null;
$file = $this->normalizePath($file);
$fileRelative = str_replace($modxBasePath, '', $file);
foreach ($mediaSources as $basePath => $id) {
$fileToSearch = $basePath . $fileRelative;
if (file_exists($fileToSearch)) {
$mediaSourceId = $id;
break;
}
}
if ($mediaSourceId !== null) {
$output .= "\n * " . '<a href="' . $managerUrl . '?a=system/file/edit&file=' . $fileRelative . '&source=' . $mediaSourceId . '">' . $file . '</a>';
}
}
$output .= "\n";
} else {
$output .= "\nPlaceholder not found in files";
}
return $output;
}
} /* End of class */
$pf = new PlaceholderFinder($modx);
$pf->init($scriptProperties);
$pf->findInObjects('modSnippet');
$pf->findInObjects('modPlugin');
$output = $pf->report();
return $output;
Coming Up
In the next and final article in this series, we'll clean up some very ugly code using PHP's heredoc syntax.
Bob Ray is the author of the MODX: The Official Guide and dozens of MODX Extras including QuickEmail, NewsPublisher, SiteCheck, GoRevo, Personalize, EZfaq, MyComponent and many more. His website is Bob’s Guides. It not only includes a plethora of MODX tutorials but there are some really great bread recipes there, as well.