Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/changelog/changelog.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use Admidio\Users\Entity\User;
use Admidio\Changelog\Service\ChangelogService;
use Admidio\Roles\Entity\Role;
use Admidio\Hooks\Hooks;



Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions src/Hooks/EventDispatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php
// src/Admidio/Hooks/Contracts.php
namespace Admidio\Hooks;

use Psr\EventDispatcher\ListenerProviderInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\StoppableEventInterface;

/**
* Minimal PSR-14 dispatcher driven by a ListenerProvider.
*/
final class EventDispatcher implements EventDispatcherInterface
{
public function __construct(
private ListenerProviderInterface $provider
) {}

public function dispatch(object $event): object
{
foreach ($this->provider->getListenersForEvent($event) as $listener) {
$listener($event);
if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) {
break;
}
}
return $event;
}
}
23 changes: 23 additions & 0 deletions src/Hooks/GenericActionEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
// src/Admidio/Hooks/Events.php
namespace Admidio\Hooks;

use Psr\EventDispatcher\StoppableEventInterface;

final class GenericActionEvent implements StoppableEventInterface
{
private bool $stopped = false;

public function __construct(
private string $name,
/** @var list<mixed> */
private array $args = []
) {}

public function name(): string { return $this->name; }
/** @return list<mixed> */
public function args(): array { return $this->args; }

public function stopPropagation(): void { $this->stopped = true; }
public function isPropagationStopped(): bool { return $this->stopped; }
}
27 changes: 27 additions & 0 deletions src/Hooks/GenericFilterEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php
namespace Admidio\Hooks;

use Psr\EventDispatcher\StoppableEventInterface;

final class GenericFilterEvent implements StoppableEventInterface
{
private bool $stopped = false;

/**
* @param list<mixed> $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<mixed> */
public function args(): array { return $this->args; }

public function stopPropagation(): void { $this->stopped = true; }
public function isPropagationStopped(): bool { return $this->stopped; }
}
102 changes: 102 additions & 0 deletions src/Hooks/HookListenerProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php
// src/Admidio/Hooks/HookListenerProvider.php
namespace Admidio\Hooks;

use Psr\EventDispatcher\ListenerProviderInterface;

/**
* Stores closures keyed by hook name and priority; yields callables for matching events.
*/
final class HookListenerProvider implements ListenerProviderInterface
{
/** @var array<string, array<int, list<callable>>> */
private array $actions = [];
/** @var array<string, array<int, list<callable>>> */
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<int, list<callable>> $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<string, array<int, list<callable>>> $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;
}
}
154 changes: 154 additions & 0 deletions src/Hooks/Hooks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php
// src/Hooks/Hooks.php
namespace Admidio\Hooks;

use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Admidio\Hooks\Events;

/**
* Developer-facing facade: add_action, add_filter, do_action, apply_filters, remove_*, has_*.
*/
final class Hooks
{
private static ?self $instance = null;

private function __construct(
private HookListenerProvider $provider,
private EventDispatcherInterface $dispatcher,
private ?LoggerInterface $logger = null
) {}

public static function boot(?LoggerInterface $logger = null): void
{
// Idempotent boot
if (self::$instance instanceof self) {
return;
}
$provider = new HookListenerProvider();
$dispatcher = new EventDispatcher($provider);
self::$instance = new self($provider, $dispatcher, $logger);
}

/** Allow DI if you have a container */
public static function with(HookListenerProvider $provider, EventDispatcherInterface $dispatcher, ?LoggerInterface $logger = null): void
{
self::$instance = new self($provider, $dispatcher, $logger);
}

private static function i(): self
{
if (!self::$instance) {
self::boot();
}
return self::$instance;
}

// ---------- Registration ----------

/**
* WordPress-like signature.
* @param callable $callback function(...$args): void
*/
public static function add_action(string $name, callable $callback, int $priority = 10, ?int $accepted_args = null): void
{
$i = self::i();
$listener = function (GenericActionEvent $e) use ($callback, $accepted_args, $i): void {
try {
$args = $e->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();
}
}
8 changes: 7 additions & 1 deletion src/Users/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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');
}

/**
Expand Down
Loading