PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-focus-keywords.php

class-focus-keywords.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.1.1, at includes/seo/class-focus-keywords.php

281 lines 9.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Focus Keywords helper.
4 *
5 * Central read/write/normalize logic for the multi-focus-keyword feature.
6 * Keywords are stored as an array in `_thinkrank_focus_keywords` (up to
7 * Focus_Keywords::MAX). The legacy single-value meta `_thinkrank_focus_keyword`
8 * is kept in sync (= the primary/first keyword) for backward compatibility with
9 * older consumers that still read a string.
10 *
11 * @package ThinkRank\SEO
12 * @since 1.0.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\SEO;
18
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Focus Keywords storage + normalization helper.
25 *
26 * @since 1.0.0
27 */
28 class Focus_Keywords {
29
30 /**
31 * Array post meta key holding the full keyword list.
32 */
33 public const META_KEY = '_thinkrank_focus_keywords';
34
35 /**
36 * Legacy single-string meta key (kept = primary keyword for back-compat).
37 */
38 public const LEGACY_META_KEY = '_thinkrank_focus_keyword';
39
40 /**
41 * Meta key holding keywords beyond the current plan's usable limit.
42 *
43 * Keywords past the free cap are stored here rather than discarded, so they
44 * are never lost: they stay gated on free and become usable automatically
45 * once ThinkRank Pro raises the limit (see get()). The 6th+ are "Pro" only.
46 */
47 public const OVERFLOW_META_KEY = '_thinkrank_focus_keywords_overflow';
48
49 /**
50 * Free-tier maximum number of usable focus keywords per post.
51 *
52 * The effective limit is plan-aware — see limit(). Pro lifts this cap.
53 */
54 public const MAX = 5;
55
56 /**
57 * Effective number of usable focus keywords for the current plan.
58 *
59 * Resolves through Plan_Config (filterable by the Pro plugin). Returns 0 for
60 * "unlimited". Use this everywhere a cap is applied so the limit follows the
61 * plan rather than being hard-coded.
62 *
63 * @return int Usable keyword limit; 0 = unlimited.
64 */
65 public static function limit(): int {
66 if (!class_exists('\ThinkRank\Core\Plan_Config')) {
67 return self::MAX;
68 }
69 $caps = \ThinkRank\Core\Plan_Config::focus_keywords();
70 return (int) ($caps['max_keywords'] ?? self::MAX);
71 }
72
73 /**
74 * Normalize arbitrary input into a clean keyword array.
75 *
76 * Accepts an array of strings or a comma-separated string. Trims and
77 * sanitizes each value, drops empties, removes case-insensitive duplicates
78 * (keeping first occurrence / original order), and caps the result at
79 * `$limit`.
80 *
81 * @param mixed $input Array of keywords or comma-separated string.
82 * @param int|null $limit Maximum keywords to return. Null (default) uses the
83 * plan-aware limit(). Pass 0 (or negative) to return
84 * the full deduped list uncapped.
85 * @return string[] Normalized keyword list.
86 */
87 public static function normalize($input, ?int $limit = null): array {
88 if ($limit === null) {
89 $limit = self::limit();
90 }
91
92 if (is_string($input)) {
93 $input = explode(',', $input);
94 }
95
96 if (!is_array($input)) {
97 return [];
98 }
99
100 $seen = [];
101 $keywords = [];
102
103 foreach ($input as $keyword) {
104 if (is_array($keyword)) {
105 continue;
106 }
107
108 $keyword = sanitize_text_field(trim((string) $keyword));
109 if ($keyword === '') {
110 continue;
111 }
112
113 $dedupe_key = function_exists('mb_strtolower')
114 ? mb_strtolower($keyword)
115 : strtolower($keyword);
116
117 if (isset($seen[$dedupe_key])) {
118 continue;
119 }
120
121 $seen[$dedupe_key] = true;
122 $keywords[] = $keyword;
123
124 if ($limit > 0 && count($keywords) >= $limit) {
125 break;
126 }
127 }
128
129 return $keywords;
130 }
131
132 /**
133 * Get the usable focus keywords for a post (capped at the plan limit).
134 *
135 * Merges the stored keywords with any gated overflow, then caps at the
136 * plan-aware limit(). On free this returns the first 5 (overflow stays
137 * gated); on Pro the overflow keywords become usable automatically — no
138 * re-import needed. Falls back to the legacy single value for back-compat.
139 *
140 * @param int $post_id Post ID.
141 * @return string[] Usable keyword list (capped at limit()).
142 */
143 public static function get(int $post_id): array {
144 $base = self::read_stored($post_id);
145 $overflow = self::read_overflow($post_id);
146
147 return self::normalize(array_merge($base, $overflow));
148 }
149
150 /**
151 * Read the stored base keyword array (array meta, legacy fallback). Uncapped.
152 *
153 * @param int $post_id Post ID.
154 * @return string[] Stored keywords (deduped, uncapped).
155 */
156 private static function read_stored(int $post_id): array {
157 $stored = get_post_meta($post_id, self::META_KEY, true);
158 if (is_array($stored) && !empty($stored)) {
159 return self::normalize($stored, 0);
160 }
161
162 // Backward compatibility: convert the old single value into an array.
163 $legacy = get_post_meta($post_id, self::LEGACY_META_KEY, true);
164 if (is_string($legacy) && $legacy !== '') {
165 return self::normalize($legacy, 0);
166 }
167
168 return [];
169 }
170
171 /**
172 * Read the gated overflow keywords (keywords beyond the free cap).
173 *
174 * @param int $post_id Post ID.
175 * @return string[] Overflow keywords (deduped, uncapped).
176 */
177 private static function read_overflow(int $post_id): array {
178 $overflow = get_post_meta($post_id, self::OVERFLOW_META_KEY, true);
179 return is_array($overflow) ? self::normalize($overflow, 0) : [];
180 }
181
182 /**
183 * Get the primary (first) focus keyword for a post.
184 *
185 * @param int $post_id Post ID.
186 * @return string Primary keyword, or '' when none set.
187 */
188 public static function get_primary(int $post_id): string {
189 $keywords = self::get($post_id);
190 return $keywords[0] ?? '';
191 }
192
193 /**
194 * Save focus keywords edited by the user (metabox / inline edit / AI).
195 *
196 * The base meta always holds at most MAX keywords; anything beyond is kept
197 * in the gated overflow meta. This storage boundary is FIXED at MAX (it does
198 * NOT follow the plan limit) so the stored data is plan-portable: toggling
199 * Pro on/off only changes how much get() reveals, never where keywords live,
200 * so no keyword is ever stranded or lost.
201 *
202 * On Pro the input is the user's complete keyword set, so it is split into
203 * base (first MAX) + overflow (rest). On free the input is only the visible
204 * first MAX keywords, so it replaces the base while the gated overflow is
205 * left untouched (preserved).
206 *
207 * @param int $post_id Post ID.
208 * @param mixed $input Array of keywords or comma-separated string.
209 * @return string[] The keyword list persisted to the base meta.
210 */
211 public static function save(int $post_id, $input): array {
212 // Pro edits the full set: split it across base + overflow at MAX.
213 if (self::is_unlimited()) {
214 return self::save_with_overflow($post_id, $input)['kept'];
215 }
216
217 // Free edits only the visible (first MAX) keywords. Cap to the base
218 // boundary and leave any gated overflow untouched.
219 $keywords = self::normalize($input, self::MAX);
220
221 if (empty($keywords)) {
222 delete_post_meta($post_id, self::META_KEY);
223 delete_post_meta($post_id, self::LEGACY_META_KEY);
224 return [];
225 }
226
227 update_post_meta($post_id, self::META_KEY, $keywords);
228 update_post_meta($post_id, self::LEGACY_META_KEY, $keywords[0]);
229
230 return $keywords;
231 }
232
233 /**
234 * Persist a full keyword list, splitting into usable + gated overflow.
235 *
236 * Used by import/migration and by Pro saves where the source may carry more
237 * keywords than the free plan reveals. The split point is FIXED at MAX (not
238 * the plan limit): the first MAX keywords are the base, the rest are stored
239 * in the overflow meta (gated on free, auto-revealed by Pro via get()). This
240 * keeps stored data plan-portable so deactivating Pro never strands or loses
241 * keywords.
242 *
243 * @param int $post_id Post ID.
244 * @param mixed $input Array of keywords or comma-separated string.
245 * @return array{kept:string[],overflow:string[]} What was stored where.
246 */
247 public static function save_with_overflow(int $post_id, $input): array {
248 $all = self::normalize($input, 0);
249
250 if (empty($all)) {
251 delete_post_meta($post_id, self::META_KEY);
252 delete_post_meta($post_id, self::LEGACY_META_KEY);
253 delete_post_meta($post_id, self::OVERFLOW_META_KEY);
254 return ['kept' => [], 'overflow' => []];
255 }
256
257 $kept = array_slice($all, 0, self::MAX);
258 $overflow = array_slice($all, self::MAX);
259
260 update_post_meta($post_id, self::META_KEY, $kept);
261 update_post_meta($post_id, self::LEGACY_META_KEY, $kept[0]);
262
263 if (!empty($overflow)) {
264 update_post_meta($post_id, self::OVERFLOW_META_KEY, $overflow);
265 } else {
266 delete_post_meta($post_id, self::OVERFLOW_META_KEY);
267 }
268
269 return ['kept' => $kept, 'overflow' => $overflow];
270 }
271
272 /**
273 * Whether the current plan allows unlimited focus keywords.
274 *
275 * @return bool True when limit() is 0 (unlimited).
276 */
277 private static function is_unlimited(): bool {
278 return self::limit() <= 0;
279 }
280 }
281