[], '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'); } }