$args Positional arguments. * @param array $assoc_args Associative arguments. */ public function stats( array $args, array $assoc_args ): void { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- CLI-only stats queries $total_events = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $wpdb->prefix . 'webcal_entry' ) ); $categories = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE cat_status = 'A'", $wpdb->prefix . 'webcal_categories' ) ); $users = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(DISTINCT cal_create_by) FROM %i', $wpdb->prefix . 'webcal_entry' ) ); $remotes = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE cal_url IS NOT NULL', $wpdb->prefix . 'webcal_nonuser_cals' ) ); $repeating = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $wpdb->prefix . 'webcal_entry_repeats' ) ); $log_entries = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $wpdb->prefix . 'webcal_entry_log' ) ); // phpcs:enable \WP_CLI::log( '' ); \WP_CLI::log( ' WebCalendar Statistics' ); \WP_CLI::log( ' ─────────────────────' ); \WP_CLI::log( sprintf( ' Total Events: %s', number_format( $total_events ) ) ); \WP_CLI::log( sprintf( ' Repeating Events: %s', number_format( $repeating ) ) ); \WP_CLI::log( sprintf( ' Categories: %s', number_format( $categories ) ) ); \WP_CLI::log( sprintf( ' Active Users: %s', number_format( $users ) ) ); \WP_CLI::log( sprintf( ' Remote Calendars: %s', number_format( $remotes ) ) ); \WP_CLI::log( sprintf( ' Audit Log Entries: %s', number_format( $log_entries ) ) ); $raw_sync = get_option( 'webcal_last_sync_time', '' ); if ( ! empty( $raw_sync ) && is_scalar( $raw_sync ) ) { $sync_time = (int) $raw_sync; // translators: %s is a relative time like "2 hours ago" \WP_CLI::log( sprintf( ' Last Sync: %s', human_time_diff( $sync_time ) . ' ' . __( 'ago', 'agenticdaisy-calendar' ) ) ); } else { \WP_CLI::log( ' Last Sync: ' . __( 'Never', 'agenticdaisy-calendar' ) ); } \WP_CLI::log( '' ); } /** * Export events to an ICS file. * * ## OPTIONS * * [] * : Output file path. Defaults to stdout. * * [--start=] * : Start date (YYYY-MM-DD). Defaults to 1 year ago. * * [--end=] * : End date (YYYY-MM-DD). Defaults to 1 year from now. * * ## EXAMPLES * * wp webcal export calendar.ics * wp webcal export --start=2026-01-01 --end=2026-12-31 > year.ics * * @subcommand export */ /** * @psalm-suppress PossiblyUnusedMethod -- Registered via WP_CLI::add_command(). * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function export( array $args, array $assoc_args ): void { $container = Container::get_instance(); $event_service = $container->get_event_service(); $export_svc = $container->get_export_service(); $category_repo = $container->get_category_repository(); $now = new DateTimeImmutable(); $start = isset( $assoc_args['start'] ) ? new DateTimeImmutable( $assoc_args['start'] ) : $now->modify( '-1 year' ); $end = isset( $assoc_args['end'] ) ? new DateTimeImmutable( $assoc_args['end'] ) : $now->modify( '+1 year' ); $range = new DateRange( $start, $end ); $events = $event_service->getEventsInDateRange( $range ); $category_map = []; foreach ( $events as $event ) { $eid = $event->id()->value(); $cats = $category_repo->getForEvent( $event->id(), $event->createdBy() ); if ( ! empty( $cats ) ) { $category_map[ $eid ] = array_map( fn( $c ) => $c->name(), $cats ); } } $ics = $export_svc->exportIcal( $events, $category_map ); $count = iterator_count( $events ); if ( isset( $args[0] ) && $args[0] !== '' ) { $written = $this->filesystem()->put_contents( $args[0], $ics ); if ( false === $written ) { \WP_CLI::error( sprintf( 'Could not write file: %s', $args[0] ) ); return; } // translators: 1: number of events, 2: file path \WP_CLI::success( sprintf( __( 'Exported %1$d events to %2$s', 'agenticdaisy-calendar' ), $count, $args[0] ) ); } else { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- ICS output to CLI stdout, not HTML echo $ics; } } /** * Import events from an ICS file. * * ## OPTIONS * * * : Path to the .ics file to import. * * [--user=] * : WordPress user login to own imported events. Defaults to current user. * * ## EXAMPLES * * wp webcal import calendar.ics * wp webcal import holidays.ics --user=admin * * @subcommand import */ /** * @psalm-suppress PossiblyUnusedMethod -- Registered via WP_CLI::add_command(). * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function import( array $args, array $assoc_args ): void { $file = $args[0]; $fs = $this->filesystem(); if ( ! $fs->exists( $file ) ) { \WP_CLI::error( sprintf( 'File not found: %s', $file ) ); return; } $content = $fs->get_contents( $file ); if ( false === $content ) { \WP_CLI::error( sprintf( 'Could not read file: %s', $file ) ); return; } $login = $assoc_args['user'] ?? wp_get_current_user()->user_login; if ( empty( $login ) ) { \WP_CLI::error( 'No user specified. Use --user= or run as a logged-in user.' ); return; } $container = Container::get_instance(); $import_service = $container->get_import_service(); try { $user_repo = $container->get_user_repository(); $user = $user_repo->findByLogin( $login ); if ( $user === null ) { \WP_CLI::error( sprintf( 'User not found: %s', $login ) ); return; } $result = $import_service->importIcal( $content, $user ); \WP_CLI::success( sprintf( // translators: 1: imported count, 2: skipped count __( 'Imported %1$d events, skipped %2$d duplicates.', 'agenticdaisy-calendar' ), $result->importedCount, $result->skippedCount ) ); } catch ( \Exception $e ) { \WP_CLI::error( $e->getMessage() ); } } /** * Show recent audit log entries. * * ## OPTIONS * * [--days=] * : Number of days to look back. Default: 7. * * [--user=] * : Filter by user login. * * [--format=] * : Output format. Accepts: table, csv, json. Default: table. * * ## EXAMPLES * * wp webcal audit-log * wp webcal audit-log --days=30 --user=admin * wp webcal audit-log --format=json * * @subcommand audit-log */ /** * @psalm-suppress PossiblyUnusedMethod -- Registered via WP_CLI::add_command(). * @psalm-suppress UnusedParam -- WP-CLI mandates the $args parameter. * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function audit_log( array $args, array $assoc_args ): void { $days = isset( $assoc_args['days'] ) ? (int) $assoc_args['days'] : 7; $login = $assoc_args['user'] ?? null; $format = $assoc_args['format'] ?? 'table'; $container = Container::get_instance(); /** @var \WebCalendar\Core\Application\Service\ActivityLogService $log_service */ $log_service = $container->get( 'activity_log_service' ); $now = new DateTimeImmutable(); $start = $now->modify( "-{$days} days" ); $range = new DateRange( $start, $now ); $entries = $log_service->getLogs( $range, $login ); if ( empty( $entries ) ) { \WP_CLI::log( __( 'No audit log entries found.', 'agenticdaisy-calendar' ) ); return; } $type_labels = [ 'C' => __( 'Create', 'agenticdaisy-calendar' ), 'U' => __( 'Update', 'agenticdaisy-calendar' ), 'A' => __( 'Approve', 'agenticdaisy-calendar' ), 'R' => __( 'Reject', 'agenticdaisy-calendar' ), 'M' => __( 'Notify', 'agenticdaisy-calendar' ), 'E' => __( 'Reminder', 'agenticdaisy-calendar' ), 'X' => __( 'Extra', 'agenticdaisy-calendar' ), ]; $rows = []; foreach ( $entries as $entry ) { $rows[] = [ 'date' => $entry->date()->format( 'Y-m-d H:i:s' ), 'user' => $entry->login(), 'action' => $type_labels[ $entry->type()->value ], 'event_id' => $entry->entryId(), 'details' => mb_substr( $entry->text(), 0, 60 ), ]; } \WP_CLI\Utils\format_items( $format, $rows, [ 'date', 'user', 'action', 'event_id', 'details' ] ); } /** * Initialise WP_Filesystem and return the global instance. * * Provides a thin seam so unit tests can substitute a test double * without requiring a real WordPress filesystem. In WP-CLI context * WP_Filesystem resolves to the "direct" backend, which is backed * by native PHP file functions — the safe indirection the WP * coding standards expect. * * @return \WP_Filesystem_Base * * @psalm-suppress UndefinedClass -- WP_Filesystem_Base is loaded by WordPress at runtime. * @psalm-suppress PossiblyUnusedMethod -- Called by export() and import(). */ protected function filesystem() { if ( ! function_exists( 'WP_Filesystem' ) ) { // phpcs:ignore WordPress.WP.DiscouragedFunctions -- Required for WP_Filesystem(). /** @psalm-suppress MissingFile -- WP-core file resolved via ABSPATH at runtime. */ require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); global $wp_filesystem; return $wp_filesystem; } }