*/ private const DEFAULTS = array( // Written by the connect flow, from what Sharely hands back. 'workspace_id' => '', 'workspace_name' => '', 'organization_id' => '', 'api_key' => '', 'key_last4' => '', 'rbac_enabled' => false, 'anonymous_role_id' => '', 'api_base_url' => 'https://api.sharely.ai', 'app_base_url' => 'https://knowledge.sharely.ai', 'app_internal_url' => '', 'site_base_url' => '', 'connected_at' => 0, // Settings. // null, not array(): never configured is not the same as "none", and // the two have to behave differently (see post_types()). 'post_types' => null, 'search_enabled' => true, 'ask_on_search' => true, 'stats_enabled' => true, 'stats_retention_days' => 30, ); /** * The stored settings, defaults filled in. * * @var array|null */ private ?array $settings = null; /** * Everything, with the constants applied over the option. * * @return array */ public function all(): array { if ( null === $this->settings ) { $stored = get_option( self::OPTION, array() ); $this->settings = wp_parse_args( is_array( $stored ) ? $stored : array(), self::DEFAULTS ); } $settings = $this->settings; foreach ( self::constants() as $key => $constant ) { if ( defined( $constant ) && '' !== (string) constant( $constant ) ) { $settings[ $key ] = (string) constant( $constant ); } } return $settings; } /** * One setting. * * @param string $key Setting name. * @param mixed $fallback Returned when unset. * * @return mixed */ public function get( string $key, $fallback = null ) { $all = $this->all(); return array_key_exists( $key, $all ) ? $all[ $key ] : $fallback; } /** * Which settings a `wp-config.php` constant can override. * * @return array */ public static function constants(): array { return array( 'workspace_id' => 'SHARELYAI_WORKSPACE_ID', 'api_key' => 'SHARELYAI_API_KEY', 'api_base_url' => 'SHARELYAI_API_URL', 'app_base_url' => 'SHARELYAI_APP_URL', 'app_internal_url' => 'SHARELYAI_APP_INTERNAL_URL', 'api_internal_url' => 'SHARELYAI_API_INTERNAL_URL', ); } /** * Whether this setting is fixed in `wp-config.php` (and so read-only here). * * @param string $key Setting name. */ public function is_locked( string $key ): bool { $constants = self::constants(); return isset( $constants[ $key ] ) && defined( $constants[ $key ] ) && '' !== (string) constant( $constants[ $key ] ); } /** * A workspace and a key are on file. */ public function is_connected(): bool { return '' !== (string) $this->get( 'workspace_id' ) && '' !== (string) $this->get( 'api_key' ); } /** * Writes settings, merged over what is there. * * Values fixed by a constant are never written: storing them would put a * stale copy in the database that the next reader has to know to ignore. * * @param array $values Settings to change. */ public function save( array $values ): void { $current = is_array( get_option( self::OPTION, array() ) ) ? get_option( self::OPTION, array() ) : array(); $next = wp_parse_args( $values, wp_parse_args( $current, self::DEFAULTS ) ); foreach ( array_keys( self::constants() ) as $key ) { if ( $this->is_locked( $key ) ) { unset( $next[ $key ] ); } } if ( isset( $next['api_key'] ) && '' !== (string) $next['api_key'] ) { $next['key_last4'] = substr( (string) $next['api_key'], -4 ); } // autoload off: the key has no business being in every page load's // option cache, and nothing here is needed on a front-end request that // is not already touching Sharely. update_option( self::OPTION, $next, false ); $this->settings = null; } /** * Forgets the connection. Nothing in Sharely is touched. */ public function forget(): void { delete_option( self::OPTION ); $this->settings = null; } /** * The API as the *browser* reaches it, without a trailing slash. * * The Ask panel streams from the platform directly, so this address has to * work from the visitor's machine. On production it is the only address * there is; it differs only where the server and the browser are on * different networks. */ public function api_base(): string { return untrailingslashit( (string) $this->get( 'api_base_url' ) ); } /** * The API as *this server* reaches it, without a trailing slash. * * The same split as `app_base()` and `app_internal_base()`, for the same * reason: WordPress in a container and the platform on the host machine * are not the same two addresses. Empty means "whatever the browser uses", * which is what production leaves it. * * Deployment-local, so provisioning never writes it — the platform cannot * know how one particular server reaches it. */ public function api_internal_base(): string { $internal = untrailingslashit( (string) $this->get( 'api_internal_url' ) ); return '' !== $internal ? $internal : $this->api_base(); } /** * The Knowledge app as the *browser* reaches it, without a trailing slash. * * Where the administrator is sent to approve the connection, and where deep * links into Sharely point. Must be reachable from the visitor's machine. */ public function app_base(): string { return untrailingslashit( (string) $this->get( 'app_base_url' ) ); } /** * The Knowledge app as *this server* reaches it, without a trailing slash. * * Usually the same host, and on production it is. It differs when the server * and the browser are on different networks — WordPress in a Docker * container and the app on the host machine, say, where the browser uses * `localhost:3000` and PHP has to use `host.docker.internal:3000`. Empty * means "the same as app_base()", which is what production leaves it. * * Deployment-local, so provisioning never writes it: the platform has no way * of knowing how this particular server reaches it. */ public function app_internal_base(): string { $internal = untrailingslashit( (string) $this->get( 'app_internal_url' ) ); return '' !== $internal ? $internal : $this->app_base(); } /** * When the site was connected, as a timestamp. Zero when it never was. */ public function connected_at(): int { return (int) $this->get( 'connected_at' ); } /** * Post types Sharely reads, defaulting to every public one. * * Unset is not the same as none: a site that has never been configured means * "everything public". An administrator who unticks every box means none, * and gets none. * * @return string[] */ public function post_types(): array { $stored = $this->get( 'post_types' ); if ( ! is_array( $stored ) ) { return self::public_post_types(); } // Intersected with what exists now, so a post type whose plugin has been // deactivated stops being in scope instead of being sent as a name. return array_values( array_intersect( self::public_post_types(), array_map( 'strval', $stored ) ) ); } /** * Every post type a visitor could reach, in the order WordPress lists them. * * Attachments are out: a media item is a file, and sending the page * WordPress wraps it in would put an empty result in search. * * @return string[] */ public static function public_post_types(): array { $types = get_post_types( array( 'public' => true ), 'names' ); unset( $types['attachment'] ); return array_values( $types ); } /** * The workspace id. */ public function workspace_id(): string { return (string) $this->get( 'workspace_id' ); } /** * The API key. Never printed, never logged. */ public function api_key(): string { return (string) $this->get( 'api_key' ); } }