wp-studio 1.7.9 → 1.7.10-beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli/{_events-ByiJRMQ1.mjs → _events-MbsmeZ64.mjs} +5 -5
  2. package/dist/cli/{certificate-manager-Bp1E0km4.mjs → certificate-manager-SVYcCL_i.mjs} +6 -1
  3. package/dist/cli/{delete-D1lAYFtg.mjs → delete-Br0zEYcv.mjs} +8 -4
  4. package/dist/cli/{helpers-Bh-WikQr.mjs → helpers-DE340RS-.mjs} +4 -153
  5. package/dist/cli/{index-CyYXE85k.mjs → index-4lan3TI_.mjs} +24 -4
  6. package/dist/cli/{index-DBCpWm8k.mjs → index-B-JMTSj2.mjs} +525 -256
  7. package/dist/cli/{index-BiCiEz-r.mjs → index-upH2G_fw.mjs} +510 -365
  8. package/dist/cli/{list-COLca0rr.mjs → list-CqctqyGC.mjs} +4 -3
  9. package/dist/cli/{login-OvZJPTV5.mjs → login-IwZhKWzT.mjs} +3 -3
  10. package/dist/cli/{logout-BaG-QFSN.mjs → logout-BJ7AzNzE.mjs} +3 -3
  11. package/dist/cli/main.mjs +2 -2
  12. package/dist/cli/paths-CqXGLB7R.mjs +195 -0
  13. package/dist/cli/plugin/.claude-plugin/plugin.json +5 -0
  14. package/dist/cli/plugin/skills/need-for-speed/SKILL.md +55 -0
  15. package/dist/cli/plugin/skills/site-spec/SKILL.md +35 -0
  16. package/dist/cli/plugin/skills/taxonomist/SKILL.md +270 -0
  17. package/dist/cli/plugin/skills/taxonomist/scripts/apply-changes.php +223 -0
  18. package/dist/cli/plugin/skills/taxonomist/scripts/backup.php +112 -0
  19. package/dist/cli/plugin/skills/taxonomist/scripts/export-posts.php +119 -0
  20. package/dist/cli/plugin/skills/taxonomist/scripts/restore.php +233 -0
  21. package/dist/cli/process-manager-daemon.mjs +11 -4
  22. package/dist/cli/{process-manager-ipc-heiF195f.mjs → process-manager-ipc-BisO0qtU.mjs} +1 -1
  23. package/dist/cli/proxy-daemon.mjs +1 -1
  24. package/dist/cli/prune-pm-logs-COryxqeo.mjs +41 -0
  25. package/dist/cli/resume-CNesCmkg.mjs +113 -0
  26. package/dist/cli/{rewrite-wp-cli-post-content-DH3hRTU5.mjs → rewrite-wp-cli-post-content-2zlfFnKT.mjs} +1 -1
  27. package/dist/cli/{set-DY9OcXFg.mjs → set-C4J6ru7I.mjs} +3 -3
  28. package/dist/cli/{set-BX9MWFxi.mjs → set-T8A1lBPU.mjs} +4 -4
  29. package/dist/cli/{status-CgY39wpU.mjs → status-CPcjT8jc.mjs} +2 -2
  30. package/dist/cli/{well-known-paths-CG_o9mSO.mjs → well-known-paths-BYA1Bw5o.mjs} +1 -1
  31. package/dist/cli/wordpress-server-child.mjs +2 -2
  32. package/dist/cli/{wp-DeUSBbLc.mjs → wp-A9VDe8QE.mjs} +2 -2
  33. package/dist/cli/wp-files/latest/available-site-translations.json +1 -1
  34. package/dist/cli/wp-files/skills/STUDIO.md +1 -1
  35. package/dist/cli/wp-files/skills/studio-cli/SKILL.md +1 -1
  36. package/package.json +9 -10
  37. package/patches/@mariozechner+pi-tui+0.54.0.patch +12 -0
  38. package/scripts/postinstall-npm.mjs +1 -0
  39. package/dist/cli/paths-BPK_RySX.mjs +0 -31
  40. package/dist/cli/resume-DshNzC7q.mjs +0 -62
