" => mode. */ public const GROUP_PREFIX = '@group:'; /** Option key prefix of a group's read-only switch: "@readonly:" => true. */ public const READONLY_PREFIX = '@readonly:'; /** * Image/embedded-resource results only work for direct tools (ruling Q6 / task 6 * dispatch): lit/media-preview (ImageContent) and lit/media-download (embedded * resource) can be direct or disabled, but never indirect. */ public const DIRECT_ONLY = ['lit/media-preview', 'lit/media-download']; /** Entry point for every conversation – must always stay a direct tool. */ public const REQUIRED = ['lit/guide']; /** @var array Ability name => mode. */ private array $tools = []; /** @var array Group id => mode. */ private array $groupModes = []; /** @var array Group ids switched to "nur lesen". */ private array $readonlyGroups = []; /** * Ability name => group id. [] = no groups known (withGroups() not called: group entries have * no effect); null = a lazy $groupSource is set and not resolved yet (groupOf() resolves it). * * @var array|null */ private ?array $groups = []; /** @var (callable(): array)|null Lazy source of $groups. */ private $groupSource = null; /** * @param array $overrides Option content: ability name => mode, plus * "@group:" => mode and "@readonly:" => true. */ public function __construct(array $overrides = []) { foreach ($overrides as $key => $value) { ['kind' => $kind, 'id' => $id] = self::parseKey((string) $key); if ($kind === 'group') { if (is_string($value) && in_array($value, self::MODES, true)) { $this->groupModes[$id] = $value; } } elseif ($kind === 'readonly') { if ($value === true) { $this->readonlyGroups[$id] = true; } } elseif (is_string($value)) { $this->tools[$id] = $value; } } } public static function fromOption(): self { return new self(self::sanitize(get_option(self::OPTION, []))); } /** * Copy of this policy that knows which group (integration id) each ability belongs to. * Without it, group entries have no effect. A callable is resolved on first use – boot runs * on plugins_loaded, before the theme (Divi) is loaded, so the map must not be built there. * * @param array|(callable(): array) $groups Ability name => group id. */ public function withGroups(array|callable $groups): self { $clone = clone $this; if (is_array($groups)) { $clone->groups = $groups; $clone->groupSource = null; } else { $clone->groups = null; $clone->groupSource = $groups; } return $clone; } public function mode(Ability $ability): string { if (self::isRequired($ability->name())) { return $this->defaultMode($ability); } if ($this->disabledByReadonly($ability)) { return self::DISABLED; } return $this->toolOverride($ability->name()) ?? $this->inheritedMode($ability); } /** Mode the ability gets without an override of its own: group override › tool default. */ public function inheritedMode(Ability $ability): string { $name = $ability->name(); $groupMode = $this->groupModeOf($name); if ($groupMode === null || self::isRequired($name)) { return $this->defaultMode($ability); } return self::isAllowed($name, $groupMode) ? $groupMode : self::DIRECT; } /** True if the group's mode is not allowed for this ability and it falls back to "direct". */ public function groupModeAdjusted(Ability $ability): bool { $name = $ability->name(); $groupMode = $this->groupModeOf($name); return $groupMode !== null && ! self::isRequired($name) && ! self::isAllowed($name, $groupMode); } /** True if the group's "nur lesen" switch disables this (writing) ability. */ public function disabledByReadonly(Ability $ability): bool { $group = $this->groupOf($ability->name()); if ($group === null || ! isset($this->readonlyGroups[$group])) { return false; } if (self::isRequired($ability->name())) { return false; } return ($ability->annotations()['readonly'] ?? false) !== true; } public function hasToolOverride(string $name): bool { return $this->toolOverride($name) !== null; } public function toolOverride(string $name): ?string { $mode = $this->tools[$name] ?? null; return $mode !== null && self::isAllowed($name, $mode) ? $mode : null; } public function groupMode(string $group): ?string { return $this->groupModes[$group] ?? null; } public function isReadonly(string $group): bool { return isset($this->readonlyGroups[$group]); } public function defaultMode(Ability $ability): string { return $ability->isDirect() ? self::DIRECT : self::INDIRECT; } /** * @param list $abilities * @return list */ public function enabled(array $abilities): array { return array_values(array_filter( $abilities, fn (Ability $ability): bool => $this->mode($ability) !== self::DISABLED )); } /** * @param list $abilities * @return list */ public function directNames(array $abilities): array { $names = []; foreach ($abilities as $ability) { if ($this->mode($ability) === self::DIRECT) { $names[] = $ability->name(); } } return $names; } public static function isAllowed(string $name, string $mode): bool { if (! in_array($mode, self::MODES, true)) { return false; } if (self::isRequired($name)) { return $mode === self::DIRECT; } if (in_array($name, self::DIRECT_ONLY, true)) { return $mode !== self::INDIRECT; } return true; } /** * Settings API sanitize callback for the option. Keeps valid ability entries (name => allowed * mode), "@group:" => mode and "@readonly:" => true; drops everything else. With * $groupIds, only those group ids are accepted. * * @param list|null $groupIds Known group (integration) ids; null = any well-formed id. * @return array */ public static function sanitize(mixed $input, ?array $groupIds = null): array { if (! is_array($input)) { return []; } $clean = []; foreach ($input as $key => $value) { if (! is_string($key)) { continue; } ['kind' => $kind, 'id' => $id] = self::parseKey($key); if ($kind === 'group') { if ( self::isGroupId($id, $groupIds) && is_string($value) && in_array($value, self::MODES, true) ) { $clean[$key] = $value; } } elseif ($kind === 'readonly') { if ( self::isGroupId($id, $groupIds) && ($value === true || $value === '1' || $value === 1) ) { $clean[$key] = true; } } elseif ( is_string($value) && preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#D', $key) === 1 && self::isAllowed($key, $value) ) { $clean[$key] = $value; } } ksort($clean); return $clean; } /** * What an option key names: "@group:" a group's mode, "@readonly:" a group's * read-only switch, anything else an ability (id = the whole key). * * @return array{kind: 'group'|'readonly'|'tool', id: string} */ private static function parseKey(string $key): array { foreach (['group' => self::GROUP_PREFIX, 'readonly' => self::READONLY_PREFIX] as $kind => $prefix) { if (str_starts_with($key, $prefix)) { return ['kind' => $kind, 'id' => substr($key, strlen($prefix))]; } } return ['kind' => 'tool', 'id' => $key]; } private static function isRequired(string $name): bool { return in_array($name, self::REQUIRED, true); } /** @param list|null $groupIds */ private static function isGroupId(string $id, ?array $groupIds): bool { if (preg_match('#^[a-z0-9-]+$#D', $id) !== 1) { return false; } return $groupIds === null || in_array($id, $groupIds, true); } private function groupOf(string $name): ?string { if ($this->groups === null) { $this->groups = $this->groupSource !== null ? ($this->groupSource)() : []; } return $this->groups[$name] ?? null; } private function groupModeOf(string $name): ?string { $group = $this->groupOf($name); return $group === null ? null : ($this->groupModes[$group] ?? null); } }