PluginProbe
Substack Importer / 1.0.2
Substack Importer v1.0.2
trunk 0.1.0 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.2.0
substack-importer / includes / class-importer-admin.php

class-importer-admin.php in Substack Importer 1.0.2, at includes/class-importer-admin.php

381 lines 9.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WP-Admin specific functionality for the plugin
4 *
5 * @package Substack_Importer
6 */
7
8 namespace SubstackImporter;
9
10 use WXR_Generator\File_Writer;
11 use WXR_Generator\Generator;
12 use WXR_Parser;
13 use WP_Import;
14 use WP_Error;
15
16
17 /**
18 * The admin specific functionality for the Substack Importer Plugin
19 *
20 *
21 */
22 class Importer_Admin {
23
24 const EXPORT_FILE_OPTION = 'substack-export-attachment';
25
26 const SUBSTACK_URL_OPTION = 'substack-newsletter-url';
27
28 const WXR_FILE_OPTION = 'substack-wxr-attachement';
29
30 const SUBSTACK_PROGRESS_OPTION = 'substack-import-progress';
31
32 public function run() {
33
34 $action = isset( $_GET['action'] ) ? $_GET['action'] : 'start';
35
36 switch ( $action ) {
37
38 case 'start':
39 default:
40 $this->render_page(
41 'start-screen',
42 array(
43 'progress' => get_option( self::SUBSTACK_PROGRESS_OPTION, false ),
44 )
45 );
46
47 break;
48
49 case 'upload':
50 $upload_result = $this->upload();
51
52 if ( ! is_wp_error( $upload_result ) ) {
53 $url = admin_url( 'admin.php?import=substack&action=progress' );
54 return wp_safe_redirect( $url );
55 }
56
57 require_once ABSPATH . 'wp-admin/admin-header.php';
58 $this->render_page(
59 'start-screen',
60 array(
61 'error' => $upload_result->get_error_message(),
62 'progress' => get_option( self::SUBSTACK_PROGRESS_OPTION, false ),
63 )
64 );
65
66 break;
67
68 case 'progress':
69 $this->render_page( 'progress' );
70 break;
71
72 case 'pre-import':
73 // Convert Substack export to WXR
74 $wxr_path = $this->convert_substack_to_wxr();
75
76 // Parse the WXR and render the author mapping step.
77 $import_data = $this->parse_wxr( $wxr_path );
78 $this->pre_import_page( $import_data );
79 break;
80
81 case 'import':
82 // Use WordPress importer to import the WXR
83 $this->import();
84 break;
85 }
86 }
87
88 /**
89 * Progresses through the posts and downloads additional data (author info, comments)
90 * through the Substack API.
91 *
92 * This method is used as an Ajax Action.
93 *
94 */
95 public function progress() {
96
97 $url = get_option( self::SUBSTACK_URL_OPTION );
98 $file = get_attached_file( get_option( self::EXPORT_FILE_OPTION ) );
99 $writer = new File_Writer( 'php://output' );
100 $converter = new Converter( new Generator( $writer ), $file, get_option( self::SUBSTACK_URL_OPTION ) );
101 $progress = get_option( self::SUBSTACK_PROGRESS_OPTION );
102
103 $result = $converter->load_meta_data( $progress, 1 );
104
105 // If no url was set, we can consider all posts as processed.
106 if ( ! $url ) {
107 $result['processed'] = $result['total'];
108 }
109
110 update_option( self::SUBSTACK_PROGRESS_OPTION, $result['processed'] );
111
112 $result['status'] = $result['processed'] === $result['total']
113 ? 'done' : 'processing';
114
115 wp_send_json( $result );
116
117 exit();
118 }
119
120 /**
121 * Try to upload the Substack Export and ensure it is a valid export that can be used in the converter.
122 *
123 * @return bool|WP_Error
124 */
125 protected function upload() {
126 check_admin_referer( 'import-upload' );
127 $file = wp_import_handle_upload();
128
129 // If the upload handler already failed, don't attempt further checks
130 if ( ! empty( $file['error'] ) ) {
131 return new WP_Error( 'upload_error', esc_html( $file['error'] ) );
132 }
133
134 if ( ! file_exists( $file['file'] ) ) {
135 $error = sprintf( __( 'The export file could not be found at <code>%s</code>. It is likely that this was caused by a permissions problem.', 'substack-importer' ), esc_html( $file['file'] ) );
136 return new WP_Error( 'upload_error', $error );
137 }
138
139 if ( mime_content_type( $file['file'] ) !== 'application/zip' ) {
140 $error = sprintf( __( 'Invalid file type uploaded. Expected a zip file, got a %s file.', 'substack-importer' ), mime_content_type( $file['file'] ) );
141 return new WP_Error( 'upload_error', $error );
142 }
143
144 $writer = new File_Writer( 'php://output' );
145 $converter = new Converter( new Generator( $writer ), $file['file'] );
146
147 $posts = $converter->get_posts();
148
149 // Something went wrong getting posts from the zip-file
150 if ( is_wp_error( $posts ) ) {
151 return $posts;
152 }
153
154 // The zip-file was valid and contained a posts.csv but it was empty.
155 if ( null === $posts->current() ) {
156 return new WP_Error( __( 'No posts were found in the uploaded export.', 'substack-importer' ) );
157 }
158
159 // Check the substack URL. If it is not empty, the url must be valid for the uploaded export file.
160 $url = ! empty( $_POST['substack-url'] )
161 ? $this->sanitize_substack_url( $_POST['substack-url'] )
162 : null;
163
164 if ( $url && ! $this->validate_substack_url( $url, $converter ) ) {
165 return new WP_Error( 'upload_error', __( 'The provided Substack Newsletter URL is invalid', 'substack-importer' ) );
166 }
167
168 update_option( self::SUBSTACK_PROGRESS_OPTION, 0 );
169 update_option( self::SUBSTACK_URL_OPTION, $url );
170 update_option( self::EXPORT_FILE_OPTION, $file['id'] );
171
172 return true;
173 }
174
175 /**
176 * Validate that the provided (sanitized) Substack leads to the correct Substack Newsletter.
177 *
178 * @param $url
179 *
180 * @return bool
181 */
182 protected function validate_substack_url( $url, Converter $converter ) {
183
184 // We need to get one post ID and check the posts comments endpoint. If we get a 200 response, the provided
185 // substack url matches the export file.
186 $post = $converter->get_posts()->current();
187
188 $id = (int) $post['post_id'];
189 $api_endpoint = sprintf( '%s/api/v1/post/%d/comments?limit=1', $url, $id );
190
191 $response = wp_remote_get( $api_endpoint );
192
193 return ! is_wp_error( $response ) && 200 === $response['response']['code'];
194 }
195
196 /**
197 * Clean up the Substack url provided by the user to only include scheme + host.
198 *
199 * Returns false if the url is invalid and can not be parsed.
200 *
201 * @param string $url URL of Substack newsletter as provided by the user.
202 *
203 * @return string|bool
204 */
205 protected function sanitize_substack_url( $url ) {
206
207 // If scheme is missing, add it
208 if ( ! preg_match( '|^.*//|', $url ) ) {
209 $url = '//' . $url;
210 }
211
212 $url_parts = wp_parse_url( $url );
213
214 if ( false === $url_parts ) {
215 return false;
216 }
217
218 return 'https://' . $url_parts['host'];
219 }
220
221 /**
222 * Convert the Substack export to a WXR and render a pre-import.
223 *
224 * @return string The path of the WXR.
225 *
226 * @throws \Exception
227 */
228 protected function convert_substack_to_wxr() {
229
230 $file = get_attached_file( get_option( self::EXPORT_FILE_OPTION ) );
231
232 // Temporarily store the WXR before sideloading it.
233 $tmp_wxr = wp_tempnam( 'substack-wxr.xml' );
234
235 $writer = new File_Writer( $tmp_wxr );
236
237 $converter = new Converter( new Generator( $writer ), $file );
238
239 // Convert the export file to a WXR.
240 $converter->convert();
241 $writer->close();
242
243 return $this->store_wxr( $tmp_wxr );
244 }
245
246 protected function pre_import_page( $import_data ) {
247 $wp_importer = new WP_Import();
248 $wp_importer->get_authors_from_import( $import_data );
249
250 // The wordpress-importer renders a form. The following filter overwrites
251 // the action url of that form. This ensures the substack-importer will handle the
252 // form submission.
253 add_filter(
254 'admin_url',
255 function( $url ) {
256
257 if ( false === strpos( $url, 'import=wordpress' ) ) { //phpcs:ignore WordPress.WP.CapitalPDangit.Misspelled
258 return $url;
259 }
260
261 return wp_nonce_url( add_query_arg( array( 'action' => 'import' ) ), 'import-substack' );
262 }
263 );
264
265 $this->render_page(
266 'pre-import-screen',
267 array(
268 'wp_importer' => $wp_importer,
269 )
270 );
271 }
272
273 /**
274 * Import the WXR.
275 */
276 protected function import() {
277
278 // To allow podcast uploads, we need to allow the mimetype.
279 $this->allow_mpga_mime();
280
281 $wp_importer = new WP_Import();
282
283 $wp_importer->fetch_attachments = ( ! empty( $_POST['fetch_attachments'] ) && $wp_importer->allow_fetch_attachments() );
284 $file = get_attached_file( get_option( self::WXR_FILE_OPTION ) );
285
286 set_time_limit( 0 );
287
288 $wp_importer->import( $file );
289
290 delete_option( self::SUBSTACK_PROGRESS_OPTION );
291 delete_option( self::SUBSTACK_URL_OPTION );
292 delete_option( self::EXPORT_FILE_OPTION );
293 }
294
295 protected function allow_mpga_mime() {
296 add_filter(
297 'upload_mimes',
298 function( $mimes ) {
299 $mimes['mpga'] = 'audio/mpeg';
300 return $mimes;
301 }
302 );
303 }
304
305 /**
306 * Parse WXR file
307 *
308 * @param $wxr_path
309 *
310 * @return array|\WP_Error
311 */
312 protected function parse_wxr( $wxr_path ) {
313 $parser = new WXR_Parser();
314 return $parser->parse( $wxr_path );
315 }
316
317 /**
318 * Sideload the WXR and store the ID as an option.
319 *
320 * @param string $wxr_path The path of the temporary WXR file that has to be sideloaded.
321 *
322 * @return string The path of the sideloaded WXR
323 */
324 protected function store_wxr( $wxr_path ) {
325
326 $filedata = array(
327 'error' => null,
328 'tmp_name' => $wxr_path,
329 'name' => 'substackw-wxr.xml',
330 'type' => 'text/plain',
331 );
332
333 $overrides = array(
334 'test_form' => false,
335 'test_type' => false,
336 );
337 $sideload = wp_handle_sideload( $filedata, $overrides );
338
339 // Construct the object array.s
340 $object = array(
341 'post_title' => wp_basename( $sideload['file'] ),
342 'post_content' => $sideload['url'],
343 'post_mime_type' => mime_content_type( $sideload['file'] ),
344 'guid' => $sideload['url'],
345 'context' => 'import',
346 'post_status' => 'private',
347 );
348
349 // Save the data.
350 $id = wp_insert_attachment( $object, $sideload['file'] );
351
352 /*
353 * Schedule a cleanup for one day from now in case of failed
354 * import or missing wp_import_cleanup() call.
355 */
356 wp_schedule_single_event( time() + DAY_IN_SECONDS, 'importer_scheduled_cleanup', array( $id ) );
357
358 update_option( self::WXR_FILE_OPTION, $id );
359
360 return $sideload['file'];
361 }
362
363 /**
364 * Render a partial template.
365 *
366 * @param string $partial The name of the partial.
367 * @param array $vars Variables to load into the partial
368 */
369 protected function render_page( $partial, $vars = array() ) {
370
371 extract( $vars, EXTR_SKIP ); //phpcs:ignore WordPress.PHP.DontExtract.extract_extract --internal usage only
372
373 ob_start();
374 include __DIR__ . '/../partials/' . $partial . '.php';
375 $content = ob_get_clean();
376
377 include __DIR__ . '/../partials/container.php';
378 }
379
380 }
381