artabro/wire/modules/System/SystemNotifications/SystemNotifications.module

850 lines
26 KiB
Text
Raw Permalink Normal View History

2024-08-27 11:35:37 +02:00
<?php namespace ProcessWire;
/**
* System Notifications for ProcessWire
*
* By Avoine and Ryan Cramer
*
* @property int $disabled Notification status (0=off, 1=on)
* @property int $reverse Notification order (0=newest to oldest, 1=oldest to newest)
* @property int $placement
* @property array $activeHooks Active automatic notification hooks (0=404s, 1=logins, 2=logouts)
* @property int|bool $trackEdits Whether or not to track page edits
* @property string $systemUserName Name of user that receives system notifications
* @property int $systemUserID ID of user that receives system notifications
* @property int $updateDelay Time between updates in ms (default=5000)
* @property string $iconMessage default icon for message notifications
* @property string $iconWarning default icon for warning notifications
* @property string $iconError default icon for error notifications
* @property string $iconProgress icon for any item that has an active progress bar
* @property string $iconDebug default icon for debug-mode notification
* @property int $ghostDelay how long a ghost appears on screen (in ms)
* @property int $ghostDelayError how long an error ghost appears on screen (in ms)
* @property int $ghostFadeSpeed speed at which ghosts fade in or out, or blank for no fade
* @property int $ghostOpacity full opacity of ghost (when fully faded in)
* @property int $ghostPos ghost position: 1=left, 2=right
* @property int $ghostLimit only show 1 summary ghost if there are more than this number
* @property string $dateFormat date format to use in notifications (anything compatible with wireDate() function)
*
*/
class SystemNotifications extends WireData implements Module {
public static function getModuleInfo() {
return array(
'title' => 'System Notifications',
'version' => 12,
'summary' => 'Adds support for notifications in ProcessWire (currently in development)',
'autoload' => true,
'installs' => 'FieldtypeNotifications',
'icon' => 'bell',
);
}
const fieldName = 'notifications';
/**
* System hooks that may be configured as active in this module
*
* Each consists of: before|after hookToClass::hooktoMethod myHookMethod
*
*/
protected $systemHooks = array(
0 => 'after ProcessPageView::pageNotFound hook404',
1 => 'after Session::login hookLogin',
2 => 'after Session::logoutSuccess hookLogout',
);
/**
* Construction SystemNotifications
*
*/
public function __construct() {
$path = dirname(__FILE__) . '/';
require_once($path . "Notification.php");
require_once($path . "NotificationArray.php");
require_once($path . "SystemNotificationsConfig.php");
// hook method to access notifications, in case field name ever needs to change for some reason
$this->addHook('User::notifications', $this, 'hookUserNotifications');
$this->set('disabled', false);
}
/**
* API init: attach hooks
*
*/
public function init() {
if($this->disabled) return;
if($this->activeHooks) foreach($this->activeHooks as $id) {
if(!isset($this->systemHooks[$id])) continue;
list($when, $hook, $method) = explode(' ', $this->systemHooks[$id]);
if($when == 'before') {
$this->addHookBefore($hook, $this, $method);
} else {
$this->addHookAfter($hook, $this, $method);
}
}
}
/**
* API ready
*
*/
public function ready() {
if($this->disabled) return;
$page = $this->wire('page');
$config = $this->wire('config');
$user = $this->wire('user');
if(!$user->isLoggedin()) return;
$this->testProgressNotification();
if($this->wire('config')->ajax) {
$adminPage = $this->wire('pages')->get($config->adminRootPageID);
if($page->parents()->has($adminPage)) {
$ajaxAction = $this->wire('input')->get('Notifications');
if($ajaxAction) $this->ajaxAction($ajaxAction, $user->get(self::fieldName), $user);
}
}
if($page->template == 'admin') {
if(!$this->wire('input')->get('modal')) {
$this->addHookAfter('AdminTheme::getExtraMarkup', $this, 'hookAdminThemeGetExtraMarkup');
}
if($page->process == 'ProcessModule' && !$this->disabled) {
$this->wire()->modules->addHookAfter('isUninstallable', function(HookEvent $event) {
$class = $event->arguments(0);
if($class !== $this->className()) return;
$returnReason = $event->arguments(1);
if($returnReason) {
$event->return = 'You must set “Notification status” to “Off” before uninstalling.';
} else {
$event->return = false;
}
});
}
}
}
/**
* Test out the progress bar notification
*
*/
protected function testProgressNotification() {
$session = $this->wire('session');
/** @var NotificationArray $notifications */
$notifications = $this->wire('user')->notifications();
if($this->wire('input')->get('test_progress')) {
// start new progress bar notification
/** @var Notification $notification */
$notification = $this->wire('user')->notifications()->message('Testing progress bar notification');
$notification->progress = 0;
$notification->flag('annoy');
$value = $session->get($this, 'test_progress');
if(!is_array($value)) $value = array();
$id = $notification->getID();
$value[$id] = $id;
$session->set($this, 'test_progress', $value);
$notifications->save();
} else if(($value = $session->get($this, 'test_progress')) && count($value)) {
// updating existing progress bar notification(s)
foreach($value as $id) {
$notification = $notifications->get($id);
if(!$notification) continue;
$notification->progress += 10;
if($notification->progress < 100) {
$notification->html = "<p>$notification->progress%</p>";
continue;
}
unset($value[$id]);
$notification->title = "Your download is now complete!";
$notification->flag('open');
$notification->flag('email');
$notification->html =
"<p>This is just an example for demo purposes and the button below doesn't actually do anything.<br />" .
"<button class='ui-button'><strong class='ui-button-text'>Download</strong></button></p>";
}
$session->set($this, 'test_progress', $value);
$notifications->save();
}
}
/**
* Convert Notification object to array
*
* @param Notification $notification
* @return array
*
*/
protected function notificationToArray(Notification $notification) {
$html = $notification->html;
if(!$html && $notification->text) $html = "<p>" . $this->sanitizer->entities($notification->text) . "</p>";
$a = array(
'id' => $notification->getID(),
'title' => $notification->title,
'from' => $notification->from,
'created' => $notification->created,
'modified' => $notification->modified,
'when' => wireDate($this->dateFormat, $notification->modified),
'href' => $notification->href,
'icon' => $notification->icon,
'flags' => $notification->flags,
'flagNames' => implode(' ', $notification->flagNames),
'progress' => $notification->progress,
'html' => $html,
'qty' => $notification->qty,
'expires' => $notification->expires,
);
if($a['progress'] > 0 && $a['progress'] < 100) {
$a['icon'] = $this->iconProgress;
}
if(empty($a['icon'])) {
if($notification->is("error")) $a['icon'] = $this->iconError;
else if($notification->is("warning")) $a['icon'] = $this->iconWarning;
else $a['icon'] = $this->iconMessage;
}
return $a;
}
/**
* Process an ajax action request
*
* @param $action
* @param NotificationArray $notifications
* @param Page $page
*
*/
protected function ajaxAction($action, NotificationArray $notifications, Page $page) {
$data = array();
$qty = 0;
$qtyNew = 0;
$qtyMessage = 0;
$qtyWarning = 0;
$qtyError = 0;
$time = (int) $this->wire('input')->get('time');
$rm = $this->wire('input')->get('rm');
$rm = $rm ? explode(',', $rm) : array();
if($this->trackEdits) {
$processKey = $this->wire('input')->get('processKey');
$this->updateProcessKey($processKey);
}
foreach($notifications->sort('-modified') as $notification) {
/** @var Notification $notification */
$qty++;
$a = $this->notificationToArray($notification);
if(in_array($a['id'], $rm)) {
$qty--;
$notifications->remove($notification);
continue;
}
if($time && $notification->modified < $time) {
continue;
}
if($notification->is('shown')) {
continue;
} else {
$notification->setFlag('shown');
$qtyNew++;
}
if($notification->flags & Notification::flagError) $qtyError++;
else if($notification->flags & Notification::flagWarning) $qtyWarning++;
else $qtyMessage++;
$data[] = $a;
}
if(count($rm) || $qtyNew) {
$this->wire('pages')->saveField($page, 'notifications', array('quiet' => true));
}
if($action == 'update') {
$data = array(
'notifications' => $data, // new notifications only
'qty' => $qty, // total notifications (new or not)
'qtyNew' => $qtyNew, // quantity of new notifications, not yet shown
'qtyMessage' => $qtyMessage,
'qtyWarning' => $qtyWarning,
'qtyError' => $qtyError,
'time' => time(), // time this info was generated
);
}
header("Content-type: application/json");
echo json_encode($data);
exit;
}
/**
* Adds markup to admin theme output to initialize notifications
*
* @param $event
*
*/
public function hookAdminThemeGetExtraMarkup($event) {
if($this->disabled) return;
$config = $this->wire('config');
$url = $config->urls->SystemNotifications . 'Notifications';
$info = self::getModuleInfo();
$config->styles->add("$url.css?v=$info[version]");
$jsfile = $config->debug ? "$url.js" : "$url.min.js";
$config->scripts->add("$jsfile?v=$info[version]");
$qty = count($this->wire('user')->get(self::fieldName));
$ghostLimit = $this->ghostLimit ? $this->ghostLimit : 20;
$properties = array(
// configured property names
'updateDelay',
'iconMessage',
'iconWarning',
'iconError',
'ghostZindex',
'ghostDelay',
'ghostDelayError',
'ghostFadeSpeed',
'ghostOpacity',
'reverse',
);
$options = array(
// runtime property names
'version' => $info['version'],
'updateLast' => time(),
);
foreach($properties as $key) {
$options[$key] = $this->get($key);
}
$options['reverse'] = (bool) ((int) $options['reverse']);
// options specified in $config->SystemNotifications
$configDefaults = array(
'classMessage' => 'NoticeMessage',
'classWarning' => 'NoticeWarning',
'classError' => 'NoticeError',
'classContainer' => 'container',
);
$configOptions = $this->wire('config')->SystemNotifications;
if(!is_array($configOptions)) $configOptions = array();
$options = array_merge($options, $configDefaults, $configOptions);
$textdomain = '/wire/core/Functions.php';
$options['i18n'] = array(
'sec' => __('sec', $textdomain),
'secs' => __('secs', $textdomain),
'min' => __('min', $textdomain),
'mins' => __('mins', $textdomain),
'hour' => __('hour', $textdomain),
'hours' => __('hours', $textdomain),
'day' => __('day', $textdomain),
'days' => __('days', $textdomain),
'expires' => $this->_('expires'),
'now' => __('now', $textdomain),
'fromNow' => __('from now', $textdomain),
'ago' => __('ago', $textdomain),
);
if($this->trackEdits) {
$processKey = $this->makeProcessKey();
if(!empty($processKey)) $options['processKey'] = $processKey;
}
$ghostClass = $this->ghostPos == 2 ? "NotificationGhostsRight" : "NotificationGhostsLeft";
$out =
"<div id='NotificationMenu' class='NotificationMenu'>" .
"<ul id='NotificationList' class='NotificationList'></ul>" .
"</div>" .
"<ul id='NotificationGhosts' class='NotificationGhosts $ghostClass'></ul>" .
"<script>Notifications.init(" . json_encode($options) . "); ";
$notices = $this->wire('notices');
$notifications = array();
$numSave = 0; // number of notifications that need to be saved after being shown
// convert runtime Notices to Notifications and bundle into array
if($this->placement == SystemNotificationsConfig::placementCombined) {
foreach($notices as $notice) {
$notification = $this->noticeToNotification($notice);
if(!$notification) continue;
$sort = time() + 2592000;
while(isset($notifications[$sort])) $sort++;
$notifications[$sort] = $notification;
}
}
// bundle user notifications into the same array
foreach($this->wire('user')->notifications as $notification) {
if($notification->is('shown')) {
$notification->setFlag('no-ghost');
} else {
$notification->setFlag('shown');
$numSave++;
}
$sort = ((int) $notification->modified) + ((int) $notification->sort);
while(isset($notifications[$sort])) $sort++;
$notifications[$sort] = $notification;
}
if($this->reverse) {
ksort($notifications);
} else {
krsort($notifications);
}
$numMessages = 0;
$numWarnings = 0;
$numErrors = 0;
$noGhost = count($notifications) > $ghostLimit;
foreach($notifications as $notification) {
if($noGhost) $notification->setFlag("no-ghost");
$notificationJS = json_encode($this->notificationToArray($notification));
$out .= "\nNotifications.add($notificationJS); ";
if($notification->is("message")) $numMessages++;
else if($notification->is("warning")) $numWarnings++;
else if($notification->is("error")) $numErrors++;
if($noGhost) $notification->removeFlag("no-ghost"); // restore in case saved to DB
$notification->setFlag('shown');
}
if($noGhost) {
$ghostTypes = array(
"message" => $numMessages,
"warning" => $numWarnings,
"error" => $numErrors
);
foreach($ghostTypes as $type => $qty) {
if(!$qty) continue;
if($type == 'message') $title = sprintf($this->_n('%d new message', '%d new messages', $qty), $qty);
else if($type == 'warning') $title = sprintf($this->_n('%d new warning', '%d new warnings', $qty), $qty);
else if($type == 'error') $title = sprintf($this->_n('%d new error', '%d new errors', $qty), $qty);
else $title = '';
$icon = $this->get("icon" . ucfirst($type));
$out .= 'Notifications._ghost({"id":"","title":"' . $title . '","icon":"' . $icon . '","flagNames":"' . $type . ' notice"});';
}
}
if($numSave) {
// updates shown flag so notification doesn't pop up another ghost
$this->wire('user')->notifications->save();
}
if($this->placement == SystemNotificationsConfig::placementCombined) {
$out .= "$('#notices').remove(); "; // in case admin theme still has #notices
}
$out .=
"$(document).ready(function() { Notifications.render(); });" .
"</script>";
$extras = $event->return;
$extras['body'] .= $out;
$extras['masthead'] .=
"<div id='NotificationBug' class='NotificationBug qty$qty' data-qty='$qty'>" .
"<span class='qty fa fa-fw'>$qty</span>" .
"<i class='NotificationSpinner fa fa-fw fa-spin fa-spinner'></i>" .
"</div>";
$adminTheme = $this->wire('adminTheme');
if($adminTheme) $adminTheme->addBodyClass('NotificationPlacement' . (int) $this->placement);
$event->return = $extras;
}
/**
* Convert ProcessWire runtime "Notice" objects to runtime Notification objects
*
* @param Notice $notice
* @return Notification|bool Returns Notification or boolean false on error
*
*/
protected function noticeToNotification(Notice $notice) {
if($notice instanceof NoticeWarning || ($notice->flags & Notice::warning)) $type = 'warning';
else if($notice instanceof NoticeError) $type = 'error';
else $type = 'message';
/** @var NotificationArray $notifications */
$notifications = $this->wire('user')->notifications();
if(!$notifications) return false;
$notification = $notifications->getNew($type, false);
if(!$notification) return false;
$notification->setFlag('notice', true);
if($notice->flags & Notice::allowMarkup) $notification->setFlag('markup', true);
if($notice->flags & Notice::log) $notification->setFlag('log', true);
if($notice->flags & Notice::logOnly) $notification->setFlag('log-only', true);
if($notice->flags & Notice::debug) {
$notification->setFlag('debug', true);
$notification->icon = $this->iconDebug;
}
if($notice->class) $notification->from = $notice->class;
if($notice->timestamp) $notification->created = $notice->timestamp;
$title = strip_tags((string) $notice);
if(strlen($title) > 100) {
$title = substr($title, 0, 100);
$title = substr($title, 0, strrpos($title, ' ')) . '...';
$notification->title = $title;
if($notice->flags & Notice::allowMarkup) {
$notification->html = (string) $notice;
} else {
$notification->text = (string) $notice;
}
} else if($notice->flags & Notice::allowMarkup) {
$notification->title = $notice->text;
} else {
$notification->title = $title;
}
return $notification;
}
/**
* Adds automatic notification for every 404
*
* @param HookEvent $event
*
*/
public function hook404(HookEvent $event) {
/** @var Page $page */
$page = $event->arguments(0);
$url = $event->arguments(1);
/** @var User $user */
$user = $this->getSystemUser();
if(!$user->id) return;
if(isset($_SERVER['HTTP_REFERER'])) {
$referer = $this->wire('sanitizer')->entities($this->wire('sanitizer')->text($_SERVER['HTTP_REFERER']));
} else {
$referer = '';
}
if(isset($_SERVER['HTTP_USER_AGENT'])) {
$useragent = $this->wire('sanitizer')->entities($this->wire('sanitizer')->text($_SERVER['HTTP_USER_AGENT']));
} else {
$useragent = '';
}
if(empty($referer)) $referer = "unknown";
if(empty($useragent)) $useragent = "unknown";
/** @var NotificationArray $notifications */
$notifications = $user->notifications();
$notification = $notifications->warning(sprintf($this->_('404 occurred: %s'), $url));
$notification->expires = 30;
$notification->html =
"<p>" .
"<b>Referer:</b> $referer<br />" .
"<b>Useragent:</b> $useragent<br />" .
"<b>IP:</b> " . $this->wire('session')->getIP() . "<br />" .
"<b>Page:</b> " . ($page->id ? $page->url : 'Unknown') . "<br />" .
"<b>User:</b> " . $this->user->name .
"</p>";
$notifications->save();
}
/**
* Creates a notifications() method with the user
*
* @param HookEvent $event
*
*/
public function hookUserNotifications(HookEvent $event) {
$user = $event->object;
$notifications = $user->get(self::fieldName);
if(!$notifications) {
$this->install();
$notifications = $user->get(self::fieldName);
}
$event->return = $notifications;
}
/**
* Automatic notification for logins
*
* @param HookEvent $event
*
*/
public function hookLogin(HookEvent $event) {
$user = $this->getSystemUser();
if(!$user->id) return;
/** @var NotificationArray $notifications */
$notifications = $user->notifications();
$loginUser = $event->return;
$loginName = $event->arguments(0);
if($loginUser && $loginUser->id) {
$notification = $notifications->message(sprintf($this->_('User logged in: %s'), $loginName));
} else {
$notification = $notifications->error(sprintf($this->_('Login failure: %s'), $loginName));
}
$useragent = $this->wire('sanitizer')->entities($this->wire('sanitizer')->text($_SERVER['HTTP_USER_AGENT']));
$notification->html =
"<p>" .
"<b>Useragent:</b> $useragent<br />" .
"<b>Time:</b> " . date('Y-m-d H:i:s') . "<br />" .
"<b>IP:</b> " . $this->wire('session')->getIP() .
"</p>";
$notifications->save();
}
/**
* Automatic notification for logouts
*
* @param HookEvent $event
*
*/
public function hookLogout(HookEvent $event) {
$user = $this->getSystemUser();
if(!$user->id) return;
$logoutUser = $event->arguments(0);
/** @var NotificationArray $notifications */
$notifications = $user->notifications();
$notifications->message(sprintf($this->_('User logged out: %s'), $logoutUser->name));
$notifications->save();
}
/**
* Return the user that receives system notifications
*
* @return User
*
*/
public function getSystemUser() {
$user = null;
if($this->systemUserName) $user = $this->wire('users')->get($this->systemUserName);
if(!$user || !$user->id) $user = $this->wire('users')->get($this->systemUserID);
if(!$user->id) {
$role = $this->wire('roles')->get('superuser');
$user = $this->wire('users')->get("roles=$role, sort=-created, include=all");
}
return $user;
}
/**
* Install notifications
*
*/
public function ___install() {
$fieldtype = $this->modules->get('FieldtypeNotifications');
$field = $this->wire('fields')->get(self::fieldName);
if($field && !$field->type instanceof FieldtypeNotifications) {
throw new WireException("There is already a field named '" . self::fieldName . "'");
}
if(!$field) {
$field = $this->wire(new Field());
$field->name = self::fieldName;
$field->label = 'Notifications';
$field->type = $fieldtype;
$field->collapsed = Inputfield::collapsedBlank;
$field->flags = Field::flagSystem;
$field->save();
}
$fieldgroup = $this->wire('fieldgroups')->get('user');
if(!$fieldgroup->hasField($field)) {
$fieldgroup->add($field);
$fieldgroup->save();
}
// make this field one that the user is allowed to configure in their profile
// $data = $this->wire('modules')->getModuleConfigData('ProcessProfile');
// $data['profileFields'][] = 'notifications';
// $this->wire('modules')->saveModuleConfigData('ProcessProfile', $data);
$notifications = $this->wire('user')->get(self::fieldName);
if($notifications) {
$notifications->message('Hello World')->text('Thank you for installing the Notifications module. This is your first notification!');
$notifications->save();
}
}
/**
* Uninstall notifications
*
*/
public function ___uninstall() {
$fieldgroup = $this->wire('fieldgroups')->get('user');
$field = $this->wire('fields')->get(self::fieldName);
if($field) {
$field->flags = Field::flagSystemOverride;
$field->flags = 0;
if($fieldgroup->hasField($field)) {
$fieldgroup->remove($field);
$fieldgroup->save();
}
$this->wire('fields')->delete($field);
}
if($this->wire('modules')->isInstalled('FieldtypeNotifications')) {
$this->wire('modules')->uninstall('FieldtypeNotifications');
}
}
/**
* Update the current processKey and its time in the cache
*
* This method is called only during ajax requests.
*
* @param string $processKey
*
*/
protected function updateProcessKey($processKey) {
// NPK.ProcessPageEdit.pageID.userID.windowID
if(!preg_match('/^NPK\.[A-Za-z]+\.\d+\.\d+\.PW\d+$/', $processKey)) return;
$this->checkProcessKey($processKey);
$times = $this->wire('cache')->get($processKey);
if($times) {
list($created, $modified) = explode(':', $times);
if($modified) {} // unused
$value = $created . ":" . time();
} else {
$value = time() . ":" . time();
}
$this->wire('cache')->save($processKey, $value, ($this->updateDelay / 1000) * 2);
}
/**
* Create a new processKey for the current request
*
* processKey example: NPK.ProcessPageEdit.pageID.userID
* Note that it excludes the windowName, which is added at the end after the
* first ajax request.
*
* This method only runs during full requests, not during ajax requests.
*
* @return string
*
*/
protected function makeProcessKey() {
$process = $this->wire('process');
if($process && ($process instanceof ProcessPageEdit || $process instanceof ProcessPageType)) {
// good, we'll use it
$page = $process->getPage();
if(!$page || !$page->id) return '';
} else {
// we don't track this process
return '';
}
// NPK = NotificationProcessKey
$processKey = "NPK." .
$process->className() .
"." . $page->id .
"." . $this->wire('user')->id;
// reset because non-ajax request
$this->wire('session')->remove($this, 'notifiedProcessKeys');
return $processKey;
}
/**
* Given a processKey, check for conflicts with other active processKeys
*
* @param $processKey
*
*/
protected function checkProcessKey($processKey) {
list($prefix, $className, $pageID, $userID, $windowName) = explode('.', $processKey);
if($userID) {} // unused
// locate all currently active processKeys editing $page
$processKeys = $this->wire('cache')->get("$prefix.$className.$pageID.*");
$notified = $this->wire('session')->getFor($this, 'notifiedProcessKeys');
if(!is_array($notified)) $notified = array();
foreach($processKeys as $_processKey => $times) {
if(isset($notified[$processKey]) && in_array($_processKey, $notified[$processKey])) continue;
list($created, $modified) = explode(":", $times);
list($_prefix, $_className, $_pageID, $_userID, $_windowName) = explode('.', $_processKey);
if($modified || $_prefix || $_className || $_pageID) {} // unused
$recordNotify = false;
if($_userID == $this->wire('user')->id) {
// same user
if($_windowName == $windowName) {
// this is the window we are already in and this is OK to skip
} else {
// editing in different window
$this->wire('user')->notifications()->error(
sprintf(
$this->_('Warning: you are editing this page in another window (editing started %s)'),
wireDate('%X', (int) $created)),
Notification::flagAnnoy | Notification::flagSession);
$recordNotify = true;
}
} else {
// different user
$editingUser = $this->wire('users')->get((int) $_userID);
if($editingUser->id) {
$this->wire('user')->notifications()->error(
sprintf($this->_('Warning: user "%s" is currently editing this page (editing started %s)'),
$editingUser->name, wireDate('%X', (int) $created)),
Notification::flagAnnoy | Notification::flagSession);
$recordNotify = true;
}
}
if($recordNotify) {
if(isset($notified[$processKey])) $notified[$processKey][] = $_processKey;
else $notified[$processKey] = array($_processKey);
}
}
if(count($notified)) {
$this->wire('session')->setFor($this, 'notifiedProcessKeys', $notified);
}
}
}