'Title', 'content' => 'Content', 'excerpt' => 'Excerpt', 'terms' => 'Categories and tags', 'author' => 'Author name (personal data)', 'meta_description' => 'SEO meta description', 'custom_fields' => 'Custom fields (listed below)', ]; public const STATUSES = ['publish', 'private']; /** * FIELDS with translated labels, for the settings screen. * * @return array */ public static function fieldLabels(): array { return [ 'title' => __('Title', 'aipcf-ai-provider-for-cloudflare'), 'content' => __('Content', 'aipcf-ai-provider-for-cloudflare'), 'excerpt' => __('Excerpt', 'aipcf-ai-provider-for-cloudflare'), 'terms' => __('Categories and tags', 'aipcf-ai-provider-for-cloudflare'), 'author' => __('Author name (personal data)', 'aipcf-ai-provider-for-cloudflare'), 'meta_description' => __('SEO meta description', 'aipcf-ai-provider-for-cloudflare'), 'custom_fields' => __('Custom fields (listed below)', 'aipcf-ai-provider-for-cloudflare'), ] + self::FIELDS; } public const TAX_MODES = ['none', 'include', 'exclude']; /** Setting keys owned by the Embeddings tab. */ public const KEYS = [ 'embeddings_enabled', 'embed_model', 'embed_store', 'vectorize_index', 'embed_daily_neurons', 'embed_post_types', 'embed_statuses', 'embed_fields', 'embed_meta_keys', 'embed_tax_mode', 'embed_terms', 'embed_after', 'embed_attachments', 'embed_store_text', 'embed_min_words', 'qdrant_url', 'qdrant_api_key', 'qdrant_collection', 'pg_host', 'pg_port', 'pg_database', 'pg_user', 'pg_password', 'pg_sslmode', 'pg_table', ]; /** Saved secrets: never shown again; a blank field keeps the saved value. */ public const SECRETS = ['qdrant_api_key', 'pg_password']; /** Each store's own fields. The tab shows (and submits) only the chosen store's. */ public const STORE_FIELDS = [ 'vectorize' => ['vectorize_index'], 'qdrant' => ['qdrant_url', 'qdrant_api_key', 'qdrant_collection'], 'pgvector' => ['pg_host', 'pg_port', 'pg_database', 'pg_user', 'pg_password', 'pg_sslmode', 'pg_table'], ]; public const SSL_MODES = ['disable', 'allow', 'prefer', 'require', 'verify-ca', 'verify-full']; /** @return array */ public static function defaults(): array { return [ 'embeddings_enabled' => 0, 'embed_model' => Models::DEFAULT, 'embed_daily_neurons' => 3000, 'embed_store' => StoreFactory::DEFAULT, 'vectorize_index' => '', 'embed_post_types' => ['post', 'page'], 'embed_statuses' => ['publish'], 'embed_fields' => ['title', 'content', 'excerpt'], 'embed_meta_keys' => [], 'embed_tax_mode' => 'none', 'embed_terms' => [], 'embed_after' => '', 'embed_attachments' => 0, 'embed_store_text' => 1, 'embed_min_words' => 0, 'qdrant_url' => '', 'qdrant_api_key' => '', 'qdrant_collection' => '', 'pg_host' => '', 'pg_port' => 5432, 'pg_database' => '', 'pg_user' => '', 'pg_password' => '', 'pg_sslmode' => 'prefer', 'pg_table' => '', ]; } /** * Whether the current user may choose the vector store and its connection. On * multisite only network administrators may: the URL and host are fetched by the * server, so a site administrator could otherwise probe the network's internal hosts. */ public static function canConfigureStores(): bool { $can = !is_multisite() || current_user_can('manage_network_options'); return (bool) apply_filters('aipcf_can_configure_vector_store', $can); } /** * Plain-language warnings about how the chosen store is reached. * * @return list */ public static function transportWarnings(): array { $s = get_plugin_settings(); $store = (string) ($s['embed_store'] ?? StoreFactory::DEFAULT); $out = []; if ($store === 'qdrant') { $url = (string) ($s['qdrant_url'] ?? ''); $host = (string) wp_parse_url($url, PHP_URL_HOST); if (stripos($url, 'http://') === 0 && !Stores\Endpoint::isLocal($host)) { $out[] = __('The Qdrant URL uses http://, so the API key and your content travel unencrypted. Use https:// unless Qdrant runs on this server.', 'aipcf-ai-provider-for-cloudflare'); } } if ($store === 'pgvector') { $host = (string) ($s['pg_host'] ?? ''); $mode = (string) ($s['pg_sslmode'] ?? 'prefer'); if ($host !== '' && !Stores\Endpoint::isLocal($host) && in_array($mode, ['disable', 'allow', 'prefer'], true)) { $out[] = __('The PostgreSQL connection may be unencrypted (SSL mode is not "require"). For a database on another machine, set SSL to "require" or stricter.', 'aipcf-ai-provider-for-cloudflare'); } } return $out; } public static function enabled(): bool { return !empty(get_plugin_settings()['embeddings_enabled']); } /** @return list */ public static function postTypes(): array { $saved = (array) (get_plugin_settings()['embed_post_types'] ?? []); $types = array_values(array_intersect(array_map('strval', $saved), self::availablePostTypes())); return (array) apply_filters('aipcf_embed_post_types', $types); } /** * Public post types that hold text worth embedding (attachments are a separate switch). * * @return list */ public static function availablePostTypes(): array { $types = get_post_types(['public' => true], 'names'); unset($types['attachment']); return array_values(array_map('strval', $types)); } /** @return list */ public static function statuses(): array { $s = array_values(array_intersect(self::STATUSES, (array) (get_plugin_settings()['embed_statuses'] ?? ['publish']))); return $s !== [] ? $s : ['publish']; } /** @return list */ public static function fields(): array { $f = array_values(array_intersect(array_keys(self::FIELDS), (array) (get_plugin_settings()['embed_fields'] ?? []))); return $f !== [] ? $f : ['title', 'content']; } /** @return list */ public static function metaKeys(): array { return array_values(array_filter(array_map('strval', (array) (get_plugin_settings()['embed_meta_keys'] ?? [])))); } public static function taxMode(): string { $m = (string) (get_plugin_settings()['embed_tax_mode'] ?? 'none'); return in_array($m, self::TAX_MODES, true) && self::terms() !== [] ? $m : 'none'; } /** * Term filters as taxonomy => slugs. * * @return array> */ public static function terms(): array { $out = []; foreach ((array) (get_plugin_settings()['embed_terms'] ?? []) as $entry) { $parts = explode(':', (string) $entry, 2); if (count($parts) === 2 && taxonomy_exists($parts[0])) { $out[$parts[0]][] = $parts[1]; } } return $out; } public static function after(): string { $d = (string) (get_plugin_settings()['embed_after'] ?? ''); return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) ? $d : ''; } public static function includeAttachments(): bool { return !empty(get_plugin_settings()['embed_attachments']); } public static function storeText(): bool { return !empty(get_plugin_settings()['embed_store_text']); } /** * Posts and pages with fewer words than this are not embedded (0: no minimum). Images are * not counted: their title, alt text and caption are short by nature. */ public static function minWords(): int { return max(0, (int) (get_plugin_settings()['embed_min_words'] ?? 0)); } public static function dailyNeurons(): int { return max(0, (int) (get_plugin_settings()['embed_daily_neurons'] ?? 3000)); } /** * Identifies what the stored vectors were written with. When it changes, every * indexed object is stale and the index must be rebuilt (the owner confirms). */ public static function signature(): string { $store = StoreFactory::current(); $id = is_wp_error($store) ? StoreFactory::currentId() : $store->id(); $index = is_wp_error($store) ? '' : $store->indexName(); return self::signatureOf($id, $index); } /** * The signature of vectors written to another store or index with the current model (a * move compares rows against the store it copies from). */ public static function signatureOf(string $storeId, string $index): string { $model = Models::current(); $dims = (int) (Models::get($model)['dims'] ?? 0); // Same formula as 1.9.0-dev on whoisabhi.com: changing it would mark every indexed item stale. return substr(md5(implode('|', [$storeId, $index, $model, $dims, Chunker::VERSION])), 0, 12); } /** * aipcf_sanitize_settings filter: the Embeddings tab's fields. Keys another * tab submitted are left as they were saved. * * @param array $clean * @param array $input * @return array */ public static function sanitize(array $clean, array $input): array { $prev = get_plugin_settings(); $tab = isset($input['_tab']) ? sanitize_key((string) $input['_tab']) : ''; $own = $tab === '' || $tab === 'embeddings'; if (!$own) { foreach (self::KEYS as $key) { $clean[$key] = $prev[$key] ?? self::defaults()[$key]; } return $clean; } // Saved from the tab, an absent checkbox or list means "none". Saved without a // tab (code calling update_option), an absent key keeps its saved value. if ($tab === '') { foreach (self::KEYS as $key) { if (!array_key_exists($key, $input)) { $input[$key] = $prev[$key] ?? self::defaults()[$key]; } } } $list = static function (string $key) use ($input): array { return isset($input[$key]) && is_array($input[$key]) ? array_values(array_map(static fn($v): string => sanitize_text_field((string) $v), $input[$key])) : []; }; // The tab sends free text; stored values (and code) use arrays. $words = static function (string $key) use ($input): array { $v = $input[$key] ?? ''; $v = is_array($v) ? implode(',', array_map('strval', $v)) : (string) $v; return preg_split('/[\s,]+/', $v) ?: []; }; $clean['embeddings_enabled'] = empty($input['embeddings_enabled']) ? 0 : 1; if (!self::canConfigureStores()) { // Not this user's to change (multisite: network administrators only). foreach (array_merge(['embed_store'], ...array_values(self::STORE_FIELDS)) as $key) { $input[$key] = $prev[$key] ?? self::defaults()[$key]; unset($input[$key . '_clear']); } foreach (self::SECRETS as $key) { $input[$key . '_keep'] = true; } } $selected = sanitize_key((string) ($input['embed_store'] ?? '')); $selected = isset(StoreFactory::available()[$selected]) ? $selected : StoreFactory::DEFAULT; foreach (self::STORE_FIELDS as $owner => $keys) { foreach ($keys as $key) { // A field of another store: not shown (and disabled) on the tab, so keep the saved // value. For a secret this also stops a browser's autofill in a hidden password // field (e.g. the WordPress login) from being saved as that store's password. $isSecret = in_array($key, self::SECRETS, true); $clearMine = $isSecret && $owner === $selected && !empty($input[$key . '_clear']); if ((!array_key_exists($key, $input) && !$clearMine) || ($owner !== $selected && $isSecret)) { $input[$key] = $prev[$key] ?? self::defaults()[$key]; unset($input[$key . '_clear']); if ($isSecret) { $input[$key . '_keep'] = true; } } } } $model = sanitize_text_field((string) ($input['embed_model'] ?? '')); $clean['embed_model'] = Models::get($model) !== null ? $model : Models::DEFAULT; $store = sanitize_key((string) ($input['embed_store'] ?? '')); $clean['embed_store'] = isset(StoreFactory::available()[$store]) ? $store : StoreFactory::DEFAULT; $index = trim((string) ($input['vectorize_index'] ?? '')); $clean['vectorize_index'] = $index === '' ? '' : VectorizeStore::sanitizeIndexName($index); $clean['embed_daily_neurons'] = max(0, min(10000000, (int) ($input['embed_daily_neurons'] ?? 3000))); $clean['embed_post_types'] = array_values(array_intersect($list('embed_post_types'), self::availablePostTypes())); $clean['embed_statuses'] = array_values(array_intersect(self::STATUSES, $list('embed_statuses'))); if ($clean['embed_statuses'] === []) { $clean['embed_statuses'] = ['publish']; } $clean['embed_fields'] = array_values(array_intersect(array_keys(self::FIELDS), $list('embed_fields'))); if ($clean['embed_fields'] === []) { $clean['embed_fields'] = ['title', 'content']; } // One meta key per line or comma; protected keys (leading _) are allowed on purpose (ACF, SEO plugins). $keys = $words('embed_meta_keys'); // Not this plugin's own meta: _aipcf_related changes whenever lists are worked out, which // would re-embed the post, which would work its list out again, forever. $clean['embed_meta_keys'] = array_values(array_unique(array_filter(array_map(static fn($k): string => (string) preg_replace('/[^A-Za-z0-9_\-]/', '', (string) $k), $keys), static fn(string $k): bool => $k !== '' && strpos($k, '_aipcf_') !== 0))); $mode = sanitize_key((string) ($input['embed_tax_mode'] ?? 'none')); $clean['embed_tax_mode'] = in_array($mode, self::TAX_MODES, true) ? $mode : 'none'; $terms = $words('embed_terms'); $clean['embed_terms'] = []; foreach ($terms as $t) { $t = strtolower(trim((string) $t)); if ($t === '') { continue; } // "sponsored" means category:sponsored; "post_tag:ads" names the taxonomy. $parts = strpos($t, ':') !== false ? explode(':', $t, 2) : ['category', $t]; $tax = sanitize_key($parts[0]); $slug = sanitize_title($parts[1]); if ($slug !== '' && taxonomy_exists($tax)) { $clean['embed_terms'][] = $tax . ':' . $slug; } } $clean['embed_terms'] = array_values(array_unique($clean['embed_terms'])); $after = trim((string) ($input['embed_after'] ?? '')); $clean['embed_after'] = preg_match('/^\d{4}-\d{2}-\d{2}$/', $after) && strtotime($after) !== false ? $after : ''; $clean['embed_attachments'] = empty($input['embed_attachments']) ? 0 : 1; $clean['embed_store_text'] = empty($input['embed_store_text']) ? 0 : 1; $clean['embed_min_words'] = isset($input['embed_min_words']) && is_numeric($input['embed_min_words']) ? max(0, min(5000, (int) $input['embed_min_words'])) : 0; $url = esc_url_raw(trim((string) ($input['qdrant_url'] ?? '')), ['http', 'https']); $clean['qdrant_url'] = rtrim($url, '/'); $name = trim((string) ($input['qdrant_collection'] ?? '')); $clean['qdrant_collection'] = $name === '' ? '' : QdrantStore::sanitizeName($name); // Only characters that are safe in a PostgreSQL connection string (no ";", "=", spaces, quotes). $clean['pg_host'] = (string) preg_replace('/[^A-Za-z0-9.:\[\]-]/', '', trim((string) ($input['pg_host'] ?? ''))); $clean['pg_port'] = max(1, min(65535, (int) ($input['pg_port'] ?? 5432))); $clean['pg_database'] = (string) preg_replace('/[^A-Za-z0-9_.@$-]/', '', trim((string) ($input['pg_database'] ?? ''))); $clean['pg_user'] = (string) preg_replace('/[^A-Za-z0-9_.@$-]/', '', trim((string) ($input['pg_user'] ?? ''))); $mode = (string) ($input['pg_sslmode'] ?? 'prefer'); $clean['pg_sslmode'] = in_array($mode, self::SSL_MODES, true) ? $mode : 'prefer'; $table = trim((string) ($input['pg_table'] ?? '')); $clean['pg_table'] = $table === '' ? '' : PgvectorStore::sanitizeName($table); foreach (self::SECRETS as $key) { $value = trim((string) ($input[$key] ?? '')); if (!empty($input[$key . '_keep'])) { $value = (string) ($prev[$key] ?? ''); } elseif (!empty($input[$key . '_clear'])) { $value = ''; } elseif ($value === '') { $value = (string) ($prev[$key] ?? ''); } // Passwords may contain anything; only control characters are dropped. $clean[$key] = (string) preg_replace('/[\x00-\x1F\x7F]/', '', $value); } // A saved secret belongs to the server it was entered for: when the URL or host // changes without a new secret, drop it rather than send it to the new address. $newKey = trim((string) ($input['qdrant_api_key'] ?? '')) !== '' && empty($input['qdrant_api_key_keep']); if ((string) ($prev['qdrant_url'] ?? '') !== '' && $clean['qdrant_url'] !== (string) $prev['qdrant_url'] && !$newKey) { $clean['qdrant_api_key'] = ''; } $newPw = trim((string) ($input['pg_password'] ?? '')) !== '' && empty($input['pg_password_keep']); $moved = $clean['pg_host'] !== (string) ($prev['pg_host'] ?? '') || $clean['pg_port'] !== (int) ($prev['pg_port'] ?? 5432); if ((string) ($prev['pg_host'] ?? '') !== '' && $moved && !$newPw) { $clean['pg_password'] = ''; } // While a move to another store runs, the store stays as it is, or goes back to where // the move started (that is Cancel). Another change waits until the move is done. $move = State::get()['move']; if ($move !== null) { $keys = ['embed_store', 'vectorize_index', 'qdrant_collection', 'pg_table']; $from = (array) ($move['from_settings'] ?? []); $unchanged = true; $isCancel = $from !== []; foreach ($keys as $k) { $unchanged = $unchanged && (string) ($clean[$k] ?? '') === (string) ($prev[$k] ?? ''); if (array_key_exists($k, $from)) { $isCancel = $isCancel && (string) ($clean[$k] ?? '') === (string) $from[$k]; } } if (!$unchanged && !$isCancel) { foreach ($keys as $k) { $clean[$k] = $prev[$k] ?? self::defaults()[$k] ?? ''; } if (function_exists('add_settings_error')) { add_settings_error(AIPCF_SETTINGS_OPTION, 'aipcf_move_running', __('A move to another vector store is under way, so the store was not changed. Wait for it to finish, or cancel it on Settings > Content index.', 'aipcf-ai-provider-for-cloudflare'), 'warning'); } } } return $clean; } }