$args Positional arguments. * @param array $assoc_args Associative arguments. */ public function schema( array $args, array $assoc_args ): void { $schema = ( new \WebCalendar\WP\Abilities() )->execute_describe_schema(); // JSON only. The data is nested, so a table is no use, and offering // YAML meant reaching for WP-CLI's bundled Spyc — a class the static // analysers cannot see — for no gain over `wp webcal schema | jq`. $json = wp_json_encode( $schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ( false === $json ) { \WP_CLI::error( 'Could not encode the schema as JSON.' ); return; } \WP_CLI::line( $json ); } /** * Display calendar statistics. * * ## EXAMPLES * * wp webcal stats * * @subcommand stats * * @psalm-suppress PossiblyUnusedMethod -- Registered via WP_CLI::add_command(). * @psalm-suppress UnusedParam -- WP-CLI mandates the $args/$assoc_args signature. * * @param array $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 ); // The whole calendar. `wp webcal export` runs with shell access to the // site, so an administrative scope is what it is for; the WordPress // capability checks that narrow the REST exports have no counterpart // here and nothing to narrow against. $events = $event_service->getEventsInDateRange( $range, EventScope::administrative() ); // Categories and tags come back together; the export keeps them apart // so X-WEBCAL-TAGS can record which of the names were tags. $category_map = []; $tag_map = []; foreach ( $events as $event ) { $eid = $event->id()->value(); $split = \WebCalendar\WP\EventLabels::partition( $category_repo->getForEvent( $event->id(), $event->createdBy() ) ); if ( array() !== $split['categories'] ) { $category_map[ $eid ] = $split['categories']; } if ( array() !== $split['tags'] ) { $tag_map[ $eid ] = $split['tags']; } } $ics = $export_svc->exportIcal( $events, $category_map, $tag_map ); $count = iterator_count( $events ); if ( isset( $args[0] ) && $args[0] !== '' ) { // WPORG-11.5: route file I/O through WP_Filesystem. In CLI // context this resolves to the "direct" backend (native file // functions), but going through the WP abstraction is what // the coding standards expect. $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 or CSV file. * * ## OPTIONS * * * : Path to the .ics or .csv file to import. * * [--user=] * : WordPress user login to own imported events. Defaults to current user. * * [--format=] * : Input format. Defaults to csv for a .csv file, ics otherwise. * --- * options: * - ics * - csv * --- * * [--map=] * : Column mapping for a CSV whose headers are not recognized as Google or * Outlook, as a JSON object of field name => zero-based column index, * e.g. '{"title":0,"start_date":1}'. Required for such files; ignored for * ICS. "title" and "start_date" must both be mapped. * * [--update-existing] * : Overwrite events whose UID is already present instead of skipping them. * Off by default, so re-running an import is safe. ICS only — matching is * by UID, which CSV rows do not carry. * * ## EXAMPLES * * wp webcal import calendar.ics * wp webcal import holidays.ics --user=admin * wp webcal import google-export.csv * wp webcal import rows.csv --map='{"title":0,"start_date":1}' * wp webcal import calendar.ics --update-existing * * @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]; // WPORG-11.5: route file I/O through WP_Filesystem; see export(). $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; } // CLI-1: before this branch existed, every file went to importIcal(), // so a CSV parsed as iCal, matched nothing, and reported the cheerful // "Imported 0 events, skipped 0 duplicates" — a silent no-op. $format = strtolower( trim( $assoc_args['format'] ?? '' ) ); if ( $format === '' ) { $format = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ) === 'csv' ? 'csv' : 'ics'; } if ( ! in_array( $format, [ 'ics', 'csv' ], true ) ) { \WP_CLI::error( sprintf( 'Unknown --format "%s". Expected ics or csv.', $format ) ); return; } if ( $format === 'csv' ) { $this->import_csv( $content, $login, $assoc_args ); 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; } // IMPORT-1: without this the CLI could never refresh an event that // changed upstream — remote sync and the admin screen both had the // option, the command line did not. $update_existing = isset( $assoc_args['update-existing'] ); $result = \WebCalendar\WP\Admin\ImportUidCollisions::fold( $import_service->importIcal( $content, $user, $update_existing ) ); \WP_CLI::success( sprintf( // translators: 1: imported count, 2: updated count, 3: skipped count __( 'Imported %1$d events, updated %2$d, skipped %3$d duplicates.', 'agenticdaisy-calendar' ), $result->importedCount, $result->updatedCount, $result->skippedCount ) ); } catch ( \Exception $e ) { \WP_CLI::error( $e->getMessage() ); } } /** * CsvImporter factory — a seam so tests can substitute a double without * standing up the whole Container behind the real importer. * * Resolving CsvImporter here also loads src/Admin/CsvImporter.php, which is * where the CsvFormat enum is declared. CsvFormat is not autoloadable on its * own (two types in one file), so nothing may reference it before this runs. * * @return CsvImporter */ protected function csv_importer(): CsvImporter { return new CsvImporter( Container::get_instance() ); } /** * Import CSV content on behalf of import(). * * @param string $content Raw CSV file content. * @param string $login Target user login. * @param array $assoc_args Associative arguments. */ private function import_csv( string $content, string $login, array $assoc_args ): void { $importer = $this->csv_importer(); $rows = $importer->parse_csv( $content ); if ( count( $rows ) < 2 ) { \WP_CLI::error( 'CSV file is empty or has no data rows.' ); return; } $format = $importer->detect_format( $rows[0] ); $column_map = []; if ( $format === CsvFormat::GENERIC ) { $raw = $assoc_args['map'] ?? ''; if ( $raw === '' ) { \WP_CLI::error( 'CSV headers were not recognized as Google or Outlook. ' . 'Supply a column mapping with --map, e.g. ' . '--map=\'{"title":0,"start_date":1}\'. ' . 'Headers found: ' . implode( ', ', $rows[0] ) ); return; } $decoded = json_decode( $raw, true ); if ( ! is_array( $decoded ) ) { \WP_CLI::error( 'Could not parse --map as JSON. Expected an object of field => column index.' ); return; } foreach ( $decoded as $field => $index ) { if ( is_string( $field ) && is_numeric( $index ) ) { $column_map[ $field ] = (int) $index; } } if ( ! isset( $column_map['title'], $column_map['start_date'] ) ) { \WP_CLI::error( '--map must include at least "title" and "start_date".' ); return; } } try { $result = $importer->import( $content, $login, $format, $column_map ); \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 ) ); foreach ( $result->warnings as $warning ) { \WP_CLI::warning( sprintf( 'Line %d: %s', $warning['line'], $warning['message'] ) ); } } catch ( \Exception $e ) { \WP_CLI::error( $e->getMessage() ); } } /** * Migrate another calendar system's data into this site. * * ## OPTIONS * * * : What to migrate from: 'legacy' (a WebCalendar 1.9 .sql export; pass the * file as the second argument) or 'tec' (The Events Calendar data already * in this site's database). A bare .sql path is accepted as an alias for * 'legacy ' for backwards compatibility. * * [] * : For 'legacy': path to the .sql export (WebCalendar 1.9 "Export for * WordPress", or a plain mysqldump of the webcal_* tables). Read straight * from disk, so it is NOT limited by upload_max_filesize. * * [--map=] * : legacy only. JSON object mapping legacy logins to WordPress logins, * e.g. '{"jsmith":"john"}'. Unlisted legacy users auto-map to a WordPress * account with the same login when one exists. * * [--create-missing] * : legacy only. Create WordPress accounts for legacy users with no * matching login (each gets a password-setup email). Otherwise their * events are skipped. * * [--include=] * : legacy only. Comma-separated extras to import. Accepts: categories, * reminders, layers, remote_cals, site_extras. Default: all. * * [--organizer=] * : tec only. What to do with organizer contact details: 'description' * (append to the event description; default) or 'drop'. * * [--calendar=] * : tec only. Assign every imported event to this shared calendar instead * of each event's original author. Must be an existing shared calendar * login; validated before anything is written. * * [--dry-run] * : Analyze and report what would be imported, without writing anything. * * ## EXAMPLES * * wp webcal migrate legacy webcal-export.sql * wp webcal migrate legacy dump.sql --create-missing --include=categories,reminders * wp webcal migrate tec --user=admin * wp webcal migrate tec --organizer=drop --dry-run * wp webcal migrate tec --user=admin --calendar=community-events * wp webcal migrate dump.sql # legacy back-compat form * * @subcommand migrate * * @psalm-suppress PossiblyUnusedMethod -- Registered via WP_CLI::add_command(). * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function migrate( array $args, array $assoc_args ): void { $source = $args[0] ?? ''; if ( 'tec' === $source ) { $this->migrate_tec( $assoc_args ); return; } if ( 'legacy' === $source ) { $this->migrate_legacy( array_slice( $args, 1 ), $assoc_args ); return; } // Back-compat: `wp webcal migrate ` predates the source // argument and always meant the 1.9 migration. $this->migrate_legacy( $args, $assoc_args ); } /** * Import The Events Calendar data through the same engine as the wizard. * * @psalm-suppress RedundantCastGivenDocblockType -- WP-CLI passes bools for * flags despite the array shape; defensive coercion stays. * * @param array $assoc_args Associative arguments. */ private function migrate_tec( array $assoc_args ): void { $organizer = isset( $assoc_args['organizer'] ) ? (string) $assoc_args['organizer'] : 'description'; if ( ! in_array( $organizer, array( 'description', 'drop' ), true ) ) { \WP_CLI::error( "--organizer must be 'description' or 'drop'." ); return; } $calendar = isset( $assoc_args['calendar'] ) && '' !== $assoc_args['calendar'] ? (string) $assoc_args['calendar'] : null; $tab = $this->tec_tab(); $analysis = $tab->analyze(); if ( ! $analysis['available'] ) { \WP_CLI::error( 'No data from The Events Calendar was found in this site\'s database.' ); return; } $publish = $analysis['status_counts']['publish'] ?? 0; if ( isset( $assoc_args['dry-run'] ) ) { if ( null !== $calendar && ! isset( $tab->shared_calendars()[ $calendar ] ) ) { \WP_CLI::error( sprintf( 'Unknown shared calendar "%s".', $calendar ) ); return; } \WP_CLI::log( sprintf( 'Dry run: %d published events (%d recurring, %d with a venue, %d with an organizer), %d categories. Unpublished events are not imported.', $publish, (int) $analysis['recurring'], (int) $analysis['with_venue'], (int) $analysis['with_organizer'], (int) $analysis['categories'] ) ); if ( null !== $calendar ) { \WP_CLI::log( sprintf( 'Events would be assigned to the shared calendar "%s".', $calendar ) ); } return; } try { $report = $tab->run_import( 'description' === $organizer, $calendar ); } catch ( \InvalidArgumentException $e ) { // Target validation happens before anything is written. \WP_CLI::error( $e->getMessage() ); return; } catch ( \Throwable $e ) { \WP_CLI::error( 'Import failed; no events were imported: ' . $e->getMessage() . ' (events whose TEC author no longer exists need --user to assign an owner)' ); return; } foreach ( $report['warnings'] as $warning ) { \WP_CLI::warning( $warning ); } if ( $report['instances'] > 0 ) { \WP_CLI::log( sprintf( '%d occurrences of non-convertible recurring events were imported as individual events.', (int) $report['instances'] ) ); } \WP_CLI::success( sprintf( 'Imported %d events (%d duplicates skipped), %d categories and %d tags created. Undo is available on the Import screen.', (int) $report['imported'], (int) $report['duplicates'], (int) $report['categories'], (int) $report['tags'] ) ); } /** * Keep WebCalendar in sync with The Events Calendar — the side-by-side * trial (STATUS.md § TEC Import, Epic 8). One-way: TEC wins, TEC data * is never modified. * * ## OPTIONS * * * : Only 'tec' is supported. * * [--enable] * : Turn scheduled sync on. Combine with --interval, --organizer and * --calendar; a later run with different values reconfigures it. * * [--disable] * : Turn scheduled sync off. Mirrored events stay as they are. * * [--interval=] * : With --enable: 'hourly' (default), 'twicedaily' or 'daily'. * * [--organizer=] * : With --enable: 'description' (append organizer details; default) * or 'drop' — match the choice made at import time. * * [--calendar=] * : With --enable: assign synced events to this shared calendar. * Validated before anything is saved. * * [--dry-run] * : Without --enable/--disable: report what a cycle would look at, * without writing anything. * * ## EXAMPLES * * wp webcal sync tec # one cycle now * wp webcal sync tec --dry-run * wp webcal sync tec --enable --interval=hourly * wp webcal sync tec --enable --organizer=drop --calendar=community-events * wp webcal sync tec --disable * * @subcommand sync * * @psalm-suppress PossiblyUnusedMethod -- Registered via WP_CLI::add_command(). * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function sync( array $args, array $assoc_args ): void { if ( 'tec' !== ( $args[0] ?? '' ) ) { \WP_CLI::error( "Only 'tec' is supported: wp webcal sync tec" ); return; } $tab = $this->tec_tab(); $scheduler = $tab->scheduler(); if ( isset( $assoc_args['disable'] ) ) { $settings = $scheduler->settings(); $scheduler->configure( false, $settings['interval'], $settings['append_organizer'], $settings['target_login'] ); \WP_CLI::success( 'Scheduled TEC sync is off. Mirrored events stay as they are.' ); return; } if ( isset( $assoc_args['enable'] ) ) { $organizer = $assoc_args['organizer'] ?? 'description'; if ( ! in_array( $organizer, array( 'description', 'drop' ), true ) ) { \WP_CLI::error( "--organizer must be 'description' or 'drop'." ); return; } $interval = $assoc_args['interval'] ?? 'hourly'; $calendar = isset( $assoc_args['calendar'] ) && '' !== $assoc_args['calendar'] ? $assoc_args['calendar'] : null; if ( null !== $calendar && ! isset( $tab->shared_calendars()[ $calendar ] ) ) { \WP_CLI::error( sprintf( 'Unknown shared calendar "%s".', $calendar ) ); return; } try { $scheduler->configure( true, $interval, 'description' === $organizer, $calendar ); } catch ( \InvalidArgumentException $e ) { \WP_CLI::error( $e->getMessage() ); return; } \WP_CLI::success( sprintf( 'Scheduled TEC sync is on (%s). Keep both plugins active while you compare; edits made in WebCalendar to mirrored events are overwritten each cycle.', $interval ) ); return; } if ( isset( $assoc_args['dry-run'] ) ) { $analysis = $tab->analyze(); if ( ! $analysis['available'] ) { \WP_CLI::error( 'No data from The Events Calendar was found in this site\'s database.' ); return; } \WP_CLI::log( sprintf( 'Dry run: a cycle would examine %d published events, mirror changes onto the imported ones, and sweep mirrored events whose TEC post is gone. Nothing was written.', $analysis['status_counts']['publish'] ?? 0 ) ); return; } try { $report = $scheduler->run_now(); } catch ( \RuntimeException $e ) { \WP_CLI::error( $e->getMessage() ); return; } catch ( \Throwable $e ) { \WP_CLI::error( 'Sync failed; nothing was written: ' . $e->getMessage() ); return; } foreach ( $report['warnings'] as $warning ) { \WP_CLI::warning( $warning ); } \WP_CLI::success( sprintf( 'Sync complete: %d added, %d updated, %d removed, %d unchanged (%d skipped).', $report['added'], $report['updated'], $report['removed'], $report['unchanged'], $report['skipped'] ) ); } /** * The wizard tab doubling as the CLI engine. Seam for tests. */ protected function tec_tab(): \WebCalendar\WP\Admin\TecMigrationTab { return \WebCalendar\WP\Admin\TecMigrationTab::create(); } /** * The WebCalendar 1.9 migration flow (see the migrate docblock). * * @psalm-suppress RedundantCastGivenDocblockType -- defensive coercion PHPStan Level 9 needs. * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ private function migrate_legacy( array $args, array $assoc_args ): void { $file = $args[0] ?? ''; $fs = $this->filesystem(); if ( '' === $file || ! $fs->exists( $file ) ) { \WP_CLI::error( sprintf( 'File not found: %s', $file ) ); return; } $explicit = array(); if ( isset( $assoc_args['map'] ) && '' !== $assoc_args['map'] ) { $decoded = json_decode( (string) $assoc_args['map'], true ); if ( ! is_array( $decoded ) ) { \WP_CLI::error( '--map must be a JSON object of legacy_login:wp_login pairs.' ); return; } foreach ( $decoded as $legacy => $wp_login ) { if ( is_scalar( $wp_login ) ) { $explicit[ (string) $legacy ] = (string) $wp_login; } } } $create_missing = isset( $assoc_args['create-missing'] ); $include = isset( $assoc_args['include'] ) ? (string) $assoc_args['include'] : null; try { $dump = ( new LegacyDumpParser() )->parse_file( $file ); } catch ( \Exception $e ) { \WP_CLI::error( 'Could not parse the SQL file: ' . $e->getMessage() ); return; } $legacy_logins = array(); foreach ( $dump->rows( 'webcal_user' ) as $row ) { $legacy_logins[] = (string) ( $row['cal_login'] ?? '' ); } $initial_map = self::migration_initial_map( $legacy_logins, $explicit, $create_missing, static fn( string $login ): bool => false !== username_exists( $login ) ); $events = $dump->count( 'webcal_entry' ); $mapped = count( array_filter( $initial_map, static fn( string $target ): bool => '' !== $target ) ); if ( isset( $assoc_args['dry-run'] ) ) { \WP_CLI::log( sprintf( 'Dry run: %d events across %d legacy users; %d users would be imported.', $events, count( $legacy_logins ), $mapped ) ); return; } global $wpdb; $pdo = Container::get_instance()->get_pdo(); $prefix = $wpdb->prefix; $tab = new LegacyMigrationTab( $pdo, $prefix ); list( $user_map, $created, $notes, $created_ids ) = $tab->resolve_user_map( $initial_map, $dump ); foreach ( $notes as $note ) { \WP_CLI::log( $note ); } $importer = new LegacyMigrationImporter( $pdo, $prefix ); try { $report = $importer->import( $dump, wp_timezone(), $user_map, self::migration_include_options( $include ) ); } catch ( \Throwable $e ) { // Import rolled back the calendar data; remove the WordPress accounts // created for this run so a failure leaves nothing behind. $tab->delete_created_users( $created_ids ); \WP_CLI::error( 'Migration failed; no data imported and any created accounts were removed: ' . $e->getMessage() ); return; } if ( $created > 0 ) { \WP_CLI::log( sprintf( 'Created %d WordPress users (password-setup emails sent).', $created ) ); } \WP_CLI::success( sprintf( 'Imported %d events (%d duplicates skipped, %d skipped for unmapped users), %d categories.', (int) $report['imported'], (int) $report['duplicates'], (int) $report['skipped'], (int) $report['categories'] ) ); } /** * Build the initial legacy→WordPress login map for a migration. * * For each legacy login: use an explicit mapping if given; else auto-map to * a WordPress account with the same login when one exists; else mark it * `__create__` when $create_missing is set; else leave it empty (skip). * * @param list $legacy_logins Legacy `cal_login` values. * @param array $explicit Explicit legacy→wp overrides. * @param bool $create_missing Create accounts for unmatched users. * @param callable(string):bool $login_exists Whether a WordPress login exists. * @return array legacy login → target (wp login, `__create__`, or ''). */ public static function migration_initial_map( array $legacy_logins, array $explicit, bool $create_missing, callable $login_exists ): array { $map = array(); foreach ( $legacy_logins as $login ) { if ( '' === $login ) { continue; } if ( isset( $explicit[ $login ] ) && '' !== $explicit[ $login ] ) { $map[ $login ] = $explicit[ $login ]; } elseif ( $login_exists( $login ) ) { $map[ $login ] = $login; } elseif ( $create_missing ) { $map[ $login ] = '__create__'; } else { $map[ $login ] = ''; } } return $map; } /** * Translate an `--include` CSV into the importer's option flags. * * @param string|null $csv Comma-separated tokens, or null for "all". * @return array{include_categories: bool, include_reminders: bool, include_layers: bool, include_remote_cals: bool, include_site_extras: bool} */ public static function migration_include_options( ?string $csv ): array { $all = array( 'categories', 'reminders', 'layers', 'remote_cals', 'site_extras' ); $selected = null === $csv ? $all : array_values( array_filter( array_map( 'trim', explode( ',', $csv ) ) ) ); return array( 'include_categories' => in_array( 'categories', $selected, true ), 'include_reminders' => in_array( 'reminders', $selected, true ), 'include_layers' => in_array( 'layers', $selected, true ), 'include_remote_cals' => in_array( 'remote_cals', $selected, true ), 'include_site_extras' => in_array( 'site_extras', $selected, true ), ); } /** * 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 ); // getLogs() now authorizes the read: a non-admin actor may only see // their own entries, and a bare request means theirs rather than // everybody's. WP-CLI has no current user to be, and shell access to // the site already outranks any capability check, so the command acts // as an admin and --user keeps meaning "whose entries to show". $actor = new User( login: 'wp-cli', firstName: 'WP', lastName: 'CLI', // Never delivered to; core's User requires a syntactically valid // address, and example.com is reserved so this one cannot exist. email: 'wp-cli@example.com', isAdmin: true, isEnabled: true ); $entries = $log_service->getLogs( $range, $actor, $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' ] ); } /** * Initialize 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 WordPress * 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(). * @psalm-suppress MissingFile -- wp-admin/includes/file.php is available at runtime in WP-CLI. */ protected function filesystem() { if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); global $wp_filesystem; return $wp_filesystem; } }