Initial Version

This commit is contained in:
root
2025-12-21 10:09:54 -05:00
commit 4ae6befc7b
422 changed files with 47225 additions and 0 deletions

112
core/lib/Module/Module.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
namespace KTXC\Module;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleConsoleInterface;
use KTXF\Module\ModuleInstanceAbstract;
/**
* Core Module
*
* Provides core system functionality and permissions
*/
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
{
public function __construct() {}
public function handle(): string
{
return 'core';
}
public function label(): string
{
return 'Core System';
}
public function author(): string
{
return 'Ktrix';
}
public function description(): string
{
return 'Core system functionality and user features';
}
public function version(): string
{
return '0.0.1';
}
public function permissions(): array
{
return [
// Core User Permissions
'user.profile.read' => [
'label' => 'Read Own Profile',
'description' => 'View own user profile information',
'group' => 'User Profile'
],
'user.profile.update' => [
'label' => 'Update Own Profile',
'description' => 'Edit own user profile information',
'group' => 'User Profile'
],
'user.settings.read' => [
'label' => 'Read Own Settings',
'description' => 'View own user settings',
'group' => 'User Settings'
],
'user.settings.update' => [
'label' => 'Update Own Settings',
'description' => 'Edit own user settings',
'group' => 'User Settings'
],
// Module Management
'module_manager.modules.view' => [
'label' => 'View Modules',
'description' => 'View list of installed and available modules',
'group' => 'Module Management'
],
'module_manager.modules.manage' => [
'label' => 'Manage Modules',
'description' => 'Install, uninstall, enable, and disable modules',
'group' => 'Module Management'
],
'module_manager.modules.*' => [
'label' => 'Full Module Management',
'description' => 'All module management operations',
'group' => 'Module Management'
],
// System Administration
'system.admin' => [
'label' => 'System Administrator',
'description' => 'Full system access (superuser)',
'group' => 'System Administration'
],
'*' => [
'label' => 'All Permissions',
'description' => 'Grants access to all features and operations',
'group' => 'System Administration'
],
];
}
public function registerCI(): array
{
return [
\KTXC\Console\ModuleListCommand::class,
\KTXC\Console\ModuleEnableCommand::class,
\KTXC\Console\ModuleDisableCommand::class,
];
}
public function registerBI(): array
{
return [];
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace KTXC\Module;
use KTXC\Application;
/**
* 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);
}
}
// Register module namespaces with Composer ClassLoader
$composerLoader = \KTXC\Application::getComposerLoader();
if ($composerLoader !== null) {
foreach ($this->namespaceMap as $namespace => $folderName) {
$composerLoader->addPsr4(
'KTXM\\' . $namespace . '\\',
$this->modulesRoot . '/' . $folderName . '/lib/'
);
}
}
$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,595 @@
<?php
namespace KTXC\Module;
use Exception;
use DI\Attribute\Inject;
use KTXC\Module\Store\ModuleStore;
use KTXC\Module\Store\ModuleEntry;
use Psr\Container\ContainerInterface;
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,
private readonly ContainerInterface $container,
#[Inject('rootDir')] private readonly string $rootDir
) {
// Initialize server root path
$this->serverRoot = $rootDir;
}
/**
* 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();
// Always include core module
$coreModule = $this->coreModule();
if ($coreModule) {
$modules['core'] = new ModuleObject($coreModule, null);
}
// 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);
}
/**
* 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(),
]);
}
}
}
/**
* 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;
}
public function moduleInstance(string $handle, ?string $namespace = null): ?ModuleInstanceInterface
{
// Load module's vendor autoloader if it exists
$this->loadModuleVendor($handle);
// 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
$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 ($this->container->has($typeName)) {
$args[] = $this->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;
}
}
/**
* Load a module's vendor autoloader if it has dependencies
*
* @param string $handle Module handle
* @throws Exception If module has dependencies but vendor directory is missing
*/
private function loadModuleVendor(string $handle): void
{
$moduleDir = $this->serverRoot . '/modules/' . $handle;
$composerJson = $moduleDir . '/composer.json';
$vendorAutoload = $moduleDir . '/lib/vendor/autoload.php';
// Check if module has a composer.json with dependencies
if (file_exists($composerJson)) {
$composerData = json_decode(file_get_contents($composerJson), true);
$hasDependencies = !empty($composerData['require']) && count($composerData['require']) > 1; // More than just PHP
if ($hasDependencies) {
if (file_exists($vendorAutoload)) {
require_once $vendorAutoload;
$this->logger->debug("Loaded vendor autoloader for module: {$handle}");
} else {
throw new Exception(
"Module '{$handle}' declares dependencies in composer.json but vendor directory is missing. "
. "Run 'composer install' in {$moduleDir}"
);
}
}
}
}
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;
}
/**
* Get all available permissions from all modules
*
* @return array Grouped permissions with metadata
*/
public function availablePermissions(): array
{
$permissions = [];
foreach ($this->list() as $module) {
$modulePermissions = $module->permissions();
foreach ($modulePermissions as $permission => $meta) {
$permissions[$permission] = array_merge($meta, [
'module' => $module->handle()
]);
}
}
// Group by category
$grouped = [];
foreach ($permissions as $permission => $meta) {
$group = $meta['group'] ?? 'Other';
if (!isset($grouped[$group])) {
$grouped[$group] = [];
}
$grouped[$group][$permission] = $meta;
}
// Sort groups alphabetically
ksort($grouped);
return $grouped;
}
/**
* Validate if a permission exists
*/
public function permissionExists(string $permission): bool
{
foreach ($this->list() as $module) {
$modulePermissions = $module->permissions();
// Exact match
if (isset($modulePermissions[$permission])) {
return true;
}
// Wildcard match (e.g., user_manager.users.create matches user_manager.users.*)
foreach (array_keys($modulePermissions) as $registered) {
if (str_ends_with($registered, '.*')) {
$prefix = substr($registered, 0, -2);
if (str_starts_with($permission, $prefix . '.')) {
return true;
}
}
}
}
return false;
}
/**
* Get the core module instance
*/
private function coreModule(): ?\KTXF\Module\ModuleInstanceInterface
{
if (isset($this->moduleInstances['core'])) {
return $this->moduleInstances['core'];
}
try {
$coreModuleClass = \KTXC\Module\Module::class;
if (!class_exists($coreModuleClass)) {
return null;
}
$instance = $this->container->get($coreModuleClass);
$this->moduleInstances['core'] = $instance;
return $instance;
} catch (\Throwable $e) {
$this->logger->error('Failed to load core module', [
'error' => $e->getMessage()
]);
return null;
}
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace KTXC\Module;
use JsonSerializable;
use KTXC\Module\Store\ModuleEntry;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleConsoleInterface;
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';
}
public function permissions(): array
{
return $this->instance?->permissions() ?? [];
}
// ===== 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 registerBI(): array | null
{
if ($this->instance instanceof ModuleBrowserInterface) {
return $this->instance->registerBI();
}
return null;
}
public function registerCI(): array | null
{
if ($this->instance instanceof ModuleConsoleInterface) {
return $this->instance->registerCI();
}
return 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]);
}
}