diff --git a/wire/core/FileLog.php b/wire/core/FileLog.php index 703d18cd..4942c7bb 100644 --- a/wire/core/FileLog.php +++ b/wire/core/FileLog.php @@ -11,15 +11,73 @@ */ class FileLog extends Wire { - - const defaultChunkSize = 12288; - const debug = false; - protected $logFilename = false; - protected $itemsLogged = array(); + /** + * Default size of chunks used for reading from logs + * + */ + const defaultChunkSize = 12288; + + /** + * Debug mode used during development of this class + * + */ + const debug = false; + + /** + * Chunk size used when reading from logs and not overridden + * + * @var int + * + */ + protected $chunkSize = self::defaultChunkSize; + + /** + * Full path to log file or false when not yet set + * + * @var bool|string + * + */ + protected $logFilename = false; + + /** + * Log items saved during this request where array keys are md5 hash of log entries and values ignored + * + * @var array + * + */ + protected $itemsLogged = array(); + + /** + * Delimiter used in log entries + * + * @var string + * + */ protected $delimeter = "\t"; + + /** + * Maximum allowed line length for a single log line + * + * @var int + * + */ protected $maxLineLength = 8192; + + /** + * File extension used for log files + * + * @var string + * + */ protected $fileExtension = 'txt'; + + /** + * Path where log files are stored + * + * @var string + * + */ protected $path = ''; /** @@ -58,52 +116,157 @@ class FileLog extends Wire { protected function cleanStr($str) { $str = str_replace(array("\r\n", "\r", "\n"), ' ', trim($str)); if(strlen($str) > $this->maxLineLength) $str = substr($str, 0, $this->maxLineLength); + if(strpos($str, ' ^+') !== false) $str = str_replace(' ^=', ' ^ +', $str); // disallowed sequence return $str; } /** * Save the given log entry string * - * @param $str - * @return bool Success state + * @param string $str + * @param array $options options to modify behavior (Added 3.0.143) + * - `allowDups` (bool): Allow duplicating same log entry in same runtime/request? (default=true) + * - `mergeDups` (int): Merge previous duplicate entries that also appear near end of file? Specify int for bytes from EOF (default=1024) + * - `maxTries` (int): If log entry fails to save, maximum times to re-try (default=20) + * - `maxTriesDelay` (int): Micro seconds (millionths of a second) to delay between re-tries (default=2000) + * @return bool Success state: true if log written, false if not. * */ - public function save($str) { - - if(!$this->logFilename) return false; + public function save($str, array $options = array()) { + + $defaults = array( + 'mergeDups' => 1024, + 'allowDups' => true, + 'maxTries' => 20, + 'maxTriesDelay' => 2000, + ); + if(!$this->logFilename) return false; + + $options = array_merge($defaults, $options); $hash = md5($str); - - // if we've already logged this during this instance, then don't do it again - if(in_array($hash, $this->itemsLogged)) return true; - $ts = date("Y-m-d H:i:s"); $str = $this->cleanStr($str); - $fp = fopen($this->logFilename, "a"); + $line = $this->delimeter . $str; // log entry, excluding timestamp + $hasLock = false; // becomes true when lock obtained + $fp = false; // becomes resource when file is open - if($fp) { - $trys = 0; - $stop = false; + // if we've already logged this during this instance, then don't do it again + if(!$options['allowDups'] && isset($this->itemsLogged[$hash])) return true; - while(!$stop) { - if(flock($fp, LOCK_EX)) { - fwrite($fp, "$ts{$this->delimeter}$str\n"); - flock($fp, LOCK_UN); - $this->itemsLogged[] = $hash; - $stop = true; - } else { - usleep(2000); - if($trys++ > 20) $stop = true; - } - } + // determine write mode + $mode = file_exists($this->logFilename) ? 'a' : 'w'; + if($mode === 'a' && $options['mergeDups']) $mode = 'r+'; - fclose($fp); - $this->wire('files')->chmod($this->logFilename); - return true; - } else { + // open the log file + for($tries = 0; $tries <= $options['maxTries']; $tries++) { + $fp = fopen($this->logFilename, $mode); + if($fp) break; + // if unable to open for reading/writing, see if we can open for append instead + if($mode === 'r+' && $tries > ($options['maxTries'] / 2)) $mode = 'a'; + usleep($options['maxTriesDelay']); + } + + // if unable to open, exit now + if(!$fp) return false; + + // obtain a lock + for($tries = 0; $tries <= $options['maxTries']; $tries++) { + $hasLock = flock($fp, LOCK_EX); + if($hasLock) break; + usleep($options['maxTriesDelay']); + } + + // if unable to obtain a lock, we cannot write to the log + if(!$hasLock) { + fclose($fp); return false; } + // if opened for reading and writing, merge duplicates of $line + if($mode === 'r+' && $options['mergeDups']) { + // do not repeat the same log entry in the same chunk + $chunkSize = is_int($options['mergeDups']) ? $options['mergeDups'] : $this->chunkSize; + fseek($fp, -1 * $chunkSize, SEEK_END); + $chunk = fread($fp, $chunkSize); + // check if our log line already appears in the immediate earlier chunk + if(strpos($chunk, $line) !== false) { + // this log entry already appears 1+ times within the last chunk of the file + // remove the duplicates and replace the chunk + $chunkLength = strlen($chunk); + $this->removeLineFromChunk($line, $chunk, $chunkSize); + fseek($fp, 0, SEEK_END); + $oldLength = ftell($fp); + $newLength = $chunkLength > $oldLength ? $oldLength - $chunkLength : 0; + ftruncate($fp, $newLength); + fseek($fp, 0, SEEK_END); + fwrite($fp, $chunk); + } + } else { + // already at EOF because we are appending or creating + } + + // add the log line + $result = fwrite($fp, "$ts$line\n"); + + // release the lock and close the file + flock($fp, LOCK_UN); + fclose($fp); + + if($result && !$options['allowDups']) $this->itemsLogged[$hash] = true; + + // if we were creating the file, make sure it has the right permission + if($mode === 'w') { + $files = $this->wire('files'); /** @var WireFileTools $files */ + $files->chmod($this->logFilename); + } + + return (int) $result > 0; + } + + /** + * Remove given $line from $chunk and add counter to end of $line indicating quantity that was removed + * + * @param string $line + * @param string $chunk + * @param int $chunkSize + * @since 3.0.143 + * + */ + protected function removeLineFromChunk(&$line, &$chunk, $chunkSize) { + + $qty = 0; + $chunkLines = explode("\n", $chunk); + + foreach($chunkLines as $key => $chunkLine) { + + $x = 1; + if($key === 0 && strlen($chunk) >= $chunkSize) continue; // skip first line since it’s likely a partial line + + // check if line appears in this chunk line + if(strpos($chunkLine, $line) === false) continue; + + // check if line also indicates a previous quantity that we should add to our quantity + if(strpos($chunkLine, ' ^+') !== false) { + list($chunkLine, $n) = explode(' ^+', $chunkLine, 2); + if(ctype_digit($n)) $x += (int) $n; + } + + // verify that these are the same line + if(strpos(trim($chunkLine) . "\n", trim($line) . "\n") === false) continue; + + // remove the line + unset($chunkLines[$key]); + + // update the quantity + $qty += $x; + } + + if($qty) { + // append quantity to line, i.e. “^+2” indicating 2 more indentical lines were above + $chunk = implode("\n", array_values($chunkLines)); + $line .= " ^+$qty"; + } } public function size() { @@ -145,7 +308,7 @@ class FileLog extends Wire { * */ protected function getChunkArray($chunkNum = 1, $chunkSize = 0, $reverse = true) { - if($chunkSize < 1) $chunkSize = self::defaultChunkSize; + if($chunkSize < 1) $chunkSize = $this->chunkSize; $lines = explode("\n", $this->getChunk($chunkNum, $chunkSize, $reverse)); foreach($lines as $key => $line) { $line = trim($line); @@ -165,15 +328,16 @@ class FileLog extends Wire { * Returned string is automatically adjusted at the beginning and * ending to contain only full log lines. * - * @param int $chunkNum Current pagination number (default=1) - * @param int $chunkSize Number of bytes to retrieve (default=12288) + * @param int $chunkNum Current chunk/pagination number (default=1, first) + * @param int $chunkSize Number of bytes to retrieve (default=0, which assigns default chunk size of 12288) * @param bool $reverse True=pull from end of file, false=pull from beginning (default=true) + * @param bool $clean Get a clean chunk that starts at the beginning of a line? (default=true) * @return string * */ - protected function getChunk($chunkNum = 1, $chunkSize = 0, $reverse = true) { + protected function getChunk($chunkNum = 1, $chunkSize = 0, $reverse = true, $clean = true) { - if($chunkSize < 1) $chunkSize = self::defaultChunkSize; + if($chunkSize < 1) $chunkSize = $this->chunkSize; if($reverse) { $offset = -1 * ($chunkSize * $chunkNum); @@ -181,7 +345,9 @@ class FileLog extends Wire { $offset = $chunkSize * ($chunkNum-1); } - if(self::debug) $this->message("chunkNum=$chunkNum, chunkSize=$chunkSize, offset=$offset, filesize=" . filesize($this->logFilename)); + if(self::debug) { + $this->message("chunkNum=$chunkNum, chunkSize=$chunkSize, offset=$offset, filesize=" . filesize($this->logFilename)); + } $data = ''; $totalChunks = $this->getTotalChunks($chunkSize); @@ -190,23 +356,27 @@ class FileLog extends Wire { if(!$fp = fopen($this->logFilename, "r")) return $data; fseek($fp, $offset, ($reverse ? SEEK_END : SEEK_SET)); - - // make chunk include up to beginning of first line - fseek($fp, -1, SEEK_CUR); - while(ftell($fp) > 0) { - $chr = fread($fp, 1); - if($chr == "\n") break; - fseek($fp, -2, SEEK_CUR); - $data = $chr . $data; + + if($clean) { + // make chunk include up to beginning of first line + fseek($fp, -1, SEEK_CUR); + while(ftell($fp) > 0) { + $chr = fread($fp, 1); + if($chr == "\n") break; + fseek($fp, -2, SEEK_CUR); + $data = $chr . $data; + } + fseek($fp, $offset, ($reverse ? SEEK_END : SEEK_SET)); } // get the big part of the chunk - fseek($fp, $offset, ($reverse ? SEEK_END : SEEK_SET)); $data .= fread($fp, $chunkSize); - - // remove last partial line - $pos = strrpos($data, "\n"); - if($pos) $data = substr($data, 0, $pos); + + if($clean) { + // remove last partial line + $pos = strrpos($data, "\n"); + if($pos) $data = substr($data, 0, $pos); + } fclose($fp); @@ -221,9 +391,9 @@ class FileLog extends Wire { * */ protected function getTotalChunks($chunkSize = 0) { - if($chunkSize < 1) $chunkSize = self::defaultChunkSize; + if($chunkSize < 1) $chunkSize = $this->chunkSize; $filesize = filesize($this->logFilename); - return ceil($filesize / $chunkSize); + return $filesize > 0 ? ceil($filesize / $chunkSize) : 0; } /** @@ -234,7 +404,7 @@ class FileLog extends Wire { */ public function getTotalLines() { - if(filesize($this->logFilename) < self::defaultChunkSize) { + if(filesize($this->logFilename) < $this->chunkSize) { $data = file($this->logFilename); return count($data); } @@ -243,7 +413,7 @@ class FileLog extends Wire { $totalLines = 0; while(!feof($fp)) { - $data = fread($fp, self::defaultChunkSize); + $data = fread($fp, $this->chunkSize); $totalLines += substr_count($data, "\n"); } @@ -323,7 +493,7 @@ class FileLog extends Wire { $cnt = 0; // number that will be written or returned by this $n = 0; // number total $chunkNum = 0; - $totalChunks = $this->getTotalChunks(self::defaultChunkSize); + $totalChunks = $this->getTotalChunks($this->chunkSize); $stopNow = false; $chunkLineHashes = array(); @@ -517,6 +687,19 @@ class FileLog extends Wire { public function setFileExtension($ext) { $this->fileExtension = $ext; } + + /** + * Get or set the default chunk size used when reading from logs and not overridden by method argument + * + * @param int $chunkSize Specify chunk size to set, or omit to get + * @return int + * @since 3.0.143 + * + */ + public function chunkSize($chunkSize = 0) { + if($chunkSize > 0) $this->chunkSize = (int) $chunkSize; + return $this->chunkSize; + } } diff --git a/wire/core/WireLog.php b/wire/core/WireLog.php index ba18c201..0da1df07 100644 --- a/wire/core/WireLog.php +++ b/wire/core/WireLog.php @@ -8,12 +8,11 @@ * * #pw-summary Enables creation of logs, logging of events, and management of logs. * - * ProcessWire 3.x, Copyright 2016 by Ryan Cramer + * ProcessWire 3.x, Copyright 2019 by Ryan Cramer * https://processwire.com * * @method bool save($name, $text, $options = array()) * - * @todo option to avoid saving same log entry text back-to-back * @todo option to disable logs by name * */ @@ -22,6 +21,14 @@ class WireLog extends Wire { protected $logExtension = 'txt'; + /** + * FileLog instances indexed by filename + * + * @var array + * + */ + protected $fileLogs = array(); + /** * Record an informational or 'success' message in the message log (messages.txt) * @@ -192,28 +199,42 @@ class WireLog extends Wire { * * #pw-group-retrieval * + * @param bool $sortNewest Sort by newest to oldest rather than by name? (default=false) Added 3.0.143 * @return array * */ - public function getLogs() { + public function getLogs($sortNewest = false) { $logs = array(); $dir = new \DirectoryIterator($this->wire('config')->paths->logs); foreach($dir as $file) { if($file->isDot() || $file->isDir()) continue; - if($file->getExtension() != 'txt') continue; - $name = basename($file, '.txt'); + if($file->getExtension() != $this->logExtension) continue; + $name = basename($file, '.' . $this->logExtension); if($name != $this->wire('sanitizer')->pageName($name)) continue; - $logs[$name] = array( + + if($sortNewest) { + $sortKey = $file->getMTime(); + while(isset($logs[$sortKey])) $sortKey++; + } else { + $sortKey = $name; + } + + $logs[$sortKey] = array( 'name' => $name, 'file' => $file->getPathname(), 'size' => $file->getSize(), 'modified' => $file->getMTime(), ); } + + if($sortNewest) { + krsort($logs); + } else { + ksort($logs); + } - ksort($logs); return $logs; } @@ -448,9 +469,13 @@ class WireLog extends Wire { * */ public function getFileLog($name, array $options = array()) { - $log = $this->wire(new FileLog($this->getFilename($name))); - if(isset($options['delimiter'])) $log->setDelimeter($options['delimiter']); - else $log->setDelimeter("\t"); + $delimiter = isset($options['delimiter']) ? $options['delimiter'] : "\t"; + $filename = $this->getFilename($name); + $key = "$filename$delimiter"; + if(isset($this->fileLogs[$key])) return $this->fileLogs[$key]; + $log = $this->wire(new FileLog($filename)); + $log->setDelimiter($delimiter); + $this->fileLogs[$key] = $log; return $log; } diff --git a/wire/modules/Process/ProcessLogger/ProcessLogger.module b/wire/modules/Process/ProcessLogger/ProcessLogger.module index 1324c1e6..a3dafdb3 100644 --- a/wire/modules/Process/ProcessLogger/ProcessLogger.module +++ b/wire/modules/Process/ProcessLogger/ProcessLogger.module @@ -3,7 +3,7 @@ /** * ProcessWire Logger (Logs Viewer) * - * ProcessWire 3.x, Copyright 2016 by Ryan Cramer + * ProcessWire 3.x, Copyright 2019 by Ryan Cramer * https://processwire.com * * @@ -15,7 +15,7 @@ class ProcessLogger extends Process { return array( 'title' => __('Logs', __FILE__), 'summary' => __('View and manage system logs.', __FILE__), - 'version' => 1, + 'version' => 2, 'author' => 'Ryan Cramer', 'icon' => 'tree', 'permission' => 'logs-view', @@ -50,12 +50,16 @@ class ProcessLogger extends Process { $options['itemLabel2'] = 'when'; $options['add'] = false; $options['edit'] = 'view/{name}/'; - $options['items'] = $this->wire('log')->getLogs(); + $options['items'] = $this->wire('log')->getLogs(true); + $options['sort'] = false; foreach($options['items'] as $key => $item) { $item['when'] = wireRelativeTimeStr($item['modified'], true, false); - if(time() - $item['modified'] > 86400) $item['icon'] = 'file-text-o'; - else $item['icon'] = 'file-text'; + if(time() - $item['modified'] > 86400) { + $item['icon'] = 'file-text-o'; + } else { + $item['icon'] = 'file-text'; + } $options['items'][$key] = $item; } @@ -63,6 +67,7 @@ class ProcessLogger extends Process { } public function ___execute() { + /** @var MarkupAdminDataTable $table */ $table = $this->wire('modules')->get('MarkupAdminDataTable'); $table->setEncodeEntities(false); $table->headerRow(array( @@ -178,22 +183,27 @@ class ProcessLogger extends Process { } while(1); if($this->wire('config')->ajax) return $this->renderLogAjax($items, $name); - + + /** @var InputfieldForm $form */ $form = $this->wire('modules')->get('InputfieldForm'); + + /** @var InputfieldFieldset $fieldset */ $fieldset = $this->wire('modules')->get('InputfieldFieldset'); $fieldset->attr('id', 'FieldsetTools'); $fieldset->label = $this->_('Helpers'); $fieldset->collapsed = Inputfield::collapsedYes; $fieldset->icon = 'sun-o'; $form->add($fieldset); - + + /** @var InputfieldText $f */ $f = $this->wire('modules')->get('InputfieldText'); $f->attr('name', 'q'); $f->label = $this->_('Text Search'); $f->icon = 'search'; $f->columnWidth = 50; - $fieldset->add($f); - + $fieldset->add($f); + + /** @var InputfieldDatetime $f */ $f = $this->wire('modules')->get('InputfieldDatetime'); $f->attr('name', 'date_from'); $f->label = $this->_('Date From'); @@ -202,7 +212,8 @@ class ProcessLogger extends Process { $f->datepicker = InputfieldDatetime::datepickerFocus; $f->attr('placeholder', 'yyyy-mm-dd'); $fieldset->add($f); - + + /** @var InputfieldDatetime $f */ $f = $this->wire('modules')->get('InputfieldDatetime'); $f->attr('name', 'date_to'); $f->icon = 'calendar'; @@ -211,7 +222,8 @@ class ProcessLogger extends Process { $f->attr('placeholder', 'yyyy-mm-dd'); $f->datepicker = InputfieldDatetime::datepickerFocus; $fieldset->add($f); - + + /** @var InputfieldSelect $f */ $f = $this->modules->get('InputfieldSelect'); $f->attr('name', 'action'); $f->label = $this->_('Actions'); @@ -226,7 +238,8 @@ class ProcessLogger extends Process { $f->addOption('add', $this->_('Grow (Add Entry)')); $f->addOption('prune', $this->_('Chop (Prune)')); $f->addOption('delete', $this->_('Burn (Delete)')); - + + /** @var InputfieldInteger $f */ $f = $this->wire('modules')->get('InputfieldInteger'); $f->attr('name', 'prune_days'); $f->label = $this->_('Chop To # Days'); @@ -236,6 +249,7 @@ class ProcessLogger extends Process { $f->showIf = "action=prune"; $fieldset->add($f); + /** @var InputfieldText $f */ $f = $this->wire('modules')->get('InputfieldText'); $f->attr('name', 'add_text'); $f->label = $this->_('New Log Entry'); @@ -243,6 +257,7 @@ class ProcessLogger extends Process { $f->showIf = "action=add"; $fieldset->add($f); + /** @var InputfieldSubmit $f */ $f = $this->wire('modules')->get('InputfieldSubmit'); $f->value = $this->_('Chop this log file now'); $f->icon = 'cut'; @@ -320,9 +335,11 @@ class ProcessLogger extends Process { } protected function renderLog(array $items, $name, $time = 0) { - + + /** @var Sanitizer $sanitizer */ $sanitizer = $this->wire('sanitizer'); - + + /** @var MarkupAdminDataTable $table */ $table = $this->wire('modules')->get('MarkupAdminDataTable'); $table->setSortable(false); $table->setEncodeEntities(false); @@ -354,6 +371,10 @@ class ProcessLogger extends Process { $date = " $date"; } + if(strpos($entry['text'], '&') !== false) { + $entry['text'] = $this->wire('sanitizer')->unentities($entry['text']); + } + foreach($entry as $key => $value) { $entry[$key] = $sanitizer->entities($value); } @@ -362,18 +383,19 @@ class ProcessLogger extends Process { if(count($templateItem) >= 4) { - $url = preg_replace('{^https?://[^/]+}', '', $entry['url']); + $row[] = $entry['user']; + + $entry['url'] = preg_replace('{^https?://[^/]+}', '', $entry['url']); + $url = $entry['url']; if($url == '/?/') { $url = 2; // array key $entry['url'] = '?'; } - - if(strlen($url) > 50) $url = substr($url, 0, 50) . '…'; - $row[] = $entry['user']; - $row[$url] = $entry['url']; + $urlLabel = $this->formatLogUrlLabel($entry['url']); + $row[$urlLabel] = $url; } - - $row[] = $entry['text']; + + $row[] = $this->formatLogText($entry['text'], $name); $table->row($row); } @@ -384,10 +406,12 @@ class ProcessLogger extends Process { reset($items); $key = key($items); list($n, $total, $start, $end, $limit) = explode('/', $key); + if($n && $end) {} // ignore $entries->import($items); $entries->setLimit($limit); $entries->setStart($start); $entries->setTotal($total); + /** @var MarkupPagerNav $pager */ $pager = $this->wire('modules')->get('MarkupPagerNav'); $options = array('baseUrl' => "../$name/"); $pagerOut = $pager->render($entries, $options); @@ -420,5 +444,110 @@ class ProcessLogger extends Process { } + /** + * Format log URL label + * + * @param string $url + * @return string + * + */ + protected function formatLogUrlLabel($url) { + + if($url === '?') return $url; + + if(strpos($url, '://') !== false) { + $url = preg_replace('{^https?://[^/]+}', '', $url); + } + + $config = $this->wire('config'); + $rootUrl = $config->urls->root; + $adminUrl = $config->urls->admin; + $isAdmin = false; + + if(strpos($url, $adminUrl) === 0) { + $isAdmin = true; + $url = substr($url, strlen($adminUrl)); + } else if($rootUrl !== '/' && strpos($url, $rootUrl) === 0) { + $url = substr($url, strlen($rootUrl)-1); + } + + if($isAdmin && strpos($url, 'page/edit/') !== false && preg_match('/[?&]id=(\d+)/', $url, $matches)) { + $url = 'page/edit/?id=' . $matches[1]; + } else if($url === '/http404/') { + $url = $this->_('404 not found'); + } + + if(strlen($url) > 50) { + $url = substr($url, 0, 50) . '…'; + } + + return $url; + } + + /** + * Format log line txt + * + * @param string $text + * @param string $logName + * @return string + * + */ + protected function formatLogText($text, $logName = '') { + + $config = $this->wire('config'); + + // shorten paths + foreach(array('site', 'wire') as $name) { + if(strpos($text, "/$name/") === false) continue; + $path = $config->paths($name); + if(strpos($text, $path) !== false) { + $text = str_replace($path, "/$name/", $text); + } else { + // $text = preg_replace('![-_/\\:a-zA-Z0-9]+/' . $name . '/!', "/$name/", $text); + } + } + + // shorten assumed namespaces + if(strpos($text, 'ProcessWire\\') !== false) { + $text = str_replace('ProcessWire\\', '', $text); + } + + // formatting of stack traces in errors/exceptions logs + if($logName === 'errors' || $logName === 'exceptions') { + if(strpos($text, '(line ') && preg_match('/\((line \d+ of [^)]+)\)/', $text, $matches)) { + $text = str_replace($matches[0], "
" . ucfirst($matches[1]) . "", $text); + } else if(strpos($text, '(in ') && preg_match('!\((in /[^)]+? line \d+)\)!', $text, $matches)) { + $text = str_replace($matches[0], "
" . ucfirst($matches[1]) . "", $text); + } + if(strpos($text, ' #0 /')) { + list($text, $traces) = explode(' #0 /', $text, 2); + $traces = preg_split('! #\d+ /!', $traces); + $text .= ""; + foreach($traces as $key => $trace) { + $n = $key + 1; + $text .= "
$n. /$trace"; + } + $text .= "
"; + } + } + + // identify recurring instances + if(strpos($text, ' ^+')) { + $_text = $text; + list($text, $qty) = explode(' ^+', $text, 2); + if(ctype_digit($qty)) { + $text .= "
" . + "" . + sprintf($this->_n('Plus %d ealier duplicate ', 'Plus %d earlier duplicates', $qty), $qty) . + ""; + } else { + // oops, restore + $text = $_text; + } + } + + return $text; + } + }