Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 22 additions & 38 deletions modules/system/controllers/ModulesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}

/**
Expand Down
176 changes: 176 additions & 0 deletions modules/system/services/Modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int,array<string,mixed>> 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.
*
Expand Down
Loading
Loading