├── .nvmrc ├── tests ├── _data │ ├── .gitkeep │ ├── Image 1.jpg │ ├── Image 2.jpg │ └── mu-plugins │ │ ├── inc │ │ ├── test-logger.php │ │ ├── test-dropin.php │ │ ├── test-cron.php │ │ ├── class-tests-logger.php │ │ ├── class-tests-dropin.php │ │ ├── class-example-dropin.php │ │ ├── disable-updates.php │ │ └── class-example-logger.php │ │ └── mu-plugin.php ├── plugins │ └── .gitkeep ├── .valetphprc ├── _output │ └── .gitignore ├── _support │ ├── _generated │ │ └── .gitignore │ ├── Helper │ │ ├── Unit.php │ │ ├── Wpunit.php │ │ ├── Acceptance.php │ │ └── Functional.php │ ├── UnitTester.php │ ├── WpunitTester.php │ ├── AcceptanceTester.php │ └── FunctionalTester.php ├── unit.suite.yml ├── phpstan │ └── bootstrap.php ├── functional │ ├── OldDropinCest.php │ ├── LoggerCest.php │ ├── DropinCest.php │ ├── DeveloperLoggersCest.php │ └── Issue373Cest.php ├── vscode-class_alias-fixer.php ├── acceptance │ ├── SimpleExportsLoggerCest.php │ ├── PluginDuplicatePostLoggerCest.php │ ├── JetpackLoggerCest.php │ ├── PluginUserSwitchingLoggerCest.php │ ├── PluginEnableMediaReplaceLoggerCest.php │ ├── GUICest.php │ └── SimpleCommentsLoggerCest.php ├── wpunit │ ├── FunctionsTest.php │ ├── APHPVersionTest.php │ ├── LoggerTest.php │ ├── OccasionsTest.php │ ├── ServicesTest.php │ ├── functions.php │ ├── MenuManagerTest.php │ └── DateOrderingTest.php ├── wpunit.suite.yml └── functional.suite.yml ├── .valetrc ├── .eslintignore ├── CLAUDE.md ├── docker └── claude-code │ ├── .env.example │ ├── entrypoint.sh │ ├── run.sh │ ├── docker-compose.yml │ └── Dockerfile ├── .github ├── FUNDING.yml ├── workflows │ ├── spelling.yml │ ├── deploy.yml │ └── claude-code-review.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── css ├── simple-history-logo.png ├── icons │ ├── filter_list_FILL0_wght400_GRAD0_opsz48.svg │ ├── check_24dp_78A75A_FILL0_wght400_GRAD0_opsz24.svg │ ├── merge_type_FILL0_wght400_GRAD0_opsz48.svg │ ├── bar_chart_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg │ ├── download_FILL0_wght400_GRAD0_opsz48.svg │ ├── web_FILL0_wght400_GRAD0_opsz48.svg │ ├── edit_FILL0_wght400_GRAD0_opsz48.svg │ ├── keep_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg │ ├── tune_FILL0_wght400_GRAD0_opsz48.svg │ ├── add_box_FILL0_wght400_GRAD0_opsz48.svg │ ├── moving_24dp_FILL0_wght400_GRAD0_opsz48.svg │ ├── pages_FILL0_wght400_GRAD0_opsz48.svg │ ├── verified_user_FILL0_wght400_GRAD0_opsz48.svg │ ├── grid_view_FILL0_wght400_GRAD0_opsz48.svg │ ├── dashboard_FILL0_wght400_GRAD0_opsz48.svg │ ├── keep_off_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg │ ├── refresh_24dp_5F6368_FILL0_wght400_GRAD0_opsz48.svg │ ├── map_FILL0_wght400_GRAD0_opsz48.svg │ ├── thumb_up_FILL0_wght400_GRAD0_opsz48.svg │ ├── history_FILL0_wght400_GRAD0_opsz48.svg │ ├── rss_feed_FILL0_wght400_GRAD0_opsz48.svg │ ├── schedule_send_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg │ ├── autorenew_FILL0_wght400_GRAD0_opsz48.svg │ ├── sync_arrow_down_FILL0_wght400_GRAD0_opsz48.svg │ ├── key_FILL0_wght400_GRAD0_opsz48.svg │ ├── troubleshoot_FILL0_wght400_GRAD0_opsz48.svg │ ├── verified_FILL0_wght400_GRAD0_opsz48.svg │ ├── link_FILL0_wght400_GRAD0_opsz48.svg │ ├── archive_FILL0_wght400_GRAD0_opsz48.svg │ ├── toggle_on_FILL0_wght400_GRAD0_opsz48.svg │ ├── experiment_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg │ ├── check_circle_24dp_3F9349_FILL0_wght400_GRAD0_opsz24.svg │ ├── format_list_numbered_FILL0_wght400_GRAD0_opsz48.svg │ ├── verified_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg │ ├── workspace_premium_FILL0_wght400_GRAD0_opsz48.svg │ ├── mark_email_unread_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg │ ├── kid_star_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg │ ├── license_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg │ ├── workspace_premium_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg │ ├── lock_clock_FILL0_wght400_GRAD0_opsz48.svg │ ├── auto_delete_FILL0_wght400_GRAD0_opsz48.svg │ ├── build_FILL0_wght400_GRAD0_opsz48.svg │ ├── overview_FILL0_wght400_GRAD0_opsz48.svg │ ├── extension_FILL0_wght400_GRAD0_opsz48.svg │ ├── lock_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg │ ├── help_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg │ ├── no_accounts_FILL0_wght400_GRAD0_opsz48.svg │ ├── info_FILL0_wght400_GRAD0_opsz48.svg │ ├── mystery_FILL0_wght400_GRAD0_opsz48.svg │ ├── person_alert_FILL0_wght400_GRAD0_opsz48.svg │ ├── vpn_lock_FILL0_wght400_GRAD0_opsz48.svg │ ├── visibility_FILL0_wght400_GRAD0_opsz48.svg │ ├── group_add_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg │ ├── preview_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg │ ├── history_toggle_off_FILL0_wght400_GRAD0_opsz48.svg │ ├── settings_FILL0_wght400_GRAD0_opsz48.svg │ ├── account_circle_FILL0_wght400_GRAD0_opsz48.svg │ ├── volunteer_activism_FILL0_wght400_GRAD0_opsz48.svg │ └── visibility_lock_48dp__FILL0_wght400_GRAD0_opsz48.svg └── simple-history-logo-icon.svg ├── .wordpress-org ├── icon-128x128.png ├── icon-256x256.png ├── screenshot-1.png ├── screenshot-2.png ├── screenshot-3.png ├── screenshot-4.png ├── screenshot-5.png ├── screenshot-6.png ├── screenshot-7.png ├── screenshot-8.png ├── screenshot-9.png ├── banner-772x250.png ├── banner-1544x500.png ├── blueprints │ └── blueprint.json └── icon.svg ├── assets └── images │ ├── map-img-blur.jpg │ └── woocommerce-logger-product-edit.png ├── examples └── readme.md ├── SECURITY.md ├── src ├── components │ ├── EventVia.jsx │ ├── EventDetails.jsx │ ├── EventDetailsMenuItem.jsx │ ├── EventHeaderItem.jsx │ ├── EventBackfilledIndicator.jsx │ ├── EventsListSkeletonList.jsx │ ├── EventCopyLinkMenuItem.jsx │ ├── PremiumFeatureSuffix.jsx │ ├── EventHeader.jsx │ ├── EventText.jsx │ ├── EventInitiator.jsx │ ├── EventsModalIfFragment.jsx │ ├── EventsControlBarActionsDropdownMenu.jsx │ ├── EventsListItemsList.jsx │ ├── EventListSkeletonEventsItem.jsx │ ├── EventOccasionsList.jsx │ ├── DashboardFooter.jsx │ ├── EventSurroundingEventsMenuItem.jsx │ ├── EventStickMenuItem.jsx │ ├── EventsControlBar.jsx │ ├── PremiumFeaturesModalContext.jsx │ ├── EventInitiatorName.jsx │ ├── EventUnstickMenuItem.jsx │ └── FetchEventsNoResultsMessage.jsx ├── EmptyFilteredComponent.js ├── images │ ├── premium-feature-export.svg │ ├── premium-feature-stick-events.svg │ └── premium-feature-create-entry.svg ├── hooks │ └── useUserHasCapability.js ├── index-admin-bar.js └── index.js ├── .eslintrc ├── inc ├── event-details │ ├── interface-event-details-container-interface.php │ ├── class-event-details-group-formatter.php │ ├── class-event-details-item-table-row-raw-formatter.php │ ├── class-event-details-item-table-row-formatter.php │ ├── class-event-details-item-diff-table-row-formatter.php │ ├── class-event-details-simple-container.php │ ├── class-event-details-item-default-formatter.php │ ├── class-event-details-group-inline-formatter.php │ ├── class-event-details-group-single-item-formatter.php │ ├── class-event-details-group-table-formatter.php │ ├── class-event-details-group-diff-table-formatter.php │ ├── class-event-details-group.php │ ├── class-event-details-item-raw-formatter.php │ └── class-event-details-item-formatter.php ├── deprecated │ ├── class-simplehistory.php │ ├── class-simplehistorylogquery.php │ ├── class-simpleloggerloglevels.php │ └── class-simpleloggerloginitiators.php ├── services │ ├── class-setup-pause-resume-actions.php │ ├── class-rest-api.php │ ├── class-service.php │ ├── class-channels-service.php │ ├── wp-cli-commands │ │ ├── class-wp-cli-stealth-mode-command.php │ │ └── class-wp-cli-add-command.php │ ├── class-plugin-list-info.php │ ├── class-menu-service.php │ └── class-plugin-list-link.php ├── class-config.php ├── oldversions.php └── channels │ └── formatters │ └── interface-formatter-interface.php ├── .claude ├── skills │ ├── code-quality │ │ └── js-standards.md │ └── git-commits │ │ └── SKILL.md ├── settings.json └── commands │ └── issue │ └── status.md ├── phpunit.xml ├── .editorconfig ├── .typos.toml ├── phpstan.neon ├── .distignore ├── dropins ├── class-dropin.php └── class-ip-info-dropin.php ├── js ├── review-notice.js ├── scripts.js └── email-promo.js ├── .env.testing ├── docs ├── wordpress-org-guidelines.md └── wp-playground-testing.md ├── codeception.dist.yml ├── loggers ├── class-simple-logger.php └── class-export-logger.php ├── gruntfile.js ├── code.md ├── .gitignore ├── uninstall.php ├── templates └── settings-general.php └── composer.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /tests/_data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/plugins/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.valetrc: -------------------------------------------------------------------------------- 1 | php=php@7.4 2 | 3 | -------------------------------------------------------------------------------- /tests/.valetphprc: -------------------------------------------------------------------------------- 1 | php@7.4 2 | -------------------------------------------------------------------------------- /tests/_output/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | vendor -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | @AGENTS.md 4 | -------------------------------------------------------------------------------- /tests/_support/_generated/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /docker/claude-code/.env.example: -------------------------------------------------------------------------------- 1 | ANTHROPIC_API_KEY=your-api-key-here 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: bonny 2 | custom: https://simple-history.com/sponsor/ 3 | -------------------------------------------------------------------------------- /tests/_data/Image 1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/tests/_data/Image 1.jpg -------------------------------------------------------------------------------- /tests/_data/Image 2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/tests/_data/Image 2.jpg -------------------------------------------------------------------------------- /css/simple-history-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/css/simple-history-logo.png -------------------------------------------------------------------------------- /.wordpress-org/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/icon-128x128.png -------------------------------------------------------------------------------- /.wordpress-org/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/icon-256x256.png -------------------------------------------------------------------------------- /.wordpress-org/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/screenshot-1.png -------------------------------------------------------------------------------- /.wordpress-org/screenshot-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/screenshot-2.png -------------------------------------------------------------------------------- /.wordpress-org/screenshot-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/screenshot-3.png -------------------------------------------------------------------------------- /.wordpress-org/screenshot-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/screenshot-4.png -------------------------------------------------------------------------------- /.wordpress-org/screenshot-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/screenshot-5.png -------------------------------------------------------------------------------- /.wordpress-org/screenshot-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/screenshot-6.png -------------------------------------------------------------------------------- /.wordpress-org/screenshot-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/screenshot-7.png -------------------------------------------------------------------------------- /.wordpress-org/screenshot-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/screenshot-8.png -------------------------------------------------------------------------------- /.wordpress-org/screenshot-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/screenshot-9.png -------------------------------------------------------------------------------- /assets/images/map-img-blur.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/assets/images/map-img-blur.jpg -------------------------------------------------------------------------------- /.wordpress-org/banner-772x250.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/banner-772x250.png -------------------------------------------------------------------------------- /.wordpress-org/banner-1544x500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/.wordpress-org/banner-1544x500.png -------------------------------------------------------------------------------- /examples/readme.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Visit https://simple-history.com/docs/ for examples and other documentation for developers. 4 | -------------------------------------------------------------------------------- /assets/images/woocommerce-logger-product-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonny/WordPress-Simple-History/HEAD/assets/images/woocommerce-logger-product-edit.png -------------------------------------------------------------------------------- /css/icons/filter_list_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/check_24dp_78A75A_FILL0_wght400_GRAD0_opsz24.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker/claude-code/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Fix ownership of .claude directory (volume may be owned by root) 3 | sudo chown -R $(id -u):$(id -g) /home/node/.claude 2>/dev/null || true 4 | 5 | # Execute the passed command 6 | exec "$@" 7 | -------------------------------------------------------------------------------- /tests/unit.suite.yml: -------------------------------------------------------------------------------- 1 | # Codeception Test Suite Configuration 2 | # 3 | # Suite for unit tests not relying WordPress code. 4 | 5 | actor: UnitTester 6 | modules: 7 | enabled: 8 | - Asserts 9 | - \Helper\Unit 10 | step_decorators: ~ 11 | -------------------------------------------------------------------------------- /tests/_support/Helper/Unit.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/bar_chart_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | ## Reporting Security Issues 4 | 5 | **Please do not report security vulnerabilities through public GitHub issues.** 6 | 7 | Instead, please report them directly to the plugin author at [security@simple-history.com](mailto:security@simple-history.com]). 8 | 9 | -------------------------------------------------------------------------------- /css/icons/download_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/phpstan/bootstrap.php: -------------------------------------------------------------------------------- 1 | { via }; 12 | } 13 | -------------------------------------------------------------------------------- /css/icons/web_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/edit_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/keep_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /css/icons/tune_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/EmptyFilteredComponent.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This will not be rendered/called if there is no filter in use, 3 | * unless the filter calls the component. 4 | * 5 | * @param {Object} props 6 | */ 7 | // eslint-disable-next-line no-unused-vars 8 | export const EmptyFilteredComponent = ( props ) => { 9 | return <>; 10 | }; 11 | -------------------------------------------------------------------------------- /css/icons/add_box_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/moving_24dp_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /css/icons/pages_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/OldDropinCest.php: -------------------------------------------------------------------------------- 1 | loginAsAdmin(); 6 | $I->amOnAdminPage('admin.php?page=simple_history_settings_page'); 7 | $I->canSee('Dropin example tab'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/_data/mu-plugins/inc/test-logger.php: -------------------------------------------------------------------------------- 1 | register_logger( \Simple_History\Tests\Logger\Tests_Logger::class ); 10 | } 11 | ); 12 | -------------------------------------------------------------------------------- /tests/vscode-class_alias-fixer.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/_data/mu-plugins/inc/test-dropin.php: -------------------------------------------------------------------------------- 1 | register_dropin( \Simple_History\Tests\Dropin\Tests_Dropin::class ); 11 | } 12 | ); 13 | -------------------------------------------------------------------------------- /css/icons/grid_view_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/_support/Helper/Acceptance.php: -------------------------------------------------------------------------------- 1 | see("yolo"); 12 | echo "testar"; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /css/icons/dashboard_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/keep_off_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /css/icons/refresh_24dp_5F6368_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/map_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/thumb_up_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/EventDetails.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Outputs event details. 3 | * 4 | * @param {Object} props 5 | */ 6 | export function EventDetails( props ) { 7 | const { event } = props; 8 | const { details_html: detailsHtml } = event; 9 | 10 | return ( 11 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": [ 6 | "plugin:@wordpress/eslint-plugin/recommended" 7 | ], 8 | "plugins": ["validate-jsx-nesting"], 9 | "rules": { 10 | "@wordpress/no-unsafe-wp-apis": "off", 11 | "no-alert": "off", 12 | "validate-jsx-nesting/no-invalid-jsx-nesting": "error" 13 | }, 14 | "globals": { 15 | "jQuery": true, 16 | "simpleHistoryScriptVars": true 17 | } 18 | } -------------------------------------------------------------------------------- /docker/claude-code/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run Claude Code in Docker with --dangerously-skip-permissions 3 | # Uses host's ~/.claude config for authentication and settings 4 | 5 | set -e 6 | cd "$(dirname "$0")" 7 | 8 | # Build the image 9 | docker compose build 10 | 11 | # Run Claude Code interactively (using run instead of up + exec) 12 | docker compose run --rm claude-code claude --dangerously-skip-permissions "$@" 13 | -------------------------------------------------------------------------------- /inc/event-details/interface-event-details-container-interface.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function to_json(); 15 | 16 | /** 17 | * @return string 18 | */ 19 | public function __toString(); 20 | } 21 | -------------------------------------------------------------------------------- /tests/acceptance/SimpleExportsLoggerCest.php: -------------------------------------------------------------------------------- 1 | loginAsAdmin(); 9 | } 10 | 11 | public function exportXml(Admin $I) { 12 | $I->amOnAdminPage('export.php'); 13 | $I->click("Download Export File"); 14 | $I->seeLogMessage('Created XML export'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /css/icons/history_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/rss_feed_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/schedule_send_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /css/icons/autorenew_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/skills/code-quality/js-standards.md: -------------------------------------------------------------------------------- 1 | # JavaScript Code Standards 2 | 3 | ## Block Formatting 4 | 5 | Always use braces on separate lines for control structures, even for single statements: 6 | 7 | ```javascript 8 | // Correct 9 | if (element) { 10 | element.addEventListener('change', handler); 11 | } 12 | 13 | // Wrong 14 | if (element) element.addEventListener('change', handler); 15 | ``` 16 | 17 | This applies to `if`, `else`, `for`, `while`, and `switch`. 18 | -------------------------------------------------------------------------------- /css/icons/sync_arrow_down_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/key_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/LoggerCest.php: -------------------------------------------------------------------------------- 1 | loginAsAdmin(); 6 | $I->amOnAdminPage('admin.php?page=simple_history_help_support&selected-tab=simple_history_help_support_general&selected-sub-tab=simple_history_help_support_debug'); 7 | $I->canSee('Tests logger'); 8 | $I->canSee('Output in footer from the logger with slug tests'); 9 | } 10 | } 11 | 12 | -------------------------------------------------------------------------------- /css/icons/troubleshoot_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/verified_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/link_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/archive_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/toggle_on_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/experiment_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /inc/deprecated/class-simplehistory.php: -------------------------------------------------------------------------------- 1 | get_instance(). 9 | */ 10 | class SimpleHistory { 11 | /** 12 | * Only static function in old class is get_instance(). 13 | * 14 | * @since 4.0 15 | */ 16 | public static function get_instance() { 17 | return Simple_History::get_instance(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /css/icons/check_circle_24dp_3F9349_FILL0_wght400_GRAD0_opsz24.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | ./tests/ 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /css/icons/format_list_numbered_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/verified_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/workspace_premium_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/mark_email_unread_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/kid_star_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/license_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/workspace_premium_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /inc/event-details/class-event-details-group-formatter.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | abstract public function to_json( $group ); 20 | } 21 | -------------------------------------------------------------------------------- /css/icons/lock_clock_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/EventDetailsMenuItem.jsx: -------------------------------------------------------------------------------- 1 | import { MenuItem } from '@wordpress/components'; 2 | import { __ } from '@wordpress/i18n'; 3 | import { info } from '@wordpress/icons'; 4 | import { navigateToEventPermalink } from '../functions'; 5 | 6 | export function EventDetailsMenuItem( { event, onClose } ) { 7 | return ( 8 | { 11 | navigateToEventPermalink( { event } ); 12 | onClose(); 13 | } } 14 | > 15 | { __( 'View event details', 'simple-history' ) } 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.php] 12 | charset = utf-8 13 | end_of_line = lf 14 | insert_final_newline = true 15 | trim_trailing_whitespace = true 16 | indent_style = space 17 | indent_size = 4 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [*.js] 23 | indent_style = space 24 | indent_size = 2 25 | 26 | [{package.json,*.yml,*.cjson}] 27 | indent_style = space 28 | indent_size = 2 29 | -------------------------------------------------------------------------------- /css/icons/auto_delete_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | extend-exclude = [ 3 | ".git/", 4 | "build/", 5 | "js/*.min.js", 6 | "tests/wordpress-tests-stubs.php", 7 | ".claude/", 8 | "AGENTS.md" 9 | ] 10 | ignore-hidden = false 11 | 12 | [default] 13 | extend-ignore-re = [ 14 | '"23 juli"', 15 | 'shop_order_placehold', 16 | 'jetpack_migation' 17 | ] 18 | extend-ignore-identifiers-re = [ 19 | "^on_setted_update_", 20 | ] 21 | 22 | [default.extend-identifiers] 23 | Automattic = "Automattic" 24 | reseted_lockout_count = "reseted_lockout_count" 25 | user_password_reseted = "user_password_reseted" 26 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - index.php 4 | - uninstall.php 5 | - dropins/ 6 | - inc/ 7 | - loggers/ 8 | - templates/ 9 | scanFiles: 10 | - vendor/php-stubs/wp-cli-stubs/wp-cli-stubs.php 11 | scanDirectories: 12 | - tests/plugins/ 13 | bootstrapFiles: 14 | - tests/phpstan/bootstrap.php 15 | level: 2 16 | ignoreErrors: 17 | # - '#Access to an undefined property object::\$context_message_key.#' 18 | WPCompat: 19 | pluginFile: index.php 20 | requiresAtLeast: '6.3' 21 | includes: 22 | - phar://phpstan.phar/conf/bleedingEdge.neon 23 | -------------------------------------------------------------------------------- /inc/deprecated/class-simplehistorylogquery.php: -------------------------------------------------------------------------------- 1 | query( $args ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /inc/deprecated/class-simpleloggerloglevels.php: -------------------------------------------------------------------------------- 1 | 16 | { /* Leave space so items can wrap on smaller screens. */ }{ ' ' } 17 | { children } 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /tests/_data/mu-plugins/inc/test-cron.php: -------------------------------------------------------------------------------- 1 | info('This is a log from a cron job'); 15 | }); 16 | 17 | wp_schedule_single_event(time(), 'simple_history/tests/cron'); 18 | }); 19 | -------------------------------------------------------------------------------- /css/icons/build_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/overview_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/DropinCest.php: -------------------------------------------------------------------------------- 1 | loginAsAdmin(); 6 | $I->amOnAdminPage('admin.php?page=simple_history_settings_page'); 7 | $I->canSee('Namespaced dropin example tab'); 8 | } 9 | 10 | public function test_can_see_dropin_tab_output( FunctionalTester $I ) { 11 | $I->loginAsAdmin(); 12 | $I->amOnAdminPage('admin.php?page=simple_history_settings_page&selected-tab=tests_dropin_settings_tab_slug'); 13 | $I->canSee('Namespaced dropin example page output'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/wpunit/FunctionsTest.php: -------------------------------------------------------------------------------- 1 | simple_history = Simple_History::get_instance(); 15 | } 16 | 17 | function test_class_functions() { 18 | $this->assertEquals( 19 | 'http://localhost:8080/wp-admin/admin.php?page=simple_history_admin_menu_page', 20 | Helpers::get_history_admin_url() 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/_support/UnitTester.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/lock_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /tests/_support/AcceptanceTester.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Export 10 | -------------------------------------------------------------------------------- /css/icons/help_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/spelling.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow 2 | 3 | name: "Spelling" 4 | 5 | on: 6 | pull_request: null 7 | push: 8 | branches: 9 | - "main" 10 | # Add [skip ci] to commit message to skip CI. 11 | 12 | permissions: 13 | contents: "read" 14 | 15 | concurrency: 16 | group: "${{ github.workflow }}-${{ github.ref }}" 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | spell_check: 21 | name: "文A Spell check" 22 | runs-on: "ubuntu-22.04" 23 | timeout-minutes: 1 24 | steps: 25 | - name: "Checkout repository" 26 | uses: "actions/checkout@v3" 27 | 28 | - name: "Search for misspellings" 29 | uses: "crate-ci/typos@master" 30 | -------------------------------------------------------------------------------- /css/icons/no_accounts_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/info_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/mystery_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /inc/event-details/class-event-details-item-table-row-raw-formatter.php: -------------------------------------------------------------------------------- 1 | html_output ) ) { 18 | return ''; 19 | } 20 | 21 | return sprintf( 22 | ' 23 | 24 | %1$s 25 | %2$s 26 | 27 | ', 28 | esc_html( $this->item->name ), 29 | $this->html_output 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/acceptance/PluginDuplicatePostLoggerCest.php: -------------------------------------------------------------------------------- 1 | loginAsAdmin(); 9 | $I->amOnPluginsPage(); 10 | $I->activatePlugin('duplicate-post'); 11 | $I->canSeePluginActivated('duplicate-post'); 12 | } 13 | 14 | public function clonePage(Admin $I) { 15 | $I->loginAsAdmin(); 16 | $I->havePageInDatabase(['post_title' => 'Test page']); 17 | $I->amOnAdminPage('edit.php?post_type=page'); 18 | $I->moveMouseOver('.table-view-list tbody tr:nth-child(1)'); 19 | $I->click('Clone'); 20 | $I->seeLogMessage('Cloned "Test page" to a new post'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /css/icons/person_alert_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/vpn_lock_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/visibility_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/wpunit/APHPVersionTest.php: -------------------------------------------------------------------------------- 1 | use_mysqli ) ) { 14 | // $mysqlVersion = mysql_get_server_info(); 15 | } else { 16 | $mysqlVersion = mysqli_get_server_info( $wpdb->dbh ); 17 | } 18 | fwrite(STDERR, "\nLOG: MySQL/MariaDB version: " . $mysqlVersion . "\n"); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /css/icons/group_add_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **System information (please complete the following information):** 27 | - WordPress version: [e.g. 5.8] 28 | - PHP version: [e.g. 7.2] 29 | - Installed plugins: [e.g. Akismet, JetPack, ...] 30 | -------------------------------------------------------------------------------- /.distignore: -------------------------------------------------------------------------------- 1 | /.claude 2 | /.cursor 3 | /.git 4 | /.github 5 | /.idea 6 | /.playwright-mcp 7 | /.vscode 8 | /.wordpress-org 9 | /assets-wp-repo 10 | /cursor 11 | /data 12 | /docker 13 | /docs 14 | /examples 15 | /node_modules 16 | /src 17 | /tests 18 | /vendor 19 | 20 | .DS_Store 21 | .distignore 22 | .editorconfig 23 | .env.testing 24 | .eslintignore 25 | .eslintrc 26 | .gitattributes 27 | .gitignore 28 | .nvmrc 29 | .prettierrc 30 | .typos.toml 31 | .valetrc 32 | AGENTS.md 33 | CLAUDE.local.md 34 | CLAUDE.md 35 | code.md 36 | codeception.dist.yml 37 | composer.json 38 | composer.lock 39 | compose.yaml 40 | gruntfile.js 41 | package-lock.json 42 | package.json 43 | phpcs.xml.dist 44 | phpstan.neon 45 | phpunit.xml 46 | README.md 47 | readme.issue-*.md 48 | readme.slot-slotfills-filters.md 49 | rector.php 50 | -------------------------------------------------------------------------------- /src/images/premium-feature-stick-events.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Stick event to top 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/EventBackfilledIndicator.jsx: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { EventHeaderItem } from './EventHeaderItem'; 3 | 4 | /** 5 | * Displays an indicator for events that were backfilled from existing WordPress data. 6 | * Shows "Backfilled entry" as plain text. 7 | * 8 | * @param {Object} props 9 | * @param {Object} props.event - The event object 10 | */ 11 | export function EventBackfilledIndicator( props ) { 12 | const { event } = props; 13 | 14 | // Check if this is a backfilled event using the dedicated field from REST API. 15 | if ( ! event.backfilled ) { 16 | return null; 17 | } 18 | 19 | const text = __( 'Backfilled entry', 'simple-history' ); 20 | 21 | return ( 22 | 23 | { text } 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /tests/_data/mu-plugins/inc/class-tests-logger.php: -------------------------------------------------------------------------------- 1 | 'Tests logger', 18 | ]; 19 | } 20 | 21 | /** @inheritDoc */ 22 | function loaded() 23 | { 24 | add_action( 'admin_footer', [ $this, 'on_admin_footer' ] ); 25 | } 26 | 27 | public function on_admin_footer() { 28 | echo "

