settings = $settings; $this->redirects = $redirects; $this->router = $router; } public function hooks() { add_filter( 'aooshop_admin_tabs', array( $this, 'tab' ) ); add_action( 'aooshop_admin_tab_' . self::TAB, array( $this, 'render' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); foreach ( array( 'save', 'delete', 'to_shop', 'settings', 'export', 'import', 'clear', 'budget_map' ) as $a ) { add_action( 'admin_post_aooshop_rd_' . $a, array( $this, 'action_' . $a ) ); } add_filter( 'aooshop_status_rows', array( $this, 'status_row' ) ); } public function tab( $tabs ) { $tabs[ self::TAB ] = __( 'Redirects (old links)', 'aoo-shop' ); return $tabs; } public function status_row( $rows ) { $s = AooShop_Redirects::summary(); $rows[] = array( __( 'Old link redirects', 'aoo-shop' ), $s['total'] ? sprintf( /* translators: 1: number of redirect rows, 2: rows with a target, 3: total hits */ __( '%1$d links · %2$d with a target · used %3$d times', 'aoo-shop' ), $s['total'], $s['matched'], $s['hits'] ) . ( $this->settings->is_preview() ? ' · ' . __( '(live mode only)', 'aoo-shop' ) : '' ) : __( 'None yet — see the Redirects tab', 'aoo-shop' ), ); return $rows; } private function page_url( array $args = array() ) { return add_query_arg( array_merge( array( 'page' => AooShop_Admin::tab_slug( self::TAB ), 'tab' => self::TAB ), $args ), admin_url( 'admin.php' ) ); } public function enqueue( $hook ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- screen check only. if ( false === strpos( (string) $hook, AooShop_Admin::SLUG ) || ! isset( $_GET['tab'] ) || self::TAB !== $_GET['tab'] ) { return; } wp_enqueue_script( 'aooshop-admin', AOOSHOP_URL . 'assets/aoo-shop-admin.js', array(), AOOSHOP_VERSION . '-' . (string) @filemtime( AOOSHOP_DIR . 'assets/aoo-shop-admin.js' ), true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged wp_localize_script( 'aooshop-admin', 'aooShopAdmin', array( 'ajax' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'aooshop_woo_import' ), 'search' => rest_url( AooShop_Rest::NS . '/search' ), 'restNonce' => wp_create_nonce( 'wp_rest' ), 'i18n' => array( 'products' => __( 'Products', 'aoo-shop' ), 'terms' => __( 'Categories/brands', 'aoo-shop' ), 'done' => __( 'Import finished — reloading…', 'aoo-shop' ), 'error' => __( 'Import failed:', 'aoo-shop' ), 'matched' => __( 'Matched', 'aoo-shop' ), 'none' => __( 'No products found', 'aoo-shop' ), 'confirm' => __( 'Import all product, category and brand links from WooCommerce? (Rows you edited yourself will not be overwritten.)', 'aoo-shop' ), ), ) ); } // ── actions ─────────────────────────────────────────────────────── /** Capability gate for every admin-post action; each action verifies its own nonce right after. */ private function require_cap() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'aoo-shop' ), 403 ); } } private function back( $msg, array $extra = array() ) { $keep = array(); foreach ( array( 'filter', 's', 'paged' ) as $k ) { if ( isset( $_REQUEST[ $k ] ) && '' !== $_REQUEST[ $k ] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked by the calling action. $keep[ $k ] = sanitize_text_field( wp_unslash( $_REQUEST[ $k ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } } wp_safe_redirect( $this->page_url( array_merge( $keep, array( 'aoo_msg' => $msg ), $extra ) ) ); exit; } public function action_save() { $this->require_cap(); check_admin_referer( 'aooshop_rd_save' ); $id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0; $old = $id ? AooShop_Redirects::get( $id ) : null; $in = array( 'source_path' => $old ? $old->source_path : ( isset( $_POST['source_path'] ) ? sanitize_text_field( wp_unslash( $_POST['source_path'] ) ) : '' ), 'target_type' => isset( $_POST['target_type'] ) ? sanitize_key( wp_unslash( $_POST['target_type'] ) ) : 'none', 'target_slug' => isset( $_POST['target_slug'] ) ? sanitize_text_field( wp_unslash( $_POST['target_slug'] ) ) : '', 'target_url' => isset( $_POST['target_url'] ) ? esc_url_raw( wp_unslash( $_POST['target_url'] ) ) : '', 'status' => isset( $_POST['status'] ) ? absint( $_POST['status'] ) : 301, 'origin' => 'manual', 'source_kind' => $old ? $old->source_kind : 'other', 'source_name' => $old ? $old->source_name : '', 'matched_by' => 'manual', ); if ( 'page' === $in['target_type'] && isset( $_POST['target_page'] ) ) { $in['target_slug'] = (string) absint( $_POST['target_page'] ); } if ( 'budget' === $in['target_type'] ) { $in['target_slug'] = isset( $_POST['target_budget'] ) ? sanitize_text_field( wp_unslash( $_POST['target_budget'] ) ) : ''; } if ( 'url' === $in['target_type'] && isset( $_POST['target_url'] ) ) { $raw = trim( sanitize_text_field( wp_unslash( $_POST['target_url'] ) ) ); $in['target_url'] = 0 === strpos( $raw, '/' ) ? $raw : esc_url_raw( $raw ); } $row = AooShop_Redirects::clean_row( $in ); if ( is_wp_error( $row ) ) { $this->back( 'error', array( 'aoo_err' => rawurlencode( $row->get_error_message() ) ) ); } if ( $old && $old->source_hash !== $row['source_hash'] ) { AooShop_Redirects::delete( $old->id ); } AooShop_Redirects::upsert( $row, false ); AooShop_Redirects::refresh_count(); $this->back( 'saved' ); } public function action_delete() { $this->require_cap(); check_admin_referer( 'aooshop_rd_delete' ); AooShop_Redirects::delete( isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0 ); AooShop_Redirects::refresh_count(); $this->back( 'deleted' ); } public function action_to_shop() { $this->require_cap(); check_admin_referer( 'aooshop_rd_to_shop' ); $old = AooShop_Redirects::get( isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0 ); if ( $old ) { $row = AooShop_Redirects::clean_row( array( 'source_path' => $old->source_path, 'source_kind' => $old->source_kind, 'source_name' => $old->source_name, 'target_type' => 'shop', 'origin' => 'manual', 'matched_by' => 'manual', 'status' => (int) $old->status, ) ); if ( ! is_wp_error( $row ) ) { AooShop_Redirects::upsert( $row, false ); AooShop_Redirects::refresh_count(); } } $this->back( 'saved' ); } public function action_clear() { $this->require_cap(); check_admin_referer( 'aooshop_rd_clear' ); AooShop_Redirects::delete_generated(); AooShop_Redirects::refresh_count(); $this->back( 'cleared' ); } /** "จับคู่หมวดงบ": one manual row per old Woo budget category the owner mapped (budget page / shop). */ public function action_budget_map() { $this->require_cap(); check_admin_referer( 'aooshop_rd_budget_map' ); // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per item. $paths = isset( $_POST['bm_path'] ) ? (array) wp_unslash( $_POST['bm_path'] ) : array(); $names = isset( $_POST['bm_name'] ) ? (array) wp_unslash( $_POST['bm_name'] ) : array(); $targets = isset( $_POST['bm_target'] ) ? (array) wp_unslash( $_POST['bm_target'] ) : array(); // phpcs:enable $slugs = wp_list_pluck( $this->settings->price_buckets(), 'slug' ); $n = 0; foreach ( $paths as $i => $path ) { $to = isset( $targets[ $i ] ) ? sanitize_text_field( (string) $targets[ $i ] ) : ''; if ( '' === $to ) { continue; // Unchanged. } if ( 'shop' !== $to && ! in_array( $to, $slugs, true ) ) { continue; } $row = AooShop_Redirects::clean_row( array( 'source_path' => sanitize_text_field( (string) $path ), 'source_kind' => 'category', 'source_name' => isset( $names[ $i ] ) ? sanitize_text_field( (string) $names[ $i ] ) : '', 'target_type' => 'shop' === $to ? 'shop' : 'budget', 'target_slug' => 'shop' === $to ? '' : $to, 'origin' => 'manual', 'matched_by' => 'budget', ) ); if ( ! is_wp_error( $row ) ) { AooShop_Redirects::upsert( $row, false ); $n++; } } AooShop_Redirects::refresh_count(); $this->back( 'budget_saved', array( 'aoo_ok' => $n ) ); } public function action_settings() { $this->require_cap(); check_admin_referer( 'aooshop_rd_settings' ); $landing = array(); $slugs = isset( $_POST['landing_slug'] ) ? (array) wp_unslash( $_POST['landing_slug'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per item. $pages = isset( $_POST['landing_page'] ) ? array_map( 'absint', (array) $_POST['landing_page'] ) : array(); foreach ( $slugs as $i => $slug ) { $slug = trim( rawurldecode( sanitize_text_field( (string) $slug ) ), '/ ' ); $slug = function_exists( 'mb_strtolower' ) ? mb_strtolower( $slug, 'UTF-8' ) : strtolower( $slug ); $page = isset( $pages[ $i ] ) ? (int) $pages[ $i ] : 0; if ( '' !== $slug && $page && 'page' === get_post_type( $page ) ) { $landing[ $slug ] = $page; } } AooShop_Redirects::save_rd_settings( array( 'fallback_category' => empty( $_POST['fallback_category'] ) ? 0 : 1, 'fallback_brand' => empty( $_POST['fallback_brand'] ) ? 0 : 1, 'brand_landing' => $landing, ) ); $this->back( 'settings_saved' ); } public function action_export() { $this->require_cap(); check_admin_referer( 'aooshop_rd_export' ); global $wpdb; $table = AooShop_Redirects::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $rows = $wpdb->get_results( $wpdb->prepare( "SELECT source_path, target_type, target_slug, target_url, status, source_kind, source_name, origin, matched_by, hits FROM %i ORDER BY source_path", $table ), ARRAY_A ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="aoo-redirects-' . gmdate( 'Ymd-His' ) . '.csv"' ); $out = fopen( 'php://output', 'w' ); fwrite( $out, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Excel needs the BOM for Thai. fputcsv( $out, array_merge( self::CSV_COLS, array( 'source_kind', 'source_name', 'origin', 'matched_by', 'hits' ) ) ); foreach ( (array) $rows as $r ) { $line = array(); foreach ( array( 'source_path', 'target_type', 'target_slug', 'target_url', 'status', 'source_kind', 'source_name', 'origin', 'matched_by', 'hits' ) as $c ) { $v = (string) $r[ $c ]; // Spreadsheet formula injection guard. if ( '' !== $v && in_array( $v[0], array( '=', '+', '-', '@' ), true ) ) { $v = "'" . $v; } $line[] = $v; } fputcsv( $out, $line ); } fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose exit; } public function action_import() { $this->require_cap(); check_admin_referer( 'aooshop_rd_import' ); // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- uploaded file, validated below. if ( empty( $_FILES['csv']['tmp_name'] ) || ! is_uploaded_file( $_FILES['csv']['tmp_name'] ) ) { $this->back( 'error', array( 'aoo_err' => rawurlencode( __( 'No file selected.', 'aoo-shop' ) ) ) ); } if ( (int) $_FILES['csv']['size'] > self::CSV_MAX ) { $this->back( 'error', array( 'aoo_err' => rawurlencode( __( 'The file is larger than 5 MB.', 'aoo-shop' ) ) ) ); } $fh = fopen( $_FILES['csv']['tmp_name'], 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen // phpcs:enable $head = $fh ? fgetcsv( $fh ) : false; if ( ! $head ) { $this->back( 'error', array( 'aoo_err' => rawurlencode( __( 'Could not read the CSV file.', 'aoo-shop' ) ) ) ); } $head[0] = preg_replace( '/^\xEF\xBB\xBF/', '', (string) $head[0] ); $cols = array_flip( array_map( 'trim', array_map( 'strtolower', $head ) ) ); if ( ! isset( $cols['source_path'], $cols['target_type'] ) ) { $this->back( 'error', array( 'aoo_err' => rawurlencode( __( 'The file must have source_path and target_type columns.', 'aoo-shop' ) ) ) ); } $ok = 0; $bad = 0; $n = 0; while ( ( $line = fgetcsv( $fh ) ) !== false && $n < 20000 ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition $n++; $get = function ( $c ) use ( $cols, $line ) { if ( ! isset( $cols[ $c ], $line[ $cols[ $c ] ] ) ) { return ''; } $v = trim( (string) $line[ $cols[ $c ] ] ); return ( '' !== $v && "'" === $v[0] && isset( $v[1] ) && in_array( $v[1], array( '=', '+', '-', '@' ), true ) ) ? substr( $v, 1 ) : $v; }; $row = AooShop_Redirects::clean_row( array( 'source_path' => $get( 'source_path' ), 'target_type' => '' !== $get( 'target_type' ) ? $get( 'target_type' ) : 'none', 'target_slug' => $get( 'target_slug' ), 'target_url' => $get( 'target_url' ), 'status' => '' !== $get( 'status' ) ? (int) $get( 'status' ) : 301, 'source_kind' => $get( 'source_kind' ), 'source_name' => $get( 'source_name' ), 'origin' => 'manual', 'matched_by' => 'csv', ) ); if ( is_wp_error( $row ) ) { $bad++; continue; } AooShop_Redirects::upsert( $row, false ); $ok++; } fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose AooShop_Redirects::refresh_count(); $this->back( 'imported', array( 'aoo_ok' => $ok, 'aoo_bad' => $bad ) ); } // ── screen ──────────────────────────────────────────────────────── private function notice() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- display only. $msg = isset( $_GET['aoo_msg'] ) ? sanitize_key( wp_unslash( $_GET['aoo_msg'] ) ) : ''; $map = array( 'saved' => __( 'Saved.', 'aoo-shop' ), 'deleted' => __( 'Deleted.', 'aoo-shop' ), 'cleared' => __( 'Removed imported and auto-generated rows (rows you edited yourself are kept).', 'aoo-shop' ), 'settings_saved' => __( 'Fallback rules saved.', 'aoo-shop' ), ); if ( 'budget_saved' === $msg ) { $map[ $msg ] = sprintf( /* translators: %d: number of categories saved */ __( 'Budget category mapping saved: %d categories.', 'aoo-shop' ), isset( $_GET['aoo_ok'] ) ? absint( $_GET['aoo_ok'] ) : 0 ); } if ( isset( $map[ $msg ] ) ) { echo '
' . esc_html( $map[ $msg ] ) . '
' . esc_html( sprintf( /* translators: 1: number of imported rows, 2: number of skipped rows */ __( 'Imported %1$d CSV rows · skipped %2$d invalid rows.', 'aoo-shop' ), $ok, $bad ) ) . '
' . esc_html( $err ) . '
settings->is_preview() ) : ?>
render_import(); $this->render_test(); $this->render_edit(); $this->render_table(); $this->render_budget_map(); $this->render_csv(); $this->render_fallback(); } private function render_import() { echo '
' . esc_html( sprintf( /* translators: 1: date and time of the last import, 2: matched products, 3: products sent, 4: match percentage, 5: matched categories/brands, 6: categories/brands sent */ __( 'Last import %1$s · products matched %2$d/%3$d (%4$s%%) · categories/brands %5$d/%6$d', 'aoo-shop' ), wp_date( 'j M Y H:i', (int) $last['finished'] ), $p['matched'], $p['sent'], $p['sent'] ? number_format_i18n( 100 * $p['matched'] / $p['sent'], 1 ) : '0', $t['matched'], $t['sent'] ) ); $by = array(); foreach ( array_merge_recursive( (array) $p['by'], (array) $t['by'] ) as $k => $v ) { $by[] = $k . ' ' . ( is_array( $v ) ? array_sum( $v ) : (int) $v ); } if ( $by ) { echo ' · ' . esc_html__( 'Matched by', 'aoo-shop' ) . ' ' . esc_html( implode( ', ', $by ) ); } echo '
'; } if ( ! AooShop_Woo_Import::available() ) { echo '' . esc_html__( 'WooCommerce is not active — importing only works while WooCommerce is still active (do it before moving the shop). You can use CSV instead.', 'aoo-shop' ) . '
'; return; } if ( ! $this->settings->is_configured() ) { echo '' . esc_html__( 'Enter the API key first (Settings tab).', 'aoo-shop' ) . '
'; return; } ?>redirects->resolve( $norm, true ) : null; $rule = array( 'row' => __( 'Matches a row in the table', 'aoo-shop' ), 'brand_landing' => __( 'Brand landing page', 'aoo-shop' ), 'fallback_category' => __( 'Fallback rule: old category → AOO category', 'aoo-shop' ), 'fallback_brand' => __( 'Fallback rule: old brand → AOO brand', 'aoo-shop' ), 'fallback_shop' => __( 'Fallback rule: not found in AOO → shop', 'aoo-shop' ), ); echo '
' . esc_html( '' !== $norm ? $norm : $test ) . ' → ';
if ( ! $hit ) {
echo esc_html__( 'No redirect (WordPress handles it — 404 if the page does not exist)', 'aoo-shop' );
} elseif ( '' === $hit['target'] ) {
echo esc_html( $rule[ $hit['rule'] ] ) . ' · ' . esc_html__( 'no target → 404', 'aoo-shop' );
} else {
echo '' . (int) $hit['status'] . ' ' . esc_html( rawurldecode( $hit['target'] ) ) . ' · ' . esc_html( $rule[ $hit['rule'] ] );
if ( ! empty( $hit['note'] ) ) {
echo ' (' . esc_html( $hit['note'] ) . ')';
}
}
echo '
'; for ( $i = 1; $i <= $pages; $i++ ) { $args = array( 'paged' => $i ); if ( '' !== $filter ) { $args['filter'] = $filter; } if ( '' !== $search ) { $args['s'] = $search; } echo $i === $paged ? '' . (int) $i . '' : '' . (int) $i . ''; } echo '
'; } } private function mini_form( $action, $id, $label, $confirm = false ) { ?> of the shop's budget pages (ping price buckets). No buckets yet → text box for the slug. * * @param string $name Field name. * @param string $selected Bucket slug. * @param bool $with_keep Add "— ไม่เปลี่ยน —" + "หน้าร้าน" (budget-map table). */ private function budget_select( $name, $selected, $with_keep ) { $buckets = $this->settings->price_buckets(); if ( ! $buckets && ! $with_keep ) { printf( ' ', esc_attr( $name ), esc_attr( $selected ) ); echo '' . esc_html__( 'The shop has no price ranges in AOO yet (or "Test connection" has not been clicked) — type a range yourself, e.g. 0-1500 · 1500-2000 · 3000-', 'aoo-shop' ) . ''; return; } echo ''; } /** * Old Woo categories for the budget table: live product_cat terms (Woo still on) or imported category rows. * Budget-looking ones only unless `$all` (name/slug has งบ · budget · budjet · ราคา · price, or a 3+ digit number). * * @return array