OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
42
app/Core/Events/Concerns/InteractsWithEvents.php
Normal file
42
app/Core/Events/Concerns/InteractsWithEvents.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Concerns;
|
||||
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
|
||||
/**
|
||||
* Shared behavior for class-based domain events.
|
||||
*
|
||||
* Provides the static dispatch() ergonomic and a default empty legacyHooks() so events
|
||||
* introduced after the class-based system don't need to declare one:
|
||||
*
|
||||
* TicketUpdated::dispatch(ticketId: $id);
|
||||
*
|
||||
* Do NOT combine with Laravel's Dispatchable trait — both define dispatch(), and the
|
||||
* Dispatchable version routes through the generic object path instead of the
|
||||
* LeantimeEvent fast path.
|
||||
*/
|
||||
trait InteractsWithEvents
|
||||
{
|
||||
/**
|
||||
* Default: no legacy string names. Override during the migration window with the
|
||||
* exact historical leantime.* name of the CURRENT emit site — rebuilt from a
|
||||
* `legacyHook: __FUNCTION__` constructor discriminator when several methods
|
||||
* historically fired the same raw hook (see the LeantimeEvent docblock).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function legacyHooks(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct and dispatch this event through the Leantime event dispatcher.
|
||||
* Arguments (named arguments included) are forwarded to the constructor.
|
||||
*/
|
||||
public static function dispatch(mixed ...$args): void
|
||||
{
|
||||
EventDispatcher::dispatch_event(new static(...$args));
|
||||
}
|
||||
}
|
||||
57
app/Core/Events/Concerns/InteractsWithFilters.php
Normal file
57
app/Core/Events/Concerns/InteractsWithFilters.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Concerns;
|
||||
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
|
||||
/**
|
||||
* Shared behavior for class-based filters.
|
||||
*
|
||||
* Provides the static dispatch() / instance apply() ergonomics and sensible defaults
|
||||
* for the LeantimeFilter contract:
|
||||
*
|
||||
* $tickets = TodoWidgetTasksFilter::dispatch(tickets: $tickets, userId: $userId);
|
||||
*
|
||||
* The default payload() returns the $payload property; filter classes that name their
|
||||
* payload something more meaningful (e.g. public array $tickets) override payload().
|
||||
*/
|
||||
trait InteractsWithFilters
|
||||
{
|
||||
/**
|
||||
* Default: no legacy string names. Override during the migration window with the
|
||||
* exact historical leantime.* name of the CURRENT emit site — rebuilt from a
|
||||
* `legacyHook: __FUNCTION__` constructor discriminator when several methods
|
||||
* historically ran the same raw hook (see the LeantimeFilter docblock).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function legacyHooks(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default payload accessor. Override when the payload property has a domain name.
|
||||
*/
|
||||
public function payload(): mixed
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the filter and run the pipeline, returning the filtered payload.
|
||||
* Arguments (named arguments included) are forwarded to the constructor.
|
||||
*/
|
||||
public static function dispatch(mixed ...$args): mixed
|
||||
{
|
||||
return EventDispatcher::dispatch_class_filter(new static(...$args));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pipeline for an already-constructed filter, returning the filtered payload.
|
||||
*/
|
||||
public function apply(): mixed
|
||||
{
|
||||
return EventDispatcher::dispatch_class_filter($this);
|
||||
}
|
||||
}
|
||||
53
app/Core/Events/Contracts/LeantimeEvent.php
Normal file
53
app/Core/Events/Contracts/LeantimeEvent.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Contracts;
|
||||
|
||||
/**
|
||||
* Contract for class-based domain events.
|
||||
*
|
||||
* Event classes live in app/Domain/{Domain}/Events/ (or app/Core/{Module}/Events/ for
|
||||
* core modules), are named {Entity}{Verb} with the verb taken from the central
|
||||
* {@see \Leantime\Core\Events\EventVerb} vocabulary, and carry their typed payload as
|
||||
* public (constructor-promoted) properties.
|
||||
*
|
||||
* Listeners subscribe to the class itself:
|
||||
*
|
||||
* EventDispatcher::add_event_listener(TicketUpdated::class, MyListener::class);
|
||||
*
|
||||
* and receive the bare event object — `MyListener::handle(TicketUpdated $event)`.
|
||||
*
|
||||
* MIGRATION WINDOW: legacyHooks() returns the exact historical string name(s) the
|
||||
* CURRENT emit site fired under (the auto-generated
|
||||
* leantime.domain.{...}.{method}.{rawHook} strings). The dispatcher dual-emits to those
|
||||
* names so existing string/wildcard listeners — plugins in particular — keep firing
|
||||
* with today's array payload, without coordinated releases.
|
||||
*
|
||||
* IMPORTANT: never statically list ALL historical emit sites — that would fire every
|
||||
* site's name on every dispatch (exact subscribers fire under the wrong conditions,
|
||||
* wildcard subscribers fire once per name instead of once per event). When the same
|
||||
* raw hook historically fired from several methods, take a constructor discriminator
|
||||
* and have each call site pass its own method name — `legacyHook: __FUNCTION__` — so
|
||||
* each dispatch rebuilds the single name that site produced (see the Tickets pilot
|
||||
* events for the pattern).
|
||||
*
|
||||
* Remove the entries (and eventually the mechanism) once all consumers have migrated
|
||||
* to the FQCN. Mirrors the client-side
|
||||
* {@see \Leantime\Core\Events\Htmx\HtmxEvents} LEGACY_ALIASES window.
|
||||
*
|
||||
* Use the {@see \Leantime\Core\Events\Concerns\InteractsWithEvents} trait for the
|
||||
* static dispatch() ergonomic and the default empty legacyHooks().
|
||||
*/
|
||||
interface LeantimeEvent
|
||||
{
|
||||
/**
|
||||
* The exact historical dotted string name(s) the CURRENT emit site fired under —
|
||||
* at most one name per dispatch in practice. When several methods historically
|
||||
* emitted the same raw hook, rebuild the right name from a
|
||||
* `legacyHook: __FUNCTION__` constructor discriminator; never statically list all
|
||||
* sites (see class docblock). Empty for events introduced after the class-based
|
||||
* system.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function legacyHooks(): array;
|
||||
}
|
||||
45
app/Core/Events/Contracts/LeantimeFilter.php
Normal file
45
app/Core/Events/Contracts/LeantimeFilter.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Contracts;
|
||||
|
||||
/**
|
||||
* Contract for class-based filters (the return-value pipeline counterpart of events).
|
||||
*
|
||||
* Filter classes live next to events in Events/, are named {Thing}Filter
|
||||
* (TodoWidgetTasksFilter), hold the initial payload plus typed context as public
|
||||
* (constructor-promoted) properties, and return the filtered payload from apply().
|
||||
*
|
||||
* Listeners subscribe to the class itself and keep the familiar filter signature —
|
||||
* they receive the current payload and the filter object as context, and must return
|
||||
* the (possibly modified) payload:
|
||||
*
|
||||
* EventDispatcher::add_filter_listener(TodoWidgetTasksFilter::class,
|
||||
* fn ($tickets, TodoWidgetTasksFilter $filter) => $tickets);
|
||||
*
|
||||
* MIGRATION WINDOW: legacyHooks() returns the exact historical string name(s) of the
|
||||
* CURRENT emit site; the dispatcher threads the payload through listeners on the FQCN
|
||||
* first, then through each legacy name where listeners receive today's
|
||||
* ($payload, $availableParams) array signature unchanged. When several methods
|
||||
* historically ran the same raw hook, rebuild the right name from a
|
||||
* `legacyHook: __FUNCTION__` constructor discriminator — never statically list all
|
||||
* sites. See {@see LeantimeEvent} for the full rationale.
|
||||
*
|
||||
* Use the {@see \Leantime\Core\Events\Concerns\InteractsWithFilters} trait for the
|
||||
* payload()/apply() plumbing and the default empty legacyHooks().
|
||||
*/
|
||||
interface LeantimeFilter
|
||||
{
|
||||
/**
|
||||
* The initial payload to thread through the filter pipeline.
|
||||
*/
|
||||
public function payload(): mixed;
|
||||
|
||||
/**
|
||||
* The exact historical dotted string name(s) the CURRENT emit site ran under.
|
||||
* Use a `legacyHook: __FUNCTION__` constructor discriminator when several methods
|
||||
* historically ran the same raw hook (see class docblock).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function legacyHooks(): array;
|
||||
}
|
||||
90
app/Core/Events/DispatchesEvents.php
Normal file
90
app/Core/Events/DispatchesEvents.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events;
|
||||
|
||||
trait DispatchesEvents
|
||||
{
|
||||
private static string $event_context = '';
|
||||
|
||||
/**
|
||||
* dispatches an event with context
|
||||
*/
|
||||
public static function dispatch_event(string $hook, mixed $available_params = [], string|int|null $function = null): void
|
||||
{
|
||||
EventDispatcher::dispatch_event($hook, $available_params, static::get_event_context($function));
|
||||
}
|
||||
|
||||
// The new dispatchEvent method is below. We're keeping both for backwards compatibility until v4.0
|
||||
// Temporary for backwards compatibility
|
||||
public static function dispatchEvent(string $hook, mixed $available_params = [], string|int|null $function = null): void
|
||||
{
|
||||
EventDispatcher::dispatch_event($hook, $available_params, static::get_event_context($function));
|
||||
}
|
||||
|
||||
/**
|
||||
* dispatches a filter with context
|
||||
*/
|
||||
public static function dispatch_filter(string $hook, mixed $payload, mixed $available_params = [], string|int|null $function = null): mixed
|
||||
{
|
||||
return EventDispatcher::dispatch_filter($hook, $payload, $available_params, static::get_event_context($function));
|
||||
}
|
||||
|
||||
// The new dispatchEvent method is below. We're keeping both for backwards compatibility until v4.0
|
||||
// Temporary for backwards compatibility
|
||||
public static function dispatchFilter(string $hook, mixed $payload, mixed $available_params = [], string|int|null $function = null): mixed
|
||||
{
|
||||
return EventDispatcher::dispatch_filter($hook, $payload, $available_params, static::get_event_context($function));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the context of the event
|
||||
*/
|
||||
protected static function get_event_context($function): string
|
||||
{
|
||||
if (empty(self::$event_context)) {
|
||||
self::$event_context = static::set_class_context();
|
||||
}
|
||||
|
||||
$eventContext = self::$event_context.'.';
|
||||
|
||||
if (! empty($function) && is_string($function) && ! is_numeric($function)) {
|
||||
|
||||
$function = $function;
|
||||
|
||||
// If context starts with leantime, the full context was provided by caller
|
||||
if (str_starts_with($function, 'leantime.')) {
|
||||
$eventContext = '';
|
||||
}
|
||||
|
||||
} else {
|
||||
$function = static::get_function_context(is_numeric($function) ? (int) $function : null);
|
||||
}
|
||||
|
||||
return $eventContext.$function;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the class Context based on path, this uses the same method as the autoloader
|
||||
* Helps create unique strings for events/filters
|
||||
*/
|
||||
protected static function set_class_context(): string
|
||||
{
|
||||
return str_replace('\\', '.', strtolower(static::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the caller function name.
|
||||
*
|
||||
* Uses debug_backtrace with limited depth and no args instead of
|
||||
* Exception::getTrace() to avoid the overhead of creating a full
|
||||
* exception object on every event dispatch (~60 times per request).
|
||||
*/
|
||||
protected static function get_function_context(?int $functionInt = null): string
|
||||
{
|
||||
$tracePointer = is_int($functionInt) ? $functionInt : 3;
|
||||
|
||||
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, $tracePointer + 1);
|
||||
|
||||
return $trace[$tracePointer]['function'] ?? '';
|
||||
}
|
||||
}
|
||||
883
app/Core/Events/EventDispatcher.php
Normal file
883
app/Core/Events/EventDispatcher.php
Normal file
@@ -0,0 +1,883 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Traits\Macroable;
|
||||
use Illuminate\Support\Traits\ReflectsClosures;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Events\Contracts\LeantimeEvent;
|
||||
use Leantime\Core\Events\Contracts\LeantimeFilter;
|
||||
use Leantime\Core\Routing\RouteLoader;
|
||||
|
||||
/**
|
||||
* EventDispatcher class - Handles all events and filters
|
||||
*/
|
||||
class EventDispatcher implements Dispatcher
|
||||
{
|
||||
use Macroable;
|
||||
use ReflectsClosures;
|
||||
|
||||
/**
|
||||
* Cache for pattern matching results
|
||||
*/
|
||||
private static array $patternMatchCache = [];
|
||||
|
||||
/**
|
||||
* Cache of compiled regex patterns, keyed by registry key. A registry key
|
||||
* always compiles to the same regex, so this is computed once per key for the
|
||||
* whole request instead of recompiling the entire registry on every distinct
|
||||
* event name (the previous per-call local cache meant hundreds of cache misses
|
||||
* each recompiled every pattern).
|
||||
*/
|
||||
private static array $compiledPatternCache = [];
|
||||
|
||||
/**
|
||||
* Version counters for registry change tracking.
|
||||
* Incremented when listeners are added, used for cache key generation
|
||||
* instead of expensive md5(serialize(array_keys($registry))) on every dispatch.
|
||||
*/
|
||||
private static int $eventRegistryVersion = 0;
|
||||
|
||||
private static int $filterRegistryVersion = 0;
|
||||
|
||||
/**
|
||||
* Registry of all events added to a hook
|
||||
*/
|
||||
private static array $eventRegistry = [];
|
||||
|
||||
/**
|
||||
* Registry of all filters added to a hook
|
||||
*/
|
||||
private static array $filterRegistry = [];
|
||||
|
||||
/**
|
||||
* Registry of all hooks available
|
||||
*/
|
||||
private static array $available_hooks = [
|
||||
'filters' => [],
|
||||
'events' => [],
|
||||
];
|
||||
|
||||
/**
|
||||
* Finds event listeners by event names,
|
||||
* Allows listeners with wildcards
|
||||
*/
|
||||
public static function findEventListeners(string $eventName, array $registry): array
|
||||
{
|
||||
// Use version counters for cache key instead of expensive md5(serialize(array_keys()))
|
||||
$registryVersion = ($registry === self::$eventRegistry)
|
||||
? self::$eventRegistryVersion
|
||||
: self::$filterRegistryVersion;
|
||||
$cacheKey = $eventName.'_'.$registryVersion;
|
||||
|
||||
if (isset(self::$patternMatchCache[$cacheKey])) {
|
||||
return self::$patternMatchCache[$cacheKey];
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
|
||||
foreach ($registry as $key => $value) {
|
||||
// Compile each registry key's regex once for the whole request. The
|
||||
// compiled pattern depends only on the key, not the event name.
|
||||
if (! isset(self::$compiledPatternCache[$key])) {
|
||||
preg_match_all('/\{RGX:(.*?):RGX\}/', $key, $regexMatches);
|
||||
self::$compiledPatternCache[$key] = self::compilePattern($key, $regexMatches);
|
||||
}
|
||||
|
||||
if (preg_match('/^'.self::$compiledPatternCache[$key].'$/', $eventName)) {
|
||||
$matches = array_merge($matches, $value);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
self::$patternMatchCache[$cacheKey] = $matches;
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles a pattern for matching
|
||||
*/
|
||||
private static function compilePattern(string $key, array $regexMatches): string
|
||||
{
|
||||
$key = strtr($key, [
|
||||
...collect($regexMatches[0] ?? [])->mapWithKeys(fn ($match, $i) => [$match => "REGEX_MATCH_$i"])->toArray(),
|
||||
'*' => 'RANDOM_STRING',
|
||||
'?' => 'RANDOM_CHARACTER',
|
||||
]);
|
||||
|
||||
$pattern = preg_quote($key, '/');
|
||||
|
||||
return strtr($pattern, [
|
||||
'RANDOM_STRING' => '.*?',
|
||||
'RANDOM_CHARACTER' => '.',
|
||||
...collect($regexMatches[1] ?? [])->mapWithKeys(fn ($match, $i) => ["REGEX_MATCH_$i" => $match])->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function dispatch(
|
||||
$event,
|
||||
$payload = [],
|
||||
$halt = false
|
||||
) {
|
||||
|
||||
$this->dispatch_event($event, $payload, '');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function dispatch_filter(
|
||||
string $filtername,
|
||||
mixed $payload = '',
|
||||
mixed $available_params = [],
|
||||
mixed $context = ''
|
||||
): mixed {
|
||||
$filtername = "$context.$filtername";
|
||||
|
||||
if (! in_array($filtername, self::$available_hooks['filters'])) {
|
||||
self::$available_hooks['filters'][] = $filtername;
|
||||
}
|
||||
|
||||
$matchedEvents = self::findEventListeners($filtername, self::$filterRegistry);
|
||||
if (count($matchedEvents) == 0) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$available_params = self::defineParams($available_params, $filtername);
|
||||
|
||||
return self::executeHandlers($matchedEvents, 'filters', $filtername, $payload, $available_params);
|
||||
}
|
||||
|
||||
public static function dispatch_event(
|
||||
$event,
|
||||
mixed $payload = [],
|
||||
string $context = ''
|
||||
): void {
|
||||
|
||||
// Class-based events bypass the string machinery (context building, payload
|
||||
// wrapping) entirely: listeners on the FQCN receive the typed object, listeners
|
||||
// on the declared legacy string names receive today's array payload.
|
||||
if ($event instanceof LeantimeEvent) {
|
||||
self::executeClassEventHandlers($event);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Laravel events can be objects. Let's get those into the right format
|
||||
// Event comes out as string, either as class string or regular old string
|
||||
// No-op for leantime events
|
||||
[$event, $payload] = [
|
||||
...self::parseEventAndPayload($event, $payload),
|
||||
];
|
||||
|
||||
if (! empty($context)) {
|
||||
$event = "$context.$event";
|
||||
}
|
||||
|
||||
if (! in_array($event, self::$available_hooks['events'])) {
|
||||
self::$available_hooks['events'][] = $event;
|
||||
}
|
||||
|
||||
$matchedEvents = self::findEventListeners($event, self::$eventRegistry);
|
||||
if (count($matchedEvents) == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload['leantime'] = self::defineParams($payload, $event);
|
||||
$payload['laravel'] = $payload;
|
||||
|
||||
self::executeHandlers($matchedEvents, 'events', $event, $payload);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes listeners for a class-based event.
|
||||
*
|
||||
* Listeners registered on the event's FQCN run first (priority-sorted) and receive
|
||||
* the bare typed event object. Then, for each declared legacy hook name, listeners
|
||||
* registered on (or wildcard-matching) that string run through the exact same code
|
||||
* path as string events, receiving today's array payload built from the event's
|
||||
* public properties — existing string/wildcard listeners (plugins) keep working
|
||||
* unchanged during the migration window.
|
||||
*/
|
||||
private static function executeClassEventHandlers(LeantimeEvent $event): void
|
||||
{
|
||||
$fqcn = get_class($event);
|
||||
$legacyHooks = $event->legacyHooks();
|
||||
|
||||
foreach ([$fqcn, ...$legacyHooks] as $name) {
|
||||
if (! in_array($name, self::$available_hooks['events'])) {
|
||||
self::$available_hooks['events'][] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$matched = self::findEventListeners($fqcn, self::$eventRegistry);
|
||||
if (count($matched) > 0) {
|
||||
usort($matched, fn ($a, $b) => $a['priority'] <=> $b['priority']);
|
||||
|
||||
try {
|
||||
foreach ($matched as $listener) {
|
||||
$callable = self::resolveClassHookCallable($listener['listener']);
|
||||
$callable($event);
|
||||
}
|
||||
} catch (\TypeError $e) {
|
||||
Log::error($e);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($legacyHooks)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$legacyPayload = get_object_vars($event);
|
||||
|
||||
foreach ($legacyHooks as $legacyName) {
|
||||
$matched = self::findEventListeners($legacyName, self::$eventRegistry);
|
||||
if (count($matched) == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = $legacyPayload;
|
||||
$payload['leantime'] = self::defineParams($legacyPayload, $legacyName);
|
||||
$payload['laravel'] = $payload;
|
||||
|
||||
self::executeHandlers($matched, 'events', $legacyName, $payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a class-based filter, threading the payload through listeners on the
|
||||
* FQCN first (signature: fn ($payload, LeantimeFilter $filter)), then through each
|
||||
* declared legacy hook name where listeners keep today's
|
||||
* fn ($payload, $availableParams) signature. Returns the final payload.
|
||||
*
|
||||
* Filter listeners are deliberately NOT deduplicated across name groups: threading
|
||||
* order is semantic, and a listener registered on both the FQCN and a legacy name
|
||||
* is a registration error that should surface, not be silently absorbed.
|
||||
*/
|
||||
public static function dispatch_class_filter(LeantimeFilter $filter): mixed
|
||||
{
|
||||
$fqcn = get_class($filter);
|
||||
$legacyHooks = $filter->legacyHooks();
|
||||
|
||||
foreach ([$fqcn, ...$legacyHooks] as $name) {
|
||||
if (! in_array($name, self::$available_hooks['filters'])) {
|
||||
self::$available_hooks['filters'][] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$payload = $filter->payload();
|
||||
|
||||
$matched = self::findEventListeners($fqcn, self::$filterRegistry);
|
||||
if (count($matched) > 0) {
|
||||
usort($matched, fn ($a, $b) => $a['priority'] <=> $b['priority']);
|
||||
|
||||
try {
|
||||
foreach ($matched as $listener) {
|
||||
$callable = self::resolveClassHookCallable($listener['listener']);
|
||||
$payload = $callable($payload, $filter);
|
||||
}
|
||||
} catch (\TypeError $e) {
|
||||
Log::error($e);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($legacyHooks as $legacyName) {
|
||||
$matched = self::findEventListeners($legacyName, self::$filterRegistry);
|
||||
if (count($matched) == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$availableParams = self::defineParams(get_object_vars($filter), $legacyName);
|
||||
$payload = self::executeHandlers($matched, 'filters', $legacyName, $payload, $availableParams);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a listener registration (closure, callable, "Class", "Class@method",
|
||||
* [Class::class, 'method']) into a callable for class-based hooks. Class listeners
|
||||
* are instantiated through the container so constructor DI works; the method
|
||||
* defaults to handle(), falling back to __invoke().
|
||||
*/
|
||||
private static function resolveClassHookCallable(mixed $listener): callable
|
||||
{
|
||||
if (is_string($listener) && ! function_exists($listener)) {
|
||||
[$class, $method] = self::parseClassCallable($listener);
|
||||
|
||||
if (! method_exists($class, $method)) {
|
||||
$method = '__invoke';
|
||||
}
|
||||
|
||||
return [app()->make($class), $method];
|
||||
}
|
||||
|
||||
if (is_array($listener) && isset($listener[0]) && is_string($listener[0])) {
|
||||
[$class, $method] = [$listener[0], $listener[1] ?? 'handle'];
|
||||
|
||||
if (! method_exists($class, $method)) {
|
||||
$method = '__invoke';
|
||||
}
|
||||
|
||||
return [app()->make($class), $method];
|
||||
}
|
||||
|
||||
return $listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the current_route to the event's/filter's available params
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
private static function defineParams(mixed $paramAttr, string $eventName): array
|
||||
{
|
||||
// Cache the current route for the duration of the request since it doesn't change
|
||||
static $current_route = null;
|
||||
$current_route ??= Frontcontroller::getCurrentRoute();
|
||||
|
||||
$default_params = [
|
||||
'current_route' => $current_route,
|
||||
'currentEvent' => $eventName,
|
||||
];
|
||||
|
||||
if (! is_array($paramAttr)) {
|
||||
$paramAttr = [$paramAttr];
|
||||
}
|
||||
|
||||
$paramAttr = array_merge($default_params, $paramAttr);
|
||||
|
||||
return $paramAttr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the given event and payload and prepare them for dispatching.
|
||||
*
|
||||
* @param mixed $event
|
||||
* @param mixed $payload
|
||||
* @return array
|
||||
*/
|
||||
protected static function parseEventAndPayload($event, $payload)
|
||||
{
|
||||
if (is_object($event)) {
|
||||
[$payload, $event] = [[$event], get_class($event)];
|
||||
}
|
||||
|
||||
return [$event, Arr::wrap($payload)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes all the handlers for a given hook
|
||||
*
|
||||
* @return array|object|null
|
||||
*/
|
||||
private static function executeHandlers(
|
||||
array $registry,
|
||||
string $registryType,
|
||||
string|object $event,
|
||||
mixed $payload,
|
||||
array|object $available_params = []
|
||||
): mixed {
|
||||
|
||||
$isEvent = ($registryType === 'events');
|
||||
$filteredPayload = null;
|
||||
$index = 0;
|
||||
|
||||
try {
|
||||
// sort matches by priority
|
||||
usort($registry, fn ($a, $b) => match (true) {
|
||||
$a['priority'] > $b['priority'] => 1,
|
||||
$a['priority'] == $b['priority'] => 0,
|
||||
default => -1,
|
||||
});
|
||||
|
||||
foreach ($registry as $index => $listener) {
|
||||
|
||||
$handler = $listener['listener'];
|
||||
|
||||
// Part 1: Handle Events
|
||||
if ($isEvent) {
|
||||
|
||||
// parsing listener to determine whether we;re dealing with a closure, class, object, string etc
|
||||
$parsedListener = self::makeListener($handler);
|
||||
|
||||
if ($listener['source'] == 'laravel') {
|
||||
$parsedListener($event, $payload['laravel']);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedListener($event, [$payload['leantime']]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Part 2: Handle Filters
|
||||
if ($index === 0) {
|
||||
$filteredPayload = $payload;
|
||||
}
|
||||
|
||||
$filteredPayload = $handler($filteredPayload, $available_params);
|
||||
|
||||
continue;
|
||||
|
||||
// // Handle Laravel style events
|
||||
// //payload has an actual object
|
||||
// //Those will never be filters
|
||||
// if (self::isLaravelEvent($payload)) {
|
||||
// self::handleLaravelEvent($handler, $payload[0]);
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
//
|
||||
// if (self::isHandleableObject($handler)) {
|
||||
// self::handleLaravelEvent($handler, $payload[0]);
|
||||
// }
|
||||
//
|
||||
// // Handle class with handle method
|
||||
// if (self::isHandleableClass($handler)) {
|
||||
//
|
||||
// if ($isEvent) {
|
||||
//
|
||||
// $handler->handle($payload);
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// $filteredPayload = $handler->handle(
|
||||
// $index == 0 ? $payload : $filteredPayload,
|
||||
// $available_params
|
||||
// );
|
||||
//
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// // Handle Closures and callable functions
|
||||
// if (is_callable($handler)) {
|
||||
//
|
||||
// if ($isEvent) {
|
||||
// self::executeCallable($handler, $payload, $available_params, $index, $isEvent);
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// $result = self::executeCallable($handler, $index == 0 ? $payload : $filteredPayload, $available_params, $index, $isEvent);
|
||||
// if ($result !== null) {
|
||||
// $filteredPayload = $result;
|
||||
// }
|
||||
// continue;
|
||||
// }
|
||||
|
||||
}
|
||||
} catch (\TypeError $e) {
|
||||
|
||||
if (! isset($filteredPayload) && $index === 0) {
|
||||
$filteredPayload = $payload;
|
||||
}
|
||||
|
||||
Log::error($e);
|
||||
}
|
||||
|
||||
return $isEvent ? null : $filteredPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all the event and filter listeners and registers them
|
||||
* (should only be executed once at the beginning of the program)
|
||||
*
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function discoverListeners(): void
|
||||
{
|
||||
static $discovered;
|
||||
$discovered ??= false;
|
||||
|
||||
if ($discovered) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((bool) config('debug') === false) {
|
||||
|
||||
$modules = Cache::store('installation')->rememberForever('domainEvents', function () {
|
||||
return EventDispatcher::getDomainPaths();
|
||||
});
|
||||
|
||||
} else {
|
||||
$modules = self::getDomainPaths();
|
||||
}
|
||||
|
||||
foreach ($modules as $module) {
|
||||
if (file_exists($moduleEventsPath = "$module/register.php")) {
|
||||
include_once $moduleEventsPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Call system plugins (defined via config)
|
||||
if (isset(app(Environment::class)->plugins)) {
|
||||
$configplugins = explode(',', app(Environment::class)->plugins);
|
||||
|
||||
// TODO: Do phar plugins get to be system plugins? Right now they dont
|
||||
foreach ($configplugins as $plugin) {
|
||||
if (file_exists($pluginEventsPath = APP_ROOT.'/app/Plugins/'.$plugin.'/register.php')) {
|
||||
include_once $pluginEventsPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load routes.php files from domains and system plugins
|
||||
// User plugin routes will be loaded via event after plugins are enabled
|
||||
RouteLoader::loadRoutes();
|
||||
|
||||
EventDispatcher::add_event_listener('leantime.core.middleware.loadplugins.handle.pluginsStart', function () {
|
||||
|
||||
if (! session('isInstalled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pluginPath = APP_ROOT.'/app/Plugins/';
|
||||
$pluginService = app()->make(\Leantime\Domain\Plugins\Services\Plugins::class);
|
||||
$enabledPlugins = $pluginService->getEnabledPlugins();
|
||||
|
||||
foreach ($enabledPlugins as $plugin) {
|
||||
|
||||
// Catch issue when plugins are cached on load but autoloader is not quite done loading.
|
||||
// Only happens because the plugin objects are stored in session and the unserialize is not keeping up.
|
||||
// Clearing session cache in that case.
|
||||
// @TODO: Check on callstack to make sure autoload loads before sessions
|
||||
if (is_a($plugin, '__PHP_Incomplete_Class')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($plugin == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($plugin->format == 'phar') {
|
||||
$pharPath = "phar://{$pluginPath}{$plugin->foldername}/{$plugin->foldername}.phar";
|
||||
|
||||
if (! file_exists($pharPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
include_once $pharPath;
|
||||
|
||||
if (! file_exists("$pharPath/register.php")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
include_once "$pharPath/register.php";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! file_exists($registerPath = "{$pluginPath}{$plugin->foldername}/register.php")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
include_once $registerPath;
|
||||
}
|
||||
});
|
||||
|
||||
$discovered = true;
|
||||
}
|
||||
|
||||
public static function getDomainPaths()
|
||||
{
|
||||
return collect(glob(APP_ROOT.'/app/Domain'.'/*', GLOB_ONLYDIR))->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener to be registered
|
||||
*/
|
||||
public static function add_event_listener(
|
||||
$event,
|
||||
$listener,
|
||||
int $priority = 10,
|
||||
$listenerSource = 'leantime'
|
||||
): void {
|
||||
|
||||
// Some backwards compatibility rules
|
||||
if (str_starts_with($event, 'leantime.core.template.tpl')) {
|
||||
$eventParts = explode('.', $event);
|
||||
|
||||
$count = count($eventParts);
|
||||
|
||||
$event = 'leantime.*.'.($eventParts[$count - 2] ?? '').'.'.($eventParts[$count - 1] ?? '');
|
||||
}
|
||||
|
||||
if ($event == 'leantime.core.*.afterFooterOpen') {
|
||||
$event = 'leantime.*.afterFooterOpen';
|
||||
}
|
||||
|
||||
if (! array_key_exists($event, self::$eventRegistry)) {
|
||||
self::$eventRegistry[$event] = [];
|
||||
}
|
||||
|
||||
// Laravel adds the listener directly without having priority. Keep that in mind!!
|
||||
self::$eventRegistry[$event][] = ['listener' => $listener, 'priority' => $priority, 'source' => $listenerSource];
|
||||
self::$eventRegistryVersion++;
|
||||
}
|
||||
|
||||
public static function addEventListener($event, $listener, $priority = 10, $source = 'leantime')
|
||||
{
|
||||
self::add_event_listener($event, $listener, $priority, $source);
|
||||
}
|
||||
|
||||
public static function add_filter_listener(
|
||||
$filtername,
|
||||
$listener,
|
||||
int $priority = 10,
|
||||
$listenerSource = 'leantime'
|
||||
): void {
|
||||
if (! array_key_exists($filtername, self::$filterRegistry)) {
|
||||
self::$filterRegistry[$filtername] = [];
|
||||
}
|
||||
self::$filterRegistry[$filtername][] = ['listener' => $listener, 'priority' => $priority, 'source' => $listenerSource];
|
||||
self::$filterRegistryVersion++;
|
||||
}
|
||||
|
||||
public static function addFilterListener(
|
||||
$filtername,
|
||||
$listener,
|
||||
int $priority = 10
|
||||
): void {
|
||||
self::add_filter_listener($filtername, $listener, $priority);
|
||||
}
|
||||
|
||||
// Laravel listen. They can do whatever.
|
||||
public function listen($events, $listener = null)
|
||||
{
|
||||
|
||||
if ($events instanceof \Closure) {
|
||||
collect($this->firstClosureParameterTypes($events))
|
||||
->each(function ($event) use ($events) {
|
||||
$this->listen($event, $events);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ((array) $events as $event) {
|
||||
$this->add_event_listener($event, $listener, 10, 'laravel');
|
||||
}
|
||||
}
|
||||
|
||||
// Different options for events and listeners
|
||||
|
||||
// Event itself is object
|
||||
// Event itself is class string
|
||||
// Event itself is just string
|
||||
|
||||
// Listener options
|
||||
|
||||
// 2 Listener is closure
|
||||
// 3 Listener is callable (array)
|
||||
// 4 Listener is class string (call handle)
|
||||
/**
|
||||
* Register an event listener with the dispatcher.
|
||||
*
|
||||
* @param \Closure|string|array $listener
|
||||
* @param bool $wildcard
|
||||
* @return \Closure
|
||||
*/
|
||||
public static function makeListener($listener, $wildcard = false)
|
||||
{
|
||||
|
||||
if (is_string($listener) && ! function_exists($listener)) {
|
||||
return self::createClassListener($listener, $wildcard);
|
||||
}
|
||||
|
||||
if (is_array($listener) && isset($listener[0]) && is_string($listener[0])) {
|
||||
return self::createClassListener($listener, $wildcard);
|
||||
}
|
||||
|
||||
// If listener is a closure, we're preparing a closure to call the closure...
|
||||
return function ($event, $payload) use ($listener, $wildcard) {
|
||||
if ($wildcard) {
|
||||
return $listener($event, $payload);
|
||||
}
|
||||
|
||||
return $listener(...array_values($payload));
|
||||
};
|
||||
}
|
||||
|
||||
public static function createClassListener($listener, $wildcard = false)
|
||||
{
|
||||
return function ($event, $payload) use ($listener, $wildcard) {
|
||||
if ($wildcard) {
|
||||
return call_user_func(self::createClassCallable($listener), $event, $payload);
|
||||
}
|
||||
|
||||
$callable = self::createClassCallable($listener);
|
||||
|
||||
return $callable(...array_values($payload));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the class based event callable.
|
||||
* Covers options 3+4
|
||||
*
|
||||
* @param array|string $listener
|
||||
* @return callable
|
||||
*/
|
||||
protected static function createClassCallable($listener)
|
||||
{
|
||||
[$class, $method] = is_array($listener)
|
||||
? $listener
|
||||
: self::parseClassCallable($listener);
|
||||
|
||||
if (! method_exists($class, $method)) {
|
||||
$method = '__invoke';
|
||||
}
|
||||
|
||||
// if ($this->handlerShouldBeQueued($class)) {
|
||||
// return $this->createQueuedHandlerCallable($class, $method);
|
||||
// }
|
||||
|
||||
$listener = app()->make($class);
|
||||
|
||||
// return $this->handlerShouldBeDispatchedAfterDatabaseTransactions($listener)
|
||||
// ? $this->createCallbackForListenerRunningAfterCommits($listener, $method)
|
||||
// : [$listener, $method];
|
||||
|
||||
return [$listener, $method];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the class listener into class and method.
|
||||
*
|
||||
* @param string $listener
|
||||
* @return array
|
||||
*/
|
||||
protected static function parseClassCallable($listener)
|
||||
{
|
||||
return Str::parseCallback($listener, 'handle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all registered listeners
|
||||
*/
|
||||
public static function get_registries(): array
|
||||
{
|
||||
return [
|
||||
'events' => array_keys(self::$eventRegistry),
|
||||
'filters' => array_keys(self::$filterRegistry),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all available hooks
|
||||
*/
|
||||
public static function get_available_hooks(): array
|
||||
{
|
||||
return self::$available_hooks;
|
||||
}
|
||||
|
||||
public static function getEventRegistry(): array
|
||||
{
|
||||
return self::$eventRegistry;
|
||||
}
|
||||
|
||||
public static function getFilterRegistry(): array
|
||||
{
|
||||
return self::$filterRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a given event has listeners.
|
||||
*
|
||||
* @param string $eventName
|
||||
* @return bool
|
||||
*/
|
||||
public function hasListeners($eventName)
|
||||
{
|
||||
return array_key_exists($eventName, self::$eventRegistry);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an event subscriber with the dispatcher.
|
||||
*
|
||||
* @param object|string $subscriber
|
||||
* @return void
|
||||
*/
|
||||
public function subscribe($subscriber) {}
|
||||
|
||||
/**
|
||||
* Dispatch an event until the first non-null response is returned.
|
||||
*
|
||||
* @param string|object $event
|
||||
* @param mixed $payload
|
||||
* @return mixed
|
||||
*/
|
||||
public function until($event, $payload = [])
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all of the listeners for a given event name.
|
||||
*
|
||||
* @param string $eventName
|
||||
* @return array
|
||||
*/
|
||||
public function getListeners($eventName)
|
||||
{
|
||||
$listeners = $this->findEventListeners($eventName, $this->getEventRegistry());
|
||||
$list = array_map(fn ($item) => $item['listener'], $listeners);
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an event and payload to be fired later.
|
||||
*
|
||||
* @param string $event
|
||||
* @param array $payload
|
||||
* @return void
|
||||
*/
|
||||
public function push($event, $payload = [])
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush a set of pushed events.
|
||||
*
|
||||
* @param string $event
|
||||
* @return void
|
||||
*/
|
||||
public function flush($event)
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a set of listeners from the dispatcher.
|
||||
*
|
||||
* @param string $event
|
||||
* @return void
|
||||
*/
|
||||
public function forget($event)
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget all of the queued listeners.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function forgetPushed()
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
}
|
||||
36
app/Core/Events/EventVerb.php
Normal file
36
app/Core/Events/EventVerb.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events;
|
||||
|
||||
/**
|
||||
* The central verb vocabulary for class-based domain events.
|
||||
*
|
||||
* Event classes are named {Entity}{Verb} (TicketCreated, MilestoneDeleted) and the verb
|
||||
* MUST be a case of this enum — one vocabulary across all domains, mirroring the client
|
||||
* (HTMX) convention lt:{domain}:{entity}.{verb} and the permission vocabulary
|
||||
* {domain}.{action}. Synonyms are deliberately rejected: it is always Updated, never
|
||||
* Changed/Edited/Saved/Modified. Add a case here only when no existing verb fits.
|
||||
*
|
||||
* All verbs are past tense: events report state changes that already happened.
|
||||
*/
|
||||
enum EventVerb: string
|
||||
{
|
||||
case Created = 'created';
|
||||
case Updated = 'updated';
|
||||
case Deleted = 'deleted';
|
||||
case Added = 'added';
|
||||
case Removed = 'removed';
|
||||
case Moved = 'moved';
|
||||
case Completed = 'completed';
|
||||
case Started = 'started';
|
||||
case Succeeded = 'succeeded';
|
||||
case Failed = 'failed';
|
||||
case Archived = 'archived';
|
||||
case Restored = 'restored';
|
||||
case Duplicated = 'duplicated';
|
||||
case Uploaded = 'uploaded';
|
||||
case Sent = 'sent';
|
||||
case Notified = 'notified';
|
||||
case Registered = 'registered';
|
||||
case Initialized = 'initialized';
|
||||
}
|
||||
47
app/Core/Events/EventsServiceProvider.php
Normal file
47
app/Core/Events/EventsServiceProvider.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Leantime\Core;
|
||||
|
||||
class EventsServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
|
||||
$this->app->singleton('events', function ($app) {
|
||||
return new Core\Events\EventDispatcher;
|
||||
});
|
||||
|
||||
$this->booting(function () {
|
||||
|
||||
// Core\Events\EventDispatcher::discover_listeners();
|
||||
|
||||
/*
|
||||
|
||||
foreach ($this->subscribe as $subscriber) {
|
||||
Event::subscribe($subscriber);
|
||||
}
|
||||
|
||||
foreach ($this->observers as $model => $observers) {
|
||||
$model::observe($observers);
|
||||
}*/
|
||||
|
||||
});
|
||||
|
||||
/*
|
||||
$this->booted(function () {
|
||||
$this->configureEmailVerification();
|
||||
});
|
||||
*/
|
||||
|
||||
}
|
||||
|
||||
public function boot() {}
|
||||
}
|
||||
38
app/Core/Events/Htmx/HtmxEvent.php
Normal file
38
app/Core/Events/Htmx/HtmxEvent.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Htmx;
|
||||
|
||||
/**
|
||||
* Contract for client (HTMX) event enums.
|
||||
*
|
||||
* Client events travel from the server to the browser on the `HX-Trigger` response header and are
|
||||
* consumed either declaratively (`hx-trigger="<event> from:body"`) for data events, or by a JS
|
||||
* listener for UI command events. Implementations are string-backed enums whose backing value IS
|
||||
* the wire name, following the convention:
|
||||
*
|
||||
* - data events: lt:{domain}:{entity}.{verb} e.g. lt:tickets:ticket.updated
|
||||
* - UI commands: lt:ui:{command} e.g. lt:ui:modal.close
|
||||
*
|
||||
* Use the {@see InteractsWithHtmxEvents} trait to satisfy this interface. Note: PHP enums cannot
|
||||
* implement Stringable / __toString, so use {@see event()} (or `->value`) to get the wire name in
|
||||
* PHP. In Blade, `{{ MyEvents::Case }}` renders the value (Laravel's e() unwraps backed enums).
|
||||
*/
|
||||
interface HtmxEvent
|
||||
{
|
||||
/**
|
||||
* The wire name (the enum's backing value), e.g. "lt:tickets:ticket.updated".
|
||||
*/
|
||||
public function event(): string;
|
||||
|
||||
/**
|
||||
* Wire name scoped to a single entity, e.g. "lt:reactions:sentiment.updated#42".
|
||||
* Lets a specific component listen for changes to one entity while broad listeners use the
|
||||
* unscoped name.
|
||||
*/
|
||||
public function scoped(int|string $id): string;
|
||||
|
||||
/**
|
||||
* The value formatted for an `hx-trigger` attribute, e.g. "lt:tickets:ticket.updated from:body".
|
||||
*/
|
||||
public function trigger(string $from = 'body'): string;
|
||||
}
|
||||
68
app/Core/Events/Htmx/HtmxEvents.php
Normal file
68
app/Core/Events/Htmx/HtmxEvents.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Htmx;
|
||||
|
||||
/**
|
||||
* Helpers for building the `HX-Trigger` response header from client event names.
|
||||
*
|
||||
* MIGRATION WINDOW: while emitters and listeners move to the lt:{domain}:{entity}.{verb} convention
|
||||
* we dual-emit legacy names alongside their canonical replacement so existing declarative listeners
|
||||
* (hx-trigger="<name> from:body") and (phar) plugins keep working without coordinated releases. Each
|
||||
* group is bidirectional — emitting ANY member puts the whole group on the wire, so old and new
|
||||
* listeners both fire regardless of which name the emitter used. Delete LEGACY_ALIASES once every
|
||||
* emitter and listener has been migrated.
|
||||
*
|
||||
* Only DOMAIN data events are aliased here. UI command events (lt:ui:*) are intentionally NOT
|
||||
* aliased: they're consumed by JS addEventListener handlers that listen for each name directly, so
|
||||
* dual-emitting them would fire the same handler once per alias (double growl, multiple modal-close
|
||||
* callbacks) for a single response.
|
||||
*/
|
||||
final class HtmxEvents
|
||||
{
|
||||
/**
|
||||
* Bidirectional alias groups. The first entry of each group is the canonical lt:* name.
|
||||
*
|
||||
* @var array<int, array<int, string>>
|
||||
*/
|
||||
private const LEGACY_ALIASES = [
|
||||
['lt:tickets:ticket.updated', 'ticket_update'],
|
||||
['lt:tickets:subtask.updated', 'subtasks_update', 'subtasksUpdated'],
|
||||
['lt:projects:project.updated', 'HTMX.updateProjectList'],
|
||||
['lt:timesheets:timer.updated', 'timerUpdate'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Expand event names to include their legacy/canonical aliases, de-duplicated and order-stable.
|
||||
* Scoped names (e.g. "lt:reactions:sentiment.updated#42") pass through unchanged.
|
||||
*
|
||||
* @param array<int, string|HtmxEvent> $names
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function expand(array $names): array
|
||||
{
|
||||
$expanded = [];
|
||||
|
||||
foreach ($names as $name) {
|
||||
$name = $name instanceof HtmxEvent ? $name->event() : (string) $name;
|
||||
$expanded[] = $name;
|
||||
|
||||
foreach (self::LEGACY_ALIASES as $group) {
|
||||
if (in_array($name, $group, true)) {
|
||||
array_push($expanded, ...$group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($expanded));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the comma-separated `HX-Trigger` header value from queued event names.
|
||||
*
|
||||
* @param array<int, string|HtmxEvent> $names
|
||||
*/
|
||||
public static function triggerHeader(array $names): string
|
||||
{
|
||||
return implode(',', self::expand($names));
|
||||
}
|
||||
}
|
||||
31
app/Core/Events/Htmx/HtmxUiEvents.php
Normal file
31
app/Core/Events/Htmx/HtmxUiEvents.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Htmx;
|
||||
|
||||
/**
|
||||
* Canonical client UI command events (the `lt:ui:*` plane).
|
||||
*
|
||||
* These are imperative commands to the JS layer (show a toast, close the modal, refresh the page),
|
||||
* as opposed to domain data events ("entity X changed") which live in per-domain Htmx{Domain}Events
|
||||
* enums. There is exactly one home for UI commands so casing/naming never drifts again — plugins
|
||||
* reuse these cases rather than minting their own `lt:ui:*` strings.
|
||||
*
|
||||
* Legacy string equivalents (e.g. HTMX.ShowNotification, closeModal) are dual-emitted during the
|
||||
* migration window via {@see HtmxEvents::expand()} so existing listeners keep working.
|
||||
*/
|
||||
enum HtmxUiEvents: string implements HtmxEvent
|
||||
{
|
||||
use InteractsWithHtmxEvents;
|
||||
|
||||
/** Fetch + show the latest growl notification. Replaces 'HTMX.ShowNotification'. */
|
||||
case Notify = 'lt:ui:notify';
|
||||
|
||||
/** Close the top-most modal. Replaces 'closeModal' / 'HTMX.closemodal' / 'Htmx.CloseModal'. */
|
||||
case ModalClose = 'lt:ui:modal.close';
|
||||
|
||||
/** Open a modal for the current url hash. */
|
||||
case ModalOpen = 'lt:ui:modal.open';
|
||||
|
||||
/** Refresh the main page url in the background. */
|
||||
case UrlRefresh = 'lt:ui:url.refresh';
|
||||
}
|
||||
37
app/Core/Events/Htmx/InteractsWithHtmxEvents.php
Normal file
37
app/Core/Events/Htmx/InteractsWithHtmxEvents.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Events\Htmx;
|
||||
|
||||
/**
|
||||
* Shared behavior for string-backed client (HTMX) event enums.
|
||||
*
|
||||
* The backing value of each case is the wire name. PHP enums cannot define __toString, so use
|
||||
* {@see event()} to obtain the wire name in PHP code; Blade's `{{ }}` renders the value directly
|
||||
* because Laravel's e() helper unwraps backed enums.
|
||||
*/
|
||||
trait InteractsWithHtmxEvents
|
||||
{
|
||||
/**
|
||||
* The wire name (the enum's backing value).
|
||||
*/
|
||||
public function event(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire name scoped to a single entity id, e.g. "lt:reactions:sentiment.updated#42".
|
||||
*/
|
||||
public function scoped(int|string $id): string
|
||||
{
|
||||
return $this->value.'#'.$id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format for an `hx-trigger` attribute, e.g. "lt:tickets:ticket.updated from:body".
|
||||
*/
|
||||
public function trigger(string $from = 'body'): string
|
||||
{
|
||||
return $this->value.($from !== '' ? ' from:'.$from : '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user