| @@ -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 | } |