*/ private array $undo_handlers = array(); /** * Creates the log. * * @param ActivityStore $store Persistence. */ public function __construct( ActivityStore $store ) { $this->store = $store; } /** * Hooks app lifecycle listeners. */ public function register_hooks(): void { add_action( 'forensix_app_data_deleted', array( $this->store, 'delete_for_app' ) ); } /** * Records a change. * * @param array $entry Change details. * @phpstan-param array{app: string, action: string, summary: string, object_type?: string, object_id?: string|int, before?: mixed, after?: mixed, user_id?: int, status?: string, undo_of?: int} $entry * @return int Entry id. */ public function log( array $entry ): int { $after = $entry['after'] ?? null; return $this->store->insert( array( 'user_id' => $entry['user_id'] ?? get_current_user_id(), 'app' => $entry['app'], 'action' => $entry['action'], 'object_type' => $entry['object_type'] ?? '', 'object_id' => (string) ( $entry['object_id'] ?? '' ), 'summary' => $entry['summary'], 'before' => $entry['before'] ?? null, 'after' => $after, 'after_hash' => ActivityRecord::hash( $after ), 'status' => $entry['status'] ?? ActivityRecord::DONE, 'undo_of' => $entry['undo_of'] ?? 0, ) ); } /** * Loads an entry. * * @param int $id Entry id. */ public function find( int $id ): ?ActivityRecord { return $this->store->find( $id ); } /** * Newest entries first. * * @param array $filters Filters. * @param int $limit Max rows. * @param int $offset Rows to skip. * @phpstan-param array{app?: string, object_type?: string, object_id?: string} $filters * @return ActivityRecord[] */ public function recent( array $filters = array(), int $limit = 50, int $offset = 0 ): array { return $this->store->recent( $filters, $limit, $offset ); } /** * Registers the undo handler for an action type. * * @param string $action Action type. * @param UndoHandler $handler Handler. */ public function register_undo_handler( string $action, UndoHandler $handler ): void { $this->undo_handlers[ $action ] = $handler; } /** * Whether an entry can be offered for undo (it does not check for later edits). * * @param ActivityRecord $record Entry. */ public function is_undoable( ActivityRecord $record ): bool { return ActivityRecord::DONE === $record->status && 0 === $record->undo_of && isset( $this->undo_handlers[ $record->action ] ); } /** * Undoes a change, unless the object was changed again since. * * @param int $id Entry id. */ public function undo( int $id ): Result { $record = $this->store->find( $id ); if ( null === $record ) { return Result::failure( 'not_found', __( 'This change is no longer in the activity log.', 'forensix-digital' ) ); } if ( ActivityRecord::UNDONE === $record->status ) { return Result::failure( 'already_undone', __( 'This change was already undone.', 'forensix-digital' ) ); } if ( ! $this->is_undoable( $record ) ) { return Result::failure( 'not_undoable', __( 'This change cannot be undone automatically.', 'forensix-digital' ) ); } $handler = $this->undo_handlers[ $record->action ]; try { $current = $handler->current_state( $record ); } catch ( \Throwable $e ) { return Result::failure( 'state_unavailable', __( 'The changed item could not be read, so nothing was undone.', 'forensix-digital' ), array( 'error' => $e->getMessage() ) ); } if ( ! hash_equals( $record->after_hash, ActivityRecord::hash( $current ) ) ) { return Result::failure( 'conflict', __( 'The item was changed after Forensix changed it. Undo was stopped so those later edits are not lost.', 'forensix-digital' ), array( 'current' => $current ) ); } try { $result = $handler->undo( $record ); } catch ( \Throwable $e ) { $result = Result::failure( 'undo_failed', __( 'The change could not be undone.', 'forensix-digital' ), array( 'error' => $e->getMessage() ) ); } if ( ! $result->ok ) { return $result; } $this->store->set_status( $record->id, ActivityRecord::UNDONE ); $this->log( array( 'app' => $record->app, 'action' => $record->action . '.undo', 'object_type' => $record->object_type, 'object_id' => $record->object_id, /* translators: %s: summary of the original change. */ 'summary' => sprintf( __( 'Undid: %s', 'forensix-digital' ), $record->summary ), 'before' => $record->after, 'after' => $record->before, 'undo_of' => $record->id, ) ); return $result; } /** * Deletes entries older than the retention period. * * @param int $days Retention in days. */ public function purge( int $days = self::DEFAULT_RETENTION_DAYS ): void { $this->store->purge_older_than( max( 1, $days ) ); } }