PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 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 All 50 releases
← All changes | includes/seo/class-focus-keywords.php +52 -129 2.1.1 → 2.9.0 View file →
@@ -2,13 +2,18 @@
2 2 /**
3 3 * Focus Keywords helper.
4 4 *
5 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`
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 8 * is kept in sync (= the primary/first keyword) for backward compatibility with
9 9 * older consumers that still read a string.
10 10 *
11 + * This plugin stores MAX keywords per post and nothing beyond. An extension
12 + * that stores more (ThinkRank Pro) receives the full submitted list through
13 + * `thinkrank_focus_keywords_saved` and returns it through
14 + * `thinkrank_focus_keywords` (#673).
15 + *
11 16 * @package ThinkRank\SEO
12 17 * @since 1.0.0
13 18 */
14 19
@@ -27,9 +32,9 @@
27 32 */
28 33 class Focus_Keywords {
29 34
30 35 /**
31 - * Array post meta key holding the full keyword list.
36 + * Array post meta key holding the keyword list.
32 37 */
33 38 public const META_KEY = '_thinkrank_focus_keywords';
34 39
35 40 /**
@@ -37,41 +42,13 @@
37 42 */
38 43 public const LEGACY_META_KEY = '_thinkrank_focus_keyword';
39 44
40 45 /**
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 + * Focus keywords stored per post.
46 47 */
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 48 public const MAX = 5;
55 49
56 50 /**
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 51 * Normalize arbitrary input into a clean keyword array.
75 52 *
76 53 * Accepts an array of strings or a comma-separated string. Trims and
77 54 * sanitizes each value, drops empties, removes case-insensitive duplicates
@@ -78,16 +55,16 @@
78 55 * (keeping first occurrence / original order), and caps the result at
79 56 * `$limit`.
80 57 *
81 58 * @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.
59 + * @param int|null $limit Maximum keywords to return. Null (default) is MAX.
60 + * Pass 0 (or negative) to return the full deduped
61 + * list uncapped.
85 62 * @return string[] Normalized keyword list.
86 63 */
87 64 public static function normalize($input, ?int $limit = null): array {
88 65 if ($limit === null) {
89 - $limit = self::limit();
66 + $limit = self::MAX;
90 67 }
91 68
92 69 if (is_string($input)) {
93 70 $input = explode(',', $input);
@@ -129,27 +106,36 @@
129 106 return $keywords;
130 107 }
131 108
132 109 /**
133 - * Get the usable focus keywords for a post (capped at the plan limit).
110 + * Get the focus keywords for a post.
134 111 *
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.
112 + * Falls back to the legacy single value for back-compat.
139 113 *
140 114 * @param int $post_id Post ID.
141 - * @return string[] Usable keyword list (capped at limit()).
115 + * @return string[] Keyword list.
142 116 */
143 117 public static function get(int $post_id): array {
144 - $base = self::read_stored($post_id);
145 - $overflow = self::read_overflow($post_id);
118 + $keywords = self::normalize(self::read_stored($post_id));
146 119
147 - return self::normalize(array_merge($base, $overflow));
120 + /**
121 + * Filter a post's focus keywords.
122 + *
123 + * This plugin stores up to Focus_Keywords::MAX. An extension that
124 + * stores more returns the full list here.
125 + *
126 + * @since 2.6.0
127 + *
128 + * @param string[] $keywords Stored keywords, in order.
129 + * @param int $post_id Post ID.
130 + */
131 + $filtered = apply_filters('thinkrank_focus_keywords', $keywords, $post_id);
132 +
133 + return is_array($filtered) ? self::normalize($filtered, 0) : $keywords;
148 134 }
149 135
150 136 /**
151 - * Read the stored base keyword array (array meta, legacy fallback). Uncapped.
137 + * Read the stored keyword array (array meta, legacy fallback). Uncapped.
152 138 *
153 139 * @param int $post_id Post ID.
154 140 * @return string[] Stored keywords (deduped, uncapped).
155 141 */
@@ -168,19 +154,8 @@
168 154 return [];
169 155 }
170 156
171 157 /**
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 158 * Get the primary (first) focus keyword for a post.
184 159 *
185 160 * @param int $post_id Post ID.
186 161 * @return string Primary keyword, or '' when none set.
@@ -190,91 +165,39 @@
190 165 return $keywords[0] ?? '';
191 166 }
192 167
193 168 /**
194 - * Save focus keywords edited by the user (metabox / inline edit / AI).
169 + * Save focus keywords (metabox / inline edit / AI / import).
195 170 *
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.
171 + * Stores the first MAX keywords and hands the full submitted list to
172 + * `thinkrank_focus_keywords_saved`.
201 173 *
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 174 * @param int $post_id Post ID.
208 175 * @param mixed $input Array of keywords or comma-separated string.
209 - * @return string[] The keyword list persisted to the base meta.
176 + * @return string[] The post's keywords after the save, as get() reads them.
210 177 */
211 178 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 - }
179 + $all = self::normalize($input, 0);
180 + $kept = array_slice($all, 0, self::MAX);
216 181
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)) {
182 + if (empty($kept)) {
222 183 delete_post_meta($post_id, self::META_KEY);
223 184 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 185 } else {
266 - delete_post_meta($post_id, self::OVERFLOW_META_KEY);
186 + update_post_meta($post_id, self::META_KEY, $kept);
187 + update_post_meta($post_id, self::LEGACY_META_KEY, $kept[0]);
267 188 }
268 189
269 - return ['kept' => $kept, 'overflow' => $overflow];
270 - }
190 + /**
191 + * Fires after a post's focus keywords are saved.
192 + *
193 + * @since 2.6.0
194 + *
195 + * @param int $post_id Post ID.
196 + * @param string[] $keywords The full submitted list, including any
197 + * beyond Focus_Keywords::MAX.
198 + */
199 + do_action('thinkrank_focus_keywords_saved', $post_id, $all);
271 200
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;
201 + return self::get($post_id);
279 202 }
280 203 }