PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 6.3.5
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v6.3.5
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
mlsimport / includes / class-mlsimport-activity-list-table.php

class-mlsimport-activity-list-table.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 6.3.5, at includes/class-mlsimport-activity-list-table.php

346 lines 11.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 exit;
4 }
5
6 /**
7 * WP_List_Table implementation for the MLSImport activity history page.
8 *
9 * Columns: created_at, action, listing, listing_id, listing_key, import_item, source.
10 * Shows the last 30 days; 50 rows per page; sortable by created_at (default DESC).
11 * Filterable by action and import_item_id via $_GET keys 'mlsimport_action' and 'mlsimport_item'
12 * — MUST use these exact keys to stay consistent with admin/partials/mlsimport-history.php.
13 *
14 * SQL safety rules:
15 * - Table name comes ONLY from mlsimport_activity_table_name() — interpolated directly,
16 * never as a $wpdb->prepare placeholder, never from request input.
17 * - 'orderby' is whitelisted to 'created_at'; 'order' to 'ASC'|'DESC' (fallback: created_at DESC).
18 * - All filter VALUES are passed through $wpdb->prepare with %s / %d.
19 */
20 class Mlsimport_Activity_List_Table extends WP_List_Table {
21
22 /**
23 * Sets up column definitions and table args.
24 */
25 public function __construct() {
26 parent::__construct(
27 array(
28 'singular' => __( 'activity record', 'mlsimport' ),
29 'plural' => __( 'activity records', 'mlsimport' ),
30 'ajax' => false,
31 )
32 );
33 }
34
35 /**
36 * Returns the list of columns.
37 *
38 * @return array<string, string>
39 */
40 public function get_columns(): array {
41 return array(
42 'created_at' => __( 'Date', 'mlsimport' ),
43 'action' => __( 'Action', 'mlsimport' ),
44 'listing' => __( 'Listing', 'mlsimport' ),
45 'listing_id' => __( 'Listing ID', 'mlsimport' ),
46 'listing_key' => __( 'ListingKey', 'mlsimport' ),
47 'import_item' => __( 'Import Task', 'mlsimport' ),
48 'source' => __( 'Source', 'mlsimport' ),
49 );
50 }
51
52 /**
53 * Returns the sortable columns.
54 * Only created_at is sortable.
55 *
56 * @return array<string, array>
57 */
58 protected function get_sortable_columns(): array {
59 return array(
60 'created_at' => array( 'created_at', true ),
61 );
62 }
63
64 /**
65 * Returns the CSS badge class string for a given action string.
66 *
67 * PURE static helper — no WordPress calls (unit-testable).
68 * Case/whitespace tolerant: normalizes with strtolower() + trim().
69 *
70 * @param string $action Raw action value.
71 * @return string CSS class string.
72 */
73 public static function action_badge_class( string $action ): string {
74 $normalized = strtolower( trim( $action ) );
75
76 $known = array( 'added', 'edited', 'deleted' );
77
78 if ( in_array( $normalized, $known, true ) ) {
79 return 'mlsimport-activity-action mlsimport-activity-action--' . $normalized;
80 }
81
82 return 'mlsimport-activity-action';
83 }
84
85 /**
86 * Returns import task options for the filter dropdown.
87 * Queries DISTINCT import_item_id values (excluding 0) and resolves titles.
88 *
89 * @return array<int, string> Map of import_item_id => import_item_title.
90 */
91 public function get_import_task_options(): array {
92 global $wpdb;
93
94 $table = mlsimport_activity_table_name();
95
96 // Table name interpolated directly — safe, comes only from mlsimport_activity_table_name().
97 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
98 $rows = $wpdb->get_results(
99 "SELECT DISTINCT import_item_id, MAX(import_item_title) AS import_item_title
100 FROM {$table}
101 WHERE import_item_id != 0
102 GROUP BY import_item_id
103 ORDER BY import_item_title ASC"
104 );
105
106 $options = array();
107 if ( ! empty( $rows ) ) {
108 foreach ( $rows as $row ) {
109 $id = (int) $row->import_item_id;
110 $title = (string) $row->import_item_title;
111
112 // Prefer the live post title if the post still exists.
113 $live_title = get_the_title( $id );
114 if ( ! empty( $live_title ) ) {
115 $title = $live_title;
116 }
117
118 $options[ $id ] = $title;
119 }
120 }
121
122 return $options;
123 }
124
125 /**
126 * Prepares the list of items for display.
127 * Reads filter keys 'mlsimport_action' and 'mlsimport_item' from $_GET.
128 *
129 * @return void
130 */
131 public function prepare_items(): void {
132 global $wpdb;
133
134 $table = mlsimport_activity_table_name();
135
136 // 30-day cutoff (WP local time).
137 $cutoff = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - 30 * DAY_IN_SECONDS );
138
139 // --- Sanitize filter inputs ---
140 // Filter GET key: 'mlsimport_action' (consistent with history partial form).
141 $filter_action = isset( $_GET['mlsimport_action'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
142 ? sanitize_text_field( wp_unslash( $_GET['mlsimport_action'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
143 : '';
144
145 // Filter GET key: 'mlsimport_item' (consistent with history partial form).
146 $filter_item = isset( $_GET['mlsimport_item'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
147 ? absint( $_GET['mlsimport_item'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
148 : 0;
149
150 // Search GET key: 'mlsimport_s' — matches Listing ID or ListingKey (consistent with history partial form).
151 $filter_search = isset( $_GET['mlsimport_s'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
152 ? trim( sanitize_text_field( wp_unslash( $_GET['mlsimport_s'] ) ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
153 : '';
154
155 // --- Whitelist orderby and order ---
156 $allowed_orderby = array( 'created_at' );
157 $orderby_raw = isset( $_GET['orderby'] ) ? sanitize_text_field( wp_unslash( $_GET['orderby'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
158 $orderby = in_array( $orderby_raw, $allowed_orderby, true ) ? $orderby_raw : 'created_at';
159
160 $order_raw = isset( $_GET['order'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_GET['order'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
161 $order = in_array( $order_raw, array( 'ASC', 'DESC' ), true ) ? $order_raw : 'DESC';
162
163 // --- Build WHERE clause ---
164 // Table name from mlsimport_activity_table_name() — interpolated directly, never a placeholder.
165 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
166 $where = $wpdb->prepare( 'WHERE created_at >= %s', $cutoff );
167
168 if ( ! empty( $filter_action ) && in_array( $filter_action, array( 'added', 'edited', 'deleted' ), true ) ) {
169 $where .= $wpdb->prepare( ' AND action = %s', $filter_action );
170 }
171
172 if ( $filter_item > 0 ) {
173 $where .= $wpdb->prepare( ' AND import_item_id = %d', $filter_item );
174 }
175
176 // Match the search term against either the numeric Listing ID or the ListingKey.
177 if ( '' !== $filter_search ) {
178 $like = '%' . $wpdb->esc_like( $filter_search ) . '%';
179 $where .= $wpdb->prepare( ' AND ( listing_key LIKE %s OR CAST(listing_id AS CHAR) LIKE %s )', $like, $like );
180 }
181
182 // --- Count total items for pagination ---
183 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
184 $total_items = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} {$where}" );
185
186 // --- Pagination ---
187 $per_page = 50;
188 $current_page = $this->get_pagenum();
189
190 $this->set_pagination_args(
191 array(
192 'total_items' => $total_items,
193 'per_page' => $per_page,
194 'total_pages' => ceil( $total_items / $per_page ),
195 )
196 );
197
198 $offset = ( $current_page - 1 ) * $per_page;
199
200 // --- Fetch items ---
201 // Table name interpolated directly — safe.
202 // orderby/order whitelisted above — safe to interpolate.
203 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
204 $sql = $wpdb->prepare(
205 "SELECT * FROM {$table} {$where} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d",
206 $per_page,
207 $offset
208 );
209 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
210 $items = $wpdb->get_results( $sql, ARRAY_A );
211
212 $this->items = $items ? $items : array();
213
214 // Set column headers.
215 $columns = $this->get_columns();
216 $hidden_columns = array();
217 $sortable_columns = $this->get_sortable_columns();
218 $this->_column_headers = array( $columns, $hidden_columns, $sortable_columns );
219 }
220
221 /**
222 * Renders the 'created_at' column.
223 *
224 * @param array $item Row data.
225 * @return string
226 */
227 public function column_created_at( array $item ): string {
228 return esc_html( $item['created_at'] );
229 }
230
231 /**
232 * Renders the 'action' column — a color-coded badge.
233 *
234 * @param array $item Row data.
235 * @return string
236 */
237 public function column_action( array $item ): string {
238 $action = isset( $item['action'] ) ? (string) $item['action'] : '';
239 $class = self::action_badge_class( $action );
240
241 return '<span class="' . esc_attr( $class ) . '">' . esc_html( $action ) . '</span>';
242 }
243
244 /**
245 * Renders the 'listing' column — linked when the post still exists, else plain text.
246 *
247 * @param array $item Row data.
248 * @return string
249 */
250 public function column_listing( array $item ): string {
251 $listing_id = (int) ( $item['listing_id'] ?? 0 );
252 $listing_title = (string) ( $item['listing_title'] ?? '' );
253 $listing_url = (string) ( $item['listing_url'] ?? '' );
254
255 // Link only when the post still exists.
256 if ( $listing_id > 0 && get_post_status( $listing_id ) ) {
257 return '<a href="' . esc_url( $listing_url ) . '">' . esc_html( $listing_title ) . '</a>';
258 }
259
260 return esc_html( $listing_title );
261 }
262
263 /**
264 * Renders the 'listing_id' column.
265 *
266 * @param array $item Row data.
267 * @return string
268 */
269 public function column_listing_id( array $item ): string {
270 return esc_html( (string) ( $item['listing_id'] ?? '' ) );
271 }
272
273 /**
274 * Renders the 'listing_key' column.
275 *
276 * @param array $item Row data.
277 * @return string
278 */
279 public function column_listing_key( array $item ): string {
280 return esc_html( (string) ( $item['listing_key'] ?? '' ) );
281 }
282
283 /**
284 * Renders the 'import_item' column — linked when the post still exists, else plain text.
285 * import_item_id = 0 renders as "Unknown import task".
286 *
287 * @param array $item Row data.
288 * @return string
289 */
290 public function column_import_item( array $item ): string {
291 $import_item_id = (int) ( $item['import_item_id'] ?? 0 );
292 $import_item_title = (string) ( $item['import_item_title'] ?? '' );
293
294 if ( 0 === $import_item_id ) {
295 return esc_html__( 'Unknown import task', 'mlsimport' );
296 }
297
298 // Link only when the post still exists.
299 if ( get_post_status( $import_item_id ) ) {
300 $edit_url = get_edit_post_link( $import_item_id );
301 if ( $edit_url ) {
302 return '<a href="' . esc_url( $edit_url ) . '">' . esc_html( $import_item_title ) . '</a>';
303 }
304 }
305
306 return esc_html( $import_item_title );
307 }
308
309 /**
310 * Renders the 'source' column with friendly, properly-cased labels.
311 * 'cron' -> "Automatically", 'manual' -> "Manual"; other values are capitalized.
312 *
313 * @param array $item Row data.
314 * @return string
315 */
316 public function column_source( array $item ): string {
317 $source = (string) ( $item['source'] ?? '' );
318 $labels = array(
319 'cron' => __( 'Automatically', 'mlsimport' ),
320 'manual' => __( 'Manual', 'mlsimport' ),
321 );
322 $display = isset( $labels[ $source ] ) ? $labels[ $source ] : ucfirst( $source );
323 return esc_html( $display );
324 }
325
326 /**
327 * Default column renderer (fallback).
328 *
329 * @param array $item Row data.
330 * @param string $column_name Column slug.
331 * @return string
332 */
333 protected function column_default( $item, $column_name ): string {
334 return isset( $item[ $column_name ] ) ? esc_html( (string) $item[ $column_name ] ) : '';
335 }
336
337 /**
338 * Renders the empty-state message when no items are found.
339 *
340 * @return void
341 */
342 public function no_items(): void {
343 echo esc_html__( 'No activity recorded in the last 30 days.', 'mlsimport' );
344 }
345 }
346