{layout (PANEL_LAYOUTS), last * (the layout a folded panel opens in), x, y, w, h (a floating panel's place and size, px; a place is both or * none), min (a floating panel folded to its head)} (clean_panel()). It wins over PANELS, which a panel docked * before 0.67.0 is still read from (docked on the right). */ public const PANEL_STATE = 'panel_state'; /** * A page's windows (0.68.13, Rooster One's request 52; Ui::windows()), per product: layout key => {o: the windows' * keys in the user's order, s: window key => its state (layout (WINDOW_LAYOUTS), last, x, y, w, h, min: a panel's, * clean_window())}. One value per layout, saved whole; the layout saved last at the end. */ public const WINDOWS = 'windows'; /** * A window's layouts (0.68.13): docked in the page's row (its place in the order), floating over the page, folded * away. */ public const WINDOW_LAYOUTS = array( 'docked', 'floating', 'folded' ); /** * Most windows in one layout (0.68.13). */ public const MAX_WINDOWS = 12; /** * A table's text size (0.68.0, the owner: busy tables, the tiered ones especially), per product: table key => its * level, 1 to 5 (TEXT_SIZE_LEVELS; 3, the default, is not stored). table-engine.php's rooster_table_text_size() reads * it on the front end from the same row (META). */ public const TEXT_SIZES = 'text_sizes'; /** * A table's text sizes (0.68.0): level => the table's scale, in percent (the CSS zoom of its body). */ public const TEXT_SIZE_LEVELS = array( 1 => 80, 2 => 90, 3 => 100, 4 => 110, 5 => 125, ); /** * A side panel's layouts (0.67.0): docked at the start (left) or the end (right) of the main column, floating over * the page, folded away. */ public const PANEL_LAYOUTS = array( 'left', 'right', 'floating', 'folded' ); /** * The page boards the user rearranged (0.58.0), per product: board key => {o: the order of the bands that move, * c: each band's columns of card keys} (Board::clean(), Board::store()), the board saved last at the end. */ public const BOARDS = 'boards'; /** * The guidance level (0.61.0, UI-Design-Patterns §5, FR19): one of GUIDANCE_LEVELS, guided when absent; not per * product (the person's, for every product). Compact turns each guidance text into an info icon beside what it * explains (Guidance). */ public const GUIDANCE = 'guidance'; /** * The columns a person keeps shown in a table with a Columns chooser (0.61.0, FR17), per product: table id => the * shown column keys (Guidance::store()), the table saved last at the end. */ public const TABLES = 'tables'; /** * The themes: light, dark, or the device's preference. */ public const THEMES = array( 'light', 'dark', 'auto' ); /** * The guidance levels: every explanation shown, or Compact (an info icon in place of each guidance text). */ public const GUIDANCE_LEVELS = array( 'guided', 'compact' ); /** * The keys a client may write, and whether each is per product. */ public const KEYS = array( self::PINS => true, self::LAYOUT => true, self::AUDIENCES => true, self::HINTS => false, self::WELCOMED => true, self::THEME => false, self::PANELS => true, self::BOARDS => true, self::GUIDANCE => false, self::TABLES => true, self::PANEL_STATE => true, self::TEXT_SIZES => true, self::WINDOWS => true, ); /** * Largest stored value (bytes of JSON); a key with a budget of its own in LIMITS. */ public const MAX_BYTES = 4096; /** * The keys whose stored value may be larger: a product's boards hold one arrangement per screen (Board::store() * drops the oldest beyond this). */ public const LIMITS = array( self::PANEL_STATE => 8192, // Rest::save() drops the oldest panel's state beyond this self::TEXT_SIZES => 8192, // and the oldest table's text size self::WINDOWS => 8192, // and the oldest layout's windows (0.68.13) self::BOARDS => 16384, self::TABLES => 8192, // Guidance::store() drops the oldest table's choice beyond this ); /** * A side panel's state, cleaned (0.67.0, PANEL_STATE), or null: a known layout; `last` a shown one (else the * layout, else right); the place within 0 to 10,000 px and both or none; the width 200 to 2,000 px, the height 80 * to 3,000 px. * * @param mixed $value The decoded value. * @return array{layout: string, last: string, x: ?int, y: ?int, w: ?int, h: ?int, min: bool}|null */ public static function clean_panel( $value ): ?array { return self::clean_place( $value, array( 'left', 'right', 'floating' ), 'right' ); } /** * A window's state, cleaned (0.68.13, WINDOWS), or null: as a panel's, its layouts WINDOW_LAYOUTS (a folded one opens * docked unless it floated last). * * @param mixed $value The decoded value. * @return array{layout: string, last: string, x: ?int, y: ?int, w: ?int, h: ?int, min: bool}|null */ public static function clean_window( $value ): ?array { return self::clean_place( $value, array( 'docked', 'floating' ), 'docked' ); } /** * A page's windows, cleaned (0.68.13, WINDOWS), or null: `o` the keys in order (each once, at most MAX_WINDOWS), `s` * each listed window's state (clean_window(); one that fails is dropped). The windows themselves are checked when * the layout is drawn (Ui::windows()). * * @param mixed $value The decoded value. * @return array{o: list, s: array>}|null */ public static function clean_windows( $value ): ?array { if ( ! is_array( $value ) || ! is_array( $value['o'] ?? null ) || count( $value['o'] ) > self::MAX_WINDOWS || ( isset( $value['s'] ) && ! is_array( $value['s'] ) ) ) { return null; } $order = array(); foreach ( $value['o'] as $key ) { if ( ! is_string( $key ) || 1 !== preg_match( '/^[a-z0-9][a-z0-9_-]{0,39}$/', $key ) || in_array( $key, $order, true ) ) { return null; } $order[] = $key; } $states = array(); foreach ( $order as $key ) { $state = isset( $value['s'][ $key ] ) ? self::clean_window( $value['s'][ $key ] ) : null; if ( null !== $state ) { $states[ $key ] = $state; } } return array( 'o' => $order, 's' => $states, ); } /** * A panel's or a window's state, cleaned: a known layout (one of $shown, or folded); `last` a shown one (else the * layout, else $fallback); the place within 0 to 10,000 px and both or none; the width 200 to 2,000 px, the height * 80 to 3,000 px. * * @param mixed $value The decoded value. * @param array $shown The layouts that show it. * @param string $fallback The layout a folded one opens in, when it has none. * @return array{layout: string, last: string, x: ?int, y: ?int, w: ?int, h: ?int, min: bool}|null */ private static function clean_place( $value, array $shown, string $fallback ): ?array { if ( ! is_array( $value ) || ! in_array( $value['layout'] ?? '', array_merge( $shown, array( 'folded' ) ), true ) ) { return null; } $int = static fn( $v, int $min, int $max ): ?int => is_numeric( $v ) && (float) $v >= $min && (float) $v <= $max ? (int) round( (float) $v ) : null; $layout = (string) $value['layout']; $x = $int( $value['x'] ?? null, 0, 10000 ); $y = $int( $value['y'] ?? null, 0, 10000 ); return array( 'layout' => $layout, 'last' => in_array( $value['last'] ?? '', $shown, true ) ? (string) $value['last'] : ( 'folded' === $layout ? $fallback : $layout ), 'x' => null !== $x && null !== $y ? $x : null, 'y' => null !== $x && null !== $y ? $y : null, 'w' => $int( $value['w'] ?? null, 200, 2000 ), 'h' => $int( $value['h'] ?? null, 80, 3000 ), 'min' => ! empty( $value['min'] ), ); } /** * The request's cache: user => the row. * * @var array> */ private static array $cache = array(); /** * The whole row of a user. * * @param int $user_id User. * @return array */ public static function all( int $user_id ): array { if ( $user_id <= 0 ) { return array(); } if ( ! isset( self::$cache[ $user_id ] ) ) { $row = get_user_meta( $user_id, self::META, true ); self::$cache[ $user_id ] = is_array( $row ) ? $row : array(); } return self::$cache[ $user_id ]; } /** * One preference of a user (of a product, for a per-product key), or $fallback. * * @param int $user_id User. * @param string $key A key of KEYS. * @param string|null $product Product slug (per-product keys). * @param mixed $fallback Value when unset. * @return mixed */ public static function get( int $user_id, string $key, ?string $product = null, $fallback = null ) { $row = self::all( $user_id ); if ( ! empty( self::KEYS[ $key ] ) ) { return $row[ $key ][ (string) $product ] ?? $fallback; } return $row[ $key ] ?? $fallback; } /** * Stores a preference. False when the user, the key, the product or the size is not acceptable. * * @param int $user_id User. * @param string $key A key of KEYS. * @param mixed $value JSON-encodable value, or null to remove it. * @param string|null $product Product slug (per-product keys). */ public static function set( int $user_id, string $key, $value, ?string $product = null ): bool { if ( $user_id <= 0 || ! isset( self::KEYS[ $key ] ) ) { return false; } $per = self::KEYS[ $key ]; if ( $per && ( null === $product || 1 !== preg_match( '/^[a-z0-9][a-z0-9_-]{0,39}$/', $product ) ) ) { return false; } $json = wp_json_encode( $value ); if ( ! is_string( $json ) || strlen( $json ) > ( self::LIMITS[ $key ] ?? self::MAX_BYTES ) ) { return false; } $row = self::all( $user_id ); if ( null === $value ) { if ( $per ) { unset( $row[ $key ][ $product ] ); if ( isset( $row[ $key ] ) && array() === $row[ $key ] ) { unset( $row[ $key ] ); } } else { unset( $row[ $key ] ); } } elseif ( $per ) { $row[ $key ] = is_array( $row[ $key ] ?? null ) ? $row[ $key ] : array(); $row[ $key ][ $product ] = $value; } else { $row[ $key ] = $value; } if ( array() === $row ) { delete_user_meta( $user_id, self::META ); } else { update_user_meta( $user_id, self::META, $row ); } self::$cache[ $user_id ] = $row; return true; } /** * Moves a user's pins from a product's old store into the one row, once (the migration kit, PLAN §11): each old * id through $map (the adapter's id table: '' = no counterpart), the known ones appended to the pins the row * already holds, without duplicates, at most Chrome::MAX_PINS; with a registry, only ids of entries the user may * open and pin. The caller deletes the old store when this returns: the result is the row's pins for the product. * * @param int $user_id User. * @param string $product Product slug. * @param list $old_ids The old store's pin ids. * @param callable $map string $old_id => string $new_id ('' = none). * @return list */ public static function migrate( int $user_id, string $product, array $old_ids, callable $map ): array { $pins = array_values( array_filter( (array) self::get( $user_id, self::PINS, $product, array() ), 'is_string' ) ); if ( $user_id <= 0 || array() === $old_ids ) { return $pins; } $registry = Library::registry(); $visible = null !== $registry ? $registry->visible( $product ) : null; foreach ( $old_ids as $old ) { $new = is_string( $old ) ? (string) $map( $old ) : ''; if ( '' === $new || in_array( $new, $pins, true ) || count( $pins ) >= Chrome::MAX_PINS ) { continue; } if ( null !== $visible && ( ! isset( $visible[ $new ] ) || ! $visible[ $new ]['available'] || ! $visible[ $new ]['pin'] ) ) { continue; } $pins[] = $new; } self::set( $user_id, self::PINS, $pins, $product ); return $pins; } /** * Forgets the request cache (tests). * * @internal */ public static function forget(): void { self::$cache = array(); } /** * The user's theme (0.24.0): 'light', 'dark' or 'auto'; light when unset or unknown. * * @param int $user_id User. */ public static function theme( int $user_id ): string { $theme = self::get( $user_id, self::THEME, null, 'light' ); return is_string( $theme ) && in_array( $theme, self::THEMES, true ) ? $theme : 'light'; } /** * The user's guidance level (0.61.0): 'guided' or 'compact'; guided when unset or unknown (a new person sees every * explanation). * * @param int $user_id User. */ public static function guidance( int $user_id ): string { $level = self::get( $user_id, self::GUIDANCE, null, 'guided' ); return is_string( $level ) && in_array( $level, self::GUIDANCE_LEVELS, true ) ? $level : 'guided'; } }