Initial Version
This commit is contained in:
182
core/lib/Module/ModuleAutoloader.php
Normal file
182
core/lib/Module/ModuleAutoloader.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
/**
|
||||
* Custom autoloader for modules that allows PascalCase namespaces
|
||||
* with lowercase folder names.
|
||||
*
|
||||
* This breaks from PSR-4 convention to allow:
|
||||
* - Folder: modules/contacts_manager/
|
||||
* - Namespace: KTXM\ContactsManager\
|
||||
*
|
||||
* The autoloader scans lazily - only when a KTXM class is first requested.
|
||||
*/
|
||||
class ModuleAutoloader
|
||||
{
|
||||
private string $modulesRoot;
|
||||
private array $namespaceMap = [];
|
||||
private bool $scanned = false;
|
||||
|
||||
public function __construct(string $modulesRoot)
|
||||
{
|
||||
$this->modulesRoot = rtrim($modulesRoot, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the autoloader
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
spl_autoload_register([$this, 'loadClass']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister the autoloader
|
||||
*/
|
||||
public function unregister(): void
|
||||
{
|
||||
spl_autoload_unregister([$this, 'loadClass']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the modules directory and build a map of namespaces to folder paths
|
||||
* This is called lazily on the first KTXM class request
|
||||
*/
|
||||
private function scan(): void
|
||||
{
|
||||
if ($this->scanned) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->namespaceMap = [];
|
||||
|
||||
if (!is_dir($this->modulesRoot)) {
|
||||
$this->scanned = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleDirs = glob($this->modulesRoot . '/*', GLOB_ONLYDIR);
|
||||
foreach ($moduleDirs as $moduleDir) {
|
||||
$moduleFile = $moduleDir . '/lib/Module.php';
|
||||
if (!file_exists($moduleFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the namespace from Module.php
|
||||
$namespace = $this->extractNamespace($moduleFile);
|
||||
if ($namespace) {
|
||||
$this->namespaceMap[$namespace] = basename($moduleDir);
|
||||
}
|
||||
}
|
||||
|
||||
$this->scanned = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a class by its fully qualified name
|
||||
*
|
||||
* @param string $className Fully qualified class name (e.g., KTXM\ContactsManager\Module)
|
||||
* @return bool True if the class was loaded, false otherwise
|
||||
*/
|
||||
public function loadClass(string $className): bool
|
||||
{
|
||||
try {
|
||||
// Only handle classes in the KTXM namespace
|
||||
if (!str_starts_with($className, 'KTXM\\')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the namespace segment (e.g., ContactsManager from KTXM\ContactsManager\Module)
|
||||
$parts = explode('\\', $className);
|
||||
if (count($parts) < 2) {
|
||||
$this->logError("Invalid class name format: $className (expected at least 2 namespace parts)");
|
||||
return false;
|
||||
}
|
||||
|
||||
$namespaceSegment = $parts[1];
|
||||
|
||||
// Check if we already have a mapping for this namespace
|
||||
if (!isset($this->namespaceMap[$namespaceSegment])) {
|
||||
// Scan only if we haven't scanned yet (this happens once, on first module access)
|
||||
if (!$this->scanned) {
|
||||
$this->scan();
|
||||
|
||||
// Check again after scanning
|
||||
if (!isset($this->namespaceMap[$namespaceSegment])) {
|
||||
$this->logError("No module found for namespace segment: $namespaceSegment (class: $className)");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
$this->logError("Module not found after scan: $namespaceSegment (class: $className)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$folderName = $this->namespaceMap[$namespaceSegment];
|
||||
|
||||
// Reconstruct the relative path
|
||||
// KTXM\ContactsManager\Module -> contacts_manager/lib/Module.php
|
||||
// KTXM\ContactsManager\Something -> contacts_manager/lib/Something.php
|
||||
$relativePath = 'lib/' . implode('/', array_slice($parts, 2)) . '.php';
|
||||
$filePath = $this->modulesRoot . '/' . $folderName . '/' . $relativePath;
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
require_once $filePath;
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->logError("File not found for class $className at path: $filePath");
|
||||
return false;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logError("Exception in ModuleAutoloader while loading $className: " . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error from the autoloader
|
||||
*
|
||||
* @param string $message Error message
|
||||
* @param array $context Additional context
|
||||
*/
|
||||
private function logError(string $message, array $context = []): void
|
||||
{
|
||||
// Log to PHP error log
|
||||
error_log('[ModuleAutoloader] ' . $message);
|
||||
|
||||
if (!empty($context)) {
|
||||
error_log('[ModuleAutoloader Context] ' . json_encode($context));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract namespace from a Module.php file
|
||||
*
|
||||
* @param string $filePath Path to the Module.php file (at modules/{handle}/lib/Module.php)
|
||||
* @return string|null The namespace segment (e.g., 'ContactsManager')
|
||||
*/
|
||||
private function extractNamespace(string $filePath): ?string
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match namespace declaration: namespace KTXM\<Namespace>;
|
||||
if (preg_match('/^\s*namespace\s+KTXM\\\\([a-zA-Z0-9_]+)\s*;/m', $content, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
23
core/lib/Module/ModuleCollection.php
Normal file
23
core/lib/Module/ModuleCollection.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXF\Utile\Collection\CollectionAbstract;
|
||||
|
||||
class ModuleCollection extends CollectionAbstract implements JsonSerializable
|
||||
{
|
||||
public function __construct(array $items = [])
|
||||
{
|
||||
parent::__construct($items, ModuleObject::class, CollectionAbstract::TYPE_STRING);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this as $key => $item) {
|
||||
$result[$key] = $item;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
464
core/lib/Module/ModuleManager.php
Normal file
464
core/lib/Module/ModuleManager.php
Normal file
@@ -0,0 +1,464 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use Exception;
|
||||
use KTXC\Module\Store\ModuleStore;
|
||||
use KTXC\Module\Store\ModuleEntry;
|
||||
use KTXC\Server;
|
||||
use KTXF\Module\ModuleInstanceInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
|
||||
class ModuleManager
|
||||
{
|
||||
private string $serverRoot = '';
|
||||
private array $moduleInstances = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ModuleStore $repository,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
// Initialize server root path
|
||||
$this->serverRoot = Server::runtimeRootLocation();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all modules as unified Module objects
|
||||
*
|
||||
* @param bool $installedOnly If true, only return modules that are in the database
|
||||
* @param bool $enabledOnly If true, only return modules that are enabled (implies installedOnly)
|
||||
* @return Module[]
|
||||
*/
|
||||
public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection
|
||||
{
|
||||
$modules = New ModuleCollection();
|
||||
// load all modules from store
|
||||
$entries = $this->repository->list();
|
||||
foreach ($entries as $entry) {
|
||||
if ($enabledOnly && !$entry->getEnabled()) {
|
||||
continue; // Skip disabled modules if filtering for enabled only
|
||||
}
|
||||
// instance module
|
||||
$handle = $entry->getHandle();
|
||||
if (isset($this->moduleInstances[$entry->getHandle()])) {
|
||||
$modules[$handle] = new ModuleObject($this->moduleInstances[$handle], $entry);
|
||||
} else {
|
||||
$moduleInstance = $this->moduleInstance($handle, $entry->getNamespace());
|
||||
$modules[$handle] = new ModuleObject($moduleInstance, $entry);
|
||||
$this->moduleInstances[$handle] = $moduleInstance;
|
||||
}
|
||||
}
|
||||
// load all modules from filesystem
|
||||
if ($installedOnly === false) {
|
||||
$discovered = $this->modulesDiscover();
|
||||
foreach ($discovered as $moduleInstance) {
|
||||
$handle = $moduleInstance->handle();
|
||||
if (!isset($modules[$handle])) {
|
||||
$modules[$handle] = new ModuleObject($moduleInstance, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
public function install(string $handle): void
|
||||
{
|
||||
// First, try to find the module by scanning the filesystem
|
||||
$modulesDir = $this->serverRoot . '/modules';
|
||||
$namespace = null;
|
||||
|
||||
// Scan for the module by checking if handle matches any folder or module's handle() method
|
||||
if (is_dir($modulesDir)) {
|
||||
$moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR);
|
||||
foreach ($moduleDirs as $moduleDir) {
|
||||
$testModuleFile = $moduleDir . '/lib/Module.php';
|
||||
if (!file_exists($testModuleFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract namespace from the Module.php file
|
||||
$testNamespace = $this->extractNamespaceFromFile($testModuleFile);
|
||||
if (!$testNamespace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to instantiate with a temporary handle to check if it matches
|
||||
$folderName = basename($moduleDir);
|
||||
$testInstance = $this->moduleInstance($folderName, $testNamespace);
|
||||
|
||||
if ($testInstance && $testInstance->handle() === $handle) {
|
||||
$namespace = $testNamespace;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$namespace) {
|
||||
$this->logger->error('Module not found for installation', ['handle' => $handle]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($handle, $namespace);
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->install();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module installation failed: ' . $handle, [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$module = new ModuleEntry();
|
||||
$module->setHandle($handle);
|
||||
$module->setVersion($moduleInstance->version());
|
||||
$module->setEnabled(false);
|
||||
$module->setInstalled(true);
|
||||
// Store the namespace we found
|
||||
$module->setNamespace($namespace);
|
||||
$this->repository->deposit($module);
|
||||
}
|
||||
|
||||
public function uninstall(string $handle): void
|
||||
{
|
||||
$moduleEntry = $this->repository->fetch($handle);
|
||||
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
|
||||
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
|
||||
throw new Exception('Module not installed: ' . $handle);
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->uninstall();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module uninstallation failed: ' . $moduleEntry->getHandle(), [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->repository->destroy($moduleEntry);
|
||||
}
|
||||
|
||||
public function enable(string $handle): void
|
||||
{
|
||||
$moduleEntry = $this->repository->fetch($handle);
|
||||
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
|
||||
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
|
||||
throw new Exception('Module not installed: ' . $handle);
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->enable();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module enabling failed: ' . $moduleEntry->getHandle(), [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleEntry->setEnabled(true);
|
||||
$this->repository->deposit($moduleEntry);
|
||||
}
|
||||
|
||||
public function disable(string $handle): void
|
||||
{
|
||||
$moduleEntry = $this->repository->fetch($handle);
|
||||
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
|
||||
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
|
||||
throw new Exception('Module not installed: ' . $handle);
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->disable();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module disabling failed: ' . $moduleEntry->getHandle(), [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleEntry->setEnabled(false);
|
||||
$this->repository->deposit($moduleEntry);
|
||||
}
|
||||
|
||||
public function upgrade(string $handle): void
|
||||
{
|
||||
$moduleEntry = $this->repository->fetch($handle);
|
||||
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
|
||||
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
|
||||
throw new Exception('Module not installed: ' . $handle);
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->upgrade();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module upgrade failed: ' . $moduleEntry->getHandle(), [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleEntry->setVersion($moduleInstance->version());
|
||||
$this->repository->deposit($moduleEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan filesystem for module directories and return module instances
|
||||
*
|
||||
* @return array<string, ModuleInstanceInterface> Map of handle => ModuleInstanceInterface
|
||||
*/
|
||||
private function modulesDiscover(): array
|
||||
{
|
||||
$modules = [];
|
||||
$modulesDir = $this->serverRoot . '/modules';
|
||||
|
||||
if (!is_dir($modulesDir)) {
|
||||
return $modules;
|
||||
}
|
||||
|
||||
// Get list of installed module handles to skip
|
||||
$installedHandles = [];
|
||||
foreach ($this->repository->list() as $entry) {
|
||||
$installedHandles[] = $entry->getHandle();
|
||||
}
|
||||
|
||||
// Scan for module directories
|
||||
$moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR);
|
||||
foreach ($moduleDirs as $moduleDir) {
|
||||
$moduleFile = $moduleDir . '/lib/Module.php';
|
||||
if (!file_exists($moduleFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract namespace from the Module.php file
|
||||
$namespace = $this->extractNamespaceFromFile($moduleFile);
|
||||
if (!$namespace) {
|
||||
$this->logger->warning('Could not extract namespace from Module.php', [
|
||||
'file' => $moduleFile
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use the folder name as a temporary handle to instantiate the module
|
||||
$folderName = basename($moduleDir);
|
||||
$moduleInstance = $this->moduleInstance($folderName, $namespace);
|
||||
if (!$moduleInstance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the actual handle from the module instance
|
||||
$handle = $moduleInstance->handle();
|
||||
|
||||
// Skip if already installed
|
||||
if (in_array($handle, $installedHandles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-cache with the correct handle if different from folder name
|
||||
if ($handle !== $folderName) {
|
||||
unset($this->moduleInstances[$folderName]);
|
||||
$this->moduleInstances[$handle] = $moduleInstance;
|
||||
}
|
||||
|
||||
$modules[$handle] = $moduleInstance;
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot all enabled modules (must be called after container is ready).
|
||||
*/
|
||||
public function modulesBoot(): void
|
||||
{
|
||||
// Only load modules that are enabled in the database
|
||||
$modules = $this->list();
|
||||
$this->logger->debug('Booting enabled modules', ['count' => count($modules)]);
|
||||
foreach ($modules as $module) {
|
||||
$handle = $module->handle();
|
||||
try {
|
||||
$module->boot();
|
||||
$this->logger->debug('Module booted', ['handle' => $handle]);
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module boot failed: ' . $handle, [
|
||||
'exception' => $e,
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function moduleInstance(string $handle, ?string $namespace = null): ?ModuleInstanceInterface
|
||||
{
|
||||
// Return from cache if already instantiated
|
||||
if (isset($this->moduleInstances[$handle])) {
|
||||
return $this->moduleInstances[$handle];
|
||||
}
|
||||
|
||||
// Determine the namespace segment
|
||||
// If namespace is provided, use it; otherwise derive from handle
|
||||
$nsSegment = $namespace ?: $this->studly($handle);
|
||||
|
||||
$className = 'KTXM\\' . $nsSegment . '\\Module';
|
||||
|
||||
if (!class_exists($className)) {
|
||||
$this->logger->error('Module class not found', [
|
||||
'handle' => $handle,
|
||||
'namespace' => $namespace,
|
||||
'resolved' => $className
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!in_array(ModuleInstanceInterface::class, class_implements($className))) {
|
||||
$this->logger->error('Module class does not implement ModuleInstanceInterface', [
|
||||
'class' => $className
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$module = $this->moduleLoad($className);
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Failed to lazily create module instance', [
|
||||
'handle' => $handle,
|
||||
'namespace' => $namespace,
|
||||
'exception' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache by handle
|
||||
if ($module) {
|
||||
$this->moduleInstances[$handle] = $module;
|
||||
}
|
||||
|
||||
return $module;
|
||||
}
|
||||
|
||||
private function moduleLoad(string $className): ?ModuleInstanceInterface
|
||||
{
|
||||
try {
|
||||
// Use reflection to check constructor requirements
|
||||
$reflectionClass = new ReflectionClass($className);
|
||||
$constructor = $reflectionClass->getConstructor();
|
||||
|
||||
if (!$constructor || $constructor->getNumberOfRequiredParameters() === 0) {
|
||||
// Simple instantiation for modules without dependencies
|
||||
return new $className();
|
||||
}
|
||||
|
||||
// For modules with dependencies, try to resolve them from the container
|
||||
$container = Server::runtimeContainer();
|
||||
$parameters = $constructor->getParameters();
|
||||
$args = [];
|
||||
|
||||
foreach ($parameters as $parameter) {
|
||||
$type = $parameter->getType();
|
||||
if ($type && !$type->isBuiltin()) {
|
||||
$typeName = $type->getName();
|
||||
|
||||
// Try to get service from container
|
||||
if ($container->has($typeName)) {
|
||||
$args[] = $container->get($typeName);
|
||||
} elseif ($parameter->isDefaultValueAvailable()) {
|
||||
$args[] = $parameter->getDefaultValue();
|
||||
} else {
|
||||
// Cannot resolve dependency
|
||||
$this->logger->warning('Cannot resolve dependency for module: ' . $className, [
|
||||
'dependency' => $typeName
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
} elseif ($parameter->isDefaultValueAvailable()) {
|
||||
$args[] = $parameter->getDefaultValue();
|
||||
} else {
|
||||
// Cannot resolve primitive dependency
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $reflectionClass->newInstanceArgs($args);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Failed to instantiate module: ' . $className, [
|
||||
'exception' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function studly(string $value): string
|
||||
{
|
||||
$value = str_replace(['-', '_'], ' ', strtolower($value));
|
||||
$value = ucwords($value);
|
||||
return str_replace(' ', '', $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the PHP namespace from a Module.php file by parsing its contents
|
||||
*
|
||||
* @param string $moduleFilePath Absolute path to the Module.php file (located at /modules/{handle}/lib/Module.php)
|
||||
* @return string|null The namespace segment (e.g., 'ContactsManager' from 'KTXM\ContactsManager')
|
||||
*/
|
||||
private function extractNamespaceFromFile(string $moduleFilePath): ?string
|
||||
{
|
||||
if (!file_exists($moduleFilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($moduleFilePath);
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match namespace declaration: namespace KTXM\<Namespace>;
|
||||
if (preg_match('/^\s*namespace\s+KTXM\\\\([a-zA-Z0-9_]+)\s*;/m', $content, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
161
core/lib/Module/ModuleObject.php
Normal file
161
core/lib/Module/ModuleObject.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXC\Module\Store\ModuleEntry;
|
||||
use KTXF\Module\ModuleInstanceInterface;
|
||||
|
||||
/**
|
||||
* Module is a unified wrapper that combines both the ModuleInterface instance
|
||||
* (from filesystem) and ModuleEntry (from database) into a single object.
|
||||
*
|
||||
* This provides a single source of truth for all module information.
|
||||
*/
|
||||
class ModuleObject implements JsonSerializable
|
||||
{
|
||||
private ?ModuleInstanceInterface $instance = null;
|
||||
private ?ModuleEntry $entry = null;
|
||||
|
||||
public function __construct(?ModuleInstanceInterface $instance = null, ?ModuleEntry $entry = null)
|
||||
{
|
||||
$this->instance = $instance;
|
||||
$this->entry = $entry;
|
||||
}
|
||||
|
||||
// ===== Serialization =====
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id(),
|
||||
'handle' => $this->handle(),
|
||||
'version' => $this->version(),
|
||||
'namespace' => $this->namespace(),
|
||||
'installed' => $this->installed(),
|
||||
'enabled' => $this->enabled(),
|
||||
'needsUpgrade' => $this->needsUpgrade(),
|
||||
];
|
||||
}
|
||||
|
||||
// ===== State from ModuleEntry (database) =====
|
||||
|
||||
public function id(): ?string
|
||||
{
|
||||
return $this->entry?->getId();
|
||||
}
|
||||
|
||||
public function installed(): bool
|
||||
{
|
||||
return $this->entry?->getInstalled() ?? false;
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->entry?->getEnabled() ?? false;
|
||||
}
|
||||
|
||||
// ===== Information from ModuleInterface (filesystem) =====
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
if ($this->instance) {
|
||||
return $this->instance->handle();
|
||||
}
|
||||
if ($this->entry) {
|
||||
return $this->entry->getHandle();
|
||||
}
|
||||
throw new \RuntimeException('Module has neither instance nor entry');
|
||||
}
|
||||
|
||||
public function namespace(): ?string
|
||||
{
|
||||
if ($this->entry) {
|
||||
return $this->entry->getNamespace();
|
||||
}
|
||||
if ($this->instance) {
|
||||
// Extract namespace from class name
|
||||
$className = get_class($this->instance);
|
||||
$parts = explode('\\', $className);
|
||||
if (count($parts) >= 2 && $parts[0] === 'KTXM') {
|
||||
return $parts[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function version(): string
|
||||
{
|
||||
// Prefer current version from filesystem
|
||||
if ($this->instance) {
|
||||
return $this->instance->version();
|
||||
}
|
||||
// Fallback to stored version
|
||||
if ($this->entry) {
|
||||
return $this->entry->getVersion();
|
||||
}
|
||||
return '0.0.0';
|
||||
}
|
||||
|
||||
// ===== Computed properties =====
|
||||
|
||||
public function needsUpgrade(): bool
|
||||
{
|
||||
if (!$this->instance || !$this->entry || !$this->installed()) {
|
||||
return false;
|
||||
}
|
||||
$currentVersion = $this->instance->version();
|
||||
$storedVersion = $this->entry->getVersion();
|
||||
return version_compare($currentVersion, $storedVersion, '>');
|
||||
}
|
||||
|
||||
// ===== Access to underlying objects =====
|
||||
|
||||
public function instance(): ?ModuleInstanceInterface
|
||||
{
|
||||
return $this->instance;
|
||||
}
|
||||
|
||||
public function entry(): ?ModuleEntry
|
||||
{
|
||||
return $this->entry;
|
||||
}
|
||||
|
||||
// ===== Lifecycle methods (delegate to instance) =====
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->instance?->boot();
|
||||
}
|
||||
|
||||
public function install(): void
|
||||
{
|
||||
$this->instance?->install();
|
||||
}
|
||||
|
||||
public function uninstall(): void
|
||||
{
|
||||
$this->instance?->uninstall();
|
||||
}
|
||||
|
||||
public function enable(): void
|
||||
{
|
||||
$this->instance?->enable();
|
||||
}
|
||||
|
||||
public function disable(): void
|
||||
{
|
||||
$this->instance?->disable();
|
||||
}
|
||||
|
||||
public function upgrade(): void
|
||||
{
|
||||
$this->instance?->upgrade();
|
||||
}
|
||||
|
||||
public function bootUi(): array | null
|
||||
{
|
||||
return $this->instance?->bootUi() ?? null;
|
||||
}
|
||||
|
||||
}
|
||||
119
core/lib/Module/Store/ModuleEntry.php
Normal file
119
core/lib/Module/Store/ModuleEntry.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module\Store;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
/**
|
||||
* Module entity representing an installed module
|
||||
*/
|
||||
class ModuleEntry implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
private ?string $id = null;
|
||||
private ?string $namespace = null;
|
||||
private ?string $handle = null;
|
||||
private bool $installed = false;
|
||||
private bool $enabled = false;
|
||||
private string $version = '0.0.1';
|
||||
|
||||
/**
|
||||
* Deserialize from associative array.
|
||||
*/
|
||||
public function jsonDeserialize(array|string $data): static
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
// Map only if key exists to avoid notices and allow partial input
|
||||
if (array_key_exists('_id', $data)) $this->id = $data['_id'] !== null ? (string)$data['_id'] : null;
|
||||
elseif (array_key_exists('id', $data)) $this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
||||
if (array_key_exists('namespace', $data)) $this->namespace = $data['namespace'] !== null ? (string)$data['namespace'] : null;
|
||||
if (array_key_exists('handle', $data)) $this->handle = $data['handle'] !== null ? (string)$data['handle'] : null;
|
||||
if (array_key_exists('installed', $data)) $this->installed = (bool)$data['installed'];
|
||||
if (array_key_exists('enabled', $data)) $this->enabled = (bool)$data['enabled'];
|
||||
if (array_key_exists('version', $data)) $this->version = (string)$data['version'];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize to JSON-friendly structure.
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'namespace' => $this->namespace,
|
||||
'handle' => $this->handle,
|
||||
'installed' => $this->installed,
|
||||
'enabled' => $this->enabled,
|
||||
'version' => $this->version,
|
||||
];
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $value): self
|
||||
{
|
||||
$this->id = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNamespace(): ?string
|
||||
{
|
||||
return $this->namespace;
|
||||
}
|
||||
|
||||
public function setNamespace(string $value): self
|
||||
{
|
||||
$this->namespace = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHandle(): ?string
|
||||
{
|
||||
return $this->handle;
|
||||
}
|
||||
|
||||
public function setHandle(string $value): self
|
||||
{
|
||||
$this->handle = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getInstalled(): bool
|
||||
{
|
||||
return $this->installed;
|
||||
}
|
||||
|
||||
public function setInstalled(bool $value): self
|
||||
{
|
||||
$this->installed = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function setEnabled(bool $value): self
|
||||
{
|
||||
$this->enabled = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function setVersion(string $value): self
|
||||
{
|
||||
$this->version = $value;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
66
core/lib/Module/Store/ModuleStore.php
Normal file
66
core/lib/Module/Store/ModuleStore.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module\Store;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
|
||||
class ModuleStore
|
||||
{
|
||||
|
||||
protected const COLLECTION_NAME = 'modules';
|
||||
|
||||
public function __construct(
|
||||
protected readonly DataStore $dataStore
|
||||
) { }
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(['enabled' => true, 'installed' => true]);
|
||||
$modules = [];
|
||||
foreach ($cursor as $entry) {
|
||||
$entity = new ModuleEntry();
|
||||
$entity->jsonDeserialize((array)$entry);
|
||||
$modules[$entity->getId()] = $entity;
|
||||
}
|
||||
return $modules;
|
||||
}
|
||||
|
||||
public function fetch(string $handle): ?ModuleEntry
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['handle' => $handle]);
|
||||
if (!$entry) { return null; }
|
||||
return (new ModuleEntry())->jsonDeserialize((array)$entry);
|
||||
}
|
||||
|
||||
public function deposit(ModuleEntry $entry): ?ModuleEntry
|
||||
{
|
||||
if ($entry->getId()) {
|
||||
return $this->update($entry);
|
||||
} else {
|
||||
return $this->create($entry);
|
||||
}
|
||||
}
|
||||
|
||||
private function create(ModuleEntry $entry): ?ModuleEntry
|
||||
{
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize());
|
||||
$entry->setId((string)$result->getInsertedId());
|
||||
return $entry;
|
||||
}
|
||||
|
||||
private function update(ModuleEntry $entry): ?ModuleEntry
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return null; }
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
|
||||
return $entry;
|
||||
}
|
||||
|
||||
public function destroy(ModuleEntry $entry): void
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return; }
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user