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