row id, in the shape `LegacyCleanup` already established for one-shot state. * * The presence check deliberately IGNORES `deleted_at`: * * - renamed or edited -> the id still resolves, so it is left completely alone * - trashed -> the row still exists, so it is left alone. Treating a trashed example as * missing would restore a fresh copy alongside the one in the bin, which is * not what anybody who emptied their trash meant to ask for. * - purged -> the id resolves to nothing, so it is re-created and the map updated */ final class Seeder { /** * key => row id for every example this site has been given. */ public const OPTION = 'plugixa_crm_examples'; /** * Every contributed example, in a stable order. * * SORTED BY `order`, not by the sequence modules happen to be discovered in. One example may * reference another — the newsletter web form points its confirmation email at a template seeded * by a different module — and "whichever module the filesystem listed first" is not a guarantee * anybody should build a cross-reference on. * * @return array> */ public function catalogue(): array { /** * Collect the example content a fresh install is given. * * Each entry is an array of: * key string Stable identifier, unique across every contributor, e.g. `email.welcome`. * group string Machine id the Settings panel groups by, e.g. `email_template`. * label string Translated, human name for that group. * table string Bare table name, as passed to `Schema::table()`, for the presence check. * order int Sort position. Lower is seeded first; use it when one example refers to another. * create callable fn(array $map): int — insert it, return the new id or 0. * * @param array> $examples Contributed examples. */ $examples = (array) apply_filters( 'plugixa_crm_examples', array() ); usort( $examples, static fn( array $a, array $b ): int => ( (int) ( $a['order'] ?? 0 ) ) <=> ( (int) ( $b['order'] ?? 0 ) ) ); return $examples; } /** * What is present and what is missing, without changing anything. * * This is what makes the Restore button honest — it can say "3 of 24 missing" and switch itself * off when there is nothing to do, rather than being a blind re-run that always claims success. * * @return array{total: int, missing: int, groups: array} */ public function status(): array { $map = $this->map(); $groups = array(); $missing = 0; $total = 0; foreach ( $this->catalogue() as $example ) { $group = (string) ( $example['group'] ?? 'other' ); if ( ! isset( $groups[ $group ] ) ) { $groups[ $group ] = array( 'kind' => $group, 'label' => (string) ( $example['label'] ?? $group ), 'total' => 0, 'missing' => 0, ); } ++$groups[ $group ]['total']; ++$total; if ( ! $this->exists( (string) ( $example['table'] ?? '' ), (int) ( $map[ $example['key'] ] ?? 0 ) ) ) { ++$groups[ $group ]['missing']; ++$missing; } } return array( 'total' => $total, 'missing' => $missing, 'groups' => array_values( $groups ), ); } /** * Insert every example that is not there, and leave every one that is. * * A failed insert costs that one example and nothing else. Aborting the run on the first `0` * would mean one bad row denies the user the other twenty-three, and the next attempt would fail * in exactly the same place. * * The running map is handed to each `create` callback so an example can reference one seeded * before it, and it is written back once at the end rather than per row. * * @return array Group => how many were created. */ public function restore(): array { $map = $this->map(); $restored = array(); foreach ( $this->catalogue() as $example ) { $group = (string) ( $example['group'] ?? 'other' ); $key = (string) $example['key']; $restored[ $group ] = $restored[ $group ] ?? 0; if ( $this->exists( (string) ( $example['table'] ?? '' ), (int) ( $map[ $key ] ?? 0 ) ) ) { continue; } if ( ! is_callable( $example['create'] ?? null ) ) { continue; } $id = (int) call_user_func( $example['create'], $map ); if ( $id > 0 ) { $map[ $key ] = $id; ++$restored[ $group ]; } } update_option( self::OPTION, $map, false ); return $restored; } /** * The key => id map, defensively cast. * * @return array */ private function map(): array { $stored = get_option( self::OPTION, array() ); $map = array(); foreach ( (array) $stored as $key => $id ) { $map[ (string) $key ] = (int) $id; } return $map; } /** * Does that row still exist — trashed or not? * * `deleted_at` is not in the WHERE clause on purpose. See the class docblock. * * @param string $table Bare table name. * @param int $id The id this site recorded, or 0 if it never had one. */ private function exists( string $table, int $id ): bool { if ( $id <= 0 || '' === $table ) { return false; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, no object cache; runs on activation and on an explicit admin click. $found = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE id = %d', Db::table( $table ), $id ) ); return null !== $found; } }