OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View 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));
}
}

View 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);
}
}

View 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;
}

View 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;
}

View 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'] ?? '';
}
}

View 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');
}
}

View 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';
}

View 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() {}
}

View 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;
}

View 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));
}
}

View 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';
}

View 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 : '');
}
}