@@ -0,0 +1,223 @@
1
+ <?php
2
+ /**
3
+ * Apply category changes from an AI-generated suggestions file.
4
+ *
5
+ * Reads a JSON file of per-post category suggestions and applies them.
6
+ * Categories are resolved by slug to prevent drift between the
7
+ * export/analysis and apply phases.
8
+ *
9
+ * Usage:
10
+ * TAXONOMIST_SUGGESTIONS=/path/to/suggestions.json studio wp eval-file apply-changes.php
11
+ *
12
+ * Environment variables:
13
+ * TAXONOMIST_SUGGESTIONS Path to the suggestions JSON file. Required.
14
+ * Format: [{"post_id": 123, "cats": ["wordpress", "ai"]}, ...]
15
+ * Values in "cats" are category slugs.
16
+ * TAXONOMIST_LOG Path for the change log TSV.
17
+ * Default: /tmp/taxonomist-changes.tsv
18
+ * TAXONOMIST_MODE "preview" (default) shows what would change.
19
+ * "apply" executes the changes.
20
+ * TAXONOMIST_REMOVE_CATS Comma-separated category slugs to strip from
21
+ * posts that receive new suggestions.
22
+ *
23
+ * @package Taxonomist
24
+ */
25
+
26
+ $suggestions_file = getenv( 'TAXONOMIST_SUGGESTIONS' );
27
+ $log_file = getenv( 'TAXONOMIST_LOG' ) ? getenv( 'TAXONOMIST_LOG' ) : '/tmp/taxonomist-changes.tsv';
28
+ $apply_mode = getenv( 'TAXONOMIST_MODE' ) ? getenv( 'TAXONOMIST_MODE' ) : 'preview';
29
+ $remove_cats_str = getenv( 'TAXONOMIST_REMOVE_CATS' ) ? getenv( 'TAXONOMIST_REMOVE_CATS' ) : '';
30
+
31
+ if ( ! $suggestions_file || ! file_exists( $suggestions_file ) ) {
32
+ WP_CLI::error( 'Set TAXONOMIST_SUGGESTIONS to the suggestions JSON path' );
33
+ }
34
+
35
+ $suggestions_json = file_get_contents( $suggestions_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
36
+ if ( false === $suggestions_json ) {
37
+ WP_CLI::error( 'Failed to read suggestions file' );
38
+ }
39
+ $suggestions = json_decode( $suggestions_json, true );
40
+ if ( null === $suggestions && JSON_ERROR_NONE !== json_last_error() ) {
41
+ WP_CLI::error( 'Failed to parse suggestions file' );
42
+ }
43
+ if ( ! is_array( $suggestions ) ) {
44
+ WP_CLI::error( 'Suggestions file must decode to a JSON array' );
45
+ }
46
+
47
+ $all_cats = get_terms(
48
+ array(
49
+ 'taxonomy' => 'category',
50
+ 'hide_empty' => false,
51
+ )
52
+ );
53
+ $slug_to_id = array();
54
+ $slug_to_name = array();
55
+ foreach ( $all_cats as $t ) {
56
+ $slug_to_id[ $t->slug ] = $t->term_id;
57
+ $slug_to_name[ $t->slug ] = $t->name;
58
+ }
59
+
60
+ $remove_slugs = array_filter( array_map( 'trim', explode( ',', $remove_cats_str ) ) );
61
+ $remove_ids = array();
62
+ foreach ( $remove_slugs as $slug ) {
63
+ if ( isset( $slug_to_id[ $slug ] ) ) {
64
+ $remove_ids[] = $slug_to_id[ $slug ];
65
+ }
66
+ }
67
+
68
+ $default_cat_id = (int) get_option( 'default_category' );
69
+ if ( in_array( $default_cat_id, $remove_ids, true ) ) {
70
+ $default_term = get_term( $default_cat_id, 'category' );
71
+ $default_name = $default_term ? $default_term->name : "ID $default_cat_id";
72
+ WP_CLI::error(
73
+ "TAXONOMIST_REMOVE_CATS includes '$default_name' which is the site's default category. " .
74
+ 'Change the default category setting first (wp option update default_category NEW_ID), then retry.'
75
+ );
76
+ }
77
+
78
+ $unresolved = array();
79
+ foreach ( $suggestions as $suggestion ) {
80
+ $suggested_refs = isset( $suggestion['cats'] ) ? $suggestion['cats'] : array();
81
+ foreach ( $suggested_refs as $ref ) {
82
+ if ( ! isset( $slug_to_id[ $ref ] ) ) {
83
+ $unresolved[ $ref ] = true;
84
+ }
85
+ }
86
+ }
87
+ if ( ! empty( $unresolved ) ) {
88
+ $list = implode( ', ', array_keys( $unresolved ) );
89
+ WP_CLI::error(
90
+ "Taxonomy drift detected: these categories from the suggestions do not exist in the live site: $list. " .
91
+ 'Re-export and re-analyze, or create the missing categories first.'
92
+ );
93
+ }
94
+
95
+ // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen
96
+ // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fclose
97
+ $log = fopen( $log_file, 'w' );
98
+ if ( false === $log ) {
99
+ WP_CLI::error( 'Failed to open log file for writing: ' . $log_file );
100
+ }
101
+ $header_written = fputcsv( $log, array( 'timestamp', 'action', 'post_id', 'post_title', 'old_categories', 'new_categories', 'cats_added', 'cats_removed' ), "\t" );
102
+ if ( false === $header_written ) {
103
+ fclose( $log );
104
+ WP_CLI::error( 'Failed to write log header to ' . $log_file );
105
+ }
106
+
107
+ $changes = 0;
108
+ $skipped = 0;
109
+ $error_count = 0;
110
+
111
+ foreach ( $suggestions as $suggestion ) {
112
+ $current_post_id = isset( $suggestion['post_id'] ) ? $suggestion['post_id'] : $suggestion['id'];
113
+ $suggested_refs = isset( $suggestion['cats'] ) ? $suggestion['cats'] : array();
114
+
115
+ if ( empty( $suggested_refs ) ) {
116
+ ++$skipped;
117
+ continue;
118
+ }
119
+
120
+ $current_post = get_post( $current_post_id );
121
+ if ( ! $current_post ) {
122
+ ++$error_count;
123
+ continue;
124
+ }
125
+
126
+ $current_ids = wp_get_post_categories( $current_post_id );
127
+ $current_names = array();
128
+ foreach ( $current_ids as $cid ) {
129
+ $t = get_term( $cid, 'category' );
130
+ if ( $t && ! is_wp_error( $t ) ) {
131
+ $current_names[ $cid ] = $t->name;
132
+ }
133
+ }
134
+
135
+ $kept_ids = array();
136
+ foreach ( $current_ids as $cid ) {
137
+ if ( ! in_array( $cid, $remove_ids, true ) ) {
138
+ $kept_ids[] = $cid;
139
+ }
140
+ }
141
+
142
+ $suggested_ids = array();
143
+ foreach ( $suggested_refs as $ref ) {
144
+ if ( isset( $slug_to_id[ $ref ] ) ) {
145
+ $suggested_ids[] = $slug_to_id[ $ref ];
146
+ }
147
+ }
148
+
149
+ $new_ids = array_values( array_unique( array_merge( array_values( $kept_ids ), $suggested_ids ) ) );
150
+
151
+ $sorted_current = $current_ids;
152
+ $sorted_new = $new_ids;
153
+ sort( $sorted_current );
154
+ sort( $sorted_new );
155
+ if ( $sorted_current === $sorted_new ) {
156
+ ++$skipped;
157
+ continue;
158
+ }
159
+
160
+ $added_ids = array_diff( $new_ids, $current_ids );
161
+ $removed_ids = array_diff( $current_ids, $new_ids );
162
+
163
+ $added_names = array();
164
+ foreach ( $added_ids as $aid ) {
165
+ $t = get_term( $aid, 'category' );
166
+ if ( $t ) {
167
+ $added_names[] = $t->name;
168
+ }
169
+ }
170
+
171
+ $removed_names = array();
172
+ foreach ( $removed_ids as $rid ) {
173
+ $removed_names[] = isset( $current_names[ $rid ] ) ? $current_names[ $rid ] : '?';
174
+ }
175
+
176
+ $new_names = array();
177
+ foreach ( $new_ids as $nid ) {
178
+ $t = get_term( $nid, 'category' );
179
+ if ( $t ) {
180
+ $new_names[] = $t->name;
181
+ }
182
+ }
183
+
184
+ $ts = gmdate( 'Y-m-d H:i:s' );
185
+ $log_row = array(
186
+ $ts,
187
+ 'SET_CATS',
188
+ $current_post_id,
189
+ $current_post->post_title,
190
+ implode( '|', array_values( $current_names ) ),
191
+ implode( '|', $new_names ),
192
+ implode( '|', $added_names ),
193
+ implode( '|', $removed_names ),
194
+ );
195
+
196
+ if ( 'apply' === $apply_mode ) {
197
+ $set_result = wp_set_post_categories( $current_post_id, $new_ids );
198
+ if ( is_wp_error( $set_result ) || false === $set_result ) {
199
+ ++$error_count;
200
+ WP_CLI::warning( 'Failed to set categories for post ID ' . $current_post_id );
201
+ continue;
202
+ }
203
+ }
204
+
205
+ $log_result = fputcsv( $log, $log_row, "\t" );
206
+ if ( false === $log_result ) {
207
+ fclose( $log );
208
+ WP_CLI::error( 'Failed to write change log row for post ID ' . $current_post_id );
209
+ }
210
+
211
+ ++$changes;
212
+ if ( 0 === $changes % 200 ) {
213
+ WP_CLI::log( "Processed $changes changes..." );
214
+ wp_cache_flush();
215
+ }
216
+ }
217
+
218
+ fclose( $log );
219
+ // phpcs:enable
220
+
221
+ $verb = ( 'apply' === $apply_mode ) ? 'Applied' : 'Would apply';
222
+ WP_CLI::success( "$verb $changes changes. Skipped: $skipped. Errors: $error_count." );
223
+ WP_CLI::log( 'Log: ' . $log_file );
@@ -0,0 +1,112 @@
1
+ <?php
2
+ /**
3
+ * Create a complete backup of the current taxonomy state.
4
+ *
5
+ * Captures every category term with its metadata and the exact category
6
+ * assignments for every published post. Together these allow a full restore
7
+ * to the pre-change state.
8
+ *
9
+ * Usage:
10
+ * studio wp eval-file backup.php
11
+ *
12
+ * Environment variables:
13
+ * TAXONOMIST_OUTPUT Path for the backup JSON file.
14
+ * Default: /tmp/taxonomist-backup.json
15
+ *
16
+ * @package Taxonomist
17
+ */
18
+
19
+ $output_file = getenv( 'TAXONOMIST_OUTPUT' ) ? getenv( 'TAXONOMIST_OUTPUT' ) : '/tmp/taxonomist-backup.json';
20
+
21
+ $terms = get_terms(
22
+ array(
23
+ 'taxonomy' => 'category',
24
+ 'hide_empty' => false,
25
+ )
26
+ );
27
+ $term_data = array();
28
+ foreach ( $terms as $t ) {
29
+ $term_data[] = array(
30
+ 'term_id' => $t->term_id,
31
+ 'name' => $t->name,
32
+ 'slug' => $t->slug,
33
+ 'description' => $t->description,
34
+ 'count' => $t->count,
35
+ 'parent' => $t->parent,
36
+ );
37
+ }
38
+
39
+ $post_cats = array();
40
+ $batch_size = 200;
41
+ $last_id = 0;
42
+
43
+ while ( true ) {
44
+ $query_args = array(
45
+ 'posts_per_page' => $batch_size,
46
+ 'post_status' => 'publish',
47
+ 'post_type' => 'post',
48
+ 'orderby' => 'ID',
49
+ 'order' => 'ASC',
50
+ 'suppress_filters' => false,
51
+ );
52
+
53
+ if ( $last_id > 0 ) {
54
+ add_filter(
55
+ 'posts_where',
56
+ $keyset_filter = function ( $where ) use ( $last_id ) {
57
+ global $wpdb;
58
+ return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $last_id );
59
+ }
60
+ );
61
+ }
62
+
63
+ $all_posts = get_posts( $query_args );
64
+
65
+ if ( isset( $keyset_filter ) ) {
66
+ remove_filter( 'posts_where', $keyset_filter );
67
+ unset( $keyset_filter );
68
+ }
69
+
70
+ if ( empty( $all_posts ) ) {
71
+ break;
72
+ }
73
+
74
+ foreach ( $all_posts as $p ) {
75
+ $cat_ids = wp_get_post_categories( $p->ID );
76
+ $cat_slugs = wp_get_post_categories( $p->ID, array( 'fields' => 'slugs' ) );
77
+
78
+ $post_cats[] = array(
79
+ 'post_id' => $p->ID,
80
+ 'post_title' => $p->post_title,
81
+ 'category_ids' => $cat_ids,
82
+ 'category_slugs' => array_values( $cat_slugs ),
83
+ );
84
+ }
85
+
86
+ $last_id = $all_posts[ count( $all_posts ) - 1 ]->ID;
87
+ wp_cache_flush();
88
+ }
89
+
90
+ $default_cat_id = (int) get_option( 'default_category' );
91
+ $default_cat_term = get_term( $default_cat_id, 'category' );
92
+ $default_cat_slug = $default_cat_term ? $default_cat_term->slug : '';
93
+
94
+ $backup = array(
95
+ 'timestamp' => gmdate( 'Y-m-d H:i:s' ),
96
+ 'site_url' => get_site_url(),
97
+ 'total_posts' => count( $post_cats ),
98
+ 'total_categories' => count( $term_data ),
99
+ 'default_category_slug' => $default_cat_slug,
100
+ 'categories' => $term_data,
101
+ 'post_categories' => $post_cats,
102
+ );
103
+
104
+ $backup_json = wp_json_encode( $backup, JSON_PRETTY_PRINT );
105
+ if ( false === $backup_json ) {
106
+ WP_CLI::error( 'Failed to JSON-encode backup payload' );
107
+ }
108
+ $bytes_written = file_put_contents( $output_file, $backup_json ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
109
+ if ( false === $bytes_written ) {
110
+ WP_CLI::error( 'Failed to write backup file: ' . $output_file );
111
+ }
112
+ WP_CLI::success( 'Backup saved to ' . $output_file . ' (' . count( $post_cats ) . ' posts, ' . count( $term_data ) . ' categories)' );
@@ -0,0 +1,119 @@
1
+ <?php
2
+ /**
3
+ * Export all published posts with full content and categories to JSON.
4
+ *
5
+ * Streams posts one-by-one to avoid memory issues on large sites.
6
+ * Output is a JSON array where each element contains the post ID, title,
7
+ * publication date, full text content (HTML stripped), assigned category
8
+ * names, and permalink URL.
9
+ *
10
+ * Usage:
11
+ * studio wp eval-file export-posts.php
12
+ *
13
+ * Environment variables:
14
+ * TAXONOMIST_OUTPUT Path for the output JSON file.
15
+ * Default: /tmp/taxonomist-export.json
16
+ *
17
+ * @package Taxonomist
18
+ */
19
+
20
+ $output_file = getenv( 'TAXONOMIST_OUTPUT' ) ? getenv( 'TAXONOMIST_OUTPUT' ) : '/tmp/taxonomist-export.json';
21
+
22
+ // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen
23
+ // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
24
+ // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fclose
25
+ $fp = fopen( $output_file, 'w' );
26
+ if ( false === $fp ) {
27
+ WP_CLI::error( 'Failed to open export file for writing: ' . $output_file );
28
+ }
29
+ $write_or_error = static function ( $file_pointer, $contents, $context ) {
30
+ if ( false === fwrite( $file_pointer, $contents ) ) {
31
+ WP_CLI::error( 'Failed to write ' . $context );
32
+ }
33
+ };
34
+ $write_or_error( $fp, "[\n", 'opening JSON array to ' . $output_file );
35
+
36
+ $batch_size = 100;
37
+ $last_id = 0;
38
+ $total_exported = 0;
39
+ $first = true;
40
+
41
+ while ( true ) {
42
+ $query_args = array(
43
+ 'posts_per_page' => $batch_size,
44
+ 'post_status' => 'publish',
45
+ 'post_type' => 'post',
46
+ 'orderby' => 'ID',
47
+ 'order' => 'ASC',
48
+ 'suppress_filters' => false,
49
+ );
50
+
51
+ if ( $last_id > 0 ) {
52
+ add_filter(
53
+ 'posts_where',
54
+ $keyset_filter = function ( $where ) use ( $last_id ) {
55
+ global $wpdb;
56
+ return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $last_id );
57
+ }
58
+ );
59
+ }
60
+
61
+ $all_posts = get_posts( $query_args );
62
+
63
+ if ( isset( $keyset_filter ) ) {
64
+ remove_filter( 'posts_where', $keyset_filter );
65
+ unset( $keyset_filter );
66
+ }
67
+
68
+ if ( empty( $all_posts ) ) {
69
+ break;
70
+ }
71
+
72
+ foreach ( $all_posts as $p ) {
73
+ if ( ! $first ) {
74
+ $write_or_error( $fp, ",\n", 'JSON separator to ' . $output_file );
75
+ }
76
+ $first = false;
77
+
78
+ $cat_names = wp_get_post_categories( $p->ID, array( 'fields' => 'names' ) );
79
+ $cat_slugs = wp_get_post_categories( $p->ID, array( 'fields' => 'slugs' ) );
80
+
81
+ $content = wp_strip_all_tags( $p->post_content );
82
+ $content = preg_replace( '/\s+/', ' ', $content );
83
+ $content = trim( $content );
84
+
85
+ $row = wp_json_encode(
86
+ array(
87
+ 'post_id' => $p->ID,
88
+ 'title' => html_entity_decode( $p->post_title, ENT_QUOTES, 'UTF-8' ),
89
+ 'date' => $p->post_date,
90
+ 'content' => $content,
91
+ 'categories' => array_values( $cat_names ),
92
+ 'category_slugs' => array_values( $cat_slugs ),
93
+ 'url' => get_permalink( $p->ID ),
94
+ )
95
+ );
96
+
97
+ if ( false === $row ) {
98
+ fclose( $fp );
99
+ WP_CLI::error( 'Failed to JSON-encode post ID ' . $p->ID );
100
+ }
101
+ $write_or_error( $fp, $row, 'post row for post ID ' . $p->ID );
102
+ ++$total_exported;
103
+
104
+ if ( 0 === $total_exported % 500 ) {
105
+ WP_CLI::log( "Exported $total_exported posts..." );
106
+ }
107
+ }
108
+
109
+ $last_id = $all_posts[ count( $all_posts ) - 1 ]->ID;
110
+ wp_cache_flush();
111
+ }
112
+
113
+ $write_or_error( $fp, "\n]", 'closing JSON array to ' . $output_file );
114
+ if ( false === fclose( $fp ) ) {
115
+ WP_CLI::error( 'Failed to close export file: ' . $output_file );
116
+ }
117
+ // phpcs:enable
118
+
119
+ WP_CLI::success( "Exported $total_exported posts to $output_file" );
@@ -0,0 +1,233 @@
1
+ <?php
2
+ /**
3
+ * Restore taxonomy state from a backup file.
4
+ *
5
+ * Performs a full authoritative restore: the category taxonomy after this
6
+ * script runs will exactly match the backup.
7
+ *
8
+ * Usage:
9
+ * TAXONOMIST_BACKUP=/path/to/backup.json studio wp eval-file restore.php
10
+ *
11
+ * Environment variables:
12
+ * TAXONOMIST_BACKUP Path to the backup JSON file created by backup.php.
13
+ * Required.
14
+ *
15
+ * @package Taxonomist
16
+ */
17
+
18
+ $backup_file = getenv( 'TAXONOMIST_BACKUP' );
19
+ if ( ! $backup_file || ! file_exists( $backup_file ) ) {
20
+ WP_CLI::error( 'Set TAXONOMIST_BACKUP env var to the backup file path' );
21
+ }
22
+
23
+ $backup = json_decode( file_get_contents( $backup_file ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
24
+ if ( ! $backup ) {
25
+ WP_CLI::error( 'Failed to parse backup file' );
26
+ }
27
+
28
+ WP_CLI::log( 'Restoring from backup: ' . $backup['timestamp'] );
29
+ WP_CLI::log( 'Posts: ' . $backup['total_posts'] . ', Categories: ' . $backup['total_categories'] );
30
+
31
+ $backup_slugs = array();
32
+ $old_id_to_slug = array();
33
+ foreach ( $backup['categories'] as $category ) {
34
+ $backup_slugs[ $category['slug'] ] = $category;
35
+ $old_id_to_slug[ $category['term_id'] ] = $category['slug'];
36
+ }
37
+
38
+ // Step 1: Recreate missing categories and update existing ones.
39
+ $existing_terms = get_terms(
40
+ array(
41
+ 'taxonomy' => 'category',
42
+ 'hide_empty' => false,
43
+ )
44
+ );
45
+ $existing_by_slug = array();
46
+ foreach ( $existing_terms as $t ) {
47
+ $existing_by_slug[ $t->slug ] = $t;
48
+ }
49
+
50
+ $slug_to_id = array();
51
+ $created = 0;
52
+ $updated = 0;
53
+
54
+ foreach ( $backup['categories'] as $category ) {
55
+ $slug = $category['slug'];
56
+
57
+ if ( isset( $existing_by_slug[ $slug ] ) ) {
58
+ $existing = $existing_by_slug[ $slug ];
59
+ $needs_update = (
60
+ $existing->name !== $category['name'] ||
61
+ $existing->description !== $category['description']
62
+ );
63
+ if ( $needs_update ) {
64
+ wp_update_term(
65
+ $existing->term_id,
66
+ 'category',
67
+ array(
68
+ 'name' => $category['name'],
69
+ 'description' => $category['description'],
70
+ )
71
+ );
72
+ ++$updated;
73
+ WP_CLI::log( 'Updated category: ' . $category['name'] );
74
+ }
75
+ $slug_to_id[ $slug ] = $existing->term_id;
76
+ } else {
77
+ $temp_name = $category['name'];
78
+ $result = wp_insert_term(
79
+ $temp_name,
80
+ 'category',
81
+ array(
82
+ 'slug' => $category['slug'],
83
+ 'description' => $category['description'],
84
+ )
85
+ );
86
+ if ( is_wp_error( $result ) && 'term_exists' === $result->get_error_code() ) {
87
+ $temp_name = $category['name'] . '-taxonomist-' . uniqid();
88
+ $result = wp_insert_term(
89
+ $temp_name,
90
+ 'category',
91
+ array(
92
+ 'slug' => $category['slug'] . '-taxonomist-' . uniqid(),
93
+ 'description' => $category['description'],
94
+ )
95
+ );
96
+ }
97
+ if ( ! is_wp_error( $result ) ) {
98
+ $slug_to_id[ $slug ] = $result['term_id'];
99
+ ++$created;
100
+ WP_CLI::log( 'Recreated category: ' . $category['name'] );
101
+ } else {
102
+ WP_CLI::warning( 'Failed to recreate ' . $category['name'] . ': ' . $result->get_error_message() );
103
+ }
104
+ }
105
+ }
106
+
107
+ // Step 2: Restore parent-child hierarchy.
108
+ $hierarchy_fixed = 0;
109
+ foreach ( $backup['categories'] as $category ) {
110
+ $slug = $category['slug'];
111
+ $old_parent = $category['parent'];
112
+
113
+ if ( ! isset( $slug_to_id[ $slug ] ) ) {
114
+ continue;
115
+ }
116
+
117
+ $current_id = $slug_to_id[ $slug ];
118
+ $target_parent_id = 0;
119
+
120
+ if ( $old_parent > 0 && isset( $old_id_to_slug[ $old_parent ] ) ) {
121
+ $parent_slug = $old_id_to_slug[ $old_parent ];
122
+ if ( isset( $slug_to_id[ $parent_slug ] ) ) {
123
+ $target_parent_id = $slug_to_id[ $parent_slug ];
124
+ } else {
125
+ WP_CLI::warning( "Parent slug '$parent_slug' not found for category '$slug'" );
126
+ }
127
+ }
128
+
129
+ $current_term = get_term( $current_id, 'category' );
130
+ if ( ! $current_term ) {
131
+ continue;
132
+ }
133
+
134
+ $needs_fix = (
135
+ (int) $current_term->parent !== $target_parent_id ||
136
+ $current_term->name !== $category['name'] ||
137
+ $current_term->slug !== $category['slug']
138
+ );
139
+ if ( $needs_fix ) {
140
+ wp_update_term(
141
+ $current_id,
142
+ 'category',
143
+ array(
144
+ 'parent' => $target_parent_id,
145
+ 'name' => $category['name'],
146
+ 'slug' => $category['slug'],
147
+ )
148
+ );
149
+ ++$hierarchy_fixed;
150
+ }
151
+ }
152
+
153
+ if ( $hierarchy_fixed > 0 ) {
154
+ WP_CLI::log( "Fixed $hierarchy_fixed parent-child relationships." );
155
+ }
156
+
157
+ // Step 3: Restore every post's categories.
158
+ $restored = 0;
159
+ $error_count = 0;
160
+ foreach ( $backup['post_categories'] as $pc ) {
161
+ $current_post_id = $pc['post_id'];
162
+ $target_ids = array();
163
+
164
+ foreach ( $pc['category_slugs'] as $slug ) {
165
+ if ( isset( $slug_to_id[ $slug ] ) ) {
166
+ $target_ids[] = $slug_to_id[ $slug ];
167
+ } else {
168
+ WP_CLI::warning( "Category slug '$slug' not found for post ID $current_post_id" );
169
+ ++$error_count;
170
+ }
171
+ }
172
+
173
+ $current_post = get_post( $current_post_id );
174
+ if ( ! $current_post ) {
175
+ WP_CLI::warning( 'Post ID ' . $current_post_id . ' no longer exists' );
176
+ ++$error_count;
177
+ continue;
178
+ }
179
+
180
+ $set_result = wp_set_post_categories( $current_post_id, $target_ids );
181
+ if ( is_wp_error( $set_result ) || false === $set_result ) {
182
+ WP_CLI::warning( 'Failed to restore categories for post ID ' . $current_post_id );
183
+ ++$error_count;
184
+ continue;
185
+ }
186
+ ++$restored;
187
+
188
+ if ( 0 === $restored % 500 && $restored > 0 ) {
189
+ WP_CLI::log( "Restored $restored posts..." );
190
+ wp_cache_flush();
191
+ }
192
+ }
193
+
194
+ // Step 4: Delete categories created after the backup.
195
+ $post_restore_terms = get_terms(
196
+ array(
197
+ 'taxonomy' => 'category',
198
+ 'hide_empty' => false,
199
+ )
200
+ );
201
+ $deleted = 0;
202
+ foreach ( $post_restore_terms as $t ) {
203
+ if ( ! isset( $backup_slugs[ $t->slug ] ) ) {
204
+ $default_cat = (int) get_option( 'default_category' );
205
+ if ( $t->term_id === $default_cat ) {
206
+ $first_backup_slug = array_key_first( $backup_slugs );
207
+ if ( $first_backup_slug && isset( $slug_to_id[ $first_backup_slug ] ) ) {
208
+ update_option( 'default_category', $slug_to_id[ $first_backup_slug ] );
209
+ }
210
+ }
211
+ wp_delete_term( $t->term_id, 'category' );
212
+ WP_CLI::log( 'Deleted post-backup category: ' . $t->name );
213
+ ++$deleted;
214
+ }
215
+ }
216
+
217
+ // Step 5: Restore the default_category setting.
218
+ if ( isset( $backup['default_category_slug'] ) ) {
219
+ $default_slug = $backup['default_category_slug'];
220
+ if ( isset( $slug_to_id[ $default_slug ] ) ) {
221
+ update_option( 'default_category', $slug_to_id[ $default_slug ] );
222
+ WP_CLI::log( 'Restored default category: ' . $default_slug );
223
+ }
224
+ }
225
+
226
+ // Step 6: Recount term usage.
227
+ WP_CLI::runcommand( 'term recount category' );
228
+
229
+ WP_CLI::success(
230
+ "Restored $restored posts. " .
231
+ "Created $created categories, updated $updated, deleted $deleted. " .
232
+ "Errors: $error_count."
233
+ );