Output in footer from the logger with slug tests.

"; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/_data/mu-plugins/inc/class-tests-dropin.php: -------------------------------------------------------------------------------- 1 | simple_history->register_settings_tab([ 19 | 'slug' => 'tests_dropin_settings_tab_slug', 20 | 'name' => 'Namespaced dropin example tab', 21 | 'function' => [$this, 'settings_tab_output'], 22 | ]); 23 | } 24 | 25 | public function settings_tab_output() { 26 | echo 'Namespaced dropin example page output'; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /css/icons/preview_48dp_1F1F1F_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/images/premium-feature-create-entry.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Create log entry 5 | 6 | Enter message... 7 | 8 | 9 | 10 | Add 11 | -------------------------------------------------------------------------------- /tests/wpunit/LoggerTest.php: -------------------------------------------------------------------------------- 1 | testedLoggerClass = new class extends Logger { 14 | // protected $slug = 'Testlogger'; 15 | 16 | // public function get_info() { 17 | // return [ 18 | // 'name' => 'Testlogger' 19 | // ]; 20 | // } 21 | // }; 22 | } 23 | function test_append_remote_addr_to_context() { 24 | $context = []; 25 | // $context = $this->testedLoggerClass->append_remote_addr_to_context(); 26 | //exit; 27 | // $this->invokeMethod( $this->testedLoggerClass, 'append_remote_addr_to_context', [ &$context ] ); 28 | // TODO: Test private functions. 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /css/icons/history_toggle_off_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/icons/settings_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dropins/class-dropin.php: -------------------------------------------------------------------------------- 1 | simple_history = $simple_history; 20 | } 21 | 22 | /** 23 | * Fired when Simple History has loaded the dropin. 24 | * 25 | * @return void 26 | */ 27 | public function loaded() { 28 | // ... 29 | } 30 | 31 | /** 32 | * Get the slug for the dropin, 33 | * i.e. the unqualified class name. 34 | * 35 | * @return string 36 | */ 37 | public function get_slug() { 38 | return Helpers::get_class_short_name( $this ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /css/icons/account_circle_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.wordpress-org/blueprints/blueprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://playground.wordpress.net/blueprint-schema.json", 3 | "landingPage": "/wp-admin/admin.php?page=simple_history_admin_menu_page", 4 | "preferredVersions": { 5 | "php": "8.2", 6 | "wp": "6.9" 7 | }, 8 | "steps": [ 9 | { 10 | "step": "login", 11 | "username": "admin", 12 | "password": "password" 13 | }, 14 | { 15 | "step": "installPlugin", 16 | "pluginData": { 17 | "resource": "wordpress.org/plugins", 18 | "slug": "simple-history" 19 | }, 20 | "options": { 21 | "activate": true 22 | } 23 | }, 24 | { 25 | "step": "runPHP", 26 | "code": " 5,'post_title' => 'Example Post','post_content' => '

a normal paragraph

','post_status' => 'publish','post_author' => 1));" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /css/simple-history-logo-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /inc/deprecated/class-simpleloggerloginitiators.php: -------------------------------------------------------------------------------- 1 | 18 | %1$s 19 | %2$s 20 | 21 | ', 22 | esc_html( $this->item->name ), 23 | $this->get_value_diff_output() 24 | ); 25 | } 26 | 27 | /** 28 | * @inheritdoc 29 | * 30 | * @return array 31 | */ 32 | public function to_json() { 33 | // Use same formatter as inline items. 34 | $item_formatter = new Event_Details_Item_Default_Formatter( $this->item ); 35 | return $item_formatter->to_json(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /css/icons/volunteer_activism_FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /inc/services/class-setup-pause-resume-actions.php: -------------------------------------------------------------------------------- 1 | add_pause_and_resume_actions(); 14 | } 15 | 16 | /** 17 | * Actions to disable and enable logging. 18 | * Useful for example when importing many things using PHP because then 19 | * the log can be overwhelmed with data. 20 | * 21 | * @since 4.0.2 22 | */ 23 | protected function add_pause_and_resume_actions() { 24 | add_action( 25 | 'simple_history/pause', 26 | function () { 27 | add_filter( 'simple_history/log/do_log', '__return_false' ); 28 | } 29 | ); 30 | 31 | add_action( 32 | 'simple_history/resume', 33 | function () { 34 | remove_filter( 'simple_history/log/do_log', '__return_false' ); 35 | } 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /js/review-notice.js: -------------------------------------------------------------------------------- 1 | /* global simpleHistoryReviewNotice */ 2 | 3 | jQuery( document ).ready( function ( $ ) { 4 | const $dismissButton = $( '.simple-history-review-notice-dismiss-button' ); 5 | 6 | // Handle click on "Maybe Later" button. 7 | $dismissButton.on( 'click', function ( e ) { 8 | e.preventDefault(); 9 | dismissNotice(); 10 | } ); 11 | 12 | /** 13 | * Send AJAX request to dismiss the notice. 14 | */ 15 | function dismissNotice() { 16 | const $notice = $dismissButton.closest( '.notice' ); 17 | 18 | $.post( simpleHistoryReviewNotice.ajaxurl, { 19 | action: simpleHistoryReviewNotice.action, 20 | nonce: simpleHistoryReviewNotice.nonce, 21 | } ) 22 | .done( function ( response ) { 23 | if ( response.success ) { 24 | $notice.fadeOut(); 25 | } 26 | } ) 27 | .fail( function () { 28 | // If AJAX call fails, at least hide the notice for current page view. 29 | $notice.fadeOut(); 30 | } ); 31 | } 32 | } ); 33 | -------------------------------------------------------------------------------- /src/components/EventsListSkeletonList.jsx: -------------------------------------------------------------------------------- 1 | import { EventListSkeletonEventsItem } from './EventListSkeletonEventsItem'; 2 | 3 | /** 4 | * Render a skeleton list of events while the real events are loading. 5 | * Only shown when events are loading and there are no events, i.e for the first page load. 6 | * 7 | * @param {*} props 8 | * @return {null|*} Nothing or the skeleton list. 9 | */ 10 | export function EventsListSkeletonList( props ) { 11 | const { eventsIsLoading, events, pagerSize } = props; 12 | 13 | if ( ! eventsIsLoading || events.length > 0 ) { 14 | return null; 15 | } 16 | 17 | const skeletonRowsCount = pagerSize.page ?? 0; 18 | 19 | return ( 20 |
21 |
    22 | { Array.from( { length: skeletonRowsCount } ).map( 23 | ( _, index ) => ( 24 | 28 | ) 29 | ) } 30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /.wordpress-org/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.testing: -------------------------------------------------------------------------------- 1 | # Example file: 2 | # https://infinum.com/handbook/wordpress/automated-testing-in-wordpress/codeception 3 | 4 | # Used by acceptance, functional 5 | TEST_SITE_DB_DSN=mysql:host=db;dbname=wp_test_site 6 | TEST_SITE_DB_HOST=db 7 | TEST_SITE_DB_NAME=wp_test_site 8 | TEST_SITE_DB_USER=dbuser 9 | TEST_SITE_DB_PASSWORD=examplepass 10 | TEST_SITE_TABLE_PREFIX=wp_ 11 | TEST_SITE_ADMIN_USERNAME=admin 12 | TEST_SITE_ADMIN_PASSWORD=admin 13 | TEST_SITE_WP_ADMIN_PATH=/wp-admin 14 | 15 | # Used by wpunit, unit 16 | WP_ROOT_FOLDER=/wordpress/ 17 | TEST_DB_NAME=tests_db 18 | TEST_DB_HOST=db 19 | TEST_DB_USER=dbuser 20 | TEST_DB_PASSWORD=examplepass 21 | TEST_TABLE_PREFIX=wp_tests_ 22 | # TEST_SITE_WP_URL=http://localhost:8080 23 | # Site must be accessible from within Docker 24 | # TEST_SITE_WP_URL=http://192.168.86.26:8080 25 | # wordpress = docker host name for WordPress installation 26 | TEST_SITE_WP_URL=http://wordpress 27 | TEST_SITE_WP_DOMAIN=localhost:8080 28 | TEST_SITE_ADMIN_EMAIL=test@example.com 29 | -------------------------------------------------------------------------------- /tests/acceptance/JetpackLoggerCest.php: -------------------------------------------------------------------------------- 1 | loginAsAdmin(); 8 | $I->amOnPluginsPage(); 9 | $I->activatePlugin('jetpack'); 10 | $I->canSeePluginActivated('jetpack'); 11 | } 12 | 13 | public function test_that_jetpack_modules_can_be_activated(Admin $I) { 14 | $I->amOnAdminPage( 'admin.php?page=jetpack#/performance' ); 15 | 16 | // Enable site accelerator. 17 | $I->click('#inspector-toggle-control-0'); 18 | $I->wait(1); // Toggle takes some time to be activated. 19 | $I->seeLogMessage('Activated Jetpack module "Asset CDN"'); 20 | 21 | // Enable Lazy Loading for images. 22 | $I->click('#inspector-toggle-control-3'); 23 | $I->wait(1); // Toggle takes some time to be activated. 24 | $I->seeLogMessage('Activated Jetpack module "Lazy Images"'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/_data/mu-plugins/mu-plugin.php: -------------------------------------------------------------------------------- 1 | register_logger( 'Example_Logger' ); 13 | } 14 | ); 15 | 16 | // We use the function "register_dropin" to tell tell Simple History that our custom logger exists. 17 | add_action( 18 | 'simple_history/add_custom_dropin', 19 | function ( $simpleHistory ) { 20 | require_once __DIR__ . '/inc/class-example-dropin.php'; 21 | $simpleHistory->register_dropin( 'Example_Dropin' ); 22 | } 23 | ); 24 | 25 | require_once __DIR__ . '/inc/disable-updates.php'; 26 | require_once __DIR__ . '/inc/test-dropin.php'; 27 | require_once __DIR__ . '/inc/test-logger.php'; 28 | require_once __DIR__ . '/inc/test-cron.php'; 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Tag to WordPress.org 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | # Semver (https://semver.org/) release pattern. 7 | - '[0-9]+.[0-9]+.[0-9]+*' 8 | jobs: 9 | tag: 10 | name: Deploy Tag 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | 15 | - name: Build 16 | run: | 17 | npm install 18 | npm run build 19 | 20 | - name: Install SVN (Subversion) 21 | run: | 22 | sudo apt-get update 23 | sudo apt-get install subversion 24 | 25 | - name: WordPress.org Plugin Deploy 26 | uses: 10up/action-wordpress-plugin-deploy@stable 27 | env: 28 | SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} 29 | SVN_USERNAME: ${{ secrets.SVN_USERNAME }} 30 | SLUG: simple-history 31 | -------------------------------------------------------------------------------- /tests/_support/Helper/Functional.php: -------------------------------------------------------------------------------- 1 | getModule( 'lucatume\WPBrowser\Module\WPDb' ); 22 | $pdo = $wpdb->_getDbh(); 23 | $pdo->exec( "DROP TABLE IF EXISTS {$table_name}" ); 24 | } 25 | 26 | /** 27 | * Drop Simple History tables. 28 | * 29 | * Convenience method for auto-recovery tests. 30 | * 31 | * @return void 32 | */ 33 | public function dropSimpleHistoryTables(): void { 34 | $this->dropTable( 'wp_simple_history' ); 35 | $this->dropTable( 'wp_simple_history_contexts' ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/components/EventCopyLinkMenuItem.jsx: -------------------------------------------------------------------------------- 1 | import { MenuItem } from '@wordpress/components'; 2 | import { useCopyToClipboard } from '@wordpress/compose'; 3 | import { useState } from '@wordpress/element'; 4 | import { __ } from '@wordpress/i18n'; 5 | import { link } from '@wordpress/icons'; 6 | 7 | export function EventCopyLinkMenuItem( { event } ) { 8 | const permalink = event.permalink; 9 | const copyText = __( 'Copy link to event details', 'simple-history' ); 10 | const copiedText = __( 'Link copied to clipboard', 'simple-history' ); 11 | 12 | const [ dynamicCopyText, setDynamicCopyText ] = useState( copyText ); 13 | 14 | const ref = useCopyToClipboard( permalink, () => { 15 | setDynamicCopyText( copiedText ); 16 | setTimeout( () => { 17 | setDynamicCopyText( copyText ); 18 | }, 2000 ); 19 | 20 | // A notice after copy link would be better but this does not work for some reason. 21 | } ); 22 | 23 | return ( 24 | 25 | { dynamicCopyText } 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /css/icons/visibility_lock_48dp__FILL0_wght400_GRAD0_opsz48.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/hooks/useUserHasCapability.js: -------------------------------------------------------------------------------- 1 | import { useSelect } from '@wordpress/data'; 2 | import { store as coreStore } from '@wordpress/core-data'; 3 | 4 | /** 5 | * React hook that checks if the current user has a specific capability. 6 | * 7 | * @param {string} capability - The capability to check for (e.g. 'edit_posts', 'publish_posts') 8 | * @return {boolean} Whether the user has the specified capability 9 | */ 10 | export const useUserHasCapability = ( capability ) => { 11 | return useSelect( 12 | ( select ) => { 13 | // Use the current user ID from core-data store 14 | const currentUserId = select( coreStore ).getCurrentUser()?.id; 15 | 16 | if ( ! currentUserId ) { 17 | return false; 18 | } 19 | 20 | const userData = select( coreStore ).getEntityRecord( 21 | 'root', 22 | 'user', 23 | currentUserId 24 | ); 25 | 26 | if ( ! userData || ! userData.capabilities ) { 27 | return false; 28 | } 29 | 30 | return !! userData.capabilities[ capability ]; 31 | }, 32 | [ capability ] 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /tests/functional/DeveloperLoggersCest.php: -------------------------------------------------------------------------------- 1 | loginAsAdmin(); 7 | $I->amOnPluginsPage(); 8 | $I->activatePlugin('developer-loggers-for-simple-history'); 9 | } 10 | 11 | public function test_that_developer_loggers_can_be_activated( FunctionalTester $I ) { 12 | $I->canSeePluginActivated('developer-loggers-for-simple-history'); 13 | } 14 | 15 | public function test_that_developer_loggers_settings_tab_exist( FunctionalTester $I, Admin $admin ) { 16 | $admin->loginAsAdminToHistorySettingsPage(); 17 | $I->canSee('Developer loggers'); 18 | } 19 | 20 | public function test_that_developer_loggers_tab_contents_exist( FunctionalTester $I ) { 21 | $I->amOnAdminPage('admin.php?page=simple_history_settings_page&selected-tab=DeveloperLoggers'); 22 | $I->canSee('Enabled loggers and plugins'); 23 | $I->canSee('HTTP API logger'); 24 | $I->canSee('WP Mail Logger'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /inc/event-details/class-event-details-item-diff-table-row-formatter.php: -------------------------------------------------------------------------------- 1 | item->prev_value, 19 | $this->item->new_value, 20 | ); 21 | 22 | return sprintf( 23 | ' 24 | 25 | %1$s 26 | %2$s 27 | 28 | ', 29 | esc_html( $this->item->name ), 30 | $value_with_diff, 31 | ); 32 | } 33 | 34 | /** 35 | * @inheritdoc 36 | * 37 | * @return array 38 | */ 39 | public function to_json() { 40 | // Use same formatter as inline items. 41 | $item_formatter = new Event_Details_Item_Default_Formatter( $this->item ); 42 | return $item_formatter->to_json(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/_data/mu-plugins/inc/class-example-dropin.php: -------------------------------------------------------------------------------- 1 | sh = $sh; 16 | $this->init(); 17 | } 18 | 19 | public function init() { 20 | add_action( 'init', array( $this, 'add_settings_tab' ) ); 21 | } 22 | 23 | public function add_settings_tab() { 24 | $this->sh->register_settings_tab( 25 | array( 26 | 'slug' => 'dropin_example_tab_slug', 27 | 'name' => __( 'Dropin example tab', 'simple-history' ), 28 | 'function' => array( $this, 'settings_tab_output' ), 29 | ) 30 | ); 31 | } 32 | 33 | public function settings_tab_output() { 34 | ?> 35 |

Hi there!

36 |

I'm the output from on settings tab.

37 | { 8 | // Tmp to ease, styling, show the menu in the admin bar without the need to hover. 9 | // setInterval( () => { 10 | // const elm = document.querySelector( '#wp-admin-bar-simple-history' ); 11 | // if ( ! elm.classList.contains( 'hover' ) ) { 12 | // elm.classList.add( 'hover' ); 13 | // } 14 | // }, 100 ); 15 | 16 | // Find the admin bar node 17 | const adminBarTarget = document.getElementById( 18 | 'wp-admin-bar-simple-history-react-root-group' 19 | ); 20 | 21 | // Bail if the admin bar target is not found. 22 | if ( ! adminBarTarget ) { 23 | return; 24 | } 25 | 26 | // Bail if createRoot is not available. 27 | // Happens for example when using Divi theme frontend builder. 28 | if ( typeof createRoot !== 'function' ) { 29 | return; 30 | } 31 | 32 | createRoot( adminBarTarget ).render( ); 33 | } ); 34 | -------------------------------------------------------------------------------- /codeception.dist.yml: -------------------------------------------------------------------------------- 1 | paths: 2 | tests: tests 3 | output: tests/_output 4 | data: tests/_data 5 | support: tests/_support 6 | envs: tests/_envs 7 | actor_suffix: Tester 8 | extensions: 9 | enabled: 10 | - Codeception\Extension\RunFailed 11 | commands: 12 | - "lucatume\\WPBrowser\\Command\\GenerateWPUnit" 13 | - "lucatume\\WPBrowser\\Command\\GenerateWPRestApi" 14 | - "lucatume\\WPBrowser\\Command\\GenerateWPRestController" 15 | - "lucatume\\WPBrowser\\Command\\GenerateWPRestPostTypeController" 16 | - "lucatume\\WPBrowser\\Command\\GenerateWPAjax" 17 | - "lucatume\\WPBrowser\\Command\\GenerateWPCanonical" 18 | - "lucatume\\WPBrowser\\Command\\GenerateWPXMLRPC" 19 | - "lucatume\\WPBrowser\\Command\\RunOriginal" 20 | - "lucatume\\WPBrowser\\Command\\RunAll" 21 | - "lucatume\\WPBrowser\\Command\\DbExport" 22 | - "lucatume\\WPBrowser\\Command\\DbImport" 23 | - "lucatume\\WPBrowser\\Command\\MonkeyCachePath" 24 | - "lucatume\\WPBrowser\\Command\\MonkeyCacheClear" 25 | params: 26 | - .env.testing 27 | -------------------------------------------------------------------------------- /loggers/class-simple-logger.php: -------------------------------------------------------------------------------- 1 | 'SimpleLogger', 27 | 'description' => __( 'The built in logger for Simple History', 'simple-history' ), 28 | 29 | // Capability required to view log entries from this logger. 30 | 'capability' => 'edit_pages', 31 | 'messages' => array( 32 | // No pre-defined variants 33 | // when adding messages __() or _x() must be used. 34 | ), 35 | ); 36 | 37 | return $arr_info; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function ( grunt ) { 2 | // Require all grunt-tasks instead of manually initialize them. 3 | require( 'load-grunt-tasks' )( grunt ); 4 | 5 | const pkg = grunt.file.readJSON( 'package.json' ); 6 | 7 | const config = {}; 8 | 9 | config.pkg = pkg; 10 | 11 | config.version = { 12 | main: { 13 | options: { 14 | prefix: 'Version:[\\s]+', 15 | }, 16 | src: [ 'index.php' ], 17 | }, 18 | main2: { 19 | options: { 20 | prefix: "'SIMPLE_HISTORY_VERSION', '", 21 | }, 22 | src: [ 'index.php' ], 23 | }, 24 | readme: { 25 | options: { 26 | prefix: 'Stable tag:[\\s]+', 27 | }, 28 | src: [ 'readme.txt' ], 29 | }, 30 | pkg: { 31 | src: [ 'package.json' ], 32 | }, 33 | }; 34 | 35 | grunt.initConfig( config ); 36 | 37 | grunt.registerTask( 38 | 'bump', 39 | 'Bump version in major, minor, patch or custom steps.', 40 | function ( version ) { 41 | if ( ! version ) { 42 | grunt.fail.fatal( 43 | 'No version specified. Usage: bump:major, bump:minor, bump:patch, bump:x.y.z' 44 | ); 45 | } 46 | 47 | grunt.task.run( [ 'version::' + version ] ); 48 | } 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /inc/class-config.php: -------------------------------------------------------------------------------- 1 | |null */ 17 | private static $container; 18 | 19 | /** 20 | * @param array $container 21 | * @return void 22 | */ 23 | public static function init(array $container) 24 | { 25 | if (isset(self::$container)) { 26 | return; 27 | } 28 | 29 | self::$container = $container; 30 | } 31 | 32 | /** 33 | * @return mixed 34 | */ 35 | public static function get(string $name) 36 | { 37 | if (! isset(self::$container) || ! array_key_exists($name, self::$container)) { 38 | return null; 39 | } 40 | 41 | return self::$container[$name]; 42 | } 43 | 44 | /** 45 | * @return array 46 | */ 47 | public static function all() { 48 | return self::$container; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /inc/oldversions.php: -------------------------------------------------------------------------------- 1 | =' ); 7 | $ok_php_version = version_compare( phpversion(), '7.4', '>=' ); 8 | ?> 9 |
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 |
16 | 25 |
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 |
    40 |
    44 |
    48 |
    52 |
    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 | 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 | --------------------------------------------------------------------------------