Initial Version

This commit is contained in:
root
2025-12-21 10:09:54 -05:00
commit 2fbddd7dbc
366 changed files with 41999 additions and 0 deletions

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

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

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

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

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

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