resource_repo = $resource_repo; $this->event_repo = $event_repo; $this->import_service = $import_service; $this->logger = $logger; } /** * Sync all remote calendars. * * @return array{success: int, failure: int} */ public function sync_all(): array { $resources = $this->resource_repo->findAll(); $stats = [ 'success' => 0, 'failure' => 0, ]; foreach ( $resources as $resource ) { if ( ! empty( $resource->url() ) ) { try { $this->sync_calendar( $resource ); ++$stats['success']; } catch ( \Exception $e ) { $this->logger->error( sprintf( 'Sync failed for %s: %s', $resource->login(), $e->getMessage() ) ); ++$stats['failure']; } } } return $stats; } /** * Sync a specific remote calendar. * * Uses wp_safe_remote_get() to block requests to private-network IPs * (SSRF hardening; checklist ยง1.11). Sites that legitimately need to * reach an internal iCal server can override via: * * add_filter( 'http_request_host_is_external', '__return_true', 10, 3 ); * * @param Resource $resource The resource/calendar entity. */ public function sync_calendar( Resource $resource ): void { $url = $resource->url(); if ( empty( $url ) ) { return; } $response = wp_safe_remote_get( $url, [ 'timeout' => 30, ] ); if ( is_wp_error( $response ) ) { throw new \Exception( esc_html( $response->get_error_message() ) ); } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { // translators: %d is the HTTP status code. throw new \Exception( sprintf( esc_html__( 'HTTP Error %d', 'agenticdaisy-calendar' ), (int) $code ) ); } $content = wp_remote_retrieve_body( $response ); if ( empty( $content ) ) { throw new \Exception( esc_html__( 'Empty response body', 'agenticdaisy-calendar' ) ); } // Use a virtual user for the import service // The login MUST match the resource login $virtual_user = new User( login: $resource->login(), firstName: $resource->name(), lastName: '', email: 'remote@example.com', isAdmin: false, isEnabled: true ); // Record existing event UIDs before import to handle deletions $existing_uids = $this->get_event_uids_by_owner( $resource->login() ); // Import the ICS content try { $this->import_service->importIcal( $content, $virtual_user ); // ImportService doesn't return what it did, so re-scan $current_uids = $this->extract_uids_from_ics( $content ); // Delete events that are no longer in the remote feed $to_delete = array_diff( $existing_uids, $current_uids ); foreach ( $to_delete as $uid ) { $event = $this->event_repo->findByUid( $uid ); if ( $event && $event->createdBy() === $resource->login() ) { $this->event_repo->delete( $event->id() ); } } } catch ( \Exception $e ) { $this->logger->error( sprintf( 'Import failed for remote calendar %s: %s', $resource->login(), $e->getMessage() ) ); // We don't re-throw here so other calendars can still sync if called from sync_all // but if called from sync_item manually, the exception is caught there. throw $e; } } /** * Extract UIDs from ICS content using Icalendar parser. * * @param string $content ICS content. * @return array */ protected function extract_uids_from_ics( string $content ): array { $uids = []; try { $parser = new \WebCalendarDeps\Icalendar\Parser\Parser( \WebCalendarDeps\Icalendar\Parser\Parser::LENIENT ); $calendar = $parser->parse( $content ); foreach ( $calendar->getComponents( 'VEVENT' ) as $event ) { $uid_prop = $event->getProperty( 'UID' ); if ( $uid_prop ) { $uids[] = trim( $uid_prop->getValue()->getRawValue() ); } } } catch ( \Exception $e ) { $this->logger->warning( 'Failed to parse ICS for UID extraction: ' . $e->getMessage() ); } return array_unique( $uids ); } /** * Delete a remote calendar and all its imported events. * * @param string $login Calendar login/ID. */ public function deleteCalendar( string $login ): void { $this->event_repo->deleteByCreator( $login ); $this->resource_repo->delete( $login ); } /** * Get all event UIDs owned by a specific login. * * @param string $login Owner login. * @return array */ protected function get_event_uids_by_owner( string $login ): array { return $this->event_repo->findUidsByCreator( $login ); } }