PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.5
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.5
4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 All 203 releases
betterdocs / includes / Core / Migration.php

Migration.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.5, at includes/Core/Migration.php

253 lines 9.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- one-shot data/schema migrations; caching unwanted.
3 namespace WPDeveloper\BetterDocs\Core;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 use WP_Query;
10 use WPDeveloper\BetterDocs\Utils\Base;
11 use WPDeveloper\BetterDocs\Utils\Database;
12 use WPDeveloper\BetterDocs\Dependencies\DI\Container;
13
14 class Migration extends Base {
15 /**
16 * Database
17 * @var Database
18 */
19 private $database;
20 private $settings;
21 private $container;
22
23 public function __construct( Container $container ) {
24 $this->container = $container;
25 $this->database = $container->get( Database::class );
26 $this->settings = $container->get( Settings::class );
27 }
28
29 public function init( $version ) {
30 if ( $version > 250 ) {
31 for ( $_version = 250; $_version <= $version; $_version++ ) {
32 if ( method_exists( $this, "v$_version" ) ) {
33 call_user_func( [ $this, "v$_version" ] );
34 }
35 }
36 }
37
38 $this->search_migration();
39 $this->fix_search_table_collation();
40 $this->backfill_search_keyword_hash();
41 $this->reset_formerly_locked_analytics_settings();
42
43 /**
44 * Settings Migration
45 */
46 $this->settings->migration( $version );
47 }
48
49 public function search_migration() {
50 global $wpdb;
51 if ( ! $this->database->get( 'betterdocs_search_data_migration', false ) ) {
52 $search_data = $this->database->get( 'betterdocs_search_data' );
53 if ( ! empty( $search_data ) ) {
54 $search_data_arr = unserialize( $search_data );
55 foreach ( $search_data_arr as $key => $value ) {
56 $args = [
57 'post_type' => 'docs',
58 'post_status' => 'publish',
59 'posts_per_page' => -1,
60 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Hooks.PreGetPosts.PreGetPosts,WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- migration must run on raw posts without language filters.
61 's' => $key
62 ];
63
64 $loop = new WP_Query( $args );
65 if ( $loop->have_posts() ) {
66 $count = $value;
67 $not_found_count = 0;
68 } else {
69 $count = 0;
70 $not_found_count = $value;
71 }
72
73 // Use BINARY comparison to avoid collation mismatch errors
74 $keyword = $wpdb->get_var(
75 $wpdb->prepare(
76 "
77 SELECT keyword
78 FROM {$wpdb->prefix}betterdocs_search_keyword
79 WHERE BINARY keyword = %s",
80 $key
81 )
82 );
83
84 if ( $keyword == null ) {
85 $insert = $wpdb->query(
86 $wpdb->prepare(
87 "INSERT INTO {$wpdb->prefix}betterdocs_search_keyword
88 ( keyword, keyword_hash )
89 VALUES ( %s, %s )",
90 [
91 $key,
92 md5( $key )
93 ]
94 )
95 );
96
97 if ( $insert ) {
98 $wpdb->query(
99 $wpdb->prepare(
100 "INSERT INTO {$wpdb->prefix}betterdocs_search_log
101 (keyword_id, count, not_found_count, created_at)
102 VALUES (%d, %d, %d, %s)",
103 [
104 $wpdb->insert_id,
105 $count,
106 $not_found_count,
107 gmdate( 'Y-m-d' )
108 ]
109 )
110 );
111 }
112 }
113 }
114
115 $this->database->save( 'betterdocs_search_data_migration', '1.0' );
116 }
117 }
118 }
119
120 /**
121 * Fix collation issues in search tables
122 * Converts tables to proper UTF-8 charset and collation to prevent
123 * "Illegal mix of collations" errors when searching with non-Latin characters
124 *
125 * @since 1.0.2
126 * @return void
127 */
128 public function fix_search_table_collation() {
129 global $wpdb;
130
131 // Check if migration already ran
132 if ( $this->database->get( 'betterdocs_search_collation_fixed', false ) ) {
133 return;
134 }
135
136 // Get the WordPress default charset and collation; restrict to a safe identifier
137 // alphabet because they're interpolated into ALTER TABLE statements below.
138 $charset = preg_replace( '/[^A-Za-z0-9_]/', '', $wpdb->charset ? $wpdb->charset : 'utf8mb4' );
139 $collate = preg_replace( '/[^A-Za-z0-9_]/', '', $wpdb->collate ? $wpdb->collate : 'utf8mb4_unicode_520_ci' );
140
141 // Fix search_keyword table
142 $search_keyword_table = $wpdb->prefix . 'betterdocs_search_keyword';
143 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- one-shot collation migration; identifiers come from $wpdb only.
144 if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $search_keyword_table ) ) === $search_keyword_table ) {
145 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter
146 $wpdb->query( "ALTER TABLE `{$search_keyword_table}` CONVERT TO CHARACTER SET {$charset} COLLATE {$collate}" );
147 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter
148 $wpdb->query( "ALTER TABLE `{$search_keyword_table}` MODIFY keyword TEXT CHARACTER SET {$charset} COLLATE {$collate} NOT NULL" );
149 }
150
151 // Fix search_log table
152 $search_log_table = $wpdb->prefix . 'betterdocs_search_log';
153 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
154 if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $search_log_table ) ) === $search_log_table ) {
155 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter
156 $wpdb->query( "ALTER TABLE `{$search_log_table}` CONVERT TO CHARACTER SET {$charset} COLLATE {$collate}" );
157 }
158
159 // Mark migration as complete
160 $this->database->save( 'betterdocs_search_collation_fixed', '1.0' );
161 }
162
163 /**
164 * Populate keyword_hash for rows that predate the column.
165 *
166 * The column is added by dbDelta with an empty default; until it holds the
167 * hash, insert_search_keyword() cannot find those rows by index and would
168 * insert duplicates. Runs in bounded batches so a large keyword table does
169 * not stall the request that triggers the upgrade — anything left over is
170 * picked up on the next admin load.
171 *
172 * @since 1.0.3
173 * @return void
174 */
175 public function backfill_search_keyword_hash() {
176 global $wpdb;
177
178 if ( $this->database->get( 'betterdocs_search_keyword_hash_filled', false ) ) {
179 return;
180 }
181
182 $table = $wpdb->prefix . 'betterdocs_search_keyword';
183
184 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- one-shot schema backfill; identifier comes from $wpdb.
185 if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) {
186 return;
187 }
188
189 // Bail if the column never made it (dbDelta failed) — retry next load.
190 if ( ! $wpdb->get_results( "SHOW COLUMNS FROM `{$table}` LIKE 'keyword_hash'" ) ) {
191 return;
192 }
193
194 $batches = 0;
195 do {
196 $updated = $wpdb->query(
197 "UPDATE `{$table}` SET keyword_hash = MD5( keyword ) WHERE keyword_hash = '' LIMIT 2000"
198 );
199 $batches++;
200 } while ( $updated > 0 && $batches < 25 );
201
202 // Only finish once nothing is left, so a table larger than 50k keywords
203 // resumes instead of being marked done half-filled.
204 $remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE keyword_hash = ''" );
205 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
206
207 if ( 0 === $remaining ) {
208 $this->database->save( 'betterdocs_search_keyword_hash_filled', '1.0.3' );
209 }
210 }
211
212 /**
213 * Clear the placeholder values the old Pro lock saved for the analytics
214 * collection settings.
215 *
216 * Until these settings were unlocked in Free (WP.org guideline 5), the
217 * settings screen merged Settings::pro_settings_default_values() into its
218 * form whenever Pro was inactive, so any save stored `analytics_from`,
219 * `unique_visitor_count`, `exclude_bot_analytics` and
220 * `child_category_exclude` as boolean false. Free ignored those keys then,
221 * so the tracker counted everyone, excluded bots and skipped unique visits.
222 *
223 * Free now reads the stored values, so a leftover `exclude_bot_analytics =
224 * false` would suddenly start counting bot traffic. A boolean false in
225 * `analytics_from` is the marker of that merge: no UI ever saves it (Free's
226 * select saves a string; Pro's Analytics → Settings never writes the key).
227 * When it is present, restore the behaviour those sites actually had.
228 * `unique_visitor_count` is left as stored — false there matches the old
229 * behaviour — and `child_category_exclude` false is already the default.
230 *
231 * @return void
232 */
233 public function reset_formerly_locked_analytics_settings() {
234 if ( $this->database->get( 'betterdocs_unlocked_analytics_settings_migrated', false ) ) {
235 return;
236 }
237
238 $settings = $this->database->get( 'betterdocs_settings', [] );
239
240 if ( is_array( $settings ) && array_key_exists( 'analytics_from', $settings ) && false === $settings['analytics_from'] ) {
241 $settings['analytics_from'] = 'everyone';
242
243 if ( array_key_exists( 'exclude_bot_analytics', $settings ) && ! $settings['exclude_bot_analytics'] ) {
244 $settings['exclude_bot_analytics'] = true;
245 }
246
247 $this->database->save( 'betterdocs_settings', $settings );
248 }
249
250 $this->database->save( 'betterdocs_unlocked_analytics_settings_migrated', '1' );
251 }
252 }
253