diff --git a/modules/changelog/changelog.php b/modules/changelog/changelog.php index e851980e1..ebddadeb2 100644 --- a/modules/changelog/changelog.php +++ b/modules/changelog/changelog.php @@ -29,6 +29,7 @@ use Admidio\Users\Entity\User; use Admidio\Changelog\Service\ChangelogService; use Admidio\Roles\Entity\Role; +use Admidio\Hooks\Hooks; @@ -144,6 +145,7 @@ $headline = $gL10n->get('SYS_CHANGE_HISTORY_GENERIC2', [$objName, implode(', ', $tableTitles)]); } } + $headline = Hooks::apply_filters('changelog_headline', $headline); // add page to navigation history $gNavigation->addUrl(CURRENT_URL, $headline); diff --git a/src/Hooks/EventDispatcher.php b/src/Hooks/EventDispatcher.php new file mode 100644 index 000000000..797811e4f --- /dev/null +++ b/src/Hooks/EventDispatcher.php @@ -0,0 +1,28 @@ +provider->getListenersForEvent($event) as $listener) { + $listener($event); + if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) { + break; + } + } + return $event; + } +} diff --git a/src/Hooks/GenericActionEvent.php b/src/Hooks/GenericActionEvent.php new file mode 100644 index 000000000..13f8ced74 --- /dev/null +++ b/src/Hooks/GenericActionEvent.php @@ -0,0 +1,23 @@ + */ + private array $args = [] + ) {} + + public function name(): string { return $this->name; } + /** @return list */ + public function args(): array { return $this->args; } + + public function stopPropagation(): void { $this->stopped = true; } + public function isPropagationStopped(): bool { return $this->stopped; } +} diff --git a/src/Hooks/GenericFilterEvent.php b/src/Hooks/GenericFilterEvent.php new file mode 100644 index 000000000..b0391c025 --- /dev/null +++ b/src/Hooks/GenericFilterEvent.php @@ -0,0 +1,27 @@ + $args Additional context args after $value + */ + public function __construct( + private string $name, + private mixed $value, + private array $args = [] + ) {} + + public function name(): string { return $this->name; } + public function get(): mixed { return $this->value; } + public function set(mixed $value): void { $this->value = $value; } + /** @return list */ + public function args(): array { return $this->args; } + + public function stopPropagation(): void { $this->stopped = true; } + public function isPropagationStopped(): bool { return $this->stopped; } +} diff --git a/src/Hooks/HookListenerProvider.php b/src/Hooks/HookListenerProvider.php new file mode 100644 index 000000000..5a5d73f26 --- /dev/null +++ b/src/Hooks/HookListenerProvider.php @@ -0,0 +1,102 @@ +>> */ + private array $actions = []; + /** @var array>> */ + private array $filters = []; + + public function addAction(string $name, callable $listener, int $priority = 10): void + { + $this->actions[$name][$priority][] = $listener; + } + + public function addFilter(string $name, callable $listener, int $priority = 10): void + { + $this->filters[$name][$priority][] = $listener; + } + + public function removeAction(string $name, callable $listener, ?int $priority = null): bool + { + return $this->removeFrom($this->actions, $name, $listener, $priority); + } + + public function removeFilter(string $name, callable $listener, ?int $priority = null): bool + { + return $this->removeFrom($this->filters, $name, $listener, $priority); + } + + public function hasAction(string $name): bool + { + return !empty($this->actions[$name]); + } + + public function hasFilter(string $name): bool + { + return !empty($this->filters[$name]); + } + + public function getListenersForEvent(object $event): iterable + { + if ($event instanceof GenericActionEvent) { + $name = $event->name(); + yield from $this->sorted($this->actions[$name] ?? []); + } elseif ($event instanceof GenericFilterEvent) { + $name = $event->name(); + yield from $this->sorted($this->filters[$name] ?? []); + } + } + + /** @param array> $byPriority */ + private function sorted(array $byPriority): iterable + { + if (!$byPriority) { + return; + } + // WordPress semantics: lower priority runs earlier. + ksort($byPriority, SORT_NUMERIC); + foreach ($byPriority as $listeners) { + foreach ($listeners as $l) { + yield $l; + } + } + } + + /** + * @param array>> $bucket + */ + private function removeFrom(array &$bucket, string $name, callable $listener, ?int $priority): bool + { + if (!isset($bucket[$name])) { + return false; + } + $found = false; + + $scan = $priority !== null ? [$priority => ($bucket[$name][$priority] ?? [])] : $bucket[$name]; + + foreach ($scan as $prio => $list) { + foreach ($list as $idx => $l) { + // Strict comparison won't work for all callables; fallback to string form when possible. + if ($l === $listener || (is_object($l) && is_object($listener) && spl_object_hash($l) === spl_object_hash($listener))) { + unset($bucket[$name][$prio][$idx]); + $found = true; + } + } + if (isset($bucket[$name][$prio]) && empty($bucket[$name][$prio])) { + unset($bucket[$name][$prio]); + } + } + if (empty($bucket[$name])) { + unset($bucket[$name]); + } + return $found; + } +} diff --git a/src/Hooks/Hooks.php b/src/Hooks/Hooks.php new file mode 100644 index 000000000..50a741d13 --- /dev/null +++ b/src/Hooks/Hooks.php @@ -0,0 +1,154 @@ +args(); + if ($accepted_args !== null) { + $args = array_slice($args, 0, max(0, $accepted_args)); + } + $callback(...$args); + } catch (\Throwable $t) { + $i->logger?->error('Action listener error', [ + 'hook' => $e->name(), + 'exception' => $t, + ]); + // Actions swallow exceptions by default; mark as stopped only if desired: + // $e->stopPropagation(); + } + }; + $i->provider->addAction($name, $listener, $priority); + } + + /** + * @param callable $callback function($value, ...$args): mixed + */ + public static function add_filter(string $name, callable $callback, int $priority = 10, ?int $accepted_args = null): void + { + $i = self::i(); + $listener = function (GenericFilterEvent $e) use ($callback, $accepted_args, $i): void { + try { + $args = [$e->get(), ...$e->args()]; + if ($accepted_args !== null) { + $args = array_slice($args, 0, max(0, $accepted_args)); + } + $result = $callback(...$args); + $e->set($result); + } catch (\Throwable $t) { + $i->logger?->error('Filter listener error', [ + 'hook' => $e->name(), + 'exception' => $t, + ]); + // On failure, keep current value and continue. + } + }; + $i->provider->addFilter($name, $listener, $priority); + } + + public static function remove_action(string $name, callable $callback, ?int $priority = null): bool + { + return self::i()->provider->removeAction($name, $callback, $priority); + } + + public static function remove_filter(string $name, callable $callback, ?int $priority = null): bool + { + return self::i()->provider->removeFilter($name, $callback, $priority); + } + + public static function has_action(string $name): bool + { + return self::i()->provider->hasAction($name); + } + + public static function has_filter(string $name): bool + { + return self::i()->provider->hasFilter($name); + } + + // ---------- Dispatch ---------- + + /** + * do_action('user_created', $user, $actorId) + */ + public static function do_action(string $name, mixed ...$args): void + { + $i = self::i(); + $event = new GenericActionEvent($name, $args); + $i->dispatcher->dispatch($event); + } + + /** + * $subject = apply_filters('mail_subject', $subject, $context); + */ + public static function apply_filters(string $name, mixed $value, mixed ...$args): mixed + { + $i = self::i(); + $event = new GenericFilterEvent($name, $value, $args); + /** @var GenericFilterEvent $out */ + $out = $i->dispatcher->dispatch($event); + return $out->get(); + } + + // ---------- Optional: short-circuit helpers ---------- + + /** + * Stop remaining listeners for an ongoing action/filter by name. + * Only meaningful when called inside a listener; provided for convenience. + */ + public static function stop_propagation(GenericActionEvent|GenericFilterEvent $event): void + { + $event->stopPropagation(); + } +} diff --git a/src/Users/Entity/User.php b/src/Users/Entity/User.php index f466b5015..7248b6cfe 100644 --- a/src/Users/Entity/User.php +++ b/src/Users/Entity/User.php @@ -14,6 +14,7 @@ use Admidio\Infrastructure\Utils\SecurityUtils; use Admidio\Infrastructure\Utils\StringUtils; use Admidio\Changelog\Entity\LogChanges; +use Admidio\Hooks\Hooks; use RobThree\Auth\TwoFactorAuth; use RobThree\Auth\Providers\Qr\QRServerProvider; @@ -1906,6 +1907,10 @@ public function save(bool $updateFingerPrint = true): bool // Register all non-empty fields for the notification $gChangeNotification->logUserCreation($usrId, $this); } + if ($newRecord) { + Hooks::do_action('user_created', $this, $gCurrentUser); + + } $this->db->endTransaction(); @@ -2304,7 +2309,8 @@ public function getProfileFieldsData() */ public function readableName(): string { - return $this->mProfileFieldsData->getValue('LAST_NAME') . ', ' . $this->mProfileFieldsData->getValue('FIRST_NAME'); + return Hooks::apply_filters('user_readable_name', $this->mProfileFieldsData->getValue('LAST_NAME') . ', ' . $this->mProfileFieldsData->getValue('FIRST_NAME'), $this); + //return $this->mProfileFieldsData->getValue('LAST_NAME') . ', ' . $this->mProfileFieldsData->getValue('FIRST_NAME'); } /** diff --git a/system/common.php b/system/common.php index db1e15887..69e54e818 100644 --- a/system/common.php +++ b/system/common.php @@ -288,3 +288,24 @@ } else { $gHomepage = ADMIDIO_URL . '/' . $gSettingsManager->getString('homepage_logout'); } + +use Admidio\Hooks\Hooks; +// Register in your plugin bootstrap +Hooks::add_action('user_created', function ($user, $actorId) { + global $gLogger; + $gLogger->warning('User Created: ', array('user' => $user, 'actorID' => $actorId)); + // log, sync, enqueue job, etc. +}, priority: 20, accepted_args: 2); + +Hooks::add_filter('changelog_headline', function ($value) { + return "HOOKED: {" . $value . "}"; +}, priority: 5, accepted_args: 2); +Hooks::add_filter('user_readable_name', function ($value, $user) { + return "HOOKED: {" . $value . "}"; +}, priority: 5, accepted_args: 2); + + +// Later in core: +// Hooks::do_action('user_created', $user, $actorId); + +/// $name = Hooks::apply_filters('display_name', $user->fullname, $user); \ No newline at end of file