PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / migrations.php

migrations.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.10, at includes/migrations.php

697 lines 25.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — one-time data migrations.
4 *
5 * A tiny, option-versioned migration runner modeled on the lazy schema
6 * installer in `includes/desktop-files/schema.php`: a stored option holds
7 * the highest migration version that has run; on every admin load we
8 * compare it against {@see OPENSTATION_MIGRATION_VERSION} and run any
9 * pending migrations exactly once. Guarded so it is a cheap no-op after
10 * the first successful pass.
11 *
12 * On a site with no history the runner fires at activation instead, so
13 * nothing here ever has to infer the past of a site from evidence that
14 * site wrote after it was installed. See
15 * {@see openstation_run_migrations_on_activation}.
16 *
17 * @package OpenStation
18 */
19
20 defined( 'ABSPATH' ) || exit;
21
22 /**
23 * Highest migration version shipped by the plugin.
24 *
25 * Bump this (and add a matching branch in
26 * {@see openstation_run_pending_migrations}) whenever a new one-time
27 * migration is needed.
28 *
29 * A new migration runs on every install, including brand-new ones: on a
30 * site with no history the runner fires at activation
31 * ({@see openstation_run_migrations_on_activation}) rather than on the
32 * first `admin_init`.
33 *
34 * - 1: native list windows flipped from opt-out (default ON) to opt-in
35 * Beta (default OFF). Clears the five `native*Enabled` flags from every
36 * user who had them persisted so the whole install reverts to opt-in.
37 * - 2: post & taxonomy-term AI analysis was removed (the copilot now only
38 * analyzes comments for spam, and the assistant finds content via native
39 * WordPress search). Unschedules any queued `desktop_mode_ai_analyze_post`
40 * / `desktop_mode_ai_analyze_term` cron events left over from prior versions.
41 * - 3: the copilot dropped its self-managed AI credentials in favour of
42 * WordPress 7.0 Connectors. Deletes the platform key option and strips the
43 * per-user `apiKey` / `apiKeys` / `provider` / `transport` fields from the
44 * stored OS settings so no provider secret lingers in the database.
45 * - 4: the OpenStation brand. Moves anyone still sitting on the PRE-brand
46 * defaults — accent `wp-blue`, wallpaper `dark` — onto the new ones,
47 * Pulse and Galaxy. Without it the rebrand only reaches fresh accounts:
48 * the stored snapshot is authoritative over the shipped default, so an
49 * existing desk keeps a blue accent on every focus ring, tab underline
50 * and sort arrow.
51 * - 5: flags the users who were using Desktop Mode before the rename, so
52 * the shell can explain the new name once to the people it happened
53 * to and to nobody else. Sets user meta and nothing else — see
54 * {@see openstation_migrate_flag_rebrand_notice} for why that is a
55 * separate migration from 4.
56 * - 6: the Trash stopped registering a desktop icon. Removes the
57 * placement the shell had auto-placed for it and closes the hole that
58 * leaves in the icon column.
59 */
60 const OPENSTATION_MIGRATION_VERSION = 8;
61
62 /**
63 * Option storing the highest migration version that has run. autoload=no.
64 *
65 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
66 * persisted or externally-visible identifier, so renaming it would
67 * orphan data already written by live installs (or break a live
68 * URL). The mismatch between this constant's name and its value is
69 * deliberate — it is NOT a half-finished rename.
70 */
71 const OPENSTATION_MIGRATION_OPTION = 'desktop_mode_migration_version';
72
73 /**
74 * Runs any pending migrations, then records the new high-water mark.
75 *
76 * Idempotent: bails immediately when the stored version is already at
77 * or above the shipped version, so it is safe to fire on every request.
78 *
79 * @return void
80 */
81 function openstation_maybe_run_migrations() {
82 $installed = (int) get_option( OPENSTATION_MIGRATION_OPTION, 0 );
83 if ( $installed >= OPENSTATION_MIGRATION_VERSION ) {
84 return;
85 }
86
87 openstation_run_pending_migrations( $installed );
88
89 update_option( OPENSTATION_MIGRATION_OPTION, OPENSTATION_MIGRATION_VERSION, false );
90 }
91 add_action( 'admin_init', 'openstation_maybe_run_migrations' );
92
93 /**
94 * Runs the pending migrations at activation, on a site with no history.
95 *
96 * Migration 5 infers who used the shell before the rename from user meta
97 * that a site can write to itself between activation and the first
98 * `admin_init` (the portal auto-enable). Running at activation is the
99 * one moment that window is still shut, so the same runner reaches the
100 * same conclusion about the same site and cannot be fooled by evidence
101 * that arrives later.
102 *
103 * The whole runner, not a subset: migrations 2 and 3 clear leftover AI
104 * cron events and a stored provider credential, neither of which any
105 * user meta predicts.
106 *
107 * @return void
108 */
109 function openstation_run_migrations_on_activation() {
110 // Migrations have already run here; their high-water mark is the
111 // truth and the runner would be a no-op anyway.
112 if ( false !== get_option( OPENSTATION_MIGRATION_OPTION, false ) ) {
113 return;
114 }
115
116 // The site has history, so this is a reactivation and not a new
117 // install. Leave it to `admin_init`, where migration 5 reads meta
118 // that is genuinely older than this request.
119 $prior_users = openstation_users_with_prior_desktop_use();
120 if ( ! empty( $prior_users ) ) {
121 return;
122 }
123
124 openstation_maybe_run_migrations();
125 }
126 register_activation_hook( OPENSTATION_FILE, 'openstation_run_migrations_on_activation' );
127
128 /**
129 * Dispatches each migration whose version is newer than what has run.
130 *
131 * @param int $from The highest migration version already applied.
132 * @return void
133 */
134 function openstation_run_pending_migrations( $from ) {
135 $from = (int) $from;
136
137 if ( $from < 1 ) {
138 openstation_migrate_os_settings_optin();
139 }
140
141 if ( $from < 2 ) {
142 openstation_migrate_unschedule_post_term_ai();
143 }
144
145 if ( $from < 3 ) {
146 openstation_migrate_delete_ai_keys();
147 }
148
149 if ( $from < 4 ) {
150 openstation_migrate_brand_defaults();
151 }
152
153 if ( $from < 5 ) {
154 openstation_migrate_flag_rebrand_notice( $from );
155 }
156
157 if ( $from < 6 ) {
158 openstation_migrate_close_recycle_bin_icon_gap();
159 }
160
161 if ( $from < 7 ) {
162 openstation_migrate_seed_agent_faces();
163 }
164
165 if ( $from < 8 ) {
166 openstation_migrate_remove_comments_ai();
167 }
168 }
169
170 /**
171 * Migration 7 — give the agents that predate faces a seed to grow one
172 * from.
173 *
174 * Agents used to share a single grey robot glyph. They now carry a Mio
175 * look, and an agent created from here on gets a seed at birth. The
176 * ones already on the site do not, and without a seed there is nothing
177 * to derive a face from.
178 *
179 * **This writes the seed and stops.** It does not write the face. The
180 * face comes from `randomMioLook()`, which lives in TypeScript, and
181 * porting it is exactly the wrong trade: it is a taste filter with a
182 * dozen judgment calls in it, pinned by `mio-randomize.test.ts`, and a
183 * PHP twin of it would drift with nothing watching. So the shell fills
184 * the looks in on its next paint of the Agents section, rolling each
185 * one from the seed written here.
186 *
187 * That is a client writing on the server's behalf, which is worth
188 * naming rather than slipping past. It is safe because it is entirely
189 * derived: the seed is `crc32` of the login, so two admins racing the
190 * backfill produce byte-identical faces, and running it twice changes
191 * nothing.
192 *
193 * The five shipped agents are unaffected: their faces are written out
194 * in `default-definitions.php` and were never rolled.
195 *
196 * @return void
197 */
198 function openstation_migrate_seed_agent_faces() {
199 // Agents is behind a feature flag, so on a site that has never
200 // turned it on there is nothing to seed, and none of the module's
201 // functions exist to call. A site that turns it on later creates
202 // its agents through `openstation_agent_create`, which seeds them
203 // at birth, so nothing is missed by returning here.
204 if (
205 ! function_exists( 'openstation_agent_get_agents' )
206 || ! function_exists( 'openstation_agent_get_face_seed' )
207 || ! defined( 'OPENSTATION_AGENT_FACE_SEED_META' )
208 ) {
209 return;
210 }
211
212 foreach ( openstation_agent_get_agents() as $agent ) {
213 $user_id = isset( $agent->ID ) ? (int) $agent->ID : 0;
214 if ( $user_id <= 0 ) {
215 continue;
216 }
217 if ( openstation_agent_get_face_seed( $user_id ) > 0 ) {
218 continue;
219 }
220 update_user_meta(
221 $user_id,
222 OPENSTATION_AGENT_FACE_SEED_META,
223 crc32( (string) $agent->user_login )
224 );
225 }
226 }
227
228 /**
229 * Grid the desktop auto-placer lays icons out on: 16px of padding, a
230 * 96px column, a 110px row. Mirrored from `src/desktop-files/grid.ts`
231 * via {@see openstation_files_auto_place_orphans}, which is what wrote
232 * the coordinates this migration edits.
233 */
234 const OPENSTATION_DESKTOP_GRID_ROW_H = 110;
235
236 /**
237 * Migration 6 — take back the Trash's desktop icon, and close the hole.
238 *
239 * The bin used to register a desktop icon, and every viewer's first
240 * hydrate auto-placed it into the icon column. Now that the
241 * registration is gone the placement is dead weight: it is no longer
242 * served (`OpenStation_Shortcut_File::can_read()` is false without a
243 * registry entry), so the tile has already vanished on its own. What it
244 * leaves behind is an empty cell with the icons that were under it
245 * still sitting where they were.
246 *
247 * So: delete the row, and pull everything below it in the same column
248 * up by one. Same column only, because the auto-placer fills
249 * column-major, so a column is the run the bin was part of. This does
250 * move tiles a user may have arranged, which is the point — the shell
251 * put that icon there and the shell is taking it away, so the shell
252 * tidies up after itself rather than leaving a gap nobody chose.
253 *
254 * A user who wants the bin back on the wallpaper picks "On the desktop"
255 * in Preferences → Navigation, which promotes the dock tile and never
256 * touches these rows.
257 *
258 * @return void
259 */
260 function openstation_migrate_close_recycle_bin_icon_gap() {
261 global $wpdb;
262
263 if ( ! function_exists( 'openstation_files_table_names' ) ) {
264 return;
265 }
266 $tables = openstation_files_table_names();
267 $tbl = $tables['placements'];
268
269 // The files schema installs lazily, so a site that never opened
270 // the desktop has no table to migrate.
271 $table_exists = (int) $wpdb->get_var(
272 $wpdb->prepare(
273 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
274 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
275 $tbl
276 )
277 );
278 if ( 0 === $table_exists ) {
279 return;
280 }
281
282 // Shift first, delete second: the derived table has to still find
283 // the bin's own row to know which cell is being vacated. It is
284 // materialized before the update runs, so reading and writing the
285 // same table in one statement is fine here.
286 //
287 // The UNIQUE index on (owner_id, parent_id, file_type, file_ref)
288 // guarantees at most one bin row per owner, so no row can be
289 // shifted twice.
290 $wpdb->query(
291 $wpdb->prepare(
292 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name, not user input.
293 "UPDATE `{$tbl}` AS p
294 INNER JOIN (
295 SELECT owner_id, x, y FROM `{$tbl}`
296 WHERE parent_id = 0
297 AND file_type = 'shortcut'
298 AND file_ref = %s
299 ) AS bin
300 ON p.owner_id = bin.owner_id
301 AND p.x = bin.x
302 AND p.y > bin.y
303 SET p.y = p.y - %d
304 WHERE p.parent_id = 0
305 AND p.trashed_at_ms IS NULL",
306 'desktop-mode-recycle-bin',
307 OPENSTATION_DESKTOP_GRID_ROW_H
308 )
309 );
310
311 $wpdb->delete(
312 $tbl,
313 array(
314 'parent_id' => 0,
315 'file_type' => 'shortcut',
316 'file_ref' => 'desktop-mode-recycle-bin',
317 ),
318 array( '%d', '%s', '%s' )
319 );
320 }
321
322 /**
323 * User meta marking someone as a Desktop Mode user from before the rebrand.
324 *
325 * Present and truthy => the shell offers this user the one-off rebrand
326 * announcement, once. Absent => they never used the plugin under its old
327 * name, so there is no rename to explain to them. Written only by
328 * migration 5, and only for users who were actually using Desktop Mode
329 * at the moment it ran.
330 *
331 * The VALUE keeps the pre-rebrand spelling for the reason every other
332 * stored key does — see {@see OPENSTATION_MIGRATION_OPTION}.
333 */
334 const OPENSTATION_REBRAND_NOTICE_META_KEY = 'desktop_mode_rebrand_notice';
335
336 /**
337 * Slug the rebrand announcement records in `desktop_mode_seen_intros`.
338 *
339 * A slug in the shared registry rather than a bespoke meta key, so the
340 * announcement is dismissed, reset and reasoned about exactly like the
341 * native-window intros beside it.
342 */
343 const OPENSTATION_REBRAND_INTRO_SLUG = 'openstation-rebrand';
344
345 /**
346 * Every user who carries proof of having used the shell on this site:
347 * `desktop_mode_mode` (the per-user opt-in, tested for EXISTENCE rather
348 * than for being `'1'`, since switching back to classic empties the
349 * value but leaves the row) or a saved `desktop_mode_os_settings`.
350 *
351 * @return int[] User IDs, unsorted and deduplicated.
352 */
353 function openstation_users_with_prior_desktop_use() {
354 return array_map(
355 'intval',
356 array_unique(
357 array_merge(
358 get_users(
359 array(
360 'fields' => 'ID',
361 'meta_key' => 'desktop_mode_mode', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- the key is indexed in usermeta; the migration callers run once per install, and the deactivation feedback route once per admin submission.
362 'meta_compare' => 'EXISTS',
363 )
364 ),
365 get_users(
366 array(
367 'fields' => 'ID',
368 'meta_key' => OPENSTATION_OS_SETTINGS_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- see above.
369 'meta_compare' => 'EXISTS',
370 )
371 )
372 )
373 )
374 );
375 }
376
377 /**
378 * Migration 5 — remember who was using Desktop Mode before the rebrand.
379 *
380 * Migration 4 moved the pre-brand *defaults* onto the brand ones. This
381 * one answers a different question: not "what should this desk look
382 * like" but "does this person need to be told why it changed". Someone
383 * who has been running Desktop Mode for months opens wp-admin one
384 * morning to a differently-named, differently-coloured shell; without a
385 * word of explanation that reads as a compromised site, not a release.
386 *
387 * Two gates. The install gate is a bare "has the rebrand already
388 * happened here", and the user gate does the actual work.
389 *
390 * **The install** must not already be past the rebrand: `$from < 4`. A
391 * `4` means migration 4 has run, which today means a checkout tracking
392 * trunk between the two release tags. Not a surprised user.
393 *
394 * Note what is deliberately NOT tested: whether `$from` is zero. It is
395 * tempting to read `0` as "fresh install, nothing to explain", and that
396 * reading is wrong in the one direction that matters. The migration
397 * runner itself only shipped in 0.9.1, so an install still on 0.9.0 or
398 * earlier that updates straight to the rebrand release has no stored
399 * version at all and arrives here with `$from === 0`, indistinguishable
400 * from a brand new site. Those are the installs that update rarely,
401 * which makes them the ones most likely to be blindsided by a rename,
402 * and gating on `$from > 0` would have silenced precisely them.
403 *
404 * **The user** has to have actually used it — see
405 * {@see openstation_users_with_prior_desktop_use} for what counts as
406 * proof. That separates a long-dormant install from a genuinely new one
407 * without needing to date the install at all, and it keeps the
408 * announcement away from an editor who joined an old site last week and
409 * enabled OpenStation this morning.
410 *
411 * What that gate does NOT do on its own is prove the evidence is old.
412 * On a new install it can be written between activation and the first
413 * `admin_init`, and then read back here as history. That window is
414 * closed by {@see openstation_run_migrations_on_activation}, which runs
415 * this migration before anything can write it.
416 *
417 * Deliberately NOT folded into migration 4, even though the two ship
418 * together: 4 has already run on trunk checkouts, and a migration that
419 * has run does not run again. Extending it would have silently skipped
420 * the flag exactly where it was easiest to believe it had been set.
421 *
422 * Flags are never cleared. Dismissal lives in the seen-intros registry,
423 * so one admin dismissing the announcement does not silence it for
424 * their editors, and "Reset what's-new dialogs" in OpenStation Preferences
425 * → Features brings it back with every other intro.
426 *
427 * @param int $from The highest migration version already applied.
428 * @return void
429 */
430 function openstation_migrate_flag_rebrand_notice( $from ) {
431 if ( (int) $from >= 4 ) {
432 return;
433 }
434
435 foreach ( openstation_users_with_prior_desktop_use() as $user_id ) {
436 update_user_meta( $user_id, OPENSTATION_REBRAND_NOTICE_META_KEY, 1 );
437 }
438 }
439
440 /**
441 * Whether the current user should be offered the rebrand announcement.
442 *
443 * Two gates: migration 5 flagged this user as one who was using Desktop
444 * Mode before the rename, and they have not already dismissed it. The
445 * seen-intros registry owns the second one, which is what makes the
446 * announcement behave like every other one-time dialog — including
447 * being brought back by "Reset what's-new dialogs".
448 *
449 * Only ever consulted while building the shell config, which is itself
450 * behind the `openstation_is_enabled()` / not-classic guard in
451 * `includes/render/assets.php`. So the announcement cannot reach the
452 * classic admin or a chromeless iframe: the bundle that would show it
453 * is not loaded there.
454 *
455 * @return bool
456 */
457 function openstation_should_show_rebrand_notice() {
458 $user_id = get_current_user_id();
459 if ( ! $user_id ) {
460 return false;
461 }
462
463 if ( ! get_user_meta( $user_id, OPENSTATION_REBRAND_NOTICE_META_KEY, true ) ) {
464 return false;
465 }
466
467 return ! openstation_has_seen_intro( $user_id, OPENSTATION_REBRAND_INTRO_SLUG );
468 }
469
470 /**
471 * Migration 4 — move the pre-brand defaults onto the OpenStation ones.
472 *
473 * The stored OS-settings snapshot outranks the shipped default, so
474 * changing `openstation_default_os_settings()` reaches new accounts and
475 * nobody else. Every existing desk would keep `wp-blue` on its focus
476 * rings, tab underlines, sort arrows and selection washes, and keep the
477 * graphite `dark` desk under the station's chrome — a half-applied
478 * rebrand, which reads as a bug rather than as a choice.
479 *
480 * **Only values still equal to the OLD default are touched.** A user who
481 * picked Indigo, or the Snow wallpaper, expressed a preference and keeps
482 * it. The one unavoidable cost is the user who deliberately chose
483 * WordPress Blue — indistinguishable from never having chosen at all,
484 * because it WAS the default — and for them it is one click in
485 * OS Settings → Appearance to set it back.
486 *
487 * Users with no stored settings are skipped entirely: they read the new
488 * defaults already.
489 *
490 * @return void
491 */
492 function openstation_migrate_brand_defaults() {
493 // The pre-brand => brand value map, keyed by OS-settings field.
494 // Deliberately not filterable: this runs once, against one release's
495 // stored defaults, and a third party rewriting which values get
496 // migrated would leave desks in a state no later migration accounts
497 // for.
498 $map = array(
499 'accent' => array(
500 'from' => 'wp-blue',
501 'to' => 'pulse',
502 ),
503 'wallpaper' => array(
504 'from' => 'dark',
505 'to' => 'galaxy',
506 ),
507 );
508
509 $user_ids = get_users(
510 array(
511 'fields' => 'ID',
512 'meta_key' => OPENSTATION_OS_SETTINGS_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-time migration; the key is indexed in usermeta and the scan is guarded to run once.
513 'meta_compare' => 'EXISTS',
514 )
515 );
516
517 foreach ( $user_ids as $user_id ) {
518 $raw = get_user_meta( (int) $user_id, OPENSTATION_OS_SETTINGS_META_KEY, true );
519 if ( ! is_array( $raw ) ) {
520 continue;
521 }
522
523 $changed = false;
524 foreach ( $map as $key => $move ) {
525 if ( ! isset( $move['from'], $move['to'] ) ) {
526 continue;
527 }
528 // An absent key already resolves to the new default.
529 if ( isset( $raw[ $key ] ) && $move['from'] === $raw[ $key ] ) {
530 $raw[ $key ] = $move['to'];
531 $changed = true;
532 }
533 }
534
535 if ( $changed ) {
536 openstation_save_os_settings( (int) $user_id, $raw );
537 }
538 }
539 }
540
541 /**
542 * Migration 1 — reset the native list windows to opt-in.
543 *
544 * The native Posts/Pages/Users/Plugins/Comments windows used to default
545 * ON (opt-out). The shell persists the whole OS-settings object on every
546 * change, so most active users already have these flags stored as `true`
547 * and would keep the native UI even after the default flips. This clears
548 * the five flags from every user who has the meta, leaving the rest of
549 * their settings (wallpaper, accent, dock order, …) untouched. On the
550 * next read the cleared keys fall back to the new `false` default, so the
551 * whole install lands on opt-in and users re-enable each window from
552 * OS Settings → Features → Beta features.
553 *
554 * Only users who actually have the meta are queried — fresh accounts and
555 * users who never touched OS Settings are skipped entirely.
556 *
557 * @return void
558 */
559 function openstation_migrate_os_settings_optin() {
560 $flags = array(
561 'nativePostsEnabled',
562 'nativePagesEnabled',
563 'nativeUsersEnabled',
564 'nativePluginsEnabled',
565 'nativeCommentsEnabled',
566 );
567
568 $user_ids = get_users(
569 array(
570 'fields' => 'ID',
571 'meta_key' => OPENSTATION_OS_SETTINGS_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-time migration; the key is indexed in usermeta and the scan is guarded to run once.
572 'meta_compare' => 'EXISTS',
573 )
574 );
575
576 foreach ( $user_ids as $user_id ) {
577 $raw = get_user_meta( (int) $user_id, OPENSTATION_OS_SETTINGS_META_KEY, true );
578 if ( ! is_array( $raw ) ) {
579 continue;
580 }
581
582 $changed = false;
583 foreach ( $flags as $flag ) {
584 if ( array_key_exists( $flag, $raw ) ) {
585 unset( $raw[ $flag ] );
586 $changed = true;
587 }
588 }
589
590 if ( ! $changed ) {
591 continue;
592 }
593
594 // Re-save through the canonical sanitizer so the cleared flags are
595 // backfilled with the new `false` default and the rest of the
596 // settings array is normalized exactly as a client write would be.
597 openstation_save_os_settings( (int) $user_id, $raw );
598 }
599 }
600
601 /**
602 * Migration 2 — unschedule leftover post/term AI analysis jobs.
603 *
604 * Post and taxonomy-term analysis was removed: the copilot now only
605 * analyzes comments (for the spam score), and the AI assistant finds
606 * content with native WordPress keyword search. Their cron callbacks no
607 * longer exist, so any single-events still queued from a prior version
608 * would simply no-op — but we clear them so the cron array stays tidy and
609 * `wp cron event list` doesn't show orphaned hooks.
610 *
611 * Existing `_desktop_mode_ai_analysis` meta on posts/terms is left in place
612 * (hidden, harmless, and cheap to ignore).
613 *
614 * @return void
615 */
616 function openstation_migrate_unschedule_post_term_ai() {
617 wp_unschedule_hook( 'desktop_mode_ai_analyze_post' );
618 wp_unschedule_hook( 'desktop_mode_ai_analyze_term' );
619 }
620
621 /**
622 * Migration 8 — retire the "Score new comments with AI" feature.
623 *
624 * Automatic AI scoring of incoming comments was removed: nothing
625 * schedules `desktop_mode_ai_analyze_comment` any more, and the
626 * `desktop_mode_comments_ai_moderation` option no longer gates
627 * anything. Queued single-events would simply no-op, but we clear
628 * them so the cron array stays tidy and `wp cron event list` doesn't
629 * show an orphaned hook.
630 *
631 * The option row is dropped too — unlike a frozen identifier that
632 * still has a reader, this one has none left, so leaving it would
633 * only strand a value no code consults.
634 *
635 * Existing `_desktop_mode_ai_analysis` comment meta is left in place
636 * (hidden, harmless, and still what the on-demand
637 * `desktop-mode/analyze-comment` ability writes).
638 *
639 * @return void
640 */
641 function openstation_migrate_remove_comments_ai() {
642 wp_unschedule_hook( 'desktop_mode_ai_analyze_comment' );
643 delete_option( 'desktop_mode_comments_ai_moderation' );
644 }
645
646 /**
647 * Migration 3 — delete self-managed AI credentials.
648 *
649 * WordPress 7.0 owns provider credentials (Settings → Connectors), so the
650 * copilot no longer stores keys of its own. Remove the platform key option and
651 * strip the now-unused key / provider / model / transport fields from every
652 * user's stored OS settings so no secret is left behind. The only `ai` field
653 * that remains is `enabled` (the per-user assistant toggle), backfilled from
654 * defaults on next read.
655 *
656 * @return void
657 */
658 function openstation_migrate_delete_ai_keys() {
659 // Platform-wide key option (formerly `desktop_mode_ai_platform`).
660 delete_option( 'desktop_mode_ai_platform' );
661
662 $user_ids = get_users(
663 array(
664 'fields' => 'ID',
665 'meta_key' => OPENSTATION_OS_SETTINGS_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-time migration; guarded to run once.
666 'meta_compare' => 'EXISTS',
667 )
668 );
669
670 foreach ( $user_ids as $user_id ) {
671 $raw = get_user_meta( (int) $user_id, OPENSTATION_OS_SETTINGS_META_KEY, true );
672 if ( ! is_array( $raw ) || ! isset( $raw['ai'] ) || ! is_array( $raw['ai'] ) ) {
673 continue;
674 }
675
676 // Strip every legacy AI field: the self-managed credentials/transport,
677 // plus the `provider` / `model` preferences — provider + model selection
678 // is now delegated entirely to the Core AI Client.
679 $changed = false;
680 foreach ( array( 'apiKey', 'apiKeys', 'transport', 'provider', 'model' ) as $stale ) {
681 if ( array_key_exists( $stale, $raw['ai'] ) ) {
682 unset( $raw['ai'][ $stale ] );
683 $changed = true;
684 }
685 }
686
687 if ( ! $changed ) {
688 continue;
689 }
690
691 openstation_save_os_settings( (int) $user_id, $raw );
692 }
693 }
694
695 // Presence owns a verified checkpoint so failures never advance unrelated migrations.
696 add_action( 'admin_init', 'openstation_presence_migration_tick', 20 );
697