576 lines
16 KiB
Text
576 lines
16 KiB
Text
|
<?php namespace ProcessWire;
|
||
|
|
||
|
/**
|
||
|
* ProcessWire Logger (Logs Viewer)
|
||
|
*
|
||
|
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
|
||
|
* https://processwire.com
|
||
|
*
|
||
|
* @method string formatLogText($text, $logName = '')
|
||
|
*
|
||
|
*
|
||
|
*/
|
||
|
|
||
|
class ProcessLogger extends Process {
|
||
|
|
||
|
public static function getModuleInfo() {
|
||
|
return array(
|
||
|
'title' => __('Logs', __FILE__),
|
||
|
'summary' => __('View and manage system logs.', __FILE__),
|
||
|
'version' => 2,
|
||
|
'author' => 'Ryan Cramer',
|
||
|
'icon' => 'tree',
|
||
|
'permission' => 'logs-view',
|
||
|
'permissions' => array(
|
||
|
'logs-view' => 'Can view system logs',
|
||
|
'logs-edit' => 'Can manage system logs',
|
||
|
),
|
||
|
'page' => array(
|
||
|
'name' => 'logs',
|
||
|
'parent' => 'setup',
|
||
|
'title' => 'Logs',
|
||
|
),
|
||
|
'useNavJSON' => true,
|
||
|
);
|
||
|
}
|
||
|
|
||
|
public function __construct() {
|
||
|
require_once(dirname(__FILE__) . '/LogEntriesArray.php');
|
||
|
parent::__construct();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Provides output navigation logs list
|
||
|
*
|
||
|
* @param array $options
|
||
|
* @return string
|
||
|
*
|
||
|
*/
|
||
|
public function ___executeNavJSON(array $options = array()) {
|
||
|
|
||
|
$options['itemLabel'] = 'name';
|
||
|
$options['itemLabel2'] = 'when';
|
||
|
$options['add'] = false;
|
||
|
$options['edit'] = 'view/{name}/';
|
||
|
$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';
|
||
|
}
|
||
|
$options['items'][$key] = $item;
|
||
|
}
|
||
|
|
||
|
return parent::___executeNavJSON($options);
|
||
|
}
|
||
|
|
||
|
public function ___execute() {
|
||
|
/** @var MarkupAdminDataTable $table */
|
||
|
$table = $this->wire('modules')->get('MarkupAdminDataTable');
|
||
|
$table->setEncodeEntities(false);
|
||
|
$table->headerRow(array(
|
||
|
$this->_x('Name', 'th'),
|
||
|
$this->_x('Modified', 'th'),
|
||
|
$this->_x('Entries', 'th'),
|
||
|
$this->_x('Size', 'th'),
|
||
|
));
|
||
|
$logs = $this->wire()->log->getLogs();
|
||
|
foreach($logs as $log) {
|
||
|
$logName = $log['name'];
|
||
|
if(ctype_digit($logName)) $logName = " $logName";
|
||
|
$table->row(array(
|
||
|
$logName => "./view/$log[name]/",
|
||
|
"<span style='font-size: 0;'>$log[modified] </span>" . wireRelativeTimeStr($log['modified']),
|
||
|
$this->wire()->log->getTotalEntries($log['name']),
|
||
|
"<span style='font-size: 0;'>$log[size] </span>" . wireBytesStr($log['size'])
|
||
|
));
|
||
|
}
|
||
|
$cnt = count($logs);
|
||
|
$out =
|
||
|
"<h2><i class='fa fa-lg fa-fw fa-tree'></i> " . sprintf($this->_n('%d log', '%d logs', $cnt), $cnt) . "</h2>" .
|
||
|
$table->render() .
|
||
|
"<p>" .
|
||
|
"<span class='detail'>" . $this->_('Create or add to log file from the API:') . "</span><br />" .
|
||
|
"<code class='notes'>wire('log')->save('name', 'entry text');</code>" .
|
||
|
"</p>";
|
||
|
|
||
|
return $out;
|
||
|
}
|
||
|
|
||
|
protected function processAction($action, $name) {
|
||
|
|
||
|
$log = $this->wire()->log;
|
||
|
$input = $this->wire()->input;
|
||
|
$session = $this->wire()->session;
|
||
|
|
||
|
if(!$input->post("submit_$action")) {
|
||
|
throw new WireException("Action missing submit");
|
||
|
}
|
||
|
|
||
|
if($action != 'download' && !$this->wire()->user->hasPermission('logs-edit')) {
|
||
|
throw new WirePermissionException("You don't have permission to execute that action");
|
||
|
}
|
||
|
|
||
|
switch($action) {
|
||
|
|
||
|
case 'delete':
|
||
|
if($log->delete($name)) $this->message(sprintf($this->_('Deleted log: %s'), $name));
|
||
|
$session->location($this->wire()->page->url);
|
||
|
break;
|
||
|
|
||
|
case 'prune':
|
||
|
$days = (int) $input->post('prune_days');
|
||
|
$qty = $log->prune($name, $days);
|
||
|
$this->message(sprintf($this->_('Pruned "%s" log file (now contains %d entries)'), $name, $qty));
|
||
|
$session->location('./');
|
||
|
break;
|
||
|
|
||
|
case 'download':
|
||
|
$filename = $log->getFilename($name);
|
||
|
if(file_exists($filename)) wireSendFile($filename, array('forceDownload' => true));
|
||
|
break;
|
||
|
|
||
|
case 'add':
|
||
|
$text = $this->wire()->sanitizer->text($input->post('add_text'));
|
||
|
if(strlen($text)) {
|
||
|
$log->save($name, $text);
|
||
|
$this->message(sprintf($this->_('Saved new log entry to "%s"'), $name));
|
||
|
} else {
|
||
|
$this->error($this->_('Log entry text was blank'));
|
||
|
}
|
||
|
$session->location('./');
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
public function ___executeView() {
|
||
|
|
||
|
$input = $this->wire()->input;
|
||
|
$session = $this->wire()->session;
|
||
|
$config = $this->wire()->config;
|
||
|
$modules = $this->wire()->modules;
|
||
|
$sanitizer = $this->wire()->sanitizer;
|
||
|
$log = $this->wire()->log;
|
||
|
|
||
|
$name = $input->urlSegment2;
|
||
|
if(!$name) $session->redirect('../');
|
||
|
$logs = $log->getLogs();
|
||
|
if(!isset($logs[$name])) {
|
||
|
$this->error(sprintf('Unknown log: %s', $name));
|
||
|
$session->location('../');
|
||
|
}
|
||
|
$action = $input->post('action');
|
||
|
if($action) $this->processAction($action, $name);
|
||
|
$limit = 100;
|
||
|
$options = array('limit' => $limit);
|
||
|
|
||
|
$q = $input->get('q');
|
||
|
if($q !== null && strlen($q)) {
|
||
|
$options['text'] = $sanitizer->text($q);
|
||
|
$input->whitelist('q', $options['text']);
|
||
|
}
|
||
|
|
||
|
$dateFrom = $input->get('date_from');
|
||
|
if($dateFrom !== null && strlen($dateFrom)) {
|
||
|
$options['dateFrom'] = ctype_digit("$dateFrom") ? (int) $dateFrom : strtotime("$dateFrom 00:00:00");
|
||
|
$input->whitelist('date_from', $options['dateFrom']);
|
||
|
}
|
||
|
|
||
|
$dateTo = $input->get('date_to');
|
||
|
if($dateTo !== null && strlen($dateTo)) {
|
||
|
$options['dateTo'] = ctype_digit("$dateTo") ? (int) $dateTo : strtotime("$dateTo 23:59:59");
|
||
|
$input->whitelist('date_to', $options['dateTo']);
|
||
|
}
|
||
|
|
||
|
$options['pageNum'] = (int) $input->pageNum;
|
||
|
|
||
|
do {
|
||
|
// since the total count the pagination is based on may not always be accurate (dups, etc.)
|
||
|
// we migrate to the last populated pagination when items turn up empty
|
||
|
$items = $log->getEntries($name, $options);
|
||
|
if(count($items)) break;
|
||
|
if($options['pageNum'] < 2) break;
|
||
|
$options['pageNum']--;
|
||
|
} while(1);
|
||
|
|
||
|
if($config->ajax) return $this->renderLogAjax($items, $name);
|
||
|
|
||
|
/** @var InputfieldForm $form */
|
||
|
$form = $modules->get('InputfieldForm');
|
||
|
|
||
|
/** @var InputfieldFieldset $fieldset */
|
||
|
$fieldset = $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 = $modules->get('InputfieldText');
|
||
|
$f->attr('name', 'q');
|
||
|
$f->label = $this->_('Text Search');
|
||
|
$f->icon = 'search';
|
||
|
$f->columnWidth = 50;
|
||
|
$fieldset->add($f);
|
||
|
|
||
|
/** @var InputfieldDatetime $f */
|
||
|
$f = $modules->get('InputfieldDatetime');
|
||
|
$f->attr('name', 'date_from');
|
||
|
$f->label = $this->_('Date From');
|
||
|
$f->icon = 'calendar';
|
||
|
$f->columnWidth = 25;
|
||
|
$f->datepicker = InputfieldDatetime::datepickerFocus;
|
||
|
$f->attr('placeholder', 'yyyy-mm-dd');
|
||
|
$fieldset->add($f);
|
||
|
|
||
|
/** @var InputfieldDatetime $f */
|
||
|
$f = $modules->get('InputfieldDatetime');
|
||
|
$f->attr('name', 'date_to');
|
||
|
$f->icon = 'calendar';
|
||
|
$f->label = $this->_('Date To');
|
||
|
$f->columnWidth = 25;
|
||
|
$f->attr('placeholder', 'yyyy-mm-dd');
|
||
|
$f->datepicker = InputfieldDatetime::datepickerFocus;
|
||
|
$fieldset->add($f);
|
||
|
|
||
|
/** @var InputfieldSelect $f */
|
||
|
$f = $modules->get('InputfieldSelect');
|
||
|
$f->attr('name', 'action');
|
||
|
$f->label = $this->_('Actions');
|
||
|
$f->description = $this->_('Select an action below. You will be asked to click a button before the action is executed.');
|
||
|
$f->icon = 'fire';
|
||
|
$f->collapsed = Inputfield::collapsedYes;
|
||
|
$f->addOption('download', $this->_('Download'));
|
||
|
$fieldset->add($f);
|
||
|
|
||
|
if($this->wire()->user->hasPermission('logs-edit')) {
|
||
|
|
||
|
$f->addOption('add', $this->_('Grow (Add Entry)'));
|
||
|
$f->addOption('prune', $this->_('Chop (Prune)'));
|
||
|
$f->addOption('delete', $this->_('Burn (Delete)'));
|
||
|
|
||
|
/** @var InputfieldInteger $f */
|
||
|
$f = $modules->get('InputfieldInteger');
|
||
|
$f->attr('name', 'prune_days');
|
||
|
$f->label = $this->_('Chop To # Days');
|
||
|
$f->inputType = 'number';
|
||
|
$f->min = 1;
|
||
|
$f->icon = 'cut';
|
||
|
$f->description = $this->_('Reduce the size of the log file to contain only entries from the last [n] days.');
|
||
|
$f->notes = $this->_('Must be 1 or greater.');
|
||
|
$f->value = 30;
|
||
|
$f->showIf = "action=prune";
|
||
|
$fieldset->add($f);
|
||
|
|
||
|
/** @var InputfieldText $f */
|
||
|
$f = $modules->get('InputfieldText');
|
||
|
$f->attr('name', 'add_text');
|
||
|
$f->label = $this->_('New Log Entry');
|
||
|
$f->icon = 'leaf';
|
||
|
$f->showIf = "action=add";
|
||
|
$fieldset->add($f);
|
||
|
|
||
|
/** @var InputfieldSubmit $f */
|
||
|
$f = $modules->get('InputfieldSubmit');
|
||
|
$f->value = $this->_('Chop this log file now');
|
||
|
$f->icon = 'cut';
|
||
|
$f->attr('name', 'submit_prune');
|
||
|
$f->showIf = 'action=prune';
|
||
|
$fieldset->add($f);
|
||
|
|
||
|
/** @var InputfieldSubmit $f */
|
||
|
$f = $modules->get('InputfieldSubmit');
|
||
|
$f->value = $this->_('Burn this log now (permanently delete)');
|
||
|
$f->icon = 'fire';
|
||
|
$f->attr('name', 'submit_delete');
|
||
|
$f->showIf = 'action=delete';
|
||
|
$fieldset->add($f);
|
||
|
|
||
|
/** @var InputfieldSubmit $f */
|
||
|
$f = $modules->get('InputfieldSubmit');
|
||
|
$f->value = $this->_('Add this log entry');
|
||
|
$f->icon = 'leaf';
|
||
|
$f->attr('name', 'submit_add');
|
||
|
$f->showIf = 'action=add';
|
||
|
$fieldset->add($f);
|
||
|
}
|
||
|
|
||
|
/** @var InputfieldSubmit $f */
|
||
|
$f = $modules->get('InputfieldSubmit');
|
||
|
$f->value = $this->_('Download this log file now');
|
||
|
$f->icon = 'download';
|
||
|
$f->attr('name', 'submit_download');
|
||
|
$f->showIf = 'action=download';
|
||
|
$fieldset->add($f);
|
||
|
|
||
|
$this->headline(ucfirst($name));
|
||
|
$this->breadcrumb('../../', $this->wire()->page->title);
|
||
|
|
||
|
return
|
||
|
$form->render() .
|
||
|
"<div id='ProcessLogEntries'>" .
|
||
|
$this->renderLog($items, $name) .
|
||
|
"</div>";
|
||
|
}
|
||
|
|
||
|
protected function renderLogAjax(array $items, $name) {
|
||
|
$input = $this->wire()->input;
|
||
|
|
||
|
$time = (int) $input->get('time');
|
||
|
$render = true;
|
||
|
$qtyNew = 0;
|
||
|
$note = '';
|
||
|
if($time) {
|
||
|
foreach($items as $entry) {
|
||
|
$entryTime = strtotime($entry['date']);
|
||
|
if($entryTime > $time) $qtyNew++;
|
||
|
}
|
||
|
if(!$qtyNew) $render = false;
|
||
|
}
|
||
|
if($qtyNew) {
|
||
|
$note = sprintf($this->_n('One new log entry on page 1', 'Multiple new log entries on page 1', $qtyNew), $qtyNew);
|
||
|
$note .= " (" . date('H:i:s') . ")";
|
||
|
}
|
||
|
$data = array(
|
||
|
'qty' => -1,
|
||
|
'qtyNew' => 0,
|
||
|
'out' => '',
|
||
|
'note' => $note,
|
||
|
'time' => time(),
|
||
|
'url' => $input->url() . '?' . $input->queryString()
|
||
|
);
|
||
|
if($render) {
|
||
|
$data = array_merge($data, array(
|
||
|
'qty' => count($items),
|
||
|
'qtyNew' => $qtyNew,
|
||
|
'out' => $this->renderLog($items, $name, $time),
|
||
|
));
|
||
|
} else {
|
||
|
// leave default data, which tells it not to render anything
|
||
|
}
|
||
|
header("Content-type: application/json;");
|
||
|
return json_encode($data);
|
||
|
}
|
||
|
|
||
|
protected function renderLog(array $items, $name, $time = 0) {
|
||
|
|
||
|
$sanitizer = $this->wire()->sanitizer;
|
||
|
|
||
|
/** @var MarkupAdminDataTable $table */
|
||
|
$table = $this->wire()->modules->get('MarkupAdminDataTable');
|
||
|
$table->setSortable(false);
|
||
|
$table->setEncodeEntities(false);
|
||
|
$templateItem = reset($items);
|
||
|
$headers = array(
|
||
|
'date' => $this->_x('Date/Time', 'th'),
|
||
|
'user' => $this->_x('User', 'th'),
|
||
|
'url' => $this->_x('URL', 'th'),
|
||
|
'text' => $this->_x('Text', 'th'),
|
||
|
);
|
||
|
|
||
|
if(empty($templateItem['user']) && empty($templateItem['url'])) {
|
||
|
unset($templateItem['user'], $templateItem['url']);
|
||
|
$table->headerRow(array(
|
||
|
$headers['date'],
|
||
|
$headers['text'],
|
||
|
));
|
||
|
} else {
|
||
|
$table->headerRow($headers);
|
||
|
}
|
||
|
|
||
|
foreach($items as $entry) {
|
||
|
|
||
|
$ts = strtotime($entry['date']);
|
||
|
$date = wireRelativeTimeStr($entry['date']);
|
||
|
|
||
|
if($time && $ts > $time) {
|
||
|
// highlight new items
|
||
|
$date = "<i class='fa fa-leaf ProcessLogNew'></i> $date";
|
||
|
}
|
||
|
|
||
|
if(strpos($entry['text'], '&') !== false) {
|
||
|
$entry['text'] = $sanitizer->unentities($entry['text']);
|
||
|
}
|
||
|
|
||
|
foreach($entry as $key => $value) {
|
||
|
$entry[$key] = $sanitizer->entities($value);
|
||
|
}
|
||
|
|
||
|
$row = array("$date<br /><span class='detail'>$entry[date]</span>");
|
||
|
|
||
|
if(count($templateItem) >= 4) {
|
||
|
|
||
|
$row[] = $entry['user'];
|
||
|
|
||
|
$entry['url'] = preg_replace('{^https?://[^/]+}', '', $entry['url']);
|
||
|
$url = $entry['url'];
|
||
|
if($url == '/?/') {
|
||
|
$url = 2; // array key
|
||
|
$entry['url'] = '?';
|
||
|
}
|
||
|
$urlLabel = $this->formatLogUrlLabel($entry['url']);
|
||
|
$row[$urlLabel] = $url;
|
||
|
}
|
||
|
|
||
|
$row[] = $this->formatLogText($entry['text'], $name);
|
||
|
|
||
|
$table->row($row);
|
||
|
}
|
||
|
|
||
|
/** @var LogEntriesArray $entries */
|
||
|
$entries = $this->wire(new LogEntriesArray());
|
||
|
|
||
|
if(count($items)) {
|
||
|
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);
|
||
|
$pagerHeadline = $entries->getPaginationString();
|
||
|
$pagerHeadline .= " " .
|
||
|
"<small class='ui-priority-secondary'>(" .
|
||
|
($pager->isLastPage() ? $this->_('actual') : $this->_('estimate')) .
|
||
|
")</small>";
|
||
|
$iconClass = '';
|
||
|
} else {
|
||
|
$pagerHeadline = $this->_('No matching log entries');
|
||
|
$iconClass = 'fa-rotate-270';
|
||
|
$pagerOut = '';
|
||
|
}
|
||
|
|
||
|
$pageNum = $this->wire('input')->pageNum();
|
||
|
$time = time();
|
||
|
|
||
|
$out =
|
||
|
"<div id='ProcessLogPage' data-page='$pageNum' data-time='$time'>" .
|
||
|
$pagerOut .
|
||
|
"<h2 id='ProcessLogHeadline'>" .
|
||
|
"<i id='ProcessLogSpinner' class='fa fa-fw fa-lg fa-tree $iconClass'></i> $pagerHeadline " .
|
||
|
"<small class='notes'></small></h2>" .
|
||
|
$table->render() .
|
||
|
"<div class='ui-helper-clearfix'>$pagerOut</div>" .
|
||
|
"</div>";
|
||
|
|
||
|
return $out;
|
||
|
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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], "<br /><span class='notes'>" . ucfirst($matches[1]) . "</span>", $text);
|
||
|
} else if(strpos($text, '(in ') && preg_match('!\((in /[^)]+? line \d+)\)!', $text, $matches)) {
|
||
|
$text = str_replace($matches[0], "<br /><span class='notes'>" . ucfirst($matches[1]) . "</span>", $text);
|
||
|
}
|
||
|
if(strpos($text, ' #0 /')) {
|
||
|
list($text, $traces) = explode(' #0 /', $text, 2);
|
||
|
$traces = preg_split('! #\d+ /!', $traces);
|
||
|
$text .= "<span class='detail'>";
|
||
|
foreach($traces as $key => $trace) {
|
||
|
$n = $key + 1;
|
||
|
$text .= "<br />$n. /$trace";
|
||
|
}
|
||
|
$text .= "</span>";
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// identify recurring instances
|
||
|
if(strpos($text, ' ^+')) {
|
||
|
$_text = $text;
|
||
|
list($text, $qty) = explode(' ^+', $text, 2);
|
||
|
if(ctype_digit($qty)) {
|
||
|
$text .= "<br />" .
|
||
|
"<span class='detail'>" .
|
||
|
sprintf($this->_n('Plus %d earlier duplicate ', 'Plus %d earlier duplicates', $qty), $qty) .
|
||
|
"</span>";
|
||
|
} else {
|
||
|
// oops, restore
|
||
|
$text = $_text;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $text;
|
||
|
}
|
||
|
|
||
|
}
|