" . \htmlspecialchars((string) $msg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
',
+ 'content' => "" . \htmlspecialchars($msg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
',
'results' => [],
];
}
@@ -447,17 +542,16 @@ public static function handlePost(string $action, string $successTitle): array
public static function renderReport(array $results, string $title = ''): string
{
if ([] === $results) {
- $msg = \defined('_AM_MODULEINSTALLER_NO_SELECTION_REPORT')
- ? \_AM_MODULEINSTALLER_NO_SELECTION_REPORT
- : 'No modules selected.';
+ $msg = Lang::text('_AM_MODULEINSTALLER_NO_SELECTION_REPORT', 'No modules selected.');
- return "" . \htmlspecialchars((string) $msg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
';
+ return "" . \htmlspecialchars($msg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
';
}
$html = '';
if ('' !== $title) {
$html .= "" . \htmlspecialchars($title, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
';
}
+ $catalog = new ModuleCatalog();
$html .= "/global.php (_YES, _NO, …)
+ *
+ * Each domain is loaded lazily, at most once per request, on the first lookup that
+ * misses. Loading goes through Xmf\Language when available — it resolves
+ * $xoopsConfig['language'], falls back to english, and refuses paths outside
+ * XOOPS_ROOT_PATH — and through xoops_loadLanguage() otherwise. Both APIs are
+ * long-standing, so this works on XOOPS 2.7.x and 2.8+ alike; the only real floor is
+ * the module's documented PHP 8.2.
+ *
+ * NOTE: this returns TEXT, never markup. Escaping stays at the call site, because the
+ * caller is the only one that knows whether the string is going into an element, an
+ * attribute or a JS literal.
+ */
+final class Lang
+{
+ /** This module's dirname, for the language domain. */
+ private const DIRNAME = 'moduleinstaller';
+
+ /** Lazy load is attempted at most once per domain per request. */
+ private static bool $moduleAttempted = false;
+ private static bool $globalAttempted = false;
+
+ /**
+ * Text for one of this module's own constants.
+ *
+ * @param string $constant e.g. _AM_MODULEINSTALLER_SUBMIT
+ * @param string $fallback English text used when the constant is not defined
+ */
+ public static function text(string $constant, string $fallback): string
+ {
+ if (! \defined($constant)) {
+ self::loadModule();
+ }
+
+ return self::usable($constant) ?? $fallback;
+ }
+
+ /**
+ * The constant's value, or null when it cannot serve as a translation.
+ *
+ * "Defined" is not the same as "translated". Three states all mean fall back:
+ *
+ * - not defined at all — the usual partial-pack case;
+ * - defined as '' or as whitespace, which a half-finished pack produces in bulk
+ * and which renders as an invisible button or an empty table header. A blank
+ * label is not a deliberate translation choice often enough to be worth
+ * honouring, and English text in an Arabic admin is recoverable where a
+ * missing control is not. "Whitespace" here includes the Unicode blanks — a
+ * lone NBSP is what a translation tool emits for "intentionally empty", and it
+ * renders exactly as invisibly as a space does;
+ * - defined as something that is not SCALAR. Casting an array to string in PHP 8
+ * emits a warning and yields the literal "Array"; a language file is ordinary
+ * PHP, so this is reachable by a typo rather than by malice.
+ *
+ * The rule is therefore "scalar, and non-blank after trim()" — not "must be a
+ * string". Two consequences are deliberate and easy to get backwards:
+ *
+ * - an int, a float or `true` IS used. A count or a version number legitimately
+ * arrives as a non-string scalar, and rejecting those would send a pack back to
+ * English over a missing pair of quotes;
+ * - `false` is NOT used, even though it is scalar, because it casts to '' — and
+ * '0' IS used, because it is a real translation of a real string and only looks
+ * empty. The blankness test is on the cast value, which gets both of these
+ * right for the same reason.
+ */
+ private static function usable(string $constant): ?string
+ {
+ if (! \defined($constant)) {
+ return null;
+ }
+
+ $value = \constant($constant);
+ if (! \is_scalar($value)) {
+ return null;
+ }
+
+ $value = (string) $value;
+
+ if ('' === \trim($value)) {
+ return null;
+ }
+
+ // trim() knows only ASCII whitespace, so the test above still passes a value
+ // built solely from Unicode blanks — an NBSP-only constant being the common
+ // one. Rejected with a pattern rather than by widening the trim() charlist:
+ // trim() matches BYTES, and \xA0 is an ordinary UTF-8 continuation byte, so
+ // trimming "\xC2\xA0" truncates any value ending in a character that happens
+ // to end in \xA0 — "†" (\xE2\x80\xA0) becomes invalid UTF-8. That would put a
+ // mojibake bug in the one class whose job is surviving bad language packs.
+ // preg_match() returns false (not 1) on invalid UTF-8, which leaves such a
+ // value usable — the same answer the ASCII test above already gave it.
+ if (1 === \preg_match('/^[\s\p{Z}\x{FEFF}]+$/u', $value)) {
+ return null;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Same as {@see self::text()} for a message with sprintf() placeholders.
+ *
+ * Formatting belongs here so a translation may reorder its arguments with
+ * %1$s/%2$s without the caller knowing or caring.
+ */
+ public static function format(string $constant, string $fallback, int|float|string ...$args): string
+ {
+ $template = self::text($constant, $fallback);
+
+ // sprintf() throws on PHP 8 when a template asks for more arguments than it
+ // is given, or uses an unknown conversion — and the template here comes from
+ // a translator, not from this codebase. Letting that propagate would take the
+ // admin page down over a typo in a language pack, which is the exact failure
+ // this class exists to prevent: it would make a bad translation worse than a
+ // missing one. Fall back to the English text, which is known to match $args.
+ try {
+ return \sprintf($template, ...$args);
+ } catch (\Throwable) {
+ try {
+ return \sprintf($fallback, ...$args);
+ } catch (\Throwable) {
+ // Even the fallback disagrees with the caller: return it unformatted
+ // rather than nothing, so the admin still sees a readable message.
+ return $fallback;
+ }
+ }
+ }
+
+ /**
+ * Text for a CORE constant from the global language file (_YES, _NO, …).
+ *
+ * Separate from {@see self::text()} because a missing _YES is not fixed by
+ * loading this module's admin.php — it is a different file in a different domain.
+ */
+ public static function core(string $constant, string $fallback): string
+ {
+ if (! \defined($constant)) {
+ self::loadGlobal();
+ }
+
+ // Same usability policy as text(). The two domains differ in WHERE the string
+ // comes from, not in what counts as a string: _YES from a half-translated core
+ // pack fails exactly the way _AM_MODULEINSTALLER_SUBMIT does, and a policy that
+ // applied to one domain and not the other would be a policy nobody could state.
+ return self::usable($constant) ?? $fallback;
+ }
+
+ private static function loadModule(): void
+ {
+ if (self::$moduleAttempted) {
+ return;
+ }
+ self::$moduleAttempted = true;
+ self::load('admin', self::DIRNAME);
+ }
+
+ private static function loadGlobal(): void
+ {
+ if (self::$globalAttempted) {
+ return;
+ }
+ self::$globalAttempted = true;
+ self::load('global', '');
+ }
+
+ /**
+ * Prefer Xmf\Language::load(): it picks the configured language, falls back to
+ * english on its own, and validates the path. xoops_loadLanguage() is the
+ * fallback for a core build without Xmf.
+ *
+ * Both routes `include` third-party PHP. A language pack is ordinary PHP, so it
+ * can throw — or be a parse error, which is a catchable `ParseError` on PHP 8.
+ * Uncaught, that would propagate out of text()/core() and take the admin page
+ * down, which is precisely the failure this class exists to prevent: a pack bad
+ * enough to throw is exactly the pack whose English fallback matters most. The
+ * attempt flag is set by the callers BEFORE this runs, so swallowing here cannot
+ * produce a retry loop — the next lookup goes straight to the fallback.
+ */
+ private static function load(string $name, string $domain): void
+ {
+ try {
+ if (\class_exists(\Xmf\Language::class)) {
+ \Xmf\Language::load($name, $domain);
+
+ return;
+ }
+
+ if (\function_exists('xoops_loadLanguage')) {
+ \xoops_loadLanguage($name, $domain);
+ }
+ } catch (\Throwable) {
+ // Deliberately swallowed: usable() reports the constant as unavailable and
+ // every caller already carries the English text it needs.
+ }
+ }
+}
diff --git a/class/ModuleActionResult.php b/class/ModuleActionResult.php
index f7e9f08..4197bc2 100644
--- a/class/ModuleActionResult.php
+++ b/class/ModuleActionResult.php
@@ -14,8 +14,54 @@
* @author Michael Beck (mamba)
*/
+use XoopsModules\Moduleinstaller\Report\LegacyModuleReportAdapter;
+use XoopsModules\Moduleinstaller\Report\LogEventHtmlRenderer;
+
/**
- * Result of a single module action (install/activate/etc.).
+ * Result of a single module action (install/uninstall/activate/deactivate/update).
+ *
+ * The message is core's install log: intentionally formatted HTML that also
+ * interpolates third-party strings without escaping them (modulesadmin.php
+ * interpolates $module->getVar('name'), $tpl['file'], $block['name'] and
+ * $db->error() raw), so it is untrusted markup that still carries meaning.
+ *
+ * Rendering policy: the log is treated as a TRANSCRIPT, not a document — it is
+ * parsed into {@see Report\LogEvent} values and rendered from string constants, so
+ * no value from the message can become a tag, an attribute name or an attribute
+ * value. See {@see Report\LegacyModuleReportAdapter} for the parse and
+ * {@see Report\LogEventHtmlRenderer} for the render; the properties that follow
+ * hold by construction rather than by policy:
+ *
+ * - the output can contain no attribute except the fixed classes the renderer
+ * writes, so there is no URL policy, no scheme denylist, no attribute allowlist;
+ * - escaping happens exactly once, in the renderer, so entities core already
+ * escaped round-trip instead of showing up as literal " ";
+ * - no optional extension is involved, so there is no fallback branch that can
+ * fail open, and no walk over a mutable tree that can fail to terminate.
+ *
+ * The module logo is resolved from validated module metadata by
+ * {@see AdminBulkPage::moduleLogoUrl()}, never from this message, so dropping the
+ * log's own
loses nothing.
+ *
+ * LEGACY COMPATIBILITY TYPE. Being replaced by {@see Report\ModuleOperationResult},
+ * which carries the transcript as data instead of as a string of HTML. The migration
+ * path exists in code, not only in prose: {@see ModuleActionService::operationResult()}
+ * and operationResults() return the new type, and {@see self::toOperationResult()}
+ * converts a result you already hold — so no new code has to name this class.
+ *
+ * No removal version is named, and that is deliberate. This shape is what every
+ * third-party caller of ModuleActionService uses, so it is supported for the whole
+ * XOOPS 2.8 line; removal becomes a question at XOOPS 4.0, where core emits the
+ * structured type itself and this class and the adapter disappear together. New code
+ * should take the Report types.
+ *
+ * Intentionally NOT tagged `@deprecated` yet. runOne()/runMany() — the primary public
+ * API of ModuleActionService — still return this type, and AdminBulkPage still renders
+ * from it, so the formal tag would mark as deprecated a type whose replacement no
+ * caller in this module has adopted. That is not a documentation nicety: it makes
+ * every internal use a deprecation violation (116 of them, which is what turned the
+ * QA gate red), training readers to ignore the tag on the release where it finally
+ * means something. The tag goes on when the internal consumers have moved.
*/
final readonly class ModuleActionResult
{
@@ -26,7 +72,7 @@
/**
* @param string $dirname Module dirname
* @param string $status One of STATUS_*
- * @param string $message Human-readable message (may contain HTML from core)
+ * @param string $message Human-readable message (untrusted HTML from core)
* @param string $action Action attempted (install, activate, …)
*/
public function __construct(
@@ -55,20 +101,88 @@ public function isFail(): bool
/**
* Message rendered safe for admin output.
*
- * Core modulesadmin returns HTML install logs, but a module's own (possibly
- * third-party) name/version is reflected inside them, so the message is fully
- * untrusted. A tag allowlist is not enough — strip_tags() keeps attributes on
- * permitted tags, so a name like "" would still execute. Instead:
- * turn block/line breaks into newlines, strip EVERY tag, HTML-escape the remaining
- * text, then re-introduce only
for the preserved breaks. The sole markup in
- * the output is that generated
, so no injected attribute can survive.
+ * Parse then render, as two steps that can be used separately. Both objects are
+ * stateless and a report renders a handful of rows, so they are built per call:
+ * `static $x = new Foo()` in a method needs PHP 8.3, and a readonly class cannot
+ * hold a `static ?Foo $x = null` cache at all, and this module supports PHP 8.2.
*/
public function messageHtml(): string
{
- $text = \preg_replace('#<(?:br\s*/?|/p|/div|/li|/tr)>#i', "\n", $this->message) ?? $this->message;
- $text = \strip_tags($text);
- $escaped = \htmlspecialchars($text, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8');
+ return (new LogEventHtmlRenderer())->render(
+ (new LegacyModuleReportAdapter())->parse($this->message)
+ );
+ }
+
+ /**
+ * The transcript as structured data — the shape everything should move to.
+ *
+ * @return list
+ */
+ public function events(): array
+ {
+ return (new LegacyModuleReportAdapter())->parse($this->message);
+ }
+
+ /** The transcript as plain text, for a CLI run, a log file or a mail. */
+ public function messageText(): string
+ {
+ return \implode(
+ "\n",
+ \array_map(static fn (Report\LogEvent $e): string => $e->plainText(), $this->events())
+ );
+ }
+
+ /**
+ * This result, projected onto the shape that replaces it.
+ *
+ * The projection lives HERE, on the legacy type, and not in the Report package
+ * — deliberately. A strangler fig only works if the dependency points from the old
+ * code to the new: Report\* must never mention ModuleActionResult, or deleting this
+ * class would mean editing the package that outlives it. When core emits events
+ * directly, this method is deleted along with its class and nothing in Report has
+ * to change.
+ *
+ * It is a pure function of $this, which is also what makes it testable without a
+ * running XOOPS. That matters: the duplicated-transcript bug this method now fixes
+ * survived a green suite precisely because the composition had only ever existed
+ * inside ModuleActionService, where no unit test could reach it.
+ *
+ * Two properties of core's string decide the mapping:
+ *
+ * - a SKIP's message is already plain prose ('Already installed'), never core
+ * HTML, because nothing ran. It becomes the reason and nothing else;
+ * - a FAILURE's message is ': ' — verdict and
+ * transcript concatenated, with no separator this class can rely on. So the
+ * first line becomes the reason and the transcript is kept AS IS.
+ * ModuleOperationResult::failed() would prepend the reason as a fresh event,
+ * which for this input means prepending a copy of the transcript to itself;
+ * failedWithTranscript() is the constructor for text that already states its
+ * own reason.
+ *
+ * That coupling is a property of core's string, not of this design, and it is
+ * exactly what disappears when core stops concatenating.
+ */
+ public function toOperationResult(): Report\ModuleOperationResult
+ {
+ $events = $this->events();
+
+ if ($this->isOk()) {
+ return Report\ModuleOperationResult::ok($this->dirname, $this->action, $events);
+ }
+
+ if ($this->isSkip()) {
+ // A skip's message is prose, not a log, so the whole of it is the reason —
+ // and skipped() builds its own single event from it.
+ return Report\ModuleOperationResult::skipped(
+ $this->dirname,
+ $this->action,
+ [] === $events ? \trim($this->message) : $events[0]->plainText()
+ );
+ }
- return \nl2br($escaped, false);
+ // No reason argument: failedWithTranscript() derives it from the first event,
+ // which is the one place the verdict can be. Passing it separately would be
+ // handing the same fact in twice and inviting the two copies to drift.
+ return Report\ModuleOperationResult::failedWithTranscript($this->dirname, $this->action, $events);
}
}
diff --git a/class/ModuleActionService.php b/class/ModuleActionService.php
index b8c4f9d..47ff88a 100644
--- a/class/ModuleActionService.php
+++ b/class/ModuleActionService.php
@@ -107,14 +107,19 @@ public function runOne(string $action, string $dirname): ModuleActionResult
$action = \mb_strtolower(\trim($action));
if ('' === $dirname) {
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Empty dirname', $action);
+ return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_EMPTY_DIRNAME', 'Empty dirname'), $action);
}
if ($this->catalog->isProtected($dirname) && \in_array($action, [self::ACTION_UNINSTALL, self::ACTION_DEACTIVATE], true)) {
return new ModuleActionResult(
$dirname,
ModuleActionResult::STATUS_SKIP,
- \sprintf('Protected module "%s" cannot be %sd', $dirname, $action),
+ Lang::format(
+ '_AM_MODULEINSTALLER_RES_PROTECTED',
+ 'Protected module "%1$s": action "%2$s" is not allowed.',
+ $dirname,
+ $action
+ ),
$action
);
}
@@ -128,18 +133,64 @@ public function runOne(string $action, string $dirname): ModuleActionResult
self::ACTION_ACTIVATE => $this->doActivate($dirname),
self::ACTION_DEACTIVATE => $this->doDeactivate($dirname),
self::ACTION_UPDATE => $this->doUpdate($dirname),
- default => new ModuleActionResult($dirname, ModuleActionResult::STATUS_FAIL, 'Unknown action: ' . $action, $action),
+ default => new ModuleActionResult(
+ $dirname,
+ ModuleActionResult::STATUS_FAIL,
+ Lang::format('_AM_MODULEINSTALLER_RES_UNKNOWN_ACTION', 'Unknown action: %s', $action),
+ $action
+ ),
};
} catch (\Throwable $e) {
return new ModuleActionResult(
$dirname,
ModuleActionResult::STATUS_FAIL,
- 'Exception: ' . $e->getMessage(),
+ Lang::format('_AM_MODULEINSTALLER_RES_EXCEPTION', 'Exception: %s', $e->getMessage()),
$action
);
}
}
+ /**
+ * The same run, as a structured result.
+ *
+ * ADDITIVE, deliberately. runOne() keeps its signature and its return type for the
+ * whole XOOPS 2.8 line, because every third-party caller in existence calls it and
+ * a service method is a published contract even when nobody published it. This is
+ * the door out: new code takes {@see Report\ModuleOperationResult}, old code keeps
+ * working unchanged, and neither has to know about the other.
+ *
+ * Until core returns structured data, this is a projection of the legacy shape
+ * rather than a second implementation — there is exactly one place that decides
+ * what an action did (runOne), and it stays that way. When core 4.0 emits events
+ * directly, the arrow reverses: this becomes the real method and messageHtml()
+ * becomes the projection.
+ *
+ * The conversion itself is NOT here. It is a pure function of one result, so it
+ * lives on {@see ModuleActionResult::toOperationResult()} where a unit test can
+ * reach it without a running XOOPS. It was written here first, and that is exactly
+ * why its duplicated-transcript bug survived a green suite: no test could construct
+ * a service, so nothing exercised the composition of two individually-tested
+ * classes. A projection that only exists inside a service is a projection nobody
+ * can test.
+ */
+ public function operationResult(string $action, string $dirname): Report\ModuleOperationResult
+ {
+ return $this->runOne($action, $dirname)->toOperationResult();
+ }
+
+ /**
+ * @param list $dirnames
+ *
+ * @return list
+ */
+ public function operationResults(string $action, array $dirnames): array
+ {
+ return \array_map(
+ static fn (ModuleActionResult $r): Report\ModuleOperationResult => $r->toOperationResult(),
+ $this->runMany($action, $dirnames)
+ );
+ }
+
/**
* Flush cpanel and active-modules caches after bulk ops.
*/
@@ -157,77 +208,77 @@ public function flushCaches(): void
private function doInstall(string $dirname): ModuleActionResult
{
if (! $this->catalog->existsOnDisk($dirname)) {
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Not found on disk', self::ACTION_INSTALL);
+ return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_NOT_ON_DISK', 'Not found on disk'), self::ACTION_INSTALL);
}
if ($this->catalog->isInstalled($dirname)) {
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Already installed', self::ACTION_INSTALL);
+ return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_ALREADY_INSTALLED', 'Already installed'), self::ACTION_INSTALL);
}
$msg = \xoops_module_install($dirname);
- return $this->verifiedResult($dirname, self::ACTION_INSTALL, (string) $msg, true, null, 'Install did not complete');
+ return $this->verifiedResult($dirname, self::ACTION_INSTALL, (string) $msg, true, null, Lang::text('_AM_MODULEINSTALLER_RES_FAIL_INSTALL', 'Install did not complete'));
}
private function doUninstall(string $dirname): ModuleActionResult
{
if (! $this->catalog->isInstalled($dirname)) {
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Not installed', self::ACTION_UNINSTALL);
+ return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_NOT_INSTALLED', 'Not installed'), self::ACTION_UNINSTALL);
}
$msg = \xoops_module_uninstall($dirname);
- return $this->verifiedResult($dirname, self::ACTION_UNINSTALL, (string) $msg, false, null, 'Uninstall did not complete');
+ return $this->verifiedResult($dirname, self::ACTION_UNINSTALL, (string) $msg, false, null, Lang::text('_AM_MODULEINSTALLER_RES_FAIL_UNINSTALL', 'Uninstall did not complete'));
}
private function doActivate(string $dirname): ModuleActionResult
{
$module = $this->catalog->getInstalled($dirname);
if (! $module instanceof \XoopsModule) {
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Not installed (cannot activate)', self::ACTION_ACTIVATE);
+ return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_NOT_INSTALLED_ACTIVATE', 'Not installed (cannot activate)'), self::ACTION_ACTIVATE);
}
if ($module->isActive()) {
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Already active', self::ACTION_ACTIVATE);
+ return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_ALREADY_ACTIVE', 'Already active'), self::ACTION_ACTIVATE);
}
$mid = (int) $module->getVar('mid');
if ($mid <= 0) {
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_FAIL, 'Invalid module id', self::ACTION_ACTIVATE);
+ return new ModuleActionResult($dirname, ModuleActionResult::STATUS_FAIL, Lang::text('_AM_MODULEINSTALLER_RES_INVALID_MID', 'Invalid module id'), self::ACTION_ACTIVATE);
}
$msg = \xoops_module_activate($mid);
- return $this->verifiedResult($dirname, self::ACTION_ACTIVATE, (string) $msg, true, true, 'Activation did not take effect');
+ return $this->verifiedResult($dirname, self::ACTION_ACTIVATE, (string) $msg, true, true, Lang::text('_AM_MODULEINSTALLER_RES_FAIL_ACTIVATE', 'Activation did not take effect'));
}
private function doDeactivate(string $dirname): ModuleActionResult
{
$module = $this->catalog->getInstalled($dirname);
if (! $module instanceof \XoopsModule) {
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Not installed (cannot deactivate)', self::ACTION_DEACTIVATE);
+ return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_NOT_INSTALLED_DEACTIVATE', 'Not installed (cannot deactivate)'), self::ACTION_DEACTIVATE);
}
if (! $module->isActive()) {
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Already inactive', self::ACTION_DEACTIVATE);
+ return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_ALREADY_INACTIVE', 'Already inactive'), self::ACTION_DEACTIVATE);
}
$mid = (int) $module->getVar('mid');
if ($mid <= 0) {
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_FAIL, 'Invalid module id', self::ACTION_DEACTIVATE);
+ return new ModuleActionResult($dirname, ModuleActionResult::STATUS_FAIL, Lang::text('_AM_MODULEINSTALLER_RES_INVALID_MID', 'Invalid module id'), self::ACTION_DEACTIVATE);
}
$msg = \xoops_module_deactivate($mid);
// Core silently refuses to deactivate system/start-page modules; a clean
// deactivation must leave the module installed AND inactive (not vanished).
- return $this->verifiedResult($dirname, self::ACTION_DEACTIVATE, (string) $msg, true, false, 'Deactivation did not take effect (start-page or protected module?)');
+ return $this->verifiedResult($dirname, self::ACTION_DEACTIVATE, (string) $msg, true, false, Lang::text('_AM_MODULEINSTALLER_RES_FAIL_DEACTIVATE', 'Deactivation did not take effect (start-page or protected module?)'));
}
private function doUpdate(string $dirname): ModuleActionResult
{
if (! $this->catalog->isInstalled($dirname)) {
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Not installed', self::ACTION_UPDATE);
+ return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_NOT_INSTALLED', 'Not installed'), self::ACTION_UPDATE);
}
if (! $this->catalog->existsOnDisk($dirname)) {
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Not found on disk', self::ACTION_UPDATE);
+ return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_NOT_ON_DISK', 'Not found on disk'), self::ACTION_UPDATE);
}
$msg = \xoops_module_update($dirname);
@@ -237,7 +288,7 @@ private function doUpdate(string $dirname): ModuleActionResult
// update callback leaves the module installed at the new version. That failure
// is therefore not detectable from DB state — we can only confirm the module
// did not vanish.
- return $this->verifiedResult($dirname, self::ACTION_UPDATE, (string) $msg, true, null, 'Update did not complete');
+ return $this->verifiedResult($dirname, self::ACTION_UPDATE, (string) $msg, true, null, Lang::text('_AM_MODULEINSTALLER_RES_FAIL_UPDATE', 'Update did not complete'));
}
/**
@@ -254,7 +305,16 @@ private function verifiedResult(string $dirname, string $action, string $msg, bo
// Post-action state could not be read (no DB / query failed). For a
// destructive bulk tool an unverifiable outcome is reported as a failure so
// a genuinely failed operation is never silently presented as success.
- return new ModuleActionResult($dirname, ModuleActionResult::STATUS_FAIL, 'Result could not be verified (module state unreadable): ' . $msg, $action);
+ return new ModuleActionResult(
+ $dirname,
+ ModuleActionResult::STATUS_FAIL,
+ Lang::format(
+ '_AM_MODULEINSTALLER_RES_UNVERIFIED',
+ 'Result could not be verified (module state unreadable): %s',
+ $msg
+ ),
+ $action
+ );
}
$reached = $state['installed'] === $wantInstalled
diff --git a/class/Report/LegacyModuleReportAdapter.php b/class/Report/LegacyModuleReportAdapter.php
new file mode 100644
index 0000000..4211fcc
--- /dev/null
+++ b/class/Report/LegacyModuleReportAdapter.php
@@ -0,0 +1,278 @@
+ */
+ public function parse(string $legacyHtml): array
+ {
+ if ('' === $legacyHtml) {
+ return [];
+ }
+
+ return $this->toEvents($this->toMarkedText($legacyHtml));
+ }
+
+ /**
+ * Build a full result from core's return value plus the verdict the caller
+ * established by re-reading module state (which core's string cannot be trusted
+ * for — see ModuleActionService::verifiedResult()).
+ */
+ public function result(string $dirname, string $action, Outcome $outcome, string $legacyHtml, ?string $reason = null): ModuleOperationResult
+ {
+ $events = $this->parse($legacyHtml);
+
+ return match ($outcome) {
+ Outcome::Ok => ModuleOperationResult::ok($dirname, $action, $events),
+ Outcome::Skipped => ModuleOperationResult::skipped($dirname, $action, $reason ?? ''),
+ Outcome::Failed => ModuleOperationResult::failed($dirname, $action, $reason ?? '', $events),
+ };
+ }
+
+ /**
+ * Steps 1–5 of the current messageHtml(), stopping one step short of escaping:
+ * the result is PLAIN TEXT with emphasis/severity markers still embedded.
+ */
+ private function toMarkedText(string $text): string
+ {
+ $m = self::M;
+
+ // 1. control bytes out — also makes the markers unforgeable
+ $text = (string) \preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
+
+ // 1b. normalise line endings. The step above deliberately keeps \r (it is
+ // not a control byte we want to silently delete), and lines are split on
+ // \n later, so CRLF input would otherwise leave a trailing \r inside a
+ // LogFragment — invisible once escaped, but wrong in a value object whose
+ // contract is clean text.
+ $text = \str_replace(["\r\n", "\r"], "\n", $text);
+
+ // 2. elements whose content is not text
+ $text = (string) \preg_replace(
+ '#<(script|style|template|noscript|textarea|title|xmp)\b[^>]*>.*?\1\s*>#is',
+ '',
+ $text
+ );
+
+ // 3. the two constructs that carry meaning become markers
+ $text = (string) \preg_replace(
+ '##i',
+ $m . 'E',
+ $text
+ );
+ // Every OTHER span gets an open marker too. It carries no severity, but it
+ // does carry a , and without a matching open that close would be
+ // read as ending the red span enclosing it — silently stopping the error
+ // run and repainting the rest of the transcript as ordinary output. Core
+ // does nest spans, so this is the common case, not a hostile one. Runs
+ // AFTER the red-span pass, which has already consumed those tags.
+ $text = (string) \preg_replace('#]*)?>#i', $m . 'O', $text);
+ $text = (string) \preg_replace('##i', $m . 'e', $text);
+ $text = (string) \preg_replace('#<(?:strong|b|em|i)(?:\s[^>]*)?>#i', $m . 'S', $text);
+ $text = (string) \preg_replace('#(?:strong|b|em|i)\s*>#i', $m . 's', $text);
+
+ // 4. line structure
+ $text = (string) \preg_replace('#<(?:br\s*/?|/p|/div|/li|/tr|/h[1-6])\s*>#i', "\n", $text);
+
+ // 5. every other tag out, core's escaping collapsed. NO htmlspecialchars():
+ // this is text now, and text is what the value object should hold.
+ return \html_entity_decode(\strip_tags($text), \ENT_QUOTES | \ENT_HTML5 | \ENT_SUBSTITUTE, 'UTF-8');
+ }
+
+ /**
+ * Marked text to events.
+ *
+ * Emphasis and severity depths are intentionally declared OUTSIDE the line loop,
+ * so an emphasis or error span that core opens before a
and closes after it
+ * still applies to both lines. That reproduces what the string renderer did (one
+ * spanning the break) while producing per-line balanced markup instead,
+ * and core does emit spans that wrap a
. The cost is that an UNCLOSED span
+ * colours every line after it; a stray close pops nothing rather than going
+ * negative, so it cannot invert the state.
+ *
+ * Spans are tracked as a STACK, not a counter, because only the red ones carry
+ * severity while all of them share one closing tag. A counter had to guess which
+ * span a