diff --git a/modules/system/controllers/ModulesController.php b/modules/system/controllers/ModulesController.php index 7a1daa6..bc3f8da 100644 --- a/modules/system/controllers/ModulesController.php +++ b/modules/system/controllers/ModulesController.php @@ -28,41 +28,18 @@ public function init() */ public function indexAction() { - $installed = (new Tiger_Model_Module())->bySlugMap(); - $activeTheme = (string) (new Tiger_Model_Config())->get(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme'); + // The rows are NOT server-rendered — the view ships an empty table and fetches them from + // System_Service_Modules::datatable over /api (client/server paradigm, WEBSERVICES §5). Here we + // only build the TYPE-filter pills: their labels + full-catalog counts, and which one starts + // active (from the remembered cookie), so the first ajax load is already filtered — no flash. + $catalog = System_Service_Modules::catalog(); - $modules = []; - foreach (Tiger_Module_Discovery::all() as $slug => $m) { - $row = $installed[$slug] ?? null; - $isTheme = ($m['type'] ?? 'module') === 'theme'; - // Active is the module FLAG for everything now, themes included — multiple themes can be - // active at once. WHICH theme is the DEFAULT site theme is the separate `tiger.theme` config. - $active = $row ? ((int) $row->active === 1) : true; - $isDefault = $isTheme && $activeTheme !== '' && $activeTheme === (string) ($m['key'] ?? $slug); - $source = $row ? $row->source : ($m['area'] === 'core' ? 'bundled' : 'custom'); - // Taxonomy resolution (AUTHORING.md): the value STORED at install (retained from the source - // listing/manifest) wins; else the live manifest that Discovery read; else its default. Read - // via toArray() so a pre-0042 DB (no columns) degrades gracefully to the manifest. - $rowArr = $row ? $row->toArray() : []; - if (!empty($rowArr['type'])) { $m['type'] = (string) $rowArr['type']; } - if (!empty($rowArr['category'])) { $m['category'] = array_values(array_filter(explode(',', (string) $rowArr['category']))); } - // Protected = the hardcoded core set OR the module's manifest `"protected": true` (Discovery - // put that in $m). Set it on $m so it wins the union below (which keeps left-hand keys). - $m['protected'] = !empty($m['protected']) || in_array($slug, System_Service_Modules::PROTECTED, true); - $modules[] = $m + [ - 'active' => $active, - 'is_default' => $isDefault, - 'source' => $source, - // Advisory: tested-version compat notice (never blocks) + who requires this module - // (drives the "required by X, Y — deactivate anyway?" confirm; empty for most). - 'compat' => Tiger_Module_Compat::check($m), - 'required_by' => $isTheme ? [] : Tiger_Module_Dependency::dependents($slug), - ]; - } + $counts = []; + foreach ($catalog as $m) { $t = (string) ($m['type'] ?? 'module'); $counts[$t] = ($counts[$t] ?? 0) + 1; } - // Type labels for the filter pills + the Type column, from the SAME data-driven registry taxonomy - // the Add Module screen uses (Apps / Themes / Plugins / Code / Developer …). Best-effort + cached; - // a derived humanize is the fallback, so the screen never depends on the registry being reachable. + // Type labels from the SAME data-driven registry taxonomy the Add Module screen uses. Best-effort + // + cached; a derived humanize (in the view) is the fallback, so the screen never depends on the + // registry being reachable. $typeLabels = []; try { $tax = Tiger_Module_Registry::taxonomy(); @@ -72,11 +49,18 @@ public function indexAction() } catch (Throwable $e) { } - $this->view->title = 'Modules — Tiger Admin'; - $this->view->modules = $modules; - $this->view->activeTheme = $activeTheme; - $this->view->typeLabels = $typeLabels; - $this->view->useDataTables = true; // the list is a client-side DataTable (sort / page / search) + // The remembered type filter (a cookie the pill click sets) decides which pill renders active, + // so the grid loads already filtered on that type instead of flashing All → the chosen tab. A + // stale cookie (a type no longer present) falls back to All. + $activeType = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) ($_COOKIE['tiger_mods_type'] ?? '')); + if ($activeType !== '' && !isset($counts[$activeType])) { $activeType = ''; } + + $this->view->title = 'Modules — Tiger Admin'; + $this->view->counts = $counts; + $this->view->total = count($catalog); + $this->view->typeLabels = $typeLabels; + $this->view->activeType = $activeType; + $this->view->useDataTables = true; } /** diff --git a/modules/system/services/Modules.php b/modules/system/services/Modules.php index ce606b1..bd9d5ae 100644 --- a/modules/system/services/Modules.php +++ b/modules/system/services/Modules.php @@ -34,6 +34,182 @@ class System_Service_Modules extends Tiger_Service_Service const NAG_DISMISSED_KEY = 'tiger.pass.nag.dismissed_at'; // UTC unix stamp of the last dismiss const NAG_DISABLED_KEY = 'tiger.pass.nag.disabled'; // '1' = the user turned the banner off + /** + * The full installed-module catalog — every module ON DISK (Tiger_Module_Discovery) joined with + * its install row, resolved taxonomy, source, compat notice and dependents. One authority shared + * by the controller (filter pills + counts) and datatable() (the grid feed). No ACL here — both + * callers are superadmin surfaces that gate themselves. + * + * @return array> the derived module rows + */ + public static function catalog(): array + { + $installed = (new Tiger_Model_Module())->bySlugMap(); + $activeTheme = (string) (new Tiger_Model_Config())->get(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme'); + + $modules = []; + foreach (Tiger_Module_Discovery::all() as $slug => $m) { + $row = $installed[$slug] ?? null; + $isTheme = ($m['type'] ?? 'module') === 'theme'; + // Active is the module FLAG for everything, themes included (many themes can be active at + // once). WHICH theme is the DEFAULT site theme is the separate `tiger.theme` config. + $active = $row ? ((int) $row->active === 1) : true; + $isDefault = $isTheme && $activeTheme !== '' && $activeTheme === (string) ($m['key'] ?? $slug); + $source = $row ? $row->source : ($m['area'] === 'core' ? 'bundled' : 'custom'); + // Taxonomy: the value STORED at install wins; else the live manifest Discovery read. + $rowArr = $row ? $row->toArray() : []; + if (!empty($rowArr['type'])) { $m['type'] = (string) $rowArr['type']; } + if (!empty($rowArr['category'])) { $m['category'] = array_values(array_filter(explode(',', (string) $rowArr['category']))); } + $m['protected'] = !empty($m['protected']) || in_array($slug, self::PROTECTED, true); + $modules[] = $m + [ + 'active' => $active, + 'is_default' => $isDefault, + 'source' => $source, + 'compat' => Tiger_Module_Compat::check($m), + 'required_by' => $isTheme ? [] : Tiger_Module_Dependency::dependents($slug), + ]; + } + return $modules; + } + + /** + * DataTables feed for the Modules screen (client/server paradigm — the view renders an EMPTY + * table and fetches rows here; rows are never server-rendered). Server-side search / type-filter + * / sort / paginate over catalog(), each row carrying its display fields plus per-row action + * flags (can_toggle / can_delete) so the client renders cells and gates controls without + * re-deriving authority. The response also carries per-type `counts` over the WHOLE catalog, so + * the filter pills stay accurate after an activate / delete without a page reload. + * + * @param array $params the DataTables request (+ an optional `type` pill filter) + * @return void + */ + public function datatable(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + + $dt = $this->_dtParams($params); + $all = self::catalog(); + + // Pill counts over the whole catalog — independent of the type/search filter below. + $counts = []; + foreach ($all as $m) { $t = (string) ($m['type'] ?? 'module'); $counts[$t] = ($counts[$t] ?? 0) + 1; } + $total = count($all); + + // Type-filter (the active pill) + global search, both server-side. + $type = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) ($params['type'] ?? '')); + $q = $dt['search'] !== '' ? strtolower($dt['search']) : ''; + $list = array_values(array_filter($all, static function ($m) use ($type, $q) { + if ($type !== '' && (string) ($m['type'] ?? 'module') !== $type) { return false; } + if ($q === '') { return true; } + $hay = strtolower(($m['name'] ?? '') . ' ' . ($m['slug'] ?? '') . ' ' . ($m['description'] ?? '') . ' ' . ($m['author'] ?? '')); + return strpos($hay, $q) !== false; + })); + $filtered = count($list); + + // Sort — only Module (col 0, by name) and Status (col 4, by active flag) are orderable. + $col = isset($dt['order'][0]) ? $dt['order'][0]['column'] : 0; + $dir = (isset($dt['order'][0]) && $dt['order'][0]['dir'] === 'DESC') ? -1 : 1; + usort($list, static function ($a, $b) use ($col, $dir) { + $cmp = ($col === 4) + ? (((int) !empty($a['active'])) <=> ((int) !empty($b['active']))) + : 0; + if ($cmp === 0) { $cmp = strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')); } + return $cmp * $dir; + }); + + $page = array_slice($list, $dt['start'], $dt['length']); + $defaultExists = (string) (new Tiger_Model_Config())->get(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme') !== ''; + $bySlug = []; foreach ($all as $mm) { $bySlug[$mm['slug']] = $mm; } + $tokenOf = static function (array $m) { + $v = trim((string) ($m['author'] ?? '')); $n = trim((string) ($m['name'] ?? '')); + return $v !== '' ? ($v . '/' . $n) : $n; + }; + + $rows = []; + foreach ($page as $m) { + $slug = (string) $m['slug']; + $modType = (string) ($m['type'] ?? 'module'); + $protected = !empty($m['protected']); + $active = !empty($m['active']); + $area = (string) ($m['area'] ?? ''); + $compat = $m['compat'] ?? null; + $deps = array_values($m['required_by'] ?? []); + $depTokens = array_map(static function ($s) use ($bySlug, $tokenOf) { return $tokenOf($bySlug[$s] ?? ['name' => $s]); }, $deps); + $rows[] = [ + 'slug' => $slug, + 'name' => (string) ($m['name'] ?? $slug), + 'description' => (string) ($m['description'] ?? ''), + 'author' => (string) ($m['author'] ?? ''), + 'license' => (string) ($m['license'] ?? ''), + 'license_short' => self::_licenseShort((string) ($m['license'] ?? '')), + 'version' => ($m['version'] ?? null) !== null ? (string) $m['version'] : '', + 'type' => $modType, + 'type_label' => self::_typeLabel($modType), + 'type_icon' => self::_typeIcon($modType), + 'source' => (string) ($m['source'] ?? 'custom'), // bundled | custom + 'active' => $active, + 'is_default' => !empty($m['is_default']), + 'is_theme' => $modType === 'theme', + 'protected' => $protected, + 'default_exists' => $defaultExists, + 'required_by' => $deps, + 'dep_tokens' => implode(', ', $depTokens), + 'token' => $tokenOf($m), + 'compat_message' => (is_array($compat) && empty($compat['ok']) && !empty($compat['message'])) ? (string) $compat['message'] : '', + 'can_toggle' => !$protected, + 'can_delete' => !$protected && $area !== 'core' && !$active, + ]; + } + + $this->_success([ + 'draw' => $dt['draw'], + 'recordsTotal' => $total, + 'recordsFiltered' => $filtered, + 'data' => $rows, + 'counts' => $counts, + ]); + } + + /** Human label for a module type id — the registry taxonomy (cached, best-effort), humanized fallback. */ + protected static function _typeLabel(string $type): string + { + static $labels = null; + if ($labels === null) { + $labels = []; + try { + $tax = Tiger_Module_Registry::taxonomy(); + foreach (($tax['types'] ?? []) as $t) { + if (!empty($t['id'])) { $labels[(string) $t['id']] = (string) ($t['label'] ?? $t['id']); } + } + } catch (Throwable $e) { + } + } + return $labels[$type] ?? ucwords(str_replace(['-', '_'], ' ', $type)); + } + + /** Font Awesome icon class for a module type (mirrors the Add Module screen). */ + protected static function _typeIcon(string $type): string + { + switch ($type) { + case 'theme': return 'fa-palette'; + case 'code': return 'fa-code'; + case 'app': return 'fa-cubes'; + case 'plugin': return 'fa-plug'; + default: return 'fa-cube'; + } + } + + /** A short license tag from an SPDX-ish string. */ + protected static function _licenseShort(string $lic): string + { + if ($lic === '') { return ''; } + if (preg_match('/\bMIT\b/i', $lic)) { return 'MIT'; } + if (preg_match('/BSD-3/i', $lic)) { return 'BSD-3'; } + if (preg_match('/BSD-2/i', $lic)) { return 'BSD-2'; } + if (preg_match('/proprietary|licenseref|commercial/i', $lic)) { return 'Commercial'; } + return $lic; + } + /** * Activate a module (by `slug`), publishing its assets. * diff --git a/modules/system/views/scripts/modules/index.phtml b/modules/system/views/scripts/modules/index.phtml index e1de4a6..0bed0a3 100644 --- a/modules/system/views/scripts/modules/index.phtml +++ b/modules/system/views/scripts/modules/index.phtml @@ -3,42 +3,20 @@ // Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. /** * Modules admin — the installed-modules list (the WordPress "Plugins" screen for Tiger). - * Server-rendered rows (Tiger_Module_Discovery + state) presented as a CLIENT-SIDE DataTable - * (sort / page / search — the module count is getting numerous). A TYPE filter mirrors the Add - * Module screen's data-driven taxonomy pills (All | Apps | Themes | Plugins | Code | Developer …). - * Activate/deactivate + delete post to System_Service_Modules over /api (delegated, so they survive - * DataTables redraws); "Add New" lands the Module Installer. + * + * Client/server paradigm (WEBSERVICES §5): the view ships an EMPTY table + the TYPE-filter pills and + * fetches rows from System_Service_Modules::datatable over /api — rows are never server-rendered. + * Server-side search / type-filter / sort / paginate; the client's column renderers build the cells + * and gate the activate / deactivate / delete controls off per-row ACL flags. The active pill is + * chosen server-side from a cookie so the first load is already filtered (no All → tab flash), and an + * activate / delete re-fetches the grid in place (table.ajax.reload) instead of reloading the page. */ -$modules = $this->modules ?? []; -$typeLabels = $this->typeLabels ?? []; // {type_id: label} from the registry taxonomy (may be empty offline) +$typeLabels = $this->typeLabels ?? []; // {type_id: label} from the registry taxonomy (may be empty offline) +$counts = $this->counts ?? []; // {type_id: count} over the whole catalog +$total = (int) ($this->total ?? 0); +$activeType = (string) ($this->activeType ?? ''); // the remembered pill (cookie), '' = All $humanize = static function ($id) { return ucwords(str_replace(['-', '_'], ' ', (string) $id)); }; -$typeOf = static function (array $m) { return (string) ($m['type'] ?? 'module'); }; -$labelOf = static function (array $m) use ($typeLabels, $humanize, $typeOf) { $t = $typeOf($m); return $typeLabels[$t] ?? $humanize($t); }; - -// License → a short tag. Source badge: bundled (platform) vs addon (installed). -$srcLabel = static function ($src) { return $src === 'bundled' ? 'bundled' : 'addon'; }; -$licShort = static function ($lic) { - $l = (string) $lic; - if ($l === '') { return ''; } - if (preg_match('/\bMIT\b/i', $l)) { return 'MIT'; } - if (preg_match('/BSD-3/i', $l)) { return 'BSD-3'; } - if (preg_match('/BSD-2/i', $l)) { return 'BSD-2'; } - if (preg_match('/proprietary|licenseref|commercial/i', $l)) { return 'Commercial'; } - return $l; -}; - -// Type pills: All + one per type PRESENT among installed modules, ordered by the taxonomy (then any -// leftover present types), each with a count. Pills filter the DataTable by the module's type id. -$counts = []; -foreach ($modules as $m) { $t = $typeOf($m); $counts[$t] = ($counts[$t] ?? 0) + 1; } -$typePills = []; // [id => label] -foreach ($typeLabels as $id => $label) { if (isset($counts[$id])) { $typePills[$id] = $label; } } -foreach (array_keys($counts) as $id) { if (!isset($typePills[$id])) { $typePills[$id] = $humanize($id); } } - -// Delete-confirmation token ("/") + a slug→module map for naming dependents. -$tokenOf = static function (array $m) { $v = trim((string) ($m['author'] ?? '')); $n = trim((string) ($m['name'] ?? '')); return $v !== '' ? ($v . '/' . $n) : $n; }; -$bySlug = []; foreach ($modules as $mm) { $bySlug[$mm['slug']] = $mm; } -$typeIcon = static function ($t) { +$typeIcon = static function ($t) { switch ($t) { case 'theme': return 'fa-palette'; case 'code': return 'fa-code'; @@ -47,11 +25,43 @@ $typeIcon = static function ($t) { default: return 'fa-cube'; } }; + +// Type pills: All + one per type PRESENT among installed modules, ordered by the taxonomy (then any +// leftover present types), each with a count. Pills filter the grid server-side by the module's type id. +$typePills = []; // [id => label] +foreach ($typeLabels as $id => $label) { if (isset($counts[$id])) { $typePills[$id] = $label; } } +foreach (array_keys($counts) as $id) { if (!isset($typePills[$id])) { $typePills[$id] = $humanize($id); } } + +// Carry the localized strings this page's JS references (via tiger.i18n.js — never inline). +$this->i18n([ + 'confirmActivate' => 'system.js.confirm_activate', + 'deleteFailed' => 'system.js.delete_failed', + 'deleteUnavailable' => 'system.js.delete_unavailable', + 'networkError' => 'system.js.network_error', + 'activateTitle' => 'system.js.activate_title', + 'activateOk' => 'system.js.activate_ok', + 'deactivateTitle' => 'system.js.deactivate_title', + 'deactivateOk' => 'system.js.deactivate_ok', + 'deactivateRequired' => 'system.js.deactivate_required', + 'deactivateTheme' => 'system.js.deactivate_theme', + 'deactivateConfirm' => 'system.js.deactivate_confirm', + 'activateThemeTitle' => 'system.js.activate_theme_title', + 'activateThemeBody' => 'system.js.activate_theme_body', + 'activateThemeMakeDefault' => 'system.js.activate_theme_make_default', + // Row display labels (badges + action buttons), rendered client-side off the row's data. + 'statusActive' => 'system.modules.status_active', + 'statusInactive' => 'system.modules.status_inactive', + 'sourceBundled' => 'system.modules.source_bundled', + 'sourceAddon' => 'system.modules.source_addon', + 'activate' => 'system.modules.activate', + 'deactivate' => 'system.modules.deactivate', + 'alwaysOn' => 'system.modules.always_on', + 'defaultTheme' => 'system.modules.default_theme', + 'defaultThemeTitle' => 'system.modules.default_theme_title', + 'protectedTitle' => 'system.modules.protected_title', + 'deleteTitle' => 'system.modules.delete_title', +]); ?> -
@@ -62,9 +72,9 @@ $typeIcon = static function ($t) {
@@ -75,55 +85,13 @@ $typeIcon = static function ($t) { t('system.modules.col_module') ?> - t('system.modules.col_type') ?> - t('system.modules.col_version') ?> - t('system.modules.col_source') ?> + t('system.modules.col_type') ?> + t('system.modules.col_version') ?> + t('system.modules.col_source') ?> t('system.modules.col_status') ?> t('system.modules.col_actions') ?> - - - - -
escape($m['name']) ?> - /escape($m['slug']) ?> - -
-
escape($m['description']) ?>
-
- escape($m['author']) ?> - escape($licShort($m['license'])) ?> -
- -
escape($m['compat']['message']) ?>
- - - escape($labelOf($m)) ?> - escape($m['version']) : '—' ?> - t($m['source'] === 'bundled' ? 'system.modules.source_bundled' : 'system.modules.source_addon') ?> - - t('system.modules.status_active') ?> - t('system.modules.status_inactive') ?> - t('system.modules.default_theme') ?> - - - - t('system.modules.always_on') ?> - - - - - - - - $s]); }, Tiger_Module_Dependency::dependents($m['slug'])); ?> - - - - - -
@@ -139,8 +107,6 @@ $typeIcon = static function ($t) { -i18n([ - 'confirmActivate' => 'system.js.confirm_activate', - 'deleteFailed' => 'system.js.delete_failed', - 'deleteUnavailable' => 'system.js.delete_unavailable', - 'networkError' => 'system.js.network_error', - 'activateTitle' => 'system.js.activate_title', - 'activateOk' => 'system.js.activate_ok', - 'deactivateTitle' => 'system.js.deactivate_title', - 'deactivateOk' => 'system.js.deactivate_ok', - 'deactivateRequired' => 'system.js.deactivate_required', - 'deactivateTheme' => 'system.js.deactivate_theme', - 'deactivateConfirm' => 'system.js.deactivate_confirm', - 'activateThemeTitle' => 'system.js.activate_theme_title', - 'activateThemeBody' => 'system.js.activate_theme_body', - 'activateThemeMakeDefault' => 'system.js.activate_theme_make_default', -]); -?>