Compare commits

...

12 Commits

Author SHA1 Message Date
gitea-actions[bot] 70d7da34b3 chore: promote changelog [Unreleased] → [01.37.00] 2026-06-23 16:04:44 +00:00
gitea-actions[bot] 13c251196b chore(release): build 01.37.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 4s
2026-06-23 16:04:41 +00:00
jmiller 4841f24eab Merge pull request 'feat: Profiles UI, snapshot detail, progress warning, action logs' (#120) from feat/batch-ui-fixes into main 2026-06-23 16:03:42 +00:00
Jonathan Miller 64ffbb9d61 feat: profiles UI, snapshot detail, progress warning, action logs (#100, #104, #108, #110)
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 10s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 39s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 18s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 4m10s
#100: Run Backup button on profiles list (per-row) and edit toolbar,
backup count badge linking to filtered backups view, View Backups
toolbar button on profile edit.

#101: Profile → filtered backup list link (included in #100).

#104: Snapshot browse modal now shows tabbed view (Articles,
Categories, Modules) with item counts. AjaxController returns
all content types. Categories show indented hierarchy.

#108: "Do not navigate away or close this window" warning banner
added to both backup and restore progress modals.

#110: Joomla Action Logs integration — RestoreEngine, SnapshotEngine,
and SnapshotRestoreEngine now dispatch events that the actionlog
plugin logs to #__action_logs.

Closes #100, closes #101, closes #104, closes #108, closes #110
2026-06-23 11:03:13 -05:00
jmiller 83e91c6fa6 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-23 15:50:28 +00:00
jmiller b1833825e7 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-23 15:50:27 +00:00
jmiller bde20e82ad chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 15:50:26 +00:00
jmiller 8348d23fe4 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-23 15:50:25 +00:00
gitea-actions[bot] d9557489d5 chore: promote changelog [Unreleased] → [01.36.00] 2026-06-23 15:48:58 +00:00
gitea-actions[bot] 089ec69595 chore(release): build 01.36.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 7s
2026-06-23 15:48:50 +00:00
jmiller 7427cbb043 Merge pull request 'feat: Clickable placeholder pills for backup dir and archive name' (#102) from feat/clickable-placeholders into main 2026-06-23 15:48:34 +00:00
Jonathan Miller 456e744d81 feat: clickable placeholder pills for backup dir and archive name fields
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Branch Policy (pull_request) Failing after 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 12s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 14s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 26s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 9m59s
Both Backup Directory and Archive Name Format fields now show clickable
placeholder tags below the input. Clicking a tag inserts the placeholder
at the current cursor position using selectionStart/End.

- FolderPickerField: pills for [HOME], [DEFAULT_DIR], [host], [site_name],
  [date], [profile_id], [profile_name], [type]
- PlaceholderTextField: new custom field type used by archive_name_format,
  configurable placeholders via XML attribute
- Cursor position preserved after insert, input event dispatched for
  live status updates
2026-06-23 10:47:45 -05:00
33 changed files with 1957 additions and 1381 deletions
+66 -66
View File
@@ -1,66 +1,66 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup mokocli tools
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
if [ -d "/opt/mokocli/cli" ]; then
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
/tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup mokocli tools
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
if [ -d "/opt/mokocli/cli" ]; then
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
/tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.35.04
# VERSION: 01.37.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -8
View File
@@ -1,14 +1,22 @@
# Changelog
## [Unreleased]
## [01.35.04] --- 2026-06-23
## [01.37.00] --- 2026-06-23
## [01.37.00] --- 2026-06-23
### Added
- Run Backup button on profiles list and edit views with backup count badges (#100, #101)
- Snapshot detail view with tabbed browser for articles, categories, and modules (#104)
- "Do not navigate away" warning in backup and restore progress modals (#108)
- Joomla Action Logs integration for restore, snapshot, and snapshot restore events (#110)
- 8 comprehensive testing issues created (#111-#118)
- Manual purge feature issue (#119)
## [01.36.00] --- 2026-06-23
## [01.36.00] --- 2026-06-23
## [01.35.04] --- 2026-06-23
## [01.35.03] --- 2026-06-23
## [01.35.03] --- 2026-06-23
## [01.35.01] --- 2026-06-23
## [01.35.01] --- 2026-06-23
## [01.35.04] --- 2026-06-23
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteBackup
<!-- VERSION: 01.35.04 -->
<!-- VERSION: 01.37.00 -->
Full-site backup and restore for Joomla — database, files, and configuration.
@@ -72,12 +72,14 @@
/>
<field
name="archive_name_format"
type="text"
type="PlaceholderText"
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
default="[host]_[datetime]_profile[profile_id]"
maxlength="512"
hint="[host]_[datetime]_profile[profile_id]"
placeholders="[host],[datetime],[date],[time],[year],[month],[day],[hour],[minute],[second],[profile_id],[profile_name],[site_name],[type],[random]"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
name="include_mokorestore"
@@ -78,6 +78,12 @@ COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile"
COM_MOKOJOOMBACKUP_PROFILE_EDIT="Edit Profile"
; Profile actions
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
; Table headings
COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION="Description"
COM_MOKOJOOMBACKUP_HEADING_PROFILE="Profile"
@@ -415,6 +421,20 @@ COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE="No IP restrictions — any IP can trigger we
COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address"
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add"
; Snapshot browse / detail view
COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE="Browse Snapshot"
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES="Articles"
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES="Categories"
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES="Modules"
COM_MOKOJOOMBACKUP_HEADING_STATE="State"
COM_MOKOJOOMBACKUP_HEADING_POSITION="Position"
COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE="Module Type"
COM_MOKOJOOMBACKUP_HEADING_LEVEL="Level"
COM_MOKOJOOMBACKUP_LOADING="Loading..."
COM_MOKOJOOMBACKUP_SELECT_ALL="Select All"
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED="Restore Selected"
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED="No articles selected for restore."
; Errors
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
@@ -35,6 +35,10 @@ COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles"
COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoSuiteBackup update site not found. Reinstall the package to register the update server."
COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.35.04</version>
<version>01.37.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -622,28 +622,67 @@ class AjaxController extends BaseController
$data = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) {
$this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->sendJson(['error' => true, 'message' => 'Invalid snapshot data']);
return;
}
$tables = $data['tables'] ?? [];
// Articles
$articles = [];
foreach ($data['tables']['#__content'] as $row) {
$articles[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $row['title'] ?? '',
'catid' => (int) ($row['catid'] ?? 0),
'state' => (int) ($row['state'] ?? 0),
'created' => $row['created'] ?? '',
];
if (!empty($tables['#__content'])) {
foreach ($tables['#__content'] as $row) {
$articles[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $row['title'] ?? '',
'catid' => (int) ($row['catid'] ?? 0),
'state' => (int) ($row['state'] ?? 0),
'created' => $row['created'] ?? '',
];
}
}
// Categories
$categories = [];
if (!empty($tables['#__categories'])) {
foreach ($tables['#__categories'] as $row) {
$categories[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $row['title'] ?? '',
'extension' => $row['extension'] ?? '',
'published' => (int) ($row['published'] ?? 0),
'level' => (int) ($row['level'] ?? 0),
];
}
}
// Modules
$modules = [];
if (!empty($tables['#__modules'])) {
foreach ($tables['#__modules'] as $row) {
$modules[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $row['title'] ?? '',
'module' => $row['module'] ?? '',
'position' => $row['position'] ?? '',
'published' => (int) ($row['published'] ?? 0),
];
}
}
$this->sendJson([
'error' => false,
'articles' => $articles,
'total' => count($articles),
'error' => false,
'articles' => $articles,
'categories' => $categories,
'modules' => $modules,
'total_articles' => \count($articles),
'total_categories' => \count($categories),
'total_modules' => \count($modules),
]);
}
@@ -23,6 +23,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Event\Event;
class RestoreEngine
{
@@ -166,6 +167,9 @@ class RestoreEngine
error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage());
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRestore(true, $recordId);
return [
'success' => true,
'message' => 'Restore complete from: ' . basename($archivePath),
@@ -185,6 +189,9 @@ class RestoreEngine
$this->recursiveDelete($this->stagingDir);
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRestore(false, $recordId);
return [
'success' => false,
'message' => 'Restore failed: ' . $e->getMessage(),
@@ -285,6 +292,26 @@ class RestoreEngine
@rmdir($dir);
}
/**
* Dispatch the onMokoSuiteBackupAfterRestore event so plugins (actionlog, etc.) can react.
*/
private function dispatchAfterRestore(bool $success, int $recordId): void
{
try {
$app = Factory::getApplication();
$event = new Event('onMokoSuiteBackupAfterRestore', [
'success' => $success,
'record_id' => $recordId,
]);
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterRestore', $event);
} catch (\Throwable $e) {
// Never let a listener failure break the restore result, but log it
error_log('MokoSuiteBackup: onAfterRestore listener error: ' . $e->getMessage());
}
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -17,6 +17,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
use Joomla\Event\Event;
class SnapshotEngine
{
@@ -214,6 +215,9 @@ class SnapshotEngine
error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage());
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterSnapshot(true, $record->id, array_values($validTypes));
return [
'success' => true,
'message' => sprintf(
@@ -227,6 +231,9 @@ class SnapshotEngine
} catch (\Exception $e) {
$this->log('FATAL: ' . $e->getMessage());
// Dispatch event for actionlog and other listeners
$this->dispatchAfterSnapshot(false, 0, $contentTypes);
return [
'success' => false,
'message' => 'Snapshot failed: ' . $e->getMessage(),
@@ -327,6 +334,27 @@ class SnapshotEngine
return $db->loadAssocList() ?: [];
}
/**
* Dispatch the onMokoSuiteBackupAfterSnapshot event so plugins (actionlog, etc.) can react.
*/
private function dispatchAfterSnapshot(bool $success, int $snapshotId, array $contentTypes): void
{
try {
$app = Factory::getApplication();
$event = new Event('onMokoSuiteBackupAfterSnapshot', [
'success' => $success,
'snapshot_id' => $snapshotId,
'content_types' => $contentTypes,
]);
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshot', $event);
} catch (\Throwable $e) {
// Never let a listener failure break the snapshot result, but log it
error_log('MokoSuiteBackup: onAfterSnapshot listener error: ' . $e->getMessage());
}
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -19,6 +19,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Event\Event;
class SnapshotRestoreEngine
{
@@ -170,6 +171,9 @@ class SnapshotRestoreEngine
error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage());
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterSnapshotRestore(true, $snapshotId, $mode);
return [
'success' => true,
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
@@ -185,6 +189,9 @@ class SnapshotRestoreEngine
$this->log('FATAL: ' . $e->getMessage());
// Dispatch event for actionlog and other listeners
$this->dispatchAfterSnapshotRestore(false, $snapshotId, $mode);
return [
'success' => false,
'message' => 'Restore failed: ' . $e->getMessage(),
@@ -537,6 +544,9 @@ class SnapshotRestoreEngine
error_log('MokoSuiteBackup: Selective restore notification failed: ' . $e->getMessage());
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterSnapshotRestore(true, $snapshotId, 'selective');
return [
'success' => true,
'message' => sprintf('Restored %d articles (%d total rows)', count($selectedRows), $totalRows),
@@ -553,6 +563,9 @@ class SnapshotRestoreEngine
$this->log('FATAL: ' . $e->getMessage());
// Dispatch event for actionlog and other listeners
$this->dispatchAfterSnapshotRestore(false, $snapshotId, 'selective');
return [
'success' => false,
'message' => 'Selective restore failed: ' . $e->getMessage(),
@@ -561,6 +574,27 @@ class SnapshotRestoreEngine
}
}
/**
* Dispatch the onMokoSuiteBackupAfterSnapshotRestore event so plugins (actionlog, etc.) can react.
*/
private function dispatchAfterSnapshotRestore(bool $success, int $snapshotId, string $mode): void
{
try {
$app = Factory::getApplication();
$event = new Event('onMokoSuiteBackupAfterSnapshotRestore', [
'success' => $success,
'snapshot_id' => $snapshotId,
'mode' => $mode,
]);
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshotRestore', $event);
} catch (\Throwable $e) {
// Never let a listener failure break the restore result, but log it
error_log('MokoSuiteBackup: onAfterSnapshotRestore listener error: ' . $e->getMessage());
}
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -100,6 +100,17 @@ class FolderPickerField extends FormField
<span class="icon-question-circle" aria-hidden="true"></span>
</button>
</div>
<div class="mt-1 mb-1" id="{$id}_placeholders" style="display:flex; flex-wrap:wrap; gap:4px;">
<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOME]" title="Home directory">[HOME]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DEFAULT_DIR]" title="Default backup dir">[DEFAULT_DIR]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[host]" title="Server hostname">[host]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[site_name]" title="Joomla site name">[site_name]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[date]" title="Date (Ymd)">[date]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[profile_id]" title="Profile ID">[profile_id]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[profile_name]" title="Profile name">[profile_name]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[type]" title="Backup type">[type]</button>
</div>
<div class="mt-1" id="{$id}_status">
<small class="{$statusClass}">
<span class="{$statusIcon}" aria-hidden="true"></span>
@@ -155,6 +166,26 @@ class FolderPickerField extends FormField
</div>
<script>
(function() {
/* Clickable placeholder insertion at cursor position */
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
var target = document.getElementById(this.getAttribute('data-field'));
var ph = this.getAttribute('data-ph');
if (!target) return;
var start = target.selectionStart || 0;
var end = target.selectionEnd || 0;
var val = target.value;
target.value = val.substring(0, start) + ph + val.substring(end);
/* Move cursor to after the inserted placeholder */
var newPos = start + ph.length;
target.setSelectionRange(newPos, newPos);
target.focus();
/* Trigger input event so status updates */
target.dispatchEvent(new Event('input', { bubbles: true }));
});
});
var fieldId = '{$id}';
var btn = document.getElementById(fieldId + '_btn');
var browser = document.getElementById(fieldId + '_browser');
@@ -0,0 +1,78 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* Text field with clickable placeholder pills that insert at cursor position.
* Used for backup directory and archive name format fields.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
class PlaceholderTextField extends FormField
{
protected $type = 'PlaceholderText';
protected function getInput(): string
{
$value = htmlspecialchars($this->value ?? $this->default ?? '', ENT_QUOTES, 'UTF-8');
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
$hint = htmlspecialchars($this->element['hint'] ?? '', ENT_QUOTES, 'UTF-8');
$max = (int) ($this->element['maxlength'] ?? 512);
$placeholderAttr = (string) ($this->element['placeholders'] ?? '');
$placeholders = array_filter(array_map('trim', explode(',', $placeholderAttr)));
if (empty($placeholders)) {
$placeholders = ['[host]', '[date]', '[datetime]', '[time]', '[year]', '[month]', '[day]',
'[hour]', '[minute]', '[second]', '[profile_id]', '[profile_name]', '[site_name]', '[type]', '[random]'];
}
$html = '<input type="text" name="' . $name . '" id="' . $id . '" value="' . $value . '"'
. ' class="form-control" maxlength="' . $max . '"'
. ($hint ? ' placeholder="' . $hint . '"' : '') . '>';
$html .= '<div class="mt-1" style="display:flex; flex-wrap:wrap; gap:4px;">';
$html .= '<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>';
foreach ($placeholders as $ph) {
$html .= '<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert"'
. ' data-field="' . $id . '" data-ph="' . htmlspecialchars($ph) . '">'
. htmlspecialchars($ph) . '</button>';
}
$html .= '</div>';
$html .= <<<JS
<script>
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
var target = document.getElementById(this.getAttribute('data-field'));
var ph = this.getAttribute('data-ph');
if (!target) return;
var start = target.selectionStart || 0;
var end = target.selectionEnd || 0;
var val = target.value;
target.value = val.substring(0, start) + ph + val.substring(end);
var newPos = start + ph.length;
target.setSelectionRange(newPos, newPos);
target.focus();
target.dispatchEvent(new Event('input', { bubbles: true }));
});
});
</script>
JS;
return $html;
}
}
@@ -40,6 +40,13 @@ class ProfilesModel extends ListModel
$query->select('a.*')
->from($db->quoteName('#__mokosuitebackup_profiles', 'a'));
// Subquery: count of backup records per profile
$subQuery = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
->where($db->quoteName('r.profile_id') . ' = ' . $db->quoteName('a.id'));
$query->select('(' . $subQuery . ') AS ' . $db->quoteName('backup_count'));
$published = $this->getState('filter.published');
if (is_numeric($published)) {
@@ -15,6 +15,9 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Toolbar\Toolbar;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
@@ -48,6 +51,27 @@ class HtmlView extends BaseHtmlView
ToolbarHelper::save('profile.save');
}
if (!$isNew) {
$toolbar = Toolbar::getInstance();
$profileId = (int) $this->item->id;
// "Run Backup Now" button — links to backup start with CSRF token
if ($user->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
$runUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $profileId . '&' . Session::getFormToken() . '=1');
$toolbar->linkButton('run-backup', 'COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW')
->url($runUrl)
->icon('icon-play')
->buttonClass('btn btn-success');
}
// "View Backups" link button
$backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[profile_id]=' . $profileId);
$toolbar->linkButton('view-backups', 'COM_MOKOJOOMBACKUP_VIEW_BACKUPS')
->url($backupsUrl)
->icon('icon-database')
->buttonClass('btn btn-info');
}
ToolbarHelper::cancel('profile.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
}
}
@@ -191,6 +191,10 @@ $listDirn = $this->escape($this->state->get('list.direction'));
<div id="mokosuitebackup-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
<span class="icon-warning-circle" aria-hidden="true"></span>
<strong>Do not navigate away or close this window</strong> while the backup is running.
</div>
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
</div>
@@ -305,6 +305,10 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
<div id="mokosuitebackup-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
<span class="icon-warning-circle" aria-hidden="true"></span>
<strong>Do not navigate away or close this window</strong> while the backup is running.
</div>
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
</div>
@@ -14,6 +14,7 @@ use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
HTMLHelper::_('behavior.multiselect');
@@ -45,9 +46,15 @@ $listDirn = $this->escape($this->state->get('list.direction'));
<th scope="col" class="w-10">
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_TYPE', 'a.backup_type', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-5 text-center">
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_BACKUPS'); ?>
</th>
<th scope="col" class="w-10">
<?php echo HTMLHelper::_('searchtools.sort', 'JSTATUS', 'a.published', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-10 text-center">
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?>
</th>
<th scope="col" class="w-5">
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
</th>
@@ -70,9 +77,26 @@ $listDirn = $this->escape($this->state->get('list.direction'));
<td>
<?php echo $this->escape($item->backup_type); ?>
</td>
<td class="text-center">
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[profile_id]=' . $item->id); ?>">
<span class="badge bg-<?php echo ($item->backup_count > 0) ? 'info' : 'secondary'; ?>">
<?php echo (int) $item->backup_count; ?>
</span>
</a>
</td>
<td>
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'profiles.'); ?>
</td>
<td class="text-center">
<?php if ($item->published == 1) : ?>
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $item->id . '&' . Session::getFormToken() . '=1'); ?>"
class="btn btn-sm btn-outline-success"
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>">
<span class="icon-play" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>
</a>
<?php endif; ?>
</td>
<td>
<?php echo (int) $item->id; ?>
</td>
@@ -99,14 +99,12 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</td>
<td>
<?php if ($item->status === 'complete' && $canManage) : ?>
<?php if (in_array('articles', $types)) : ?>
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
data-id="<?php echo (int) $item->id; ?>"
data-desc="<?php echo $this->escape($item->description); ?>"
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
<span class="icon-search"></span>
</button>
<?php endif; ?>
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
data-id="<?php echo (int) $item->id; ?>"
data-desc="<?php echo $this->escape($item->description); ?>"
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
<span class="icon-search"></span>
</button>
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
data-id="<?php echo (int) $item->id; ?>"
data-types="<?php echo $this->escape($item->content_types); ?>"
@@ -235,9 +233,9 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
</div>
<!-- Browse Snapshot Articles Modal -->
<!-- Browse Snapshot Detail Modal -->
<div id="mb-snapshot-browse-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-height:80vh; display:flex; flex-direction:column;">
<div style="max-width:800px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-height:80vh; display:flex; flex-direction:column;">
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
<h4 style="margin:0;" id="mb-browse-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?></h4>
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
@@ -251,25 +249,86 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
<div id="mb-browse-error" class="alert alert-danger" style="display:none;"></div>
<div id="mb-browse-content" style="display:none;">
<div class="mb-2">
<label class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
<span class="form-check-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SELECT_ALL'); ?></span>
</label>
<span class="text-muted ms-2" id="mb-browse-count"></span>
<!-- Bootstrap tabs -->
<ul class="nav nav-tabs" id="mb-browse-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="mb-tab-articles-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-articles" type="button" role="tab" aria-controls="mb-tab-articles" aria-selected="true">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES'); ?>
<span class="badge bg-secondary ms-1" id="mb-tab-articles-count">0</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="mb-tab-categories-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-categories" type="button" role="tab" aria-controls="mb-tab-categories" aria-selected="false">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES'); ?>
<span class="badge bg-secondary ms-1" id="mb-tab-categories-count">0</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="mb-tab-modules-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-modules" type="button" role="tab" aria-controls="mb-tab-modules" aria-selected="false">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES'); ?>
<span class="badge bg-secondary ms-1" id="mb-tab-modules-count">0</span>
</button>
</li>
</ul>
<div class="tab-content pt-3" id="mb-browse-tabs-content">
<!-- Articles tab -->
<div class="tab-pane fade show active" id="mb-tab-articles" role="tabpanel" aria-labelledby="mb-tab-articles-btn">
<div class="mb-2">
<label class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
<span class="form-check-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SELECT_ALL'); ?></span>
</label>
<span class="text-muted ms-2" id="mb-browse-count"></span>
</div>
<table class="table table-sm table-striped" id="mb-browse-table">
<thead>
<tr>
<th class="w-1"></th>
<th>ID</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE'); ?></th>
</tr>
</thead>
<tbody id="mb-browse-tbody"></tbody>
</table>
</div>
<!-- Categories tab -->
<div class="tab-pane fade" id="mb-tab-categories" role="tabpanel" aria-labelledby="mb-tab-categories-btn">
<table class="table table-sm table-striped" id="mb-browse-categories-table">
<thead>
<tr>
<th>ID</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_LEVEL'); ?></th>
</tr>
</thead>
<tbody id="mb-browse-categories-tbody"></tbody>
</table>
</div>
<!-- Modules tab -->
<div class="tab-pane fade" id="mb-tab-modules" role="tabpanel" aria-labelledby="mb-tab-modules-btn">
<table class="table table-sm table-striped" id="mb-browse-modules-table">
<thead>
<tr>
<th>ID</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_POSITION'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
</tr>
</thead>
<tbody id="mb-browse-modules-tbody"></tbody>
</table>
</div>
</div>
<table class="table table-sm table-striped" id="mb-browse-table">
<thead>
<tr>
<th class="w-1"></th>
<th>ID</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE'); ?></th>
</tr>
</thead>
<tbody id="mb-browse-tbody"></tbody>
</table>
</div>
</div>
<div style="padding:0.75rem 1.5rem; border-top:1px solid #dee2e6; text-align:right;">
@@ -388,9 +447,16 @@ $listDirn = $this->escape($this->state->get('list.direction'));
document.getElementById('mb-browse-restore-btn').disabled = true;
document.getElementById('mb-browse-select-all').checked = false;
// Reset to Articles tab
var firstTab = document.querySelector('#mb-tab-articles-btn');
if (firstTab && typeof bootstrap !== 'undefined') {
var tab = new bootstrap.Tab(firstTab);
tab.show();
}
document.getElementById('mb-snapshot-browse-modal').style.display = 'block';
// Fetch articles via AJAX
// Fetch snapshot content via AJAX
var token = <?php echo json_encode(Session::getFormToken()); ?>;
var url = 'index.php?option=com_mokosuitebackup&task=ajax.browseSnapshot&id=' + encodeURIComponent(id) + '&' + token + '=1';
@@ -405,13 +471,14 @@ $listDirn = $this->escape($this->state->get('list.direction'));
return;
}
var tbody = document.getElementById('mb-browse-tbody');
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
var stateLabels = { '1': 'Published', '0': 'Unpublished', '-1': 'Trashed', '-2': 'Archived' };
var stateBadges = { '1': 'bg-success', '0': 'bg-secondary', '-1': 'bg-danger', '-2': 'bg-info' };
data.articles.forEach(function(article) {
// --- Articles ---
var tbody = document.getElementById('mb-browse-tbody');
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
(data.articles || []).forEach(function(article) {
var tr = document.createElement('tr');
var tdCheck = document.createElement('td');
@@ -445,12 +512,84 @@ $listDirn = $this->escape($this->state->get('list.direction'));
tbody.appendChild(tr);
});
document.getElementById('mb-browse-count').textContent = data.total + ' article(s)';
document.getElementById('mb-browse-count').textContent = data.total_articles + ' article(s)';
document.getElementById('mb-tab-articles-count').textContent = data.total_articles;
// --- Categories ---
var catTbody = document.getElementById('mb-browse-categories-tbody');
while (catTbody.firstChild) catTbody.removeChild(catTbody.firstChild);
(data.categories || []).forEach(function(cat) {
var tr = document.createElement('tr');
var tdId = document.createElement('td');
tdId.textContent = cat.id;
tr.appendChild(tdId);
var tdTitle = document.createElement('td');
tdTitle.textContent = '\u2003'.repeat(Math.max(0, cat.level - 1)) + cat.title;
tr.appendChild(tdTitle);
var tdExt = document.createElement('td');
tdExt.textContent = cat.extension;
tr.appendChild(tdExt);
var tdState = document.createElement('td');
var badge = document.createElement('span');
badge.className = 'badge ' + (stateBadges[String(cat.published)] || 'bg-secondary');
badge.textContent = stateLabels[String(cat.published)] || 'Unknown';
tdState.appendChild(badge);
tr.appendChild(tdState);
var tdLevel = document.createElement('td');
tdLevel.textContent = cat.level;
tr.appendChild(tdLevel);
catTbody.appendChild(tr);
});
document.getElementById('mb-tab-categories-count').textContent = data.total_categories;
// --- Modules ---
var modTbody = document.getElementById('mb-browse-modules-tbody');
while (modTbody.firstChild) modTbody.removeChild(modTbody.firstChild);
(data.modules || []).forEach(function(mod) {
var tr = document.createElement('tr');
var tdId = document.createElement('td');
tdId.textContent = mod.id;
tr.appendChild(tdId);
var tdTitle = document.createElement('td');
tdTitle.textContent = mod.title;
tr.appendChild(tdTitle);
var tdType = document.createElement('td');
tdType.textContent = mod.module;
tr.appendChild(tdType);
var tdPos = document.createElement('td');
tdPos.textContent = mod.position;
tr.appendChild(tdPos);
var tdState = document.createElement('td');
var badge = document.createElement('span');
badge.className = 'badge ' + (stateBadges[String(mod.published)] || 'bg-secondary');
badge.textContent = stateLabels[String(mod.published)] || 'Unknown';
tdState.appendChild(badge);
tr.appendChild(tdState);
modTbody.appendChild(tr);
});
document.getElementById('mb-tab-modules-count').textContent = data.total_modules;
document.getElementById('mb-browse-content').style.display = 'block';
})
.catch(function(err) {
document.getElementById('mb-browse-loading').style.display = 'none';
document.getElementById('mb-browse-error').textContent = 'Failed to load articles: ' + err.message;
document.getElementById('mb-browse-error').textContent = 'Failed to load snapshot content: ' + err.message;
document.getElementById('mb-browse-error').style.display = 'block';
});
});
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.35.04</version>
<version>01.37.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -27,7 +27,10 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
return [
'onContentAfterSave' => 'onContentAfterSave',
'onContentAfterDelete' => 'onContentAfterDelete',
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
'onMokoSuiteBackupAfterRestore' => 'onMokoSuiteBackupAfterRestore',
'onMokoSuiteBackupAfterSnapshot' => 'onMokoSuiteBackupAfterSnapshot',
'onMokoSuiteBackupAfterSnapshotRestore' => 'onMokoSuiteBackupAfterSnapshotRestore',
];
}
@@ -130,6 +133,94 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
);
}
/**
* Log when a backup is restored.
*/
public function onMokoSuiteBackupAfterRestore(Event $event): void
{
$args = $event->getArguments();
$success = $args['success'] ?? false;
$recordId = $args['record_id'] ?? 0;
$messageKey = $success
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE'
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED';
$this->addLog(
[
$messageKey,
'id' => $recordId,
'title' => 'Backup #' . $recordId,
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
$messageKey,
'com_mokosuitebackup.backup',
$this->getCurrentUserId()
);
}
/**
* Log when a content snapshot is created.
*/
public function onMokoSuiteBackupAfterSnapshot(Event $event): void
{
$args = $event->getArguments();
$success = $args['success'] ?? false;
$snapshotId = $args['snapshot_id'] ?? 0;
$contentTypes = $args['content_types'] ?? [];
$messageKey = $success
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED'
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED';
$this->addLog(
[
$messageKey,
'id' => $snapshotId,
'title' => 'Snapshot #' . $snapshotId,
'content_types' => implode(', ', $contentTypes),
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
$messageKey,
'com_mokosuitebackup.snapshot',
$this->getCurrentUserId()
);
}
/**
* Log when a snapshot is restored.
*/
public function onMokoSuiteBackupAfterSnapshotRestore(Event $event): void
{
$args = $event->getArguments();
$success = $args['success'] ?? false;
$snapshotId = $args['snapshot_id'] ?? 0;
$mode = $args['mode'] ?? 'replace';
$messageKey = $success
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE'
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED';
$this->addLog(
[
$messageKey,
'id' => $snapshotId,
'title' => 'Snapshot #' . $snapshotId,
'mode' => $mode,
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
$messageKey,
'com_mokosuitebackup.snapshot',
$this->getCurrentUserId()
);
}
/**
* Write an action log entry.
*/
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name>
<version>01.35.04</version>
<version>01.37.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>01.35.04</version>
<version>01.37.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name>
<version>01.35.04</version>
<version>01.37.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name>
<version>01.35.04</version>
<version>01.37.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.35.04</version>
<version>01.37.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.35.04</version>
<version>01.37.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>01.35.04</version>
<version>01.37.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>