10 | ';
13 | printf(
14 | esc_html(
15 | /* translators: 1: PHP version */
16 | __(
17 | 'Simple History is a great plugin, but to use it your server must have at least PHP 7.4 installed (you have version %s).',
18 | 'simple-history'
19 | )
20 | ),
21 | esc_html( phpversion() ) // 1
22 | );
23 | echo '';
24 | }
25 |
26 | if ( ! $ok_wp_version ) {
27 | echo '
';
28 | printf(
29 | esc_html(
30 | /* translators: 1: WordPress version */
31 | __(
32 | 'Simple History requires WordPress version 6.3 or higher (you have version %s).',
33 | 'simple-history'
34 | )
35 | ),
36 | esc_html( $GLOBALS['wp_version'] ) // 1
37 | );
38 | echo '
';
39 | }
40 | ?>
41 |
42 | html = $html;
18 | }
19 |
20 | /**
21 | * @inheritdoc
22 | */
23 | public function to_html() {
24 | if ( $this->html instanceof Event_Details_Container_Interface ) {
25 | return $this->html->to_html();
26 | }
27 |
28 | return $this->html;
29 | }
30 |
31 | /**
32 | * @inheritdoc
33 | *
34 | * @return string
35 | */
36 | public function __toString() {
37 | return (string) $this->to_html();
38 | }
39 |
40 | /**
41 | * Old event details does not have support for JSON output,
42 | * so we return an empty array.
43 | *
44 | * @return array Empty array.
45 | */
46 | public function to_json() {
47 | return [];
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/inc/services/class-rest-api.php:
--------------------------------------------------------------------------------
1 | register_routes();
26 |
27 | $search_options_controller = new WP_REST_SearchOptions_Controller();
28 | $search_options_controller->register_routes();
29 |
30 | $stats_controller = new WP_REST_Stats_Controller();
31 | $stats_controller->register_routes();
32 |
33 | // Only register dev tools routes when dev mode is enabled.
34 | if ( Helpers::dev_mode_is_enabled() ) {
35 | $dev_tools_controller = new WP_REST_Devtools_Controller();
36 | $dev_tools_controller->register_routes();
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | log-tests.php
3 | .npm-debug.log
4 | tmp
5 | .tags
6 | .tags1
7 | .DS_Store
8 | vendor
9 | /.vscode
10 | workspace.code-workspace
11 | /.idea
12 |
13 | # Docker related data
14 | data/
15 |
16 | # Tests related data
17 | tests/*
18 | !tests/.valetphprc
19 | !tests/readme.md
20 | !tests/*.yml
21 | !tests/*.php
22 | !tests/_data
23 | tests/_data/*
24 | !tests/_data/.gitkeep
25 | !tests/_data/Image 1.jpg
26 | !tests/_data/Image 2.jpg
27 | !tests/_data/mu-plugins
28 | !tests/acceptance
29 | !tests/functional
30 | !tests/unit
31 | !tests/phpstan
32 | !tests/wpunit
33 | !tests/_output
34 | tests/_output/*
35 | !tests/_output/.gitignore
36 | !tests/_support
37 | !tests/plugins
38 | tests/plugins/*
39 | !tests/plugins/.gitkeep
40 |
41 | # Local environment files
42 | db-export-*.sql
43 | readme.branch.*.md
44 | simple-history-test.zip
45 |
46 | # Ignore the build directory
47 | # Since 2024-12-30 we build during GitHub deployment.
48 | build/
49 |
50 | # Ignore Claude config files (but allow commands for cross-machine use)
51 | .claude/*
52 | !.claude/settings.json
53 | !.claude/commands/
54 | !.claude/skills/
55 | scripts/copy-current-to-simple-history-com.sh
56 | CLAUDE.local.md
57 |
58 | # Docker Claude Code environment
59 | docker/claude-code/.env
60 |
--------------------------------------------------------------------------------
/docker/claude-code/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | claude-code:
3 | build:
4 | context: .
5 | dockerfile: Dockerfile
6 | container_name: claude-code
7 | stdin_open: true
8 | tty: true
9 | environment:
10 | # Explicitly set config directory to fix login persistence (see issue #1736)
11 | - CLAUDE_CONFIG_DIR=/home/node/.claude
12 | volumes:
13 | # Main plugin
14 | - /Users/bonny/Projects/Personal/WordPress-Simple-History:/workspace/WordPress-Simple-History
15 | # Premium add-ons
16 | - /Users/bonny/Projects/Personal/simple-history-add-ons:/workspace/simple-history-add-ons
17 | # Docker compose folder (read-only for reference)
18 | - /Users/bonny/Projects/_docker-compose-to-run-on-system-boot:/workspace/docker-compose:ro
19 | # Share host's Claude config (settings, auth, theme)
20 | - ~/.claude:/home/node/.claude
21 | # Share claude.json for settings persistence (bypassPermissionsModeAccepted, etc.)
22 | - ~/.claude.json:/home/node/.claude.json
23 | networks:
24 | - docker-compose-to-run-on-system-boot_default
25 | working_dir: /workspace/WordPress-Simple-History
26 |
27 | networks:
28 | docker-compose-to-run-on-system-boot_default:
29 | external: true
30 |
--------------------------------------------------------------------------------
/src/components/PremiumFeatureSuffix.jsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@wordpress/components';
2 | import { __ } from '@wordpress/i18n';
3 | import { lockSmall, starEmpty, starFilled, unlock } from '@wordpress/icons';
4 | import verifiedSvgImage from '../../css/icons/verified_FILL0_wght400_GRAD0_opsz48.svg';
5 |
6 | export const PremiumFeatureSuffix = function ( props ) {
7 | const { variant, char = null } = props;
8 |
9 | let icon;
10 | let svgIcon;
11 |
12 | switch ( variant ) {
13 | case 'unlocked':
14 | icon = starFilled;
15 | break;
16 | case 'unlocked2':
17 | icon = starEmpty;
18 | break;
19 | case 'unlocked3':
20 | icon = unlock;
21 | break;
22 | case 'locked':
23 | icon = lockSmall;
24 | break;
25 | case 'verified':
26 | svgIcon = verifiedSvgImage;
27 | break;
28 | default:
29 | icon = null;
30 | }
31 |
32 | return (
33 |
34 | { __( 'Premium', 'simple-history' ) }
35 |
36 | { icon ? : null }
37 |
38 | { svgIcon ? (
39 |
44 | ) : null }
45 |
46 | { char ? (
47 | { char }
48 | ) : null }
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/uninstall.php:
--------------------------------------------------------------------------------
1 | prefix . 'simple_history';
36 | $wpdb->query( "DROP TABLE IF EXISTS $table_name" ); // PHPCS:ignore
37 |
38 | $table_name = $wpdb->prefix . 'simple_history_contexts';
39 | $wpdb->query( "DROP TABLE IF EXISTS $table_name" ); // PHPCS:ignore
40 |
41 | // Remove scheduled events.
42 | $timestamp = wp_next_scheduled( 'simple_history/email_report' );
43 | if ( $timestamp ) {
44 | wp_unschedule_event( $timestamp, 'simple_history/email_report' );
45 | }
46 |
47 |
48 | // And we are done. Simple History is ... history.
49 |
--------------------------------------------------------------------------------
/inc/channels/formatters/interface-formatter-interface.php:
--------------------------------------------------------------------------------
1 | loginAsAdmin();
10 | $I->amOnPluginsPage();
11 | $I->activatePlugin($plugin_slug);
12 | $I->canSeePluginActivated($plugin_slug);
13 |
14 | $I->haveUserInDatabase('anna', 'author', ['user_pass' => 'password']);
15 | $I->haveUserInDatabase('erik', 'editor', ['user_pass' => 'password']);
16 | }
17 |
18 | public function switchUser(Admin $I) {
19 | $I->amOnAdminPage('users.php');
20 |
21 | // Move over second user row with Anna.
22 | $I->moveMouseOver('.wp-list-table tbody tr:nth-child(2)');
23 | // Click "Switch To" link. Can not use plain "Switch to" text because link
24 | // contains " .
25 | $I->click('//*[@id="user-2"]/td[1]/div/span[5]/a');
26 | $I->seeLogMessage('Switched to user "anna" from user "admin"');
27 |
28 | // Switch back to admin.
29 | $I->wait(2);
30 |
31 | // Click "Switch back to admin" link.
32 | $I->click('//*[@id="user_switching"]/p/a');
33 | $I->seeLogMessage('Switched back to user "admin" from user "anna"');
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/EventHeader.jsx:
--------------------------------------------------------------------------------
1 | import { EventDate } from './EventDate';
2 | import { EventInitiatorName } from './EventInitiatorName';
3 | import { EventIPAddresses } from './EventIPAddresses';
4 | import { EventVia } from './EventVia';
5 | import { EventBackfilledIndicator } from './EventBackfilledIndicator';
6 |
7 | /**
8 | * Outputs event "meta": name of the event initiator (who), the date, and the via text (if any).
9 | *
10 | * @param {Object} props
11 | */
12 | export function EventHeader( props ) {
13 | const {
14 | event,
15 | eventVariant,
16 | hasExtendedSettingsAddOn,
17 | hasPremiumAddOn,
18 | mapsApiKey,
19 | isSurroundingEventsMode,
20 | } = props;
21 |
22 | return (
23 |
24 | { isSurroundingEventsMode && (
25 |
26 | #{ event.id }
27 |
28 | ) }
29 |
30 |
31 |
32 |
33 |
34 |
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/inc/services/class-service.php:
--------------------------------------------------------------------------------
1 | simple_history = $simple_history;
27 | }
28 |
29 | /**
30 | * Get the slug for the service,
31 | * i.e. the unqualified class name.
32 | *
33 | * @return string
34 | */
35 | public function get_slug() {
36 | return Helpers::get_class_short_name( $this );
37 | }
38 |
39 | /**
40 | * Called when service is loaded.
41 | */
42 | abstract public function loaded();
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/EventText.jsx:
--------------------------------------------------------------------------------
1 | import { clsx } from 'clsx';
2 | import { __ } from '@wordpress/i18n';
3 | import { pinSmall } from '@wordpress/icons';
4 | import { Icon, Tooltip, VisuallyHidden } from '@wordpress/components';
5 |
6 | /**
7 | * Outputs the main event text, i.e.:
8 | * - "Logged in"
9 | * - "Created post"
10 | *
11 | * @param {Object} props
12 | * @param {Object} props.event
13 | * @return {Object} React element
14 | */
15 | export function EventText( { event } ) {
16 | const logLevelClassNames = clsx(
17 | 'SimpleHistoryLogitem--logleveltag',
18 | `SimpleHistoryLogitem--logleveltag-${ event.loglevel }`
19 | );
20 |
21 | return (
22 |
23 |
24 | { ' ' }
27 | { event.loglevel }
28 |
29 | );
30 | }
31 |
32 | function EventStickyIcon( { event } ) {
33 | const stickyClassNames = clsx( 'SimpleHistoryLogitem--sticky' );
34 |
35 | return event.sticky ? (
36 |
37 |
38 |
39 | { __( 'Sticky', 'simple-history' ) }
40 |
41 |
42 |
43 |
44 | ) : null;
45 | }
46 |
--------------------------------------------------------------------------------
/docs/wp-playground-testing.md:
--------------------------------------------------------------------------------
1 | # WP Playground CLI for Quick Testing
2 |
3 | For quick, ephemeral WordPress testing without Docker setup, use [WP Playground CLI](https://wordpress.github.io/wordpress-playground/). It auto-mounts the current directory as a plugin.
4 |
5 | ## Basic Usage
6 |
7 | ```bash
8 | # Start WordPress with current directory mounted as plugin
9 | npx @wp-playground/cli@latest server --auto-mount --login --wp=6.9 --php=8.2
10 |
11 | # Test with different WordPress/PHP versions
12 | npx @wp-playground/cli@latest server --auto-mount --login --wp=6.7 --php=8.3
13 | npx @wp-playground/cli@latest server --auto-mount --login --wp=6.5 --php=7.4
14 | ```
15 |
16 | ## Options
17 |
18 | | Flag | Description |
19 | |------|-------------|
20 | | `--auto-mount` | Mounts current directory as a WordPress plugin |
21 | | `--login` | Auto-logs you into wp-admin |
22 | | `--wp=X.X` | Specify WordPress version (e.g., 6.7, 6.9) |
23 | | `--php=X.X` | Specify PHP version (e.g., 7.4, 8.2, 8.3) |
24 |
25 | ## When to Use
26 |
27 | - Quick smoke testing of plugin changes
28 | - Testing compatibility with different WP/PHP versions
29 | - Demos and screenshots
30 | - No persistent data needed
31 |
32 | ## Notes
33 |
34 | - Data is ephemeral and lost when the server stops
35 | - For persistent testing, use Docker-based installations
36 | - See https://wordpress.github.io/wordpress-playground/developers/cli for full documentation
37 |
--------------------------------------------------------------------------------
/src/components/EventInitiator.jsx:
--------------------------------------------------------------------------------
1 | export function EventInitiatorImageWPUser( props ) {
2 | const { event } = props;
3 | const { initiator_data: initiatorData } = event;
4 |
5 | return (
6 |
11 | );
12 | }
13 |
14 | export function EventInitiatorImageWebUser( props ) {
15 | const { event } = props;
16 | const { initiator_data: initiatorData } = event;
17 |
18 | return (
19 |
24 | );
25 | }
26 |
27 | /**
28 | * Initiator is "other" or "wp" or "wp_cli".
29 | * Image is added using CSS.
30 | */
31 | export function EventInitiatorImageFromCSS() {
32 | return
;
33 | }
34 |
35 | export function EventInitiatorImage( props ) {
36 | const { event } = props;
37 | const { initiator } = event;
38 |
39 | switch ( initiator ) {
40 | case 'wp_user':
41 | return ;
42 | case 'web_user':
43 | return ;
44 | case 'wp_cli':
45 | case 'wp':
46 | case 'other':
47 | return ;
48 | default:
49 | return Add image for initiator "{ initiator }"
;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/inc/event-details/class-event-details-item-default-formatter.php:
--------------------------------------------------------------------------------
1 | item->name ) ) {
18 | $name = sprintf( '%1$s: ', esc_html( $this->item->name ) );
19 | }
20 |
21 | return sprintf(
22 | '%1$s%2$s ',
23 | $name,
24 | $this->get_value_diff_output(),
25 | );
26 | }
27 |
28 | /**
29 | * @inheritdoc
30 | *
31 | * @return array
32 | */
33 | public function to_json() {
34 | $return = [];
35 |
36 | if ( isset( $this->item->name ) ) {
37 | $return['name'] = $this->item->name;
38 | }
39 |
40 | if ( isset( $this->item->new_value ) ) {
41 | $return['new_value'] = $this->item->new_value;
42 | }
43 |
44 | if ( isset( $this->item->prev_value ) ) {
45 | $return['prev_value'] = $this->item->prev_value;
46 | }
47 |
48 | if ( isset( $this->item->slug_new ) ) {
49 | $return['slug_new'] = $this->item->slug_new;
50 | }
51 |
52 | if ( isset( $this->item->slug_prev ) ) {
53 | $return['slug_prev'] = $this->item->slug_prev;
54 | }
55 |
56 | return $return;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/tests/functional.suite.yml:
--------------------------------------------------------------------------------
1 | # Codeception Test Suite Configuration
2 | #
3 | # Suite for functional tests
4 | # Emulate web requests and make WordPress process them
5 |
6 | actor: FunctionalTester
7 | modules:
8 | enabled:
9 | - lucatume\WPBrowser\Module\WPDb:
10 | dsn: "%TEST_SITE_DB_DSN%"
11 | user: "%TEST_SITE_DB_USER%"
12 | password: "%TEST_SITE_DB_PASSWORD%"
13 | dump: "tests/_data/dump.sql"
14 | populate: true
15 | cleanup: true
16 | waitlock: 10
17 | url: "%TEST_SITE_WP_URL%"
18 | urlReplacement: true
19 | tablePrefix: "%TEST_SITE_TABLE_PREFIX%"
20 |
21 | - lucatume\WPBrowser\Module\WPBrowser:
22 | url: "%TEST_SITE_WP_URL%"
23 | adminUsername: "%TEST_SITE_ADMIN_USERNAME%"
24 | adminPassword: "%TEST_SITE_ADMIN_PASSWORD%"
25 | adminPath: "%TEST_SITE_WP_ADMIN_PATH%"
26 | headers:
27 | X_TEST_REQUEST: 1
28 | X_WPBROWSER_REQUEST: 1
29 |
30 | - lucatume\WPBrowser\Module\WPCLI:
31 | path: "%WP_ROOT_FOLDER%"
32 | debug: true
33 | allow-root: true
34 |
35 | # - WPFilesystem
36 | - Asserts
37 | - \Helper\Functional
38 |
39 | config:
40 | WPFilesystem:
41 | wpRootFolder: "%WP_ROOT_FOLDER%"
42 | plugins: "/wp-content/plugins"
43 | mu-plugins: "/wp-content/mu-plugins"
44 | themes: "/wp-content/themes"
45 | uploads: "/wp-content/uploads"
46 |
--------------------------------------------------------------------------------
/src/components/EventsModalIfFragment.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from '@wordpress/element';
2 | import { EventInfoModal } from './EventInfoModal';
3 | import { useURLFragment } from '../functions.js';
4 |
5 | /**
6 | * Opens a modal with event details when URL contains a fragment.
7 | *
8 | * Show a modal when the URL contains a fragment.
9 | * Removes the fragment from the URL after the modal is closed.
10 | */
11 | export function EventsModalIfFragment() {
12 | const fragment = useURLFragment();
13 | const [ showModal, setShowModal ] = useState( false );
14 | const [ matchedEventId, setMatchedEventId ] = useState( null );
15 |
16 | // Open modal with info when URL changes and contains fragment.
17 | useEffect( () => {
18 | // Match only some fragments, that begins with
19 | // '#simple-history/event/'
20 | const matchedEventFragment = fragment.match(
21 | /^#simple-history\/event\/(\d+)/
22 | );
23 |
24 | if ( matchedEventFragment === null ) {
25 | setShowModal( false );
26 | return;
27 | }
28 |
29 | setMatchedEventId( parseInt( matchedEventFragment[ 1 ], 10 ) );
30 | setShowModal( true );
31 | }, [ fragment ] );
32 |
33 | const closeModal = () => {
34 | setShowModal( false );
35 | window.location.hash = '';
36 | };
37 |
38 | if ( showModal ) {
39 | return (
40 |
44 | );
45 | }
46 |
47 | return null;
48 | }
49 |
--------------------------------------------------------------------------------
/templates/settings-general.php:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
26 |
27 |
30 |
31 |
41 |
42 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/inc/services/class-channels-service.php:
--------------------------------------------------------------------------------
1 | channels_manager = new Channels_Manager( $this->simple_history );
29 | $this->channels_manager->loaded();
30 |
31 | /**
32 | * Fires after the channels service is loaded.
33 | *
34 | * @since 4.4.0
35 | *
36 | * @param Channels_Manager $channels_manager The channels manager instance.
37 | * @param Channels_Service $service This service instance.
38 | */
39 | do_action( 'simple_history/channels/service_loaded', $this->channels_manager, $this );
40 | }
41 |
42 | /**
43 | * Get the channels manager instance.
44 | *
45 | * @return Channels_Manager|null The channels manager or null if not loaded.
46 | */
47 | public function get_channels_manager() {
48 | return $this->channels_manager;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/tests/acceptance/PluginEnableMediaReplaceLoggerCest.php:
--------------------------------------------------------------------------------
1 | loginAsAdmin();
10 | $I->amOnPluginsPage();
11 | $I->activatePlugin($plugin_slug);
12 | $I->canSeePluginActivated($plugin_slug);
13 | }
14 |
15 | public function replaceImage(Admin $I) {
16 | // Upload first image.
17 | $I->amOnAdminPage('/media-new.php');
18 | $I->click("browser uploader");
19 | $I->attachFile('#async-upload', 'Image 1.jpg');
20 | $I->click("Upload");
21 | $I->seeLogMessage('Created attachment "Image 1"');
22 |
23 | // Upload second image, that replaces first image.
24 | $I->amOnAdminPage('upload.php?mode=list');
25 | $I->click('Image 1');
26 | $I->click('Upload a new file');
27 | $I->attachFile('#userfile', 'Image 2.jpg');
28 | $I->click('Upload');
29 |
30 | $I->seeLogMessage('Replaced attachment "Image 1" with new attachment "Image 2.jpg"');
31 | $I->seeLogContext([
32 | 'prev_attachment_title' => 'Image 1',
33 | 'new_attachment_title' => 'Image 2.jpg',
34 | 'new_attachment_type' => 'image/jpeg',
35 | 'new_attachment_size' => '586250',
36 | 'replace_type' => 'replace',
37 | ]);
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/inc/event-details/class-event-details-group-inline-formatter.php:
--------------------------------------------------------------------------------
1 | get_title() ) {
20 | $output .= '' . esc_html( $group->get_title() ) . ' ';
21 | }
22 |
23 | $output .= '';
24 |
25 | foreach ( $group->items as $item ) {
26 | $item_formatter = $item->get_formatter( new Event_Details_Item_Default_Formatter() );
27 | $output .= $item_formatter->to_html();
28 | }
29 |
30 | $output .= '
';
31 |
32 | return $output;
33 | }
34 |
35 | /**
36 | * @inheritdoc
37 | *
38 | * @param Event_Details_Group $group Group to format.
39 | * @return array
40 | */
41 | public function to_json( $group ) {
42 | $output = [];
43 |
44 | foreach ( $group->items as $item ) {
45 | $item_formatter = $item->get_formatter( new Event_Details_Item_Default_Formatter() );
46 | $output[] = $item_formatter->to_json();
47 | }
48 |
49 | return [
50 | 'title' => $group->get_title(),
51 | 'items' => $output,
52 | ];
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/inc/services/wp-cli-commands/class-wp-cli-stealth-mode-command.php:
--------------------------------------------------------------------------------
1 | get_service( Stealth_Mode::class );
26 |
27 | $full_stealth_mode_enabled = $stealth_mode_service->is_full_stealth_mode_enabled();
28 | $partial_stealth_mode_enabled = $stealth_mode_service->is_stealth_mode_enabled();
29 |
30 | WP_CLI\Utils\format_items(
31 | 'table',
32 | [
33 | [
34 | 'mode' => __( 'Full Stealth Mode', 'simple-history' ),
35 | 'status' => $full_stealth_mode_enabled ? __( 'Enabled', 'simple-history' ) : __( 'Disabled', 'simple-history' ),
36 | ],
37 | [
38 | 'mode' => __( 'Partial Stealth Mode', 'simple-history' ),
39 | 'status' => $partial_stealth_mode_enabled ? __( 'Enabled', 'simple-history' ) : __( 'Disabled', 'simple-history' ),
40 | ],
41 | ],
42 | [ 'mode', 'status' ]
43 | );
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | // Entrypoint used by wp-scripts start and build.
2 | import { SlotFillProvider, withFilters } from '@wordpress/components';
3 | import domReady from '@wordpress/dom-ready';
4 | import { createRoot } from '@wordpress/element';
5 | import EventsGUI from './components/EventsGui';
6 | import { EmptyFilteredComponent } from './EmptyFilteredComponent';
7 | import { NuqsAdapter } from 'nuqs/adapters/react';
8 | import { PremiumFeaturesModalProvider } from './components/PremiumFeaturesModalContext';
9 |
10 | // Filter that can be used by other plugins as a gateway to add content to different areas of
11 | // the core plugin, using slots.
12 | // Based on solution here:
13 | // https://nickdiego.com/a-primer-on-wordpress-slotfill-technology/
14 | // Filter can only be called called multiple times, but make sure to add
15 | // ` ` in each call, so all calls "stack up".
16 | const EventsControlBarSlotfillsFilter = withFilters(
17 | 'SimpleHistory.FilteredComponent'
18 | )( EmptyFilteredComponent );
19 |
20 | domReady( () => {
21 | const target = document.getElementById( 'simple-history-react-root' );
22 |
23 | if ( ! target ) {
24 | return;
25 | }
26 |
27 | if ( ! createRoot ) {
28 | return;
29 | }
30 |
31 | createRoot( target ).render(
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | } );
42 |
--------------------------------------------------------------------------------
/tests/_data/mu-plugins/inc/disable-updates.php:
--------------------------------------------------------------------------------
1 | last_checked = time();
31 |
32 | // Unset any found updated themes and plugins.
33 | $value->response = array();
34 |
35 | // Unset any found WordPress core update.
36 | $value->updates = array();
37 |
38 | return $value;
39 | }
40 |
--------------------------------------------------------------------------------
/inc/event-details/class-event-details-group-single-item-formatter.php:
--------------------------------------------------------------------------------
1 | get_title() ) {
22 | $output .= '' . esc_html( $group->get_title() ) . ' ';
23 | }
24 |
25 | foreach ( $group->items as $item ) {
26 | $formatter = $item->get_formatter();
27 | $output .= $formatter->to_html();
28 | }
29 |
30 | return $output;
31 | }
32 |
33 | /**
34 | * @inheritdoc
35 | *
36 | * @param Event_Details_Group $group Group to format.
37 | * @return array
38 | */
39 | public function to_json( $group ) {
40 | $output = [];
41 |
42 | // Use same formatter as inline items.
43 | foreach ( $group->items as $item ) {
44 | $formatter = $item->get_formatter();
45 | $output[] = $formatter->to_json();
46 | }
47 |
48 | return [
49 | 'title' => $group->get_title(),
50 | 'items' => $output,
51 | ];
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/EventsControlBarActionsDropdownMenu.jsx:
--------------------------------------------------------------------------------
1 | import { DropdownMenu, Slot } from '@wordpress/components';
2 | import { __ } from '@wordpress/i18n';
3 | import { moreHorizontalMobile } from '@wordpress/icons';
4 | import { PremiumAddonsPromoMenuGroup } from './PremiumAddonsPromoMenuGroup';
5 |
6 | /**
7 | * Dropdown menu with information about the actions you can do with the events.
8 | * By default is has "placeholder" items that are promoted to premium features.
9 | *
10 | * @param {Object} props
11 | */
12 | export function EventsControlBarActionsDropdownMenu( props ) {
13 | const { eventsQueryParams, eventsTotal } = props;
14 |
15 | return (
16 |
26 | { ( { onClose } ) => (
27 | <>
28 | { /*
29 |
30 | Copy link to search
31 |
32 | */ }
33 |
34 |
37 |
38 |
46 | >
47 | ) }
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/EventsListItemsList.jsx:
--------------------------------------------------------------------------------
1 | import { clsx } from 'clsx';
2 | import { Event } from './Event';
3 |
4 | export function EventsListItemsList( props ) {
5 | const {
6 | events,
7 | prevEventsMaxId,
8 | mapsApiKey,
9 | hasExtendedSettingsAddOn,
10 | hasPremiumAddOn,
11 | eventsIsLoading,
12 | eventsSettingsPageURL,
13 | eventsAdminPageURL,
14 | userCanManageOptions,
15 | surroundingEventId,
16 | } = props;
17 |
18 | // Bail if no events.
19 | if ( ! events || events.length === 0 ) {
20 | return null;
21 | }
22 |
23 | const isSurroundingEventsMode = Boolean( surroundingEventId );
24 |
25 | const ulClasses = clsx( {
26 | SimpleHistoryLogitems: true,
27 | 'is-loading': eventsIsLoading,
28 | 'is-loaded': ! eventsIsLoading,
29 | } );
30 |
31 | return (
32 |
33 | { events.map( ( event, index ) => (
34 | prevEventsMaxId }
47 | isCenterEvent={ event.id === surroundingEventId }
48 | isSurroundingEventsMode={ isSurroundingEventsMode }
49 | />
50 | ) ) }
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/EventListSkeletonEventsItem.jsx:
--------------------------------------------------------------------------------
1 | import { randomIntFromInterval } from '../functions';
2 |
3 | export function EventListSkeletonEventsItem( props ) {
4 | const { index } = props;
5 |
6 | const headerStyles = {
7 | backgroundColor: 'var(--sh-color-gray-4)',
8 | width: randomIntFromInterval( 40, 50 ) + '%',
9 | height: '1rem',
10 | };
11 |
12 | const textStyles = {
13 | backgroundColor: 'var(--sh-color-gray-4)',
14 | width: randomIntFromInterval( 55, 75 ) + '%',
15 | height: '1.25rem',
16 | };
17 |
18 | const detailsStyles = {
19 | backgroundColor: 'var(--sh-color-gray-4)',
20 | width: randomIntFromInterval( 50, 60 ) + '%',
21 | height: '3rem',
22 | };
23 |
24 | return (
25 |
29 |
38 |
39 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/EventOccasionsList.jsx:
--------------------------------------------------------------------------------
1 | import { __ } from '@wordpress/i18n';
2 | import { clsx } from 'clsx';
3 | import { Event } from './Event';
4 |
5 | export function EventOccasionsList( props ) {
6 | const {
7 | occasions,
8 | isLoadingOccasions,
9 | subsequent_occasions_count: subsequentOccasionsCount,
10 | occasionsCountMaxReturn,
11 | } = props;
12 |
13 | const ulClassNames = clsx( {
14 | SimpleHistoryLogitems: true,
15 | SimpleHistoryLogitem__occasionsItems: true,
16 | haveOccasionsAdded: isLoadingOccasions === false,
17 | } );
18 |
19 | return (
20 |
28 |
29 | { occasions.map( ( event ) => (
30 |
31 | ) ) }
32 |
33 | { /* // If occasionsCount is more than occasionsCountMaxReturn then show a message */ }
34 | { subsequentOccasionsCount > occasionsCountMaxReturn ? (
35 |
36 |
37 |
38 |
39 | { __(
40 | 'Sorry, but there are too many similar events to show.',
41 | 'simple-history'
42 | ) }
43 |
44 |
45 |
46 | ) : null }
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/DashboardFooter.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | __experimentalDivider as Divider,
3 | ExternalLink,
4 | __experimentalHStack as HStack,
5 | } from '@wordpress/components';
6 | import { __ } from '@wordpress/i18n';
7 | import { getTrackingUrl } from '../functions';
8 |
9 | function DashboardFooter() {
10 | return (
11 | <>
12 |
13 |
14 |
19 |
29 | { __( 'Blog', 'simple-history' ) }
30 |
31 |
32 |
42 | { __( 'Support', 'simple-history' ) }
43 |
44 |
45 |
52 | { __( 'Get Premium', 'simple-history' ) }
53 |
54 |
55 | >
56 | );
57 | }
58 |
59 | export { DashboardFooter };
60 |
--------------------------------------------------------------------------------
/tests/_data/mu-plugins/inc/class-example-logger.php:
--------------------------------------------------------------------------------
1 | __( '404 Logger', 'simple-history' ),
12 | 'description' => __( 'Logs access to pages that result in page not found errors (error code 404)', 'simple-history' ),
13 | 'capability' => 'edit_pages',
14 | 'messages' => array(
15 | 'page_not_found' => __( 'Got a 404-page when trying to visit "{request_uri}"', 'simple-history' ),
16 | ),
17 | 'labels' => array(
18 | 'search' => array(
19 | 'label' => _x( 'Pages not found (404 errors)', 'User logger: 404', 'simple-history' ),
20 | 'options' => array(
21 | _x( 'Pages not found', 'User logger: 404', 'simple-history' ) => array(
22 | 'page_not_found',
23 | ),
24 | ),
25 | ), // end search
26 | ), // end labels
27 | );
28 |
29 | return $arr_info;
30 | }
31 |
32 | public function loaded() {
33 | add_action( '404_template', array( $this, 'handle_404_template' ), 10, 1 );
34 | }
35 |
36 | public function handle_404_template( $template ) {
37 | $context = array(
38 | '_initiator' => SimpleLoggerLogInitiators::WEB_USER,
39 | 'request_uri' => isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '',
40 | 'http_referer' => isset( $_SERVER['HTTP_REFERER'] ) ? $_SERVER['HTTP_REFERER'] : '',
41 | );
42 |
43 | $this->warningMessage( 'page_not_found', $context );
44 |
45 | return $template;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/EventSurroundingEventsMenuItem.jsx:
--------------------------------------------------------------------------------
1 | import { MenuItem } from '@wordpress/components';
2 | import { __ } from '@wordpress/i18n';
3 | import { positionCenter } from '@wordpress/icons';
4 | import { addQueryArgs } from '@wordpress/url';
5 |
6 | /**
7 | * Menu item that opens surrounding events in a new tab.
8 | * Shows events chronologically before and after the selected event.
9 | * Only visible to administrators.
10 | *
11 | * The surrounding_count parameter defaults to 5 but can be manually
12 | * edited in the URL by experienced users.
13 | *
14 | * @param {Object} props
15 | * @param {Object} props.event The event object
16 | * @param {string} props.eventsAdminPageURL URL to the events admin page
17 | * @param {boolean} props.userCanManageOptions Whether the user can manage options (is admin)
18 | */
19 | export function EventSurroundingEventsMenuItem( {
20 | event,
21 | eventsAdminPageURL,
22 | userCanManageOptions,
23 | } ) {
24 | // Only show for administrators.
25 | if ( ! userCanManageOptions ) {
26 | return null;
27 | }
28 |
29 | // Bail if no event ID.
30 | if ( ! event?.id ) {
31 | return null;
32 | }
33 |
34 | const handleClick = () => {
35 | const surroundingEventsURL = addQueryArgs( eventsAdminPageURL, {
36 | surrounding_event_id: event.id,
37 | surrounding_count: 5,
38 | } );
39 | // Open in new tab to preserve current search/pagination.
40 | window.open( surroundingEventsURL, '_blank', 'noopener,noreferrer' );
41 | };
42 |
43 | return (
44 |
45 | { __( 'Show surrounding events', 'simple-history' ) }
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/EventStickMenuItem.jsx:
--------------------------------------------------------------------------------
1 | import { MenuItem } from '@wordpress/components';
2 | import { __ } from '@wordpress/i18n';
3 | import { pin } from '@wordpress/icons';
4 | import { usePremiumFeaturesModal } from './PremiumFeaturesModalContext';
5 | import stickEventsFeatureImage from '../images/premium-feature-stick-events.svg';
6 |
7 | /**
8 | * Menu item to promote Stick events.
9 | * When clicked the premium version of Simple History is promoted.
10 | *
11 | * @param {Object} props
12 | * @param {Function} props.onClose Callback to close the dropdown
13 | * @param {Object} props.event The event object
14 | * @param {boolean} props.hasPremiumAddOn Whether the premium add-on is installed
15 | * @return {Object|null} React element or null if variant is modal
16 | */
17 | export function EventStickMenuItem( { event, onClose, hasPremiumAddOn } ) {
18 | const { showModal } = usePremiumFeaturesModal();
19 | // Bail if premium add-on is installed.
20 | if ( hasPremiumAddOn ) {
21 | return null;
22 | }
23 |
24 | // Bail if event is sticky already.
25 | if ( event.sticky ) {
26 | return null;
27 | }
28 |
29 | const handleStickClick = () => {
30 | showModal(
31 | __( 'Unlock Sticky Events', 'simple-history' ),
32 | __(
33 | 'Pin important events to the top of your log. Great for keeping critical changes visible, like security incidents or major updates.',
34 | 'simple-history'
35 | ),
36 | pin,
37 | stickEventsFeatureImage
38 | );
39 |
40 | onClose();
41 | };
42 |
43 | return (
44 |
45 | { __( 'Stick event to top…', 'simple-history' ) }
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/inc/services/class-plugin-list-info.php:
--------------------------------------------------------------------------------
1 | simple_history->get_service( AddOns_Licences::class );
32 | $addon_plugins = $licences_service->get_addon_plugins();
33 |
34 | foreach ( $addon_plugins as $addon_plugin ) {
35 | if ( $file !== $addon_plugin->id ) {
36 | continue;
37 | }
38 |
39 | if ( empty( $addon_plugin->get_license_key() ) ) {
40 | $licences_page_url = Helpers::get_settings_page_sub_tab_url( 'general_settings_subtab_licenses' );
41 |
42 | $links[] = sprintf(
43 | '%1$s ',
44 | __( 'Add licence key to enable updates', 'simple-history' ),
45 | esc_url( $licences_page_url )
46 | );
47 | }
48 |
49 | break;
50 | }
51 |
52 | return $links;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/tests/wpunit/OccasionsTest.php:
--------------------------------------------------------------------------------
1 | factory->user->create(
10 | array(
11 | 'role' => 'administrator',
12 | )
13 | );
14 |
15 | wp_set_current_user( $admin_user_id );
16 |
17 | $context = array(
18 | '_occasionsID' => 'my_custom_occasions_id',
19 | );
20 |
21 | // First occasion.
22 | SimpleLogger()->notice(
23 | 'Custom message with custom occasions notice 1',
24 | $context
25 | );
26 |
27 | SimpleLogger()->notice(
28 | 'Custom message with custom occasions, notice 2',
29 | $context
30 | );
31 |
32 | $query_args = array(
33 | 'posts_per_page' => 2,
34 | );
35 |
36 | $log_query = new Log_Query();
37 | $query_results = $log_query->query( $query_args );
38 | $this->assertEquals(
39 | 2,
40 | $query_results['log_rows'][0]->subsequentOccasions, // subsequentOccasions
41 | 'One occasion'
42 | );
43 |
44 | // Second occasion.
45 | $context['_occasionsID'] = 'another_custom_occasions_id';
46 |
47 | $num_occasions_to_add = 10;
48 | for ($i = 0; $i < $num_occasions_to_add; $i++) {
49 | SimpleLogger()->notice(
50 | 'Another custom message with custom occasions id ' . $i,
51 | $context
52 | );
53 | }
54 |
55 | $log_query = new Log_Query();
56 | $query_results = $log_query->query( $query_args );
57 | $this->assertEquals(
58 | $num_occasions_to_add,
59 | $query_results['log_rows'][0]->subsequentOccasions, // subsequentOccasions
60 | $num_occasions_to_add . ' occasions'
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/inc/event-details/class-event-details-group-table-formatter.php:
--------------------------------------------------------------------------------
1 | items ) ) {
18 | return '';
19 | }
20 |
21 | $output = '';
22 |
23 | // Add group title if present (screen reader only for accessibility).
24 | if ( $group->get_title() ) {
25 | $output .= '' . esc_html( $group->get_title() ) . ' ';
26 | }
27 | $output .= '';
28 | $output .= '';
29 |
30 | foreach ( $group->items as $item ) {
31 | $item_formatter = $item->get_formatter( new Event_Details_Item_Table_Row_Formatter() );
32 | $output .= $item_formatter->to_html();
33 | }
34 |
35 | $output .= ' ';
36 | $output .= '
';
37 |
38 | return $output;
39 | }
40 |
41 | /**
42 | * @inheritdoc
43 | *
44 | * @param Event_Details_Group $group Group to format.
45 | * @return array
46 | */
47 | public function to_json( $group ) {
48 | $items_output = [];
49 |
50 | foreach ( $group->items as $item ) {
51 | $item_formatter = $item->get_formatter( new Event_Details_Item_Table_Row_Formatter() );
52 | $items_output[] = $item_formatter->to_json();
53 | }
54 |
55 | return [
56 | 'title' => $group->get_title(),
57 | 'items' => $items_output,
58 | ];
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/tests/wpunit/ServicesTest.php:
--------------------------------------------------------------------------------
1 | simple_history = Simple_History::get_instance();
17 | }
18 |
19 | function test_services_loaded() {
20 | $services = $this->simple_history->get_instantiated_services();
21 | $actual_slugs = array_map(fn (Service $service) => $service->get_slug(), $services);
22 |
23 | $expected_slugs = [
24 | 'Admin_Page_Premium_Promo',
25 | 'Review_Reminder_Service',
26 | 'AddOns_Licences',
27 | 'Setup_Database',
28 | 'Scripts_And_Templates',
29 | 'Admin_Pages',
30 | 'Setup_Settings_Page',
31 | 'Loggers_Loader',
32 | 'Dropins_Loader',
33 | 'Setup_Log_Filters',
34 | 'Setup_Pause_Resume_Actions',
35 | 'WP_CLI_Commands',
36 | 'Setup_Purge_DB_Cron',
37 | 'Dashboard_Widget',
38 | 'Network_Menu_Items',
39 | 'Plugin_List_Link',
40 | 'Licences_Settings_Page',
41 | 'Plugin_List_Info',
42 | 'REST_API',
43 | 'Stealth_Mode',
44 | 'Menu_Service',
45 | 'Stats_Service',
46 | 'Notification_Bar',
47 | 'Email_Report_Service',
48 | 'Simple_History_Updates',
49 | 'Experimental_Features_Page',
50 | 'Import_Handler',
51 | 'Auto_Backfill_Service',
52 | 'History_Insights_Sidebar_Service',
53 | 'Channels_Service',
54 | 'Channels_Settings_Page'
55 | ];
56 |
57 | $this->assertEqualsCanonicalizing($expected_slugs, $actual_slugs);
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/tests/wpunit/functions.php:
--------------------------------------------------------------------------------
1 | get_events_table_name();
16 | // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
17 | $latest_row = $wpdb->get_row( "SELECT * FROM {$db_table} ORDER BY id DESC", ARRAY_A );
18 |
19 | if ( $unset_fields ) {
20 | unset( $latest_row['id'], $latest_row['date'], $latest_row['occasionsID'] );
21 | }
22 |
23 | return $latest_row;
24 | }
25 |
26 | /**
27 | * Get the latest context from the contexts table.
28 | *
29 | * @param bool $unset_fields Unset some fields that are different on each run, making it difficult to compare.
30 | * @return array
31 | */
32 | function get_latest_context( $unset_fields = true ) {
33 | $latest_row = get_latest_row( false );
34 |
35 | global $wpdb;
36 | $db_table_contexts = Simple_History::get_instance()->get_contexts_table_name();
37 | $latest_context = $wpdb->get_results(
38 | $wpdb->prepare(
39 | "SELECT * FROM {$db_table_contexts} WHERE history_id = %d ORDER BY `key` ASC",
40 | $latest_row['id']
41 | ),
42 | ARRAY_A
43 | );
44 |
45 | if ( $unset_fields ) {
46 | $latest_context = array_map(
47 | function( $value ) {
48 | unset( $value['context_id'], $value['history_id'] );
49 | return $value;
50 | },
51 | $latest_context
52 | );
53 | }
54 |
55 | return $latest_context;
56 | }
57 |
--------------------------------------------------------------------------------
/inc/event-details/class-event-details-group-diff-table-formatter.php:
--------------------------------------------------------------------------------
1 | items ) ) {
18 | return '';
19 | }
20 |
21 | $output = '';
22 |
23 | // Add group title if present (screen reader only for accessibility).
24 | if ( $group->get_title() ) {
25 | $output .= '' . esc_html( $group->get_title() ) . ' ';
26 | }
27 | $output .= '';
28 | $output .= '';
29 |
30 | foreach ( $group->items as $item ) {
31 | $item_formatter = $item->get_formatter( new Event_Details_Item_Diff_Table_Row_Formatter() );
32 | $output .= $item_formatter->to_html();
33 | }
34 |
35 | $output .= ' ';
36 | $output .= '
';
37 |
38 | return $output;
39 | }
40 |
41 | /**
42 | * @inheritdoc
43 | *
44 | * @param Event_Details_Group $group Group to output JSON for.
45 | * @return array
46 | */
47 | public function to_json( $group ) {
48 | $output = [];
49 |
50 | foreach ( $group->items as $item ) {
51 | $item_formatter = $item->get_formatter( new Event_Details_Item_Diff_Table_Row_Formatter() );
52 | $output[] = $item_formatter->to_json();
53 | }
54 |
55 | return [
56 | 'title' => $group->get_title(),
57 | 'items' => $output,
58 | ];
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/loggers/class-export-logger.php:
--------------------------------------------------------------------------------
1 | __( 'Export Logger', 'simple-history' ),
22 | 'description' => __( 'Logs updates to WordPress export', 'simple-history' ),
23 | 'capability' => 'export',
24 | 'messages' => array(
25 | 'created_export' => __( 'Created XML export', 'simple-history' ),
26 | ),
27 | 'labels' => array(
28 | 'search' => array(
29 | 'label' => _x( 'Export', 'Export logger: search', 'simple-history' ),
30 | 'options' => array(
31 | _x( 'Created exports', 'Export logger: search', 'simple-history' ) => array(
32 | 'created_export',
33 | ),
34 | ),
35 | ),
36 | ),
37 | );
38 |
39 | return $arr_info;
40 | }
41 |
42 | /**
43 | * Called when logger is loaded
44 | */
45 | public function loaded() {
46 | add_action( 'export_wp', array( $this, 'on_export_wp' ), 10, 1 );
47 | }
48 |
49 | /**
50 | * Called when export is created.
51 | * Fired from filter "export_wp".
52 | *
53 | * @param array $args Arguments passed to export_wp().
54 | */
55 | public function on_export_wp( $args ) {
56 | $content = $args['content'] ?? '';
57 |
58 | $this->info_message(
59 | 'created_export',
60 | array(
61 | 'export_content' => $content,
62 | 'export_args' => Helpers::json_encode( $args ),
63 | )
64 | );
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/EventsControlBar.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Flex,
3 | FlexItem,
4 | __experimentalHStack as HStack,
5 | Spinner,
6 | __experimentalText as Text,
7 | } from '@wordpress/components';
8 | import { _n, _x, sprintf } from '@wordpress/i18n';
9 | import { EventsControlBarActionsDropdownMenu } from './EventsControlBarActionsDropdownMenu';
10 |
11 | /**
12 | * Control bar at the top of the events listing
13 | * with number of events, reload button, more actions like export,
14 | * and so on.
15 | *
16 | * @param {Object} props
17 | */
18 | export function EventsControlBar( props ) {
19 | const { eventsIsLoading, eventsTotal, eventsQueryParams } = props;
20 |
21 | const loadingIndicator = eventsIsLoading ? (
22 |
23 |
24 |
25 | { _x(
26 | 'Loading…',
27 | 'Message visible while waiting for log to load from server the first time',
28 | 'simple-history'
29 | ) }
30 |
31 | ) : null;
32 |
33 | const eventsCount = eventsTotal ? (
34 |
35 | { sprintf(
36 | /* translators: %s: number of events */
37 | _n( '%s event', '%s events', eventsTotal, 'simple-history' ),
38 | eventsTotal
39 | ) }
40 |
41 | ) : null;
42 |
43 | return (
44 | <>
45 |
51 |
52 |
53 |
54 | { eventsCount }
55 | { loadingIndicator }
56 |
57 |
58 |
59 |
60 |
64 |
65 |
66 |
67 | >
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/inc/event-details/class-event-details-group.php:
--------------------------------------------------------------------------------
1 | */
10 | public array $items = [];
11 |
12 | /** @var Event_Details_Group_Formatter */
13 | public Event_Details_Group_Formatter $formatter;
14 |
15 | /** @var string|null Group title. Used in for example JSON output. */
16 | public ?string $title = null;
17 |
18 | /**
19 | * Constructor.
20 | */
21 | public function __construct() {
22 | $this->formatter = new Event_Details_Group_Table_Formatter();
23 | }
24 |
25 | /**
26 | * @param array $items Items to add.
27 | * @return Event_Details_Group $this Fluent return.
28 | */
29 | public function add_items( $items ) {
30 | $this->items = array_merge( $this->items, $items );
31 |
32 | return $this;
33 | }
34 |
35 | /**
36 | * @param Event_Details_Item $item Item to add.
37 | * @return Event_Details_Group $this
38 | */
39 | public function add_item( $item ) {
40 | $this->items[] = $item;
41 |
42 | return $this;
43 | }
44 |
45 | /**
46 | * @param Event_Details_Group_Formatter $formatter Formatter to use.
47 | * @return Event_Details_Group $this
48 | */
49 | public function set_formatter( $formatter ) {
50 | $this->formatter = $formatter;
51 |
52 | return $this;
53 | }
54 |
55 | /**
56 | * @param string $title Title for group.
57 | * @return Event_Details_Group $this
58 | */
59 | public function set_title( $title = null ) {
60 | $this->title = $title;
61 |
62 | return $this;
63 | }
64 |
65 | /**
66 | * Get title for group.
67 | *
68 | * @return string
69 | */
70 | public function get_title() {
71 | return $this->title;
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/js/scripts.js:
--------------------------------------------------------------------------------
1 | /**
2 | * When the clear-log-button in admin is clicked then check that users really wants to clear the log
3 | */
4 | jQuery( '.js-SimpleHistory-Settings-ClearLog' ).on( 'click', function ( e ) {
5 | if ( ! confirm( simpleHistoryScriptVars.settingsConfirmClearLog ) ) {
6 | e.preventDefault();
7 | }
8 | } );
9 |
10 | /**
11 | * Handle premium plugin toggle button click (dev mode only)
12 | */
13 | jQuery( document ).ready( function( $ ) {
14 | $( '#sh-premium-toggle' ).on( 'click', function( e ) {
15 | e.preventDefault();
16 |
17 | const $button = $( this );
18 | const plugin = $button.data( 'plugin' );
19 | const nonce = $button.data( 'nonce' );
20 |
21 | // Disable button during request
22 | $button.prop( 'disabled', true );
23 |
24 | // Get REST API root URL - try wpApiSettings first, fallback to relative path
25 | let apiRoot = '/wp-json/';
26 | if ( typeof wpApiSettings !== 'undefined' && wpApiSettings.root ) {
27 | apiRoot = wpApiSettings.root;
28 | }
29 |
30 | // Make API request to toggle plugin
31 | $.ajax( {
32 | url: apiRoot + 'simple-history/v1/dev-tools/toggle-plugin',
33 | method: 'POST',
34 | beforeSend: function( xhr ) {
35 | xhr.setRequestHeader( 'X-WP-Nonce', nonce );
36 | },
37 | data: {
38 | plugin: plugin
39 | },
40 | success: function( response ) {
41 | // Reload the page to reflect the new plugin state
42 | window.location.reload();
43 | },
44 | error: function( xhr, status, error ) {
45 | let errorMessage = 'Failed to toggle plugin.';
46 |
47 | if ( xhr.responseJSON && xhr.responseJSON.message ) {
48 | errorMessage = xhr.responseJSON.message;
49 | }
50 |
51 | alert( errorMessage );
52 | $button.prop( 'disabled', false );
53 | }
54 | } );
55 | } );
56 | } );
57 |
--------------------------------------------------------------------------------
/tests/functional/Issue373Cest.php:
--------------------------------------------------------------------------------
1 | amOnPage('index.php?p=404');
17 | $I->seeResponseCodeIs(404);
18 |
19 | $I->loginAsAdmin();
20 | $I->amOnPluginsPage();
21 | $I->activatePlugin('issue-373-disable-core-loggers');
22 |
23 | $I->amGoingTo('See if any loggers are active on the debug tab');
24 |
25 | // Go to debug tab/Help & Support » Debug
26 | $I->amOnAdminPage('admin.php?page=simple_history_help_support&selected-tab=simple_history_help_support_general&selected-sub-tab=simple_history_help_support_debug');
27 | $I->dontSee('There has been a critical error on this website.');
28 | $I->see('Listing 2 loggers');
29 |
30 | // Check that main feed works.
31 | $I->amGoingTo('Check that the main history feed works');
32 | $I->amOnPage('/wp-admin/index.php?page=simple_history_page');
33 | $I->seeResponseCodeIsSuccessful('Response code is successful when visiting simple history page');
34 |
35 | $I->amGoingTo('Deactivate the test plugin');
36 | $I->amOnPluginsPage();
37 | $I->deactivatePlugin('issue-373-disable-core-loggers');
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/PremiumFeaturesModalContext.jsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useState } from '@wordpress/element';
2 | import { PremiumFeaturesUnlockModal } from './PremiumFeaturesUnlockModal';
3 |
4 | const PremiumFeaturesModalContext = createContext( null );
5 |
6 | export const PremiumFeaturesModalProvider = ( { children } ) => {
7 | const [ isOpen, setIsOpen ] = useState( false );
8 | const [ modalProps, setModalProps ] = useState( {
9 | premiumFeatureModalTitle: '',
10 | premiumFeatureDescription: '',
11 | icon: null,
12 | image: '',
13 | } );
14 |
15 | /**
16 | * Show the premium feature modal.
17 | *
18 | * @param {string} title - The feature name (e.g., "Export results")
19 | * @param {string} description - Description of the feature
20 | * @param {Object} icon - Feature-specific icon (JSX/SVG)
21 | * @param {string} image - Path to feature screenshot image
22 | */
23 | const showModal = ( title, description, icon, image ) => {
24 | setModalProps( {
25 | premiumFeatureModalTitle: title,
26 | premiumFeatureDescription: description,
27 | icon,
28 | image,
29 | } );
30 | setIsOpen( true );
31 | };
32 |
33 | const handleClose = () => {
34 | setIsOpen( false );
35 | };
36 |
37 | return (
38 |
39 | { children }
40 | { isOpen && (
41 |
45 | ) }
46 |
47 | );
48 | };
49 |
50 | export const usePremiumFeaturesModal = () => {
51 | const context = useContext( PremiumFeaturesModalContext );
52 | if ( ! context ) {
53 | throw new Error(
54 | 'usePremiumFeaturesModal must be used within a PremiumFeaturesModalProvider'
55 | );
56 | }
57 | return context;
58 | };
59 |
--------------------------------------------------------------------------------
/tests/acceptance/GUICest.php:
--------------------------------------------------------------------------------
1 | haveUserInDatabase('erik', 'editor', ['user_pass' => 'password']);
8 |
9 | $I->loginAsAdmin();
10 | $I->amOnAdminPage( 'admin.php?page=simple_history_admin_menu_page' );
11 |
12 | $I->see( 'Simple History' );
13 |
14 | // Wait for items to be loaded, or it will catch the skeleton loading items.
15 | $I->waitForElement( '.SimpleHistoryLogitems.is-loaded' );
16 |
17 | $I->waitForElement( '.SimpleHistoryLogitem__text' );
18 |
19 | $I->see('Logged in', '.SimpleHistoryLogitem__text' );
20 |
21 | // Search filters, unexpanded and expanded.
22 | $I->dontSee('Log levels');
23 | $I->dontSee('Message types');
24 | $I->dontSee('Enter 2 or more characters to search for users.');
25 |
26 | $I->click('Show search options');
27 | $I->see('Log levels');
28 | $I->see('Message types');
29 | $I->see('Users');
30 |
31 | // Sidebar boxes.
32 | $I->see('History Insights');
33 | $I->see('Most active users');
34 | $I->see('Unlock more features with Simple History Premium');
35 |
36 | $I->see('Review this plugin if you like it');
37 | $I->see('Visit the support forum');
38 |
39 | // Erik editor
40 | $I->loginAs('erik', 'password');
41 | $I->amOnAdminPage( 'index.php?page=simple_history_page' );
42 | $I->see( 'Simple History' );
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/inc/services/class-menu-service.php:
--------------------------------------------------------------------------------
1 | simple_history = $simple_history;
22 | $this->menu_manager = new Menu_Manager();
23 | }
24 |
25 | /**
26 | * Called when service is loaded.
27 | * Adds required filters and actions.
28 | */
29 | public function loaded() {
30 | // Register the pages early.
31 | // add_action( 'init', [ $this, 'register_pages' ], 10 );
32 | // Services handle their page registration where appropriate.
33 |
34 | // Register menus late in admin_menu so other plugins can modify their menus first.
35 | add_action( 'admin_menu', [ $this, 'register_admin_menus' ], 100 );
36 | add_action( 'current_screen', [ $this, 'redirect_menu_pages' ] );
37 | }
38 |
39 | /**
40 | * Register WordPress admin menus.
41 | */
42 | public function register_admin_menus() {
43 | $this->menu_manager->register_pages();
44 | }
45 |
46 | /**
47 | * Get the menu manager instance.
48 | *
49 | * @return Menu_Manager
50 | */
51 | public function get_menu_manager() {
52 | return $this->menu_manager;
53 | }
54 |
55 | /**
56 | * Checks if current request is for a menu page
57 | * that should be redirected to it's first child.
58 | * This is usually used to select the first sub-tab.
59 | */
60 | public function redirect_menu_pages() {
61 | $this->menu_manager->redirect_menu_pages();
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/docker/claude-code/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-bookworm
2 |
3 | # Install minimal system dependencies
4 | RUN apt-get update && apt-get install -y \
5 | git \
6 | curl \
7 | php-cli \
8 | php-xml \
9 | sudo \
10 | && rm -rf /var/lib/apt/lists/*
11 |
12 | # Install GitHub CLI
13 | RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
14 | && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
15 | && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
16 | && apt-get update \
17 | && apt-get install -y gh \
18 | && rm -rf /var/lib/apt/lists/*
19 |
20 | # Configure node user (already exists in node:20 image with UID 1000)
21 | # Give node user sudo access and create required directories
22 | RUN echo "node ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers && \
23 | mkdir -p /workspace /home/node/.claude /home/node/.local/bin && \
24 | chown -R node:node /workspace /home/node/.claude /home/node/.local
25 |
26 | # Switch to node user to install Claude Code natively (installs to ~/.local/bin)
27 | USER node
28 | RUN curl -fsSL https://claude.ai/install.sh | bash
29 | USER root
30 |
31 | # Create entrypoint script inline to avoid permission issues
32 | RUN printf '#!/bin/bash\nsudo chown -R $(id -u):$(id -g) /home/node/.claude 2>/dev/null || true\nexec "$@"\n' > /entrypoint.sh && \
33 | chmod +x /entrypoint.sh
34 |
35 | # Set environment variables
36 | ENV DEVCONTAINER=true
37 | ENV TERM=xterm-256color
38 | ENV COLORTERM=truecolor
39 | ENV PATH="/home/node/.local/bin:${PATH}"
40 |
41 | # Switch to non-root user
42 | USER node
43 |
44 | WORKDIR /workspace
45 |
46 | ENTRYPOINT ["/entrypoint.sh"]
47 | CMD ["bash"]
48 |
--------------------------------------------------------------------------------
/.claude/skills/git-commits/SKILL.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: git-commits
3 | description: Create well-structured git commits. ALWAYS use this skill when committing - even for simple single-file commits. Triggers: "commit", "stage", "add and commit", or after completing any code changes.
4 | ---
5 |
6 | # Git Commits
7 |
8 | **Always invoke this skill for every commit.** This ensures consistent, well-structured commits.
9 |
10 | ## Workflow
11 |
12 | 1. Run `git status` and `git diff` to see changes
13 | 2. Run `git log --oneline -5` to see recent commit style
14 | 3. Determine if changes should be one or multiple commits
15 | 4. Stage and commit with clear message
16 |
17 | ## When to Split vs Combine
18 |
19 | **Separate commits:**
20 | - CSS vs PHP logic
21 | - Different features (even in same file)
22 | - Refactoring vs new functionality
23 | - Multiple bug fixes (one per fix)
24 |
25 | **Single commit:**
26 | - Related changes for one feature
27 | - A handler + its CSS
28 | - Tests for the feature being added
29 |
30 | ## Commit Message Format
31 |
32 | ```
33 |
34 |
35 |
36 | ```
37 |
38 | ## Examples
39 |
40 | **Input:** Single file change to update details
41 | ```diff
42 | - $title = __( 'Old title', 'simple-history' );
43 | + $title = __( 'New title', 'simple-history' );
44 | ```
45 | **Output:**
46 | ```
47 | Update 5.22.0 update details title
48 | ```
49 |
50 | **Input:** Multiple related changes across files
51 | ```diff
52 | # file1.php
53 | + public function new_feature() { ... }
54 |
55 | # file2.php
56 | + add_filter( 'hook', [ $this, 'new_feature' ] );
57 | ```
58 | **Output:**
59 | ```
60 | Add new feature for X
61 |
62 | Register hook and implement handler.
63 | ```
64 |
65 | ## Multiple Repositories
66 |
67 | When changes span core + premium:
68 | 1. Commit core first, then premium
69 | 2. Use related commit messages
70 |
--------------------------------------------------------------------------------
/js/email-promo.js:
--------------------------------------------------------------------------------
1 | /**
2 | * JavaScript for the email promo card dismissal functionality.
3 | *
4 | * @param {Object} $ jQuery object
5 | */
6 | ( function ( $ ) {
7 | 'use strict';
8 |
9 | $( document ).ready( function () {
10 | const $card = $( '#simple-history-email-promo-card' );
11 |
12 | if ( ! $card.length ) {
13 | return;
14 | }
15 |
16 | // Handle "Subscribe now" button click
17 | $card.on( 'click', '.sh-EmailPromoCard-cta', function () {
18 | // Don't prevent default - let the link navigate
19 | // But dismiss the card in the background via AJAX
20 | dismissPromo();
21 | } );
22 |
23 | // Handle "No thanks, not interested" button click
24 | $card.on( 'click', '.sh-EmailPromoCard-dismiss', function ( e ) {
25 | e.preventDefault();
26 |
27 | // Fade out the card first for better UX
28 | $card.fadeOut( 300, function () {
29 | dismissPromo();
30 | } );
31 | } );
32 |
33 | /**
34 | * Send AJAX request to dismiss the promo card.
35 | */
36 | function dismissPromo() {
37 | $.ajax( {
38 | url: window.simpleHistoryEmailPromo.ajaxUrl,
39 | type: 'POST',
40 | data: {
41 | action: window.simpleHistoryEmailPromo.action,
42 | nonce: window.simpleHistoryEmailPromo.nonce,
43 | },
44 | success( response ) {
45 | if ( response.success ) {
46 | // Card successfully dismissed
47 | // eslint-disable-next-line no-console
48 | // console.log( 'Email promo card dismissed' );
49 | } else {
50 | // eslint-disable-next-line no-console
51 | console.error(
52 | 'Failed to dismiss email promo card:',
53 | response.data
54 | );
55 | }
56 | },
57 | error( xhr, status, error ) {
58 | // eslint-disable-next-line no-console
59 | console.error(
60 | 'AJAX error dismissing email promo card:',
61 | error
62 | );
63 | },
64 | } );
65 | }
66 | } );
67 | } )( jQuery );
68 |
--------------------------------------------------------------------------------
/dropins/class-ip-info-dropin.php:
--------------------------------------------------------------------------------
1 | logger ) {
50 | return $bool_value;
51 | }
52 |
53 | // Bail if no message key.
54 | if ( empty( $row->context_message_key ) ) {
55 | return $bool_value;
56 | }
57 |
58 | // Message keys to show IP Addresses for.
59 | $arr_keys_to_log = array(
60 | 'user_logged_in',
61 | 'user_login_failed',
62 | 'user_unknown_login_failed',
63 | 'user_unknown_logged_in',
64 | );
65 |
66 | // Bail if not correct message key.
67 | if ( ! in_array( $row->context_message_key, $arr_keys_to_log, true ) ) {
68 | return $bool_value;
69 | }
70 |
71 | return true;
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/.claude/commands/issue/status.md:
--------------------------------------------------------------------------------
1 | ---
2 | allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
3 | description: Find the status of the issue.
4 | ---
5 |
6 | ## Context
7 |
8 | - Current git status: !`git status`
9 | - Current git diff (staged and unstaged changes): !`git diff HEAD`
10 | - Current branch: !`git branch --show-current`
11 | - Issue readme files: !`ls readme.issue-*.md 2>/dev/null || echo "No issue readme files found"`
12 | - Issue number: $ARGUMENTS
13 |
14 | ## Your task
15 |
16 | Tell the user the status of the issue by following these steps in order:
17 |
18 | 1. **Determine the issue number**:
19 | - If an issue number is passed as `$ARGUMENTS`, use that
20 | - Otherwise, extract the issue number from the current branch name (e.g., `issue-584-...` → issue #584)
21 |
22 | 2. **Read the issue readme file**:
23 | - Look for `readme.issue--*.md` file in the project root
24 | - If found, read and display the key sections:
25 | * Status line (look for "Status:" or "**Status**:")
26 | * Summary or overview
27 | * Implementation status/progress
28 | * Any pending tasks or follow-up issues
29 | - If multiple readme files exist for the issue, read the main one (without "subissue" in the name)
30 | - If no readme file exists, note that and continue
31 |
32 | 3. **Check GitHub status**:
33 | - Use `gh issue view ` to get the GitHub issue status
34 | - Show: title, state (open/closed), labels, assignees
35 |
36 | 4. **Check project board status** (if applicable):
37 | - Use GitHub CLI to check which column the issue is in on the project board
38 | - Project: "Simple History kanban" (Project #4)
39 |
40 | 5. **Summarize git status**:
41 | - Show current branch
42 | - Show if there are uncommitted changes
43 | - Show if branch is ahead/behind remote
44 |
45 | Present the information in a clear, organized format highlighting the most important status information at the top.
46 |
--------------------------------------------------------------------------------
/inc/event-details/class-event-details-item-raw-formatter.php:
--------------------------------------------------------------------------------
1 | */
15 | protected $json_output = [];
16 |
17 | /**
18 | * @inheritdoc
19 | *
20 | * @return string
21 | */
22 | public function to_html() {
23 | return $this->html_output;
24 | }
25 |
26 | /**
27 | * @inheritdoc
28 | *
29 | * @return array
30 | */
31 | public function to_json() {
32 | $output = $this->json_output;
33 |
34 | // Include item name if it exists and isn't already in custom output,
35 | // but only if the custom output seems to be structured data that could benefit from a name field.
36 | // If it's a simple array with basic keys, don't add the name to avoid breaking existing usage.
37 | if ( $this->item && $this->item->name && ! isset( $output['name'] ) ) {
38 | // Only add name if the output has some structured content (contains 'type' or 'content' keys)
39 | // This ensures we don't interfere with purely custom JSON outputs.
40 | if ( isset( $output['type'] ) || isset( $output['content'] ) ) {
41 | $output['name'] = $this->item->name;
42 | }
43 | }
44 |
45 | return $output;
46 | }
47 |
48 | /**
49 | * @param string $html HTML output.
50 | * @return Event_Details_Item_RAW_Formatter $this
51 | */
52 | public function set_html_output( $html ) {
53 | $this->html_output = $html;
54 |
55 | return $this;
56 | }
57 |
58 | /**
59 | * @param array $json JSON output.
60 | * @return Event_Details_Item_RAW_Formatter $this
61 | */
62 | public function set_json_output( $json ) {
63 | $this->json_output = $json;
64 |
65 | return $this;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/tests/acceptance/SimpleCommentsLoggerCest.php:
--------------------------------------------------------------------------------
1 | loginAsAdmin();
9 | }
10 |
11 | public function editComment(Admin $I) {
12 | $post_id = $I->havePostInDatabase([
13 | 'post_title' => 'My test post'
14 | ]);
15 | $I->haveManyCommentsInDatabase(1, $post_id);
16 |
17 | // Unapprove.
18 | $I->amOnAdminPage('edit-comments.php');
19 | $I->moveMouseOver('.wp-list-table tbody tr:nth-child(1)');
20 | $I->click('Unapprove');
21 | $I->waitForJqueryAjax();
22 | $I->seeLogMessage('Unapproved a comment to "My test post" by Mr WordPress ()');
23 |
24 | // Approve.
25 | $I->click('Approve');
26 | $I->waitForJqueryAjax();
27 | $I->seeLogMessage('Approved a comment to "My test post" by Mr WordPress ()');
28 |
29 | // Trash.
30 | $I->click('Trash');
31 | $I->waitForJqueryAjax();
32 | $I->seeLogMessage('Trashed a comment to "My test post" by Mr WordPress ()');
33 |
34 | // Untrash.
35 | $I->amOnAdminPage('edit-comments.php?comment_status=trash');
36 | $I->moveMouseOver('.wp-list-table tbody tr:nth-child(1)');
37 | $I->click('Restore');
38 | $I->waitForJqueryAjax();
39 | $I->seeLogMessage('Restored a comment to "My test post" by Mr WordPress () from the trash');
40 |
41 | // Trash and Delete permanently.
42 | $I->amOnAdminPage('edit-comments.php');
43 | $I->moveMouseOver('.wp-list-table tbody tr:nth-child(1)');
44 | $I->click('Trash');
45 | $I->amOnAdminPage('edit-comments.php?comment_status=trash');
46 | $I->moveMouseOver('.wp-list-table tbody tr:nth-child(1)');
47 | $I->click('Delete Permanently');
48 | $I->waitForJqueryAjax();
49 | $I->seeLogMessage('Deleted a comment to "My test post" by Mr WordPress ()');
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/inc/event-details/class-event-details-item-formatter.php:
--------------------------------------------------------------------------------
1 | item = $item;
20 | }
21 |
22 | /**
23 | * @param Event_Details_Item $item Item to format.
24 | * @return Event_Details_Item_Formatter $this
25 | */
26 | public function set_item( $item ) {
27 | $this->item = $item;
28 |
29 | return $this;
30 | }
31 |
32 | /**
33 | * @return Event_Details_Item
34 | */
35 | public function get_item() {
36 | return $this->item;
37 | }
38 |
39 | /**
40 | * @return string
41 | */
42 | abstract public function to_html();
43 |
44 | /**
45 | * @return array
46 | */
47 | abstract public function to_json();
48 |
49 | /**
50 | * @return string
51 | */
52 | protected function get_value_diff_output() {
53 | $value_output = '';
54 |
55 | if ( $this->item->is_changed ) {
56 | $value_output .= sprintf(
57 | '
58 | %1$s
59 | %2$s
60 | ',
61 | esc_html( $this->item->new_value ),
62 | esc_html( $this->item->prev_value )
63 | );
64 | } elseif ( $this->item->is_removed ) {
65 | $value_output .= sprintf(
66 | '%1$s ',
67 | esc_html( $this->item->prev_value )
68 | );
69 | } else {
70 | $value_output .= sprintf(
71 | '%1$s ',
72 | esc_html( $this->item->new_value )
73 | );
74 | }
75 |
76 | return $value_output;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/inc/services/class-plugin-list-link.php:
--------------------------------------------------------------------------------
1 | Installed Plugins screen.
9 | */
10 | class Plugin_List_Link extends Service {
11 | /** @inheritdoc */
12 | public function loaded() {
13 | add_filter( 'plugin_action_links_simple-history/index.php', array( $this, 'on_plugin_action_links' ), 10, 4 );
14 | }
15 |
16 | /**
17 | * Add a link to the History Settings Page on the Plugins -> Installed Plugins screen.
18 | *
19 | * @param array $actions Array of plugin action links.
20 | * @param string $plugin_file Path to the plugin file relative to the plugins directory.
21 | * @param array $plugin_data An array of plugin data.
22 | * @param string $context The plugin context. By default this can be 'all', 'active', 'inactive',
23 | * 'recently_activated', 'upgrade', 'mustuse', 'dropins', 'search',
24 | * 'paused', 'auto-update', 'dropin'.
25 | * @return array
26 | */
27 | public function on_plugin_action_links( $actions, $plugin_file, $plugin_data, $context ) {
28 | // Only add link if user has the right to view the settings page.
29 | // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is filterable, defaults to 'manage_options'.
30 | if ( ! current_user_can( Helpers::get_view_settings_capability() ) ) {
31 | return $actions;
32 | }
33 |
34 | if ( empty( $actions ) ) {
35 | // Create array if actions is empty (and therefore is assumed to be a string by PHP & results in PHP 7.1+ fatal error due to trying to make array modifications on what's assumed to be a string).
36 | $actions = [];
37 | } elseif ( is_string( $actions ) ) {
38 | // Convert the string (which it might've been retrieved as) to an array for future use as an array.
39 | $actions = [ $actions ];
40 | }
41 |
42 | $actions[] = "" . __( 'Settings', 'simple-history' ) . ' ';
43 |
44 | return $actions;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/EventInitiatorName.jsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@wordpress/components';
2 | import { __ } from '@wordpress/i18n';
3 | import { EventHeaderItem } from './EventHeaderItem';
4 |
5 | /**
6 | * Outputs "WordPress" or "John Doe - erik@example.com".
7 | *
8 | * @param {Object} props
9 | */
10 | export function EventInitiatorName( props ) {
11 | const { event, eventVariant } = props;
12 | const { initiator_data: initiatorData } = event;
13 |
14 | switch ( event.initiator ) {
15 | case 'wp_user':
16 | const nameToDisplay =
17 | initiatorData.user_display_name || initiatorData.user_login;
18 |
19 | let userDisplay;
20 |
21 | if ( eventVariant === 'compact' ) {
22 | userDisplay = { nameToDisplay } ;
23 | } else if ( eventVariant === 'modal' ) {
24 | userDisplay = { nameToDisplay } ;
25 | } else {
26 | userDisplay = (
27 |
31 | <>
32 | { nameToDisplay }
33 | ({ initiatorData.user_email })
34 | >
35 |
36 | );
37 | }
38 |
39 | return { userDisplay } ;
40 |
41 | case 'web_user':
42 | return (
43 |
44 |
45 | { __( 'Anonymous web user', 'simple-history' ) }
46 |
47 |
48 | );
49 | case 'wp_cli':
50 | return (
51 |
52 | { __( 'WP-CLI', 'simple-history' ) }
53 |
54 | );
55 | case 'wp':
56 | return (
57 |
58 | { __( 'WordPress', 'simple-history' ) }
59 |
60 | );
61 | case 'other':
62 | return (
63 |
64 | { __( 'Other', 'simple-history' ) }
65 |
66 | );
67 | default:
68 | return (
69 |
70 | Unknown initiator: { event.initiator }
71 |
72 | );
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/tests/wpunit/MenuManagerTest.php:
--------------------------------------------------------------------------------
1 | menu_manager = new Menu_Manager();
12 | }
13 |
14 | public function test_add_page() {
15 | $page = new Menu_Page();
16 | $page->set_menu_slug('test-slug');
17 |
18 | $this->menu_manager->add_page($page);
19 |
20 | $this->assertEquals(
21 | $page,
22 | $this->menu_manager->get_page_by_slug('test-slug')
23 | );
24 | }
25 |
26 | public function test_get_page_by_slug() {
27 | $page1 = new Menu_Page();
28 | $page1->set_menu_slug('test-slug-1');
29 |
30 | $page2 = new Menu_Page();
31 | $page2->set_menu_slug('test-slug-2');
32 |
33 | $this->menu_manager->add_page($page1);
34 | $this->menu_manager->add_page($page2);
35 |
36 | $this->assertEquals($page1, $this->menu_manager->get_page_by_slug('test-slug-1'));
37 | $this->assertEquals($page2, $this->menu_manager->get_page_by_slug('test-slug-2'));
38 | $this->assertNull($this->menu_manager->get_page_by_slug('non-existent-slug'));
39 | }
40 |
41 | public function test_get_pages() {
42 | $page1 = new Menu_Page();
43 | $page2 = new Menu_Page();
44 |
45 | $this->menu_manager->add_page($page1);
46 | $this->menu_manager->add_page($page2);
47 |
48 | $pages = $this->menu_manager->get_pages();
49 |
50 | $this->assertCount(2, $pages);
51 | $this->assertContains($page1, $pages);
52 | $this->assertContains($page2, $pages);
53 | }
54 |
55 | public function test_get_all_slugs() {
56 | $page1 = new Menu_Page();
57 | $page1->set_menu_slug('test-slug-1');
58 |
59 | $page2 = new Menu_Page();
60 | $page2->set_menu_slug('test-slug-2');
61 |
62 | $this->menu_manager->add_page($page1);
63 | $this->menu_manager->add_page($page2);
64 |
65 | $slugs = $this->menu_manager->get_all_slugs();
66 |
67 | $this->assertCount(2, $slugs);
68 | $this->assertContains('test-slug-1', $slugs);
69 | $this->assertContains('test-slug-2', $slugs);
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/.github/workflows/claude-code-review.yml:
--------------------------------------------------------------------------------
1 | name: Claude Code Review
2 |
3 | on:
4 | pull_request:
5 | types: [opened, synchronize]
6 | # Optional: Only run on specific file changes
7 | # paths:
8 | # - "src/**/*.ts"
9 | # - "src/**/*.tsx"
10 | # - "src/**/*.js"
11 | # - "src/**/*.jsx"
12 |
13 | jobs:
14 | claude-review:
15 | # Optional: Filter by PR author
16 | # if: |
17 | # github.event.pull_request.user.login == 'external-contributor' ||
18 | # github.event.pull_request.user.login == 'new-developer' ||
19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
20 |
21 | runs-on: ubuntu-latest
22 | permissions:
23 | contents: read
24 | pull-requests: read
25 | issues: read
26 | id-token: write
27 |
28 | steps:
29 | - name: Checkout repository
30 | uses: actions/checkout@v4
31 | with:
32 | fetch-depth: 1
33 |
34 | - name: Run Claude Code Review
35 | id: claude-review
36 | uses: anthropics/claude-code-action@v1
37 | with:
38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39 | prompt: |
40 | REPO: ${{ github.repository }}
41 | PR NUMBER: ${{ github.event.pull_request.number }}
42 |
43 | Please review this pull request and provide feedback on:
44 | - Code quality and best practices
45 | - Potential bugs or issues
46 | - Performance considerations
47 | - Security concerns
48 | - Test coverage
49 |
50 | Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
51 |
52 | Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
53 |
54 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
55 | # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
56 | claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
57 |
58 |
--------------------------------------------------------------------------------
/src/components/EventUnstickMenuItem.jsx:
--------------------------------------------------------------------------------
1 | import apiFetch from '@wordpress/api-fetch';
2 | import {
3 | __experimentalConfirmDialog as ConfirmDialog,
4 | MenuItem,
5 | } from '@wordpress/components';
6 | import { useState } from '@wordpress/element';
7 | import { __, sprintf } from '@wordpress/i18n';
8 | import { pin } from '@wordpress/icons';
9 |
10 | /**
11 | * Menu item to unstick a sticky event.
12 | *
13 | * @param {Object} props
14 | * @param {Object} props.event The event object
15 | * @param {Function} props.onClose Callback to close the dropdown
16 | * @param {boolean} props.userCanManageOptions Whether the user can manage options (is admin)
17 | */
18 | export function EventUnstickMenuItem( {
19 | event,
20 | onClose,
21 | userCanManageOptions,
22 | } ) {
23 | const [ isConfirmDialogOpen, setIsConfirmDialogOpen ] = useState( false );
24 |
25 | // Bail if event is not sticky or user is not an admin.
26 | if ( ! event.sticky || ! userCanManageOptions ) {
27 | return null;
28 | }
29 |
30 | const handleUnstickClick = () => {
31 | setIsConfirmDialogOpen( true );
32 | };
33 |
34 | const handleUnstickClickConfirm = async () => {
35 | try {
36 | await apiFetch( {
37 | path: `/simple-history/v1/events/${ event.id }/unstick`,
38 | method: 'POST',
39 | } );
40 | } catch ( error ) {
41 | // Silently fail - the user will see the event is still sticky.
42 | } finally {
43 | onClose();
44 | }
45 | };
46 |
47 | return (
48 | <>
49 |
50 | { __( 'Unstick event…', 'simple-history' ) }
51 |
52 |
53 | { isConfirmDialogOpen ? (
54 | setIsConfirmDialogOpen( false ) }
62 | >
63 | { sprintf(
64 | /* translators: %s: The message of the event. */
65 | __( 'Unstick event "%s"?', 'simple-history' ),
66 | event.message
67 | ) }
68 |
69 | ) : null }
70 | >
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/FetchEventsNoResultsMessage.jsx:
--------------------------------------------------------------------------------
1 | import { Icon, __experimentalText as Text } from '@wordpress/components';
2 | import { __ } from '@wordpress/i18n';
3 |
4 | export function FetchEventsNoResultsMessage( props ) {
5 | const { eventsIsLoading, events } = props;
6 |
7 | // Bail if loading.
8 | if ( eventsIsLoading ) {
9 | return null;
10 | }
11 |
12 | // Bail if there are events.
13 | if ( events.length && events.length > 0 ) {
14 | return null;
15 | }
16 |
17 | // Icon = "Search Off".
18 | // https://fonts.google.com/icons?selected=Material+Symbols+Outlined:search_off:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=search&icon.size=24&icon.color=%23000000&icon.platform=web
19 | const icon = (
20 |
27 |
28 |
29 | );
30 |
31 | const containerStyles = {
32 | marginBlock: '2rem',
33 | display: 'flex',
34 | flexDirection: 'column',
35 | alignItems: 'center',
36 | };
37 |
38 | const pStyles = {
39 | fontSize: '1rem',
40 | fontWeight: '500',
41 | marginBlock: '.25rem',
42 | color: 'var(--sh-color-gray-2)',
43 | };
44 |
45 | return (
46 |
47 |
55 |
56 |
57 | { __(
58 | 'Your search did not match any history events.',
59 | 'simple-history'
60 | ) }
61 |
62 |
63 |
64 | { __(
65 | 'Try different search options or clear the search.',
66 | 'simple-history'
67 | ) }
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bonny/wordpress-simple-history",
3 | "type": "wordpress-plugin",
4 | "description": "View recent changes made within WordPress, directly on your dashboard or on a separate page.",
5 | "license": "GPL-2.0-or-later",
6 | "keywords": [
7 | "WordPress",
8 | "log",
9 | "history"
10 | ],
11 | "homepage": "https://simple-history.com/",
12 | "minimum-stability": "stable",
13 | "require-dev": {
14 | "dealerdirect/phpcodesniffer-composer-installer": "*",
15 | "phpcompatibility/php-compatibility": "*",
16 | "wp-coding-standards/wpcs": "^3.0",
17 | "phpcompatibility/phpcompatibility-wp": "*",
18 | "lucatume/wp-browser": "^3.5",
19 | "codeception/module-asserts": "^1.0",
20 | "codeception/module-phpbrowser": "^1.0",
21 | "codeception/module-webdriver": "^1.0",
22 | "codeception/module-db": "^1.0",
23 | "codeception/module-filesystem": "^1.0",
24 | "codeception/module-cli": "^1.0",
25 | "codeception/util-universalframework": "^1.0",
26 | "rector/rector": "^2.0",
27 | "wp-cli/wp-cli-bundle": "^2.9 <2.12",
28 | "php-stubs/wp-cli-stubs": "dev-master",
29 | "phpstan/phpstan": "^2.0",
30 | "phpstan/phpstan-deprecation-rules": "^2.0",
31 | "phpstan/extension-installer": "^1.3.1",
32 | "szepeviktor/phpstan-wordpress": "^2.0",
33 | "johnbillion/wp-compat": "^1.1",
34 | "automattic/vipwpcs": "^3.0"
35 | },
36 | "require": {
37 | "php": "^7.4|^8.0"
38 | },
39 | "authors": [
40 | {
41 | "name": "Pär Thernström",
42 | "homepage": "https://eskapism.se/"
43 | }
44 | ],
45 | "support": {
46 | "issues": "https://github.com/bonny/WordPress-Simple-History/issues",
47 | "forum": "https://wordpress.org/support/plugin/simple-history/",
48 | "source": "https://github.com/bonny/WordPress-Simple-History",
49 | "docs": "https://simple-history.com/docs/"
50 | },
51 | "dist": {
52 | "url": "https://downloads.wordpress.org/plugin/simple-history.zip",
53 | "type": "zip"
54 | },
55 | "scripts": {
56 | "lint": "./vendor/bin/phpcs .",
57 | "lint-fix": "./vendor/bin/phpcbf .",
58 | "test": "./vendor/bin/phpunit"
59 | },
60 | "config": {
61 | "allow-plugins": {
62 | "dealerdirect/phpcodesniffer-composer-installer": true,
63 | "phpstan/extension-installer": true
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/tests/wpunit/DateOrderingTest.php:
--------------------------------------------------------------------------------
1 | factory->user->create(
12 | array(
13 | 'role' => 'administrator',
14 | )
15 | );
16 |
17 | wp_set_current_user( $admin_user_id );
18 |
19 | // Create test events with various dates
20 | $dates = array(
21 | '2024-12-01 10:00:00',
22 | '2024-01-15 14:30:00',
23 | '2024-06-20 08:15:00',
24 | '2024-03-10 12:00:00',
25 | '2024-09-05 16:45:00',
26 | );
27 |
28 | foreach ( $dates as $index => $date ) {
29 | SimpleLogger()->notice(
30 | "Test event $index",
31 | array(
32 | '_date' => $date,
33 | '_occasionsID' => "test_event_$index",
34 | )
35 | );
36 | }
37 |
38 | // Query all events
39 | $log_query = new Log_Query();
40 | $query_results = $log_query->query(
41 | array(
42 | 'posts_per_page' => 100, // Get many events
43 | )
44 | );
45 |
46 | $events = array_values( $query_results['log_rows'] );
47 |
48 | $this->assertGreaterThanOrEqual(
49 | 2,
50 | count( $events ),
51 | 'Should have at least 2 events to compare'
52 | );
53 |
54 | // Verify ALL events are in descending date order
55 | for ( $i = 0; $i < count( $events ) - 1; $i++ ) {
56 | $current_date = $events[ $i ]->date;
57 | $next_date = $events[ $i + 1 ]->date;
58 |
59 | $this->assertGreaterThanOrEqual(
60 | $next_date,
61 | $current_date,
62 | sprintf(
63 | 'Event %d (date: %s) should be >= Event %d (date: %s)',
64 | $i,
65 | $current_date,
66 | $i + 1,
67 | $next_date
68 | )
69 | );
70 |
71 | // If dates are equal, verify IDs are in descending order
72 | if ( $current_date === $next_date ) {
73 | $this->assertGreaterThan(
74 | $events[ $i + 1 ]->id,
75 | $events[ $i ]->id,
76 | sprintf(
77 | 'When dates are equal, ID %d should be > ID %d',
78 | $events[ $i ]->id,
79 | $events[ $i + 1 ]->id
80 | )
81 | );
82 | }
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/inc/services/wp-cli-commands/class-wp-cli-add-command.php:
--------------------------------------------------------------------------------
1 |
21 | * : The message to log.
22 | *
23 | * [--note=]
24 | * : Additional note or details about the event.
25 | *
26 | * [--level=]
27 | * : Log level. One of: emergency, alert, critical, error, warning, notice, info, debug.
28 | * ---
29 | * default: info
30 | * options:
31 | * - emergency
32 | * - alert
33 | * - critical
34 | * - error
35 | * - warning
36 | * - notice
37 | * - info
38 | * - debug
39 | * ---
40 | *
41 | * ## EXAMPLES
42 | *
43 | * # Add a simple info message
44 | * $ wp simple-history event add "Deployed a new version of the website"
45 | *
46 | * # Add a warning with a note
47 | * $ wp simple-history event add "Failed login attempt" --level=warning --note="IP: 192.168.1.1"
48 | *
49 | * @param array $args Command arguments.
50 | * @param array $assoc_args Command options.
51 | */
52 | public function add( $args, $assoc_args ) {
53 | /** @var Simple_History $simple_history */
54 | $simple_history = Simple_History::get_instance();
55 |
56 | $message = $args[0];
57 | $note = $assoc_args['note'] ?? '';
58 | $level = $assoc_args['level'] ?? 'info';
59 |
60 | // Get the instantiated logger.
61 | $custom_entry_logger = $simple_history->get_instantiated_logger_by_slug( 'CustomEntryLogger' );
62 |
63 | $context = [
64 | 'message' => $message,
65 | ];
66 |
67 | if ( ! empty( $note ) ) {
68 | $context['note'] = $note;
69 | }
70 |
71 | if ( ! Log_Levels::is_valid_level( $level ) ) {
72 | WP_CLI::error( 'Invalid log level specified.' );
73 | }
74 |
75 | $method = $level . '_message';
76 |
77 | $custom_entry_logger->$method( 'custom_entry_added', $context );
78 |
79 | WP_CLI::success( 'Event logged successfully.' );
80 | }
81 | }
82 |
--------------------------------------------------------------------------------