*/ private array $data = []; public static function get_instance(): ?self { return self::$instance; } public function __construct() { self::$instance = $this; $stored = get_option( self::OPTION_KEY, [] ); $this->data = wp_parse_args( $stored, $this->defaults() ); } /* ------------------------------------------------------------------ Read ------------------------------------------------------------------ */ public function get( string $key, mixed $fallback = null ): mixed { return $this->data[ $key ] ?? $fallback; } public function all(): array { return $this->data; } /* ------------------------------------------------------------------ Write ------------------------------------------------------------------ */ public function set( string $key, mixed $value ): void { $this->data[ $key ] = $value; update_option( self::OPTION_KEY, $this->data, false ); } public function save( array $data ): void { $this->data = wp_parse_args( $data, $this->defaults() ); update_option( self::OPTION_KEY, $this->data, false ); } /* ------------------------------------------------------------------ Convenience getters ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ AI — effective configuration Every AI caller uses these. On multisite they resolve between this site's own key and the network key (see NetworkAi); on single site they are simply this site's settings. ------------------------------------------------------------------ */ /** Where the AI configuration comes from: 'site', 'network' or 'none'. */ public function ai_source(): string { $site = $this->has_site_ai_api_key(); if ( class_exists( NetworkAi::class ) && NetworkAi::enabled() ) { // NetworkAi is premium return NetworkAi::source_for( $site ); } return $site ? 'site' : 'none'; } public function ai_provider(): string { return $this->ai_source() === 'network' ? NetworkAi::provider() : $this->site_ai_provider(); } public function ai_model(): string { return $this->ai_source() === 'network' ? NetworkAi::model() : $this->site_ai_model(); } /** Decrypted effective API key, or '' if none. */ public function ai_api_key(): string { return match ( $this->ai_source() ) { 'site' => $this->site_ai_api_key(), 'network' => NetworkAi::api_key(), default => '', }; } /** True if an effective key exists (without exposing it). */ public function has_ai_api_key(): bool { return $this->ai_source() !== 'none'; } /* ------------------------------------------------------------------ AI — this site's own settings (Settings page reads/writes these) ------------------------------------------------------------------ */ public function site_ai_provider(): string { return $this->get( 'ai_provider', 'openai' ); } public function site_ai_model(): string { return $this->get( 'ai_model', 'gpt-4o-mini' ); } /** This site's own decrypted key, or '' if none stored. */ public function site_ai_api_key(): string { return self::decrypt_stored( (string) $this->get( 'ai_api_key', '' ) ); } /** True if this site stores its own key. */ public function has_site_ai_api_key(): bool { return $this->get( 'ai_api_key', '' ) !== ''; } /** * A site key is stored but can no longer be decrypted — WordPress's AUTH_KEY * changed after it was saved (new salts, a clone or move with another * wp-config.php). has_site_ai_api_key() is still true then (0.98.174). */ public function site_ai_key_unreadable(): bool { return $this->has_site_ai_api_key() && $this->site_ai_api_key() === ''; } /** The key AI calls would use (site or network) is stored but can't be decrypted. */ public function ai_key_unreadable(): bool { return match ( $this->ai_source() ) { 'site' => $this->site_ai_key_unreadable(), 'network' => NetworkAi::has_key() && NetworkAi::api_key() === '', default => false, }; } /** Stored value ("cci_enc:…" or legacy plain text) → plain key, or ''. */ public static function decrypt_stored( string $stored ): string { if ( $stored === '' ) return ''; // Legacy: if not prefixed, it's a plain-text key from before encryption. if ( strncmp( $stored, 'cci_enc:', 8 ) !== 0 ) return $stored; return self::decrypt_key( substr( $stored, 8 ) ) ?: ''; } /** Plain key → value to store ("cci_enc:…", or plain if OpenSSL is missing). */ public static function encrypt_for_storage( string $key ): string { $encrypted = self::encrypt_key( $key ); return $encrypted !== false ? 'cci_enc:' . $encrypted : $key; } /** Encrypt and store the API key. */ public function save_ai_api_key( string $key ): void { if ( $key === '' ) return; $data = $this->all(); $data['ai_api_key'] = self::encrypt_for_storage( $key ); $this->data = $data; // keep this request's copy in sync (a later set() would drop the key) update_option( 'clusterlane_settings', $data ); } private static function get_encryption_key(): string { // Derive a 32-byte key from WP's AUTH_KEY so it never touches the DB. $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'clusterlane-fallback-salt-change-me'; return substr( hash( 'sha256', $salt . 'cci_api_key', true ), 0, 32 ); } private static function encrypt_key( string $plaintext ): string|false { if ( ! function_exists( 'openssl_encrypt' ) ) return false; $iv = random_bytes( 16 ); $enc = openssl_encrypt( $plaintext, 'aes-256-cbc', self::get_encryption_key(), 0, $iv ); if ( $enc === false ) return false; return base64_encode( $iv . base64_decode( $enc ) ); } private static function decrypt_key( string $ciphertext ): string|false { if ( ! function_exists( 'openssl_decrypt' ) ) return false; $raw = base64_decode( $ciphertext, true ); if ( $raw === false || strlen( $raw ) < 17 ) return false; $iv = substr( $raw, 0, 16 ); $enc = base64_encode( substr( $raw, 16 ) ); return openssl_decrypt( $enc, 'aes-256-cbc', self::get_encryption_key(), 0, $iv ) ?: false; } /** Custom gallery shortcode names for image counting (one per line). */ public function gallery_shortcodes(): array { $raw = $this->get( 'gallery_shortcodes', '' ); if ( ! $raw ) return []; return array_values( array_filter( array_map( 'trim', preg_split( '/[\n,]+/', $raw ) ) ) ); } /** How to truncate article titles in Clusterlane's own views (never touches DB). */ public function title_truncation(): string { return $this->get( 'title_truncation', 'none' ); } /** Custom regex pattern for title truncation (used when title_truncation = 'custom'). */ public function title_truncation_custom(): string { return $this->get( 'title_truncation_custom', '' ); } public function language(): string { return $this->get( 'language', 'en' ); } /** Custom stop words for Text Match Scanner, as an array of lowercase strings. */ public function tms_stop_words(): array { $raw = self::heal_entities( (string) $this->get( 'tms_stop_words', '' ) ); if ( ! $raw ) return []; return array_values( array_filter( array_map( 'trim', preg_split( '/[\n,]+/', $raw ) ) ) ); } public function post_types(): array { return $this->get( 'post_types', ['post'] ); } /** * Scanned post types whose published pages are read as well (0.98.177): links * and text the theme adds through its templates — lists, cards, related * articles — which are not in the article text. Off by default. */ public function rendered_types(): array { return array_values( array_intersect( array_map( 'sanitize_key', (array) $this->get( 'rendered_types', [] ) ), array_map( 'sanitize_key', (array) $this->post_types() ) ) ); } /** * Terms of any other taxonomy chosen as clusters (0.98.177) — attraction * categories, regions, genres … — as taxonomy → term IDs. Categories, tags * and product categories keep their own settings. * @return array */ public function enabled_terms(): array { $out = []; foreach ( (array) $this->get( 'enabled_terms', [] ) as $tax => $ids ) { $tax = sanitize_key( (string) $tax ); if ( $tax === '' || in_array( $tax, [ 'category', 'post_tag', 'product_cat', 'custom' ], true ) ) continue; $out[ $tax ] = array_values( array_unique( array_filter( array_map( 'intval', (array) $ids ) ) ) ); } return $out; } /** User-defined "See also" / link-insertion HTML templates. Each: {id, name, html}. */ public function link_templates(): array { return $this->get( 'link_templates', [] ); } public function taxonomies(): array { return $this->get( 'taxonomies', [ 'category' ] ); } public function taxonomy(): string { // Legacy single-taxonomy accessor — returns first active taxonomy. $taxs = $this->taxonomies(); return $taxs[0] ?? 'category'; } public function min_term_count(): int { return (int) $this->get( 'min_term_count', 1 ); } /** Cluster keys (term slugs) that are disabled by the user. */ public function disabled_clusters(): array { return $this->get( 'disabled_clusters', [] ); } /** Individual category term IDs explicitly chosen as clusters on the Cluster Admin page. */ public function enabled_categories(): array { return array_map( 'intval', (array) $this->get( 'enabled_categories', [] ) ); } /** Product categories (WooCommerce product_cat term IDs) chosen as clusters on the Cluster Admin page (0.98.172). */ public function enabled_product_cats(): array { return array_map( 'intval', (array) $this->get( 'enabled_product_cats', [] ) ); } /** Individual tag term IDs explicitly chosen as clusters on the Cluster Admin page. */ public function enabled_tags(): array { return array_map( 'intval', (array) $this->get( 'enabled_tags', [] ) ); } /** * Whether the user has explicitly saved a category/tag selection on * the Cluster Admin page at least once. Before that, category/tag * clustering falls back to the taxonomy-wide min_term_count behavior * so the plugin still works out of the box with zero configuration. */ public function cluster_terms_configured(): bool { return (bool) $this->get( 'cluster_terms_configured', false ); } /** CSS selectors whose content the Text Match Scanner should ignore. */ public function tms_ignore_selectors(): array { return $this->get( 'tms_ignore_selectors', ['.info-list', '.visitor-info'] ); } /* ------------------------------------------------------------------ AI prompts — overrideable per-site ------------------------------------------------------------------ */ public function prompt( string $key ): string { $prompts = $this->get( 'prompts', [] ); return isset( $prompts[ $key ] ) ? self::heal_entities( (string) $prompts[ $key ] ) : ( $this->default_prompts()[ $key ] ?? '' ); } /** * Plain text from a settings textarea (prompts, stop words, instructions). * Not sanitize_textarea_field(): for text with a lone "<" (a prompt's * "<=155 chars", a regex "(? 'openai', 'ai_api_key' => '', 'ai_model' => 'gpt-4o-mini', 'language' => 'en', 'post_types' => ['post'], 'taxonomies' => ['category'], 'min_term_count' => 1, 'batch_size' => 25, 'title_truncation' => 'none', 'title_truncation_custom' => '', // SEO title & description (SeoMeta): auto = active SEO plugin, else these fields. 'seo_source' => 'auto', 'seo_title_field' => 'seo_title', 'seo_description_field' => 'seo_description', 'seo_fallback' => true, 'show_thumbnails' => true, // small featured images in article lists // AI suggestions for SEO title/description (Pro). 'seo_ai_suffix' => '', 'seo_ai_suffix_mode' => 'none', // none | include | auto 'seo_ai_language' => '', // '' = same as the article 'seo_ai_instructions' => '', 'seo_ai_write' => true, // custom fields only (SeoMeta::can_write) 'gallery_shortcodes' => '', 'disabled_clusters' => [], 'enabled_categories' => [], 'enabled_tags' => [], 'enabled_product_cats' => [], 'enabled_terms' => [], // other taxonomies: taxonomy => term IDs (0.98.177) 'rendered_types' => [], // post types whose published pages are read (0.98.177) 'rendered_cached' => false, // read them as the page cache serves them (0.98.178) 'enabled_hubs' => [], // post types whose pages are clusters (0.98.180) 'cluster_terms_configured'=> false, 'tms_ignore_selectors' => ['.info-list', '.visitor-info'], 'prompts' => $this->default_prompts(), 'link_templates' => $this->default_link_templates(), ]; } /** Public static accessor so the Settings page can read defaults without an instance. */ public static function default_prompts_public(): array { return ( new self() )->default_prompts(); } private function default_prompts(): array { return [ 'link_style_suggest' => 'You are a web designer. Suggest 3 distinct color/style variants for a small ' . '"link card" — a short highlighted box inserted into article text, with an optional ' . 'bold heading line and a sentence containing a link. Base the palette on the ' . 'description given. Each variant needs a heading (a short label like "Worth exploring", ' . 'or empty string for no heading), a border_color and background_color that are ' . 'harmonious and readable together, a text_color with strong contrast against the ' . 'background, whether to show a left border accent (has_border), and a border_radius ' . 'in pixels (0-16). All colors as hex strings like "#2271b1". ' . 'Return ONLY valid JSON: {"variants":[{"heading":"text","has_border":true,' . '"border_color":"#hex","background_color":"#hex","text_color":"#hex","border_radius":4}]}', 'cluster_detect' => 'You are a content strategist. The site already groups its articles by category and tag. ' . 'Find CROSS-CUTTING themes those groups miss — reader situations such as a season or occasion, ' . 'an audience, a budget, an area or a day itinerary — and group articles from different existing ' . 'clusters into new clusters a reader would follow. ' . 'Return ONLY valid JSON in this exact shape: ' . '{"clusters":[{"name":"Cluster name","slug":"cluster-slug","post_ids":[1,2,3],"rationale":"Why these belong together"}]}', 'gap_analysis' => 'You are a content strategist. The following articles form a content cluster. ' . 'Analyse the cluster and identify: ' . '1. Topics that are under-covered or missing entirely. ' . '2. Three specific new article ideas that would strengthen the cluster. ' . 'Be concrete and specific to the subject matter. ' . 'Return ONLY valid JSON: ' . '{"analysis":"Your analysis","suggestions":[{"title":"Article title","rationale":"Why it helps"}]}', 'gap_brief' => 'You are a senior content strategist writing a brief for a NEW article that will strengthen an existing content cluster. ' . 'You are given the cluster name, the proposed article title and rationale, and the existing cluster articles with their IDs, URLs, roles and excerpts. ' . 'Write a practical brief a human writer (or a writing assistant) can execute. ' . 'Rules: sections must be specific to the subject, not generic; link_to must ONLY use post IDs from the list; ' . 'prefer pillar/support articles for link_from; keep the brief concise. ' . 'Return ONLY valid JSON: ' . '{"purpose":"1-2 sentences on what this article adds to the cluster",' . '"target_keyword":"primary search phrase",' . '"meta_description":"<=155 chars",' . '"sections":[{"heading":"H2 heading","notes":"1-2 sentences on what this section should cover"}],' . '"link_to":[{"post_id":123,"anchor":"suggested anchor text","why":"short reason"}],' . '"link_from":[{"post_id":123,"why":"short reason"}]}', 'link_suggest' => 'You are an SEO specialist. I want to add an internal link from Article A to Article B. ' . 'Read the excerpt from Article A and suggest 3 different ways to naturally include ' . 'a link to Article B — varying anchor text, surrounding context, and position in the text. ' . 'The anchor_text must not repeat words that already appear elsewhere in the context ' . 'sentence — it should read naturally once substituted for [LINK], with no phrase duplicated. ' . 'Return ONLY valid JSON: ' . '{"suggestions":[{"anchor_text":"text","context":"surrounding sentence with [LINK] placeholder","position":"beginning|middle|end"}]}', ]; } /** * Starter "See also" card designs, shown out of the box so a fresh * install isn't just a blank textarea. Matches the exact style format * the Settings page's Quick Style panel produces (same placeholders), * so any of these can be reopened there and tweaked normally. Also * offered as a one-click "✨ Add starter templates" button on the * Settings page itself, for sites that already have link_templates * saved — this code default only applies to brand-new installs, since * an existing site's saved settings always win over these. */ private function default_link_templates(): array { return [ [ 'id' => 'tpl_worth_exploring', 'name' => '💡 Worth exploring', 'html' => '
Worth exploring
{sentence}
', ], [ 'id' => 'tpl_also_worth_reading', 'name' => '📚 Also worth reading', 'html' => '
Also worth reading
{sentence}
', ], [ 'id' => 'tpl_nearby', 'name' => '📍 Nearby', 'html' => '
Nearby
{sentence}
', ], [ 'id' => 'tpl_related', 'name' => '➜ Related', 'html' => '
{sentence}
', ], ]; } }