76c01e44df
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入 並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
275 lines
8.9 KiB
PHP
275 lines
8.9 KiB
PHP
<?php
|
||
/**
|
||
* TMDO_Export — Report export endpoints (v2.5.0 M14).
|
||
*
|
||
* Three export types:
|
||
* 1. health — wp_wpdo_audit op='health_check_daily' over last N days
|
||
* 2. snapshots — wp_wpdo_snapshots metadata (no inline_blob payload)
|
||
* 3. monthly — wpdo_monthly_summary_history (latest + up to 12 archives)
|
||
*
|
||
* Triggered via admin GET:tools.php?page=wp-data-optimizer&wpdo_export=health&format=csv&days=30
|
||
* Capability: manage_options + nonce.
|
||
*
|
||
* Filenames:wpdo-{type}-{site_slug}-{YYYYMMDD}.{csv|json}
|
||
*
|
||
* @package WP_Data_Optimizer
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
if ( ! defined( 'ABSPATH' ) ) {
|
||
exit;
|
||
}
|
||
|
||
/**
|
||
* Stateless static export handler.
|
||
*/
|
||
class TMDO_Export {
|
||
|
||
public const NONCE = 'wpdo_export';
|
||
|
||
/**
|
||
* Hook into admin_init to handle export requests.
|
||
*
|
||
* @return void
|
||
*/
|
||
public static function register(): void {
|
||
add_action( 'admin_init', array( __CLASS__, 'maybe_handle' ), 1 );
|
||
}
|
||
|
||
/**
|
||
* Detect + serve an export request.
|
||
*
|
||
* @return void
|
||
*/
|
||
public static function maybe_handle(): void {
|
||
if ( ! isset( $_GET['wpdo_export'] ) ) {
|
||
return;
|
||
}
|
||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||
return;
|
||
}
|
||
if ( ! check_admin_referer( self::NONCE ) ) {
|
||
return;
|
||
}
|
||
$type = sanitize_key( wp_unslash( (string) $_GET['wpdo_export'] ) );
|
||
$format = sanitize_key( wp_unslash( (string) ( $_GET['format'] ?? 'csv' ) ) );
|
||
$days = isset( $_GET['days'] ) ? max( 1, min( 365, (int) $_GET['days'] ) ) : 30;
|
||
if ( ! in_array( $format, array( 'csv', 'json' ), true ) ) {
|
||
$format = 'csv';
|
||
}
|
||
switch ( $type ) {
|
||
case 'health':
|
||
self::send_health( $format, $days );
|
||
break;
|
||
case 'snapshots':
|
||
self::send_snapshots( $format );
|
||
break;
|
||
case 'monthly':
|
||
self::send_monthly( $format );
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build a download URL with nonce.
|
||
*
|
||
* @param string $type health|snapshots|monthly.
|
||
* @param string $format csv|json.
|
||
* @param int $days For health only.
|
||
* @return string
|
||
*/
|
||
public static function url( string $type, string $format = 'csv', int $days = 30 ): string {
|
||
$args = array(
|
||
'page' => '2meet-data-optimizer',
|
||
'wpdo_export' => $type,
|
||
'format' => $format,
|
||
);
|
||
if ( 'health' === $type ) {
|
||
$args['days'] = $days;
|
||
}
|
||
return wp_nonce_url( add_query_arg( $args, admin_url( 'tools.php' ) ), self::NONCE );
|
||
}
|
||
|
||
// ─── handlers ──────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Send health export.
|
||
*
|
||
* @param string $format csv|json.
|
||
* @param int $days Number of days to include.
|
||
* @return void
|
||
*/
|
||
private static function send_health( string $format, int $days ): void {
|
||
global $wpdb;
|
||
$audit = $wpdb->prefix . 'wpdo_audit';
|
||
$exists = (int) $wpdb->get_var(
|
||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
||
$audit
|
||
)
|
||
);
|
||
$rows = array();
|
||
if ( 1 === $exists ) {
|
||
$raw = (array) $wpdb->get_results(
|
||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$audit} is a trusted table name via $wpdb->prefix
|
||
"SELECT ts, op, value_after FROM `{$audit}` WHERE op = 'health_check_daily' AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY) ORDER BY ts DESC",
|
||
$days
|
||
),
|
||
ARRAY_A
|
||
);
|
||
foreach ( $raw as $r ) {
|
||
$ctx = isset( $r['value_after'] ) ? json_decode( (string) $r['value_after'], true ) : array();
|
||
if ( ! is_array( $ctx ) ) {
|
||
$ctx = array();
|
||
}
|
||
$rows[] = array(
|
||
'ts' => (string) $r['ts'],
|
||
'critical' => (int) ( $ctx['critical'] ?? 0 ),
|
||
'recommended' => (int) ( $ctx['recommended'] ?? 0 ),
|
||
'duration_ms' => (int) ( $ctx['duration_ms'] ?? 0 ),
|
||
'autoload_kb' => (int) ( $ctx['autoload_kb'] ?? 0 ),
|
||
'conflicts' => (int) ( $ctx['conflicts'] ?? 0 ),
|
||
'module_suggestions_count' => (int) ( $ctx['module_suggestions_count'] ?? 0 ),
|
||
);
|
||
}
|
||
}
|
||
$headers = array( 'ts', 'critical', 'recommended', 'duration_ms', 'autoload_kb', 'conflicts', 'module_suggestions_count' );
|
||
self::respond( $format, 'health', $headers, $rows );
|
||
}
|
||
|
||
/**
|
||
* Send snapshots export.
|
||
*
|
||
* @param string $format csv|json.
|
||
* @return void
|
||
*/
|
||
private static function send_snapshots( string $format ): void {
|
||
global $wpdb;
|
||
$snap = $wpdb->prefix . 'wpdo_snapshots';
|
||
$exists = (int) $wpdb->get_var(
|
||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
||
$snap
|
||
)
|
||
);
|
||
$rows = array();
|
||
if ( 1 === $exists ) {
|
||
$rows = (array) $wpdb->get_results(
|
||
"SELECT snapshot_id, trigger_type, size_bytes, row_count, storage, file_path, file_sha256, notes, created_at, expires_at FROM `{$snap}` ORDER BY created_at DESC", // phpcs:ignore WordPress.DB
|
||
ARRAY_A
|
||
);
|
||
}
|
||
$headers = array( 'snapshot_id', 'trigger_type', 'size_bytes', 'row_count', 'storage', 'file_path', 'file_sha256', 'notes', 'created_at', 'expires_at' );
|
||
self::respond( $format, 'snapshots', $headers, $rows );
|
||
}
|
||
|
||
/**
|
||
* Send monthly summary export.
|
||
*
|
||
* @param string $format csv|json.
|
||
* @return void
|
||
*/
|
||
private static function send_monthly( string $format ): void {
|
||
$latest = (array) get_option( 'wpdo_monthly_summary_latest', array() );
|
||
$history = (array) get_option( 'wpdo_monthly_summary_history', array() );
|
||
$all = array();
|
||
if ( ! empty( $latest ) ) {
|
||
$all[] = $latest;
|
||
}
|
||
foreach ( $history as $h ) {
|
||
if ( is_array( $h ) ) {
|
||
$all[] = $h;
|
||
}
|
||
}
|
||
if ( 'json' === $format ) {
|
||
self::respond_json( 'monthly', $all );
|
||
return;
|
||
}
|
||
// Flatten for CSV — top-level metric only.
|
||
$rows = array();
|
||
foreach ( $all as $row ) {
|
||
$health = (array) ( $row['health'] ?? array() );
|
||
$snap = (array) ( $row['snapshots'] ?? array() );
|
||
$rows[] = array(
|
||
'period_start' => (string) ( $row['period_start'] ?? '' ),
|
||
'period_end' => (string) ( $row['period_end'] ?? '' ),
|
||
'generated_at' => (string) ( $row['generated_at'] ?? '' ),
|
||
'health_total' => (int) ( $health['total'] ?? 0 ),
|
||
'health_success' => (int) ( $health['success'] ?? 0 ),
|
||
'health_critical' => (int) ( $health['critical'] ?? 0 ),
|
||
'snapshots_total' => (int) ( $snap['total'] ?? 0 ),
|
||
'snapshots_bytes' => (int) ( $snap['total_bytes'] ?? 0 ),
|
||
'autoload_size' => (int) ( $row['autoload_size'] ?? 0 ),
|
||
);
|
||
}
|
||
$headers = array( 'period_start', 'period_end', 'generated_at', 'health_total', 'health_success', 'health_critical', 'snapshots_total', 'snapshots_bytes', 'autoload_size' );
|
||
self::respond_csv( 'monthly', $headers, $rows );
|
||
}
|
||
|
||
// ─── output helpers ────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Dispatch to csv or json responder.
|
||
*
|
||
* @param string $format csv|json.
|
||
* @param string $type Export type slug.
|
||
* @param array $headers Column headers.
|
||
* @param array $rows Data rows.
|
||
* @return void
|
||
*/
|
||
private static function respond( string $format, string $type, array $headers, array $rows ): void {
|
||
if ( 'json' === $format ) {
|
||
self::respond_json( $type, $rows );
|
||
return;
|
||
}
|
||
self::respond_csv( $type, $headers, $rows );
|
||
}
|
||
|
||
/**
|
||
* Send CSV response.
|
||
*
|
||
* @param string $type Export type slug.
|
||
* @param array $headers Column headers.
|
||
* @param array $rows Data rows.
|
||
* @return void
|
||
*/
|
||
private static function respond_csv( string $type, array $headers, array $rows ): void {
|
||
$body = TMDO_CSV_Writer::build( $headers, $rows );
|
||
self::send_attachment( $type, 'csv', 'text/csv; charset=UTF-8', $body );
|
||
}
|
||
|
||
/**
|
||
* Send JSON response.
|
||
*
|
||
* @param string $type Export type slug.
|
||
* @param array $rows Data rows.
|
||
* @return void
|
||
*/
|
||
private static function respond_json( string $type, array $rows ): void {
|
||
$body = wp_json_encode( $rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
|
||
self::send_attachment( $type, 'json', 'application/json; charset=UTF-8', (string) $body );
|
||
}
|
||
|
||
/**
|
||
* Send file attachment response.
|
||
*
|
||
* @param string $type Export type slug.
|
||
* @param string $ext File extension.
|
||
* @param string $mime MIME type.
|
||
* @param string $body File body content.
|
||
* @return void
|
||
*/
|
||
private static function send_attachment( string $type, string $ext, string $mime, string $body ): void {
|
||
$site_slug = sanitize_title( (string) get_option( 'blogname', 'site' ) ) ?: 'site';
|
||
$filename = sprintf( 'wpdo-%s-%s-%s.%s', $type, $site_slug, gmdate( 'Ymd' ), $ext );
|
||
nocache_headers();
|
||
header( 'Content-Type: ' . $mime );
|
||
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
|
||
header( 'Content-Length: ' . strlen( $body ) );
|
||
echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||
exit;
|
||
}
|
||
}
|