PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.1.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.1.2
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 7.1.2, at includes/class-mlsimport-activity-list-table.php

377 lines 12.8 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 // Pass singular/plural labels to WP_List_Table; ajax disabled (full page reload per action).
27 parent::__construct(
28 array(
29 'singular' => __( 'activity record', 'mlsimport' ),
30 'plural' => __( 'activity records', 'mlsimport' ),
31 'ajax' => false,
32 )
33 );
34 }
35
36 /**
37 * Returns the list of columns.
38 *
39 * @return array<string, string>
40 */
41 public function get_columns(): array {
42 return array(
43 'created_at' => __( 'Date', 'mlsimport' ),
44 'action' => __( 'Action', 'mlsimport' ),
45 'listing' => __( 'Listing', 'mlsimport' ),
46 'listing_mls_id' => __( 'MLS #', 'mlsimport' ),
47 'listing_status' => __( 'Status', 'mlsimport' ),
48 'listing_id' => __( 'Listing ID', 'mlsimport' ),
49 'listing_key' => __( 'ListingKey', 'mlsimport' ),
50 'import_item' => __( 'Import Task', 'mlsimport' ),
51 'source' => __( 'Source', 'mlsimport' ),
52 'explanation' => __( 'Explanation', 'mlsimport' ),
53 );
54 }
55
56 /**
57 * Returns the sortable columns.
58 * Only created_at is sortable.
59 *
60 * @return array<string, array>
61 */
62 protected function get_sortable_columns(): array {
63 return array(
64 'created_at' => array( 'created_at', true ),
65 );
66 }
67
68 /**
69 * Returns the CSS badge class string for a given action string.
70 *
71 * PURE static helper — no WordPress calls (unit-testable).
72 * Case/whitespace tolerant: normalizes with strtolower() + trim().
73 *
74 * @param string $action Raw action value.
75 * @return string CSS class string.
76 */
77 public static function action_badge_class( string $action ): string {
78 // Normalize casing/whitespace so 'Added', ' added ' etc. all match the known set.
79 $normalized = strtolower( trim( $action ) );
80
81 // Recognised action values that get a modifier CSS class.
82 $known = array( 'added', 'edited', 'deleted' );
83
84 // Known action: append the BEM-style modifier for per-action color.
85 if ( in_array( $normalized, $known, true ) ) {
86 return 'mlsimport-activity-action mlsimport-activity-action--' . $normalized;
87 }
88
89 // Unknown action: base badge class only.
90 return 'mlsimport-activity-action';
91 }
92
93 /**
94 * Returns import task options for the filter dropdown.
95 * Queries DISTINCT import_item_id values (excluding 0) and resolves titles.
96 *
97 * @return array<int, string> Map of import_item_id => import_item_title.
98 */
99 public function get_import_task_options(): array {
100 global $wpdb;
101
102 $table = mlsimport_activity_table_name();
103
104 // Table name interpolated directly — safe, comes only from mlsimport_activity_table_name().
105 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
106 $rows = $wpdb->get_results(
107 "SELECT DISTINCT import_item_id, MAX(import_item_title) AS import_item_title
108 FROM {$table}
109 WHERE import_item_id != 0
110 GROUP BY import_item_id
111 ORDER BY import_item_title ASC"
112 );
113
114 // Build id => title map from the distinct rows.
115 $options = array();
116 if ( ! empty( $rows ) ) {
117 foreach ( $rows as $row ) {
118 $id = (int) $row->import_item_id;
119 $title = (string) $row->import_item_title;
120
121 // Prefer the live post title if the post still exists.
122 $live_title = get_the_title( $id );
123 if ( ! empty( $live_title ) ) {
124 $title = $live_title;
125 }
126
127 $options[ $id ] = $title;
128 }
129 }
130
131 return $options;
132 }
133
134 /**
135 * Prepares the list of items for display.
136 * Reads filter keys 'mlsimport_action' and 'mlsimport_item' from $_GET.
137 *
138 * @return void
139 */
140 public function prepare_items(): void {
141 global $wpdb;
142
143 $table = mlsimport_activity_table_name();
144
145 // 30-day cutoff (WP local time).
146 $cutoff = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - 30 * DAY_IN_SECONDS );
147
148 // --- Sanitize filter inputs ---
149 // Filter GET key: 'mlsimport_action' (consistent with history partial form).
150 $filter_action = isset( $_GET['mlsimport_action'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
151 ? sanitize_text_field( wp_unslash( $_GET['mlsimport_action'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
152 : '';
153
154 // Filter GET key: 'mlsimport_item' (consistent with history partial form).
155 $filter_item = isset( $_GET['mlsimport_item'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
156 ? absint( $_GET['mlsimport_item'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
157 : 0;
158
159 // Search GET key: 'mlsimport_s' — matches Listing ID or ListingKey (consistent with history partial form).
160 $filter_search = isset( $_GET['mlsimport_s'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
161 ? trim( sanitize_text_field( wp_unslash( $_GET['mlsimport_s'] ) ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
162 : '';
163
164 // --- Whitelist orderby and order ---
165 $allowed_orderby = array( 'created_at' );
166 $orderby_raw = isset( $_GET['orderby'] ) ? sanitize_text_field( wp_unslash( $_GET['orderby'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
167 $orderby = in_array( $orderby_raw, $allowed_orderby, true ) ? $orderby_raw : 'created_at';
168
169 $order_raw = isset( $_GET['order'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_GET['order'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
170 $order = in_array( $order_raw, array( 'ASC', 'DESC' ), true ) ? $order_raw : 'DESC';
171
172 // --- Build WHERE clause ---
173 // Table name from mlsimport_activity_table_name() — interpolated directly, never a placeholder.
174 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
175 $where = $wpdb->prepare( 'WHERE created_at >= %s', $cutoff );
176
177 if ( ! empty( $filter_action ) && in_array( $filter_action, array( 'added', 'edited', 'deleted' ), true ) ) {
178 $where .= $wpdb->prepare( ' AND action = %s', $filter_action );
179 }
180
181 if ( $filter_item > 0 ) {
182 $where .= $wpdb->prepare( ' AND import_item_id = %d', $filter_item );
183 }
184
185 // Match the search term against the MLS #, the ListingKey, or the numeric Listing ID.
186 if ( '' !== $filter_search ) {
187 $like = '%' . $wpdb->esc_like( $filter_search ) . '%';
188 $where .= $wpdb->prepare( ' AND ( listing_mls_id LIKE %s OR listing_key LIKE %s OR CAST(listing_id AS CHAR) LIKE %s )', $like, $like, $like );
189 }
190
191 // --- Count total items for pagination ---
192 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
193 $total_items = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} {$where}" );
194
195 // --- Pagination ---
196 $per_page = 50;
197 $current_page = $this->get_pagenum();
198
199 // Register pagination metadata so WP_List_Table renders the pager.
200 $this->set_pagination_args(
201 array(
202 'total_items' => $total_items,
203 'per_page' => $per_page,
204 'total_pages' => ceil( $total_items / $per_page ),
205 )
206 );
207
208 // Row offset for the current page's LIMIT clause.
209 $offset = ( $current_page - 1 ) * $per_page;
210
211 // --- Fetch items ---
212 // Table name interpolated directly — safe.
213 // orderby/order whitelisted above — safe to interpolate.
214 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
215 $sql = $wpdb->prepare(
216 "SELECT * FROM {$table} {$where} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d",
217 $per_page,
218 $offset
219 );
220 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
221 $items = $wpdb->get_results( $sql, ARRAY_A );
222
223 // Feed rows to WP_List_Table (empty array when no results).
224 $this->items = $items ? $items : array();
225
226 // Set column headers.
227 $columns = $this->get_columns();
228 $hidden_columns = array();
229 $sortable_columns = $this->get_sortable_columns();
230 $this->_column_headers = array( $columns, $hidden_columns, $sortable_columns );
231 }
232
233 /**
234 * Renders the 'created_at' column.
235 *
236 * @param array $item Row data.
237 * @return string
238 */
239 public function column_created_at( array $item ): string {
240 return esc_html( $item['created_at'] );
241 }
242
243 /**
244 * Renders the 'action' column — a color-coded badge.
245 *
246 * @param array $item Row data.
247 * @return string
248 */
249 public function column_action( array $item ): string {
250 $action = isset( $item['action'] ) ? (string) $item['action'] : '';
251 $class = self::action_badge_class( $action );
252
253 return '<span class="' . esc_attr( $class ) . '">' . esc_html( $action ) . '</span>';
254 }
255
256 /**
257 * Renders the 'listing' column — linked when the post still exists, else plain text.
258 *
259 * @param array $item Row data.
260 * @return string
261 */
262 public function column_listing( array $item ): string {
263 $listing_id = (int) ( $item['listing_id'] ?? 0 );
264 $listing_title = (string) ( $item['listing_title'] ?? '' );
265 $listing_url = (string) ( $item['listing_url'] ?? '' );
266
267 // Link only when the post still exists.
268 if ( $listing_id > 0 && get_post_status( $listing_id ) ) {
269 return '<a href="' . esc_url( $listing_url ) . '">' . esc_html( $listing_title ) . '</a>';
270 }
271
272 return esc_html( $listing_title );
273 }
274
275 /**
276 * Renders the 'listing_id' column.
277 *
278 * @param array $item Row data.
279 * @return string
280 */
281 public function column_listing_id( array $item ): string {
282 return esc_html( (string) ( $item['listing_id'] ?? '' ) );
283 }
284
285 /**
286 * Renders the 'listing_key' column.
287 *
288 * @param array $item Row data.
289 * @return string
290 */
291 public function column_listing_key( array $item ): string {
292 return esc_html( (string) ( $item['listing_key'] ?? '' ) );
293 }
294
295 /**
296 * Renders the 'import_item' column — linked when the post still exists, else plain text.
297 * import_item_id = 0 renders as "Unknown import task".
298 *
299 * @param array $item Row data.
300 * @return string
301 */
302 public function column_import_item( array $item ): string {
303 $import_item_id = (int) ( $item['import_item_id'] ?? 0 );
304 $import_item_title = (string) ( $item['import_item_title'] ?? '' );
305
306 if ( 0 === $import_item_id ) {
307 return esc_html__( 'Unknown import task', 'mlsimport' );
308 }
309
310 // Link only when the post still exists.
311 if ( get_post_status( $import_item_id ) ) {
312 $edit_url = get_edit_post_link( $import_item_id );
313 if ( $edit_url ) {
314 return '<a href="' . esc_url( $edit_url ) . '">' . esc_html( $import_item_title ) . '</a>';
315 }
316 }
317
318 return esc_html( $import_item_title );
319 }
320
321 /**
322 * Renders the 'source' column with friendly, properly-cased labels.
323 * 'cron' -> "Automatically", 'manual' -> "Manual"; other values are capitalized.
324 *
325 * @param array $item Row data.
326 * @return string
327 */
328 public function column_source( array $item ): string {
329 $source = (string) ( $item['source'] ?? '' );
330 $labels = array(
331 'cron' => __( 'Automatically', 'mlsimport' ),
332 'manual' => __( 'Manual', 'mlsimport' ),
333 );
334 $display = isset( $labels[ $source ] ) ? $labels[ $source ] : ucfirst( $source );
335 return esc_html( $display );
336 }
337
338 /**
339 * Render friendly text for a stable reconciliation deletion reason.
340 *
341 * Administrators see translated prose rather than internal reason codes.
342 * Rows without a known reconciliation reason display an em dash.
343 *
344 * @param array $item Activity row.
345 * @return string Escaped explanation.
346 */
347 public function column_explanation( array $item ): string {
348 $reason_code = isset( $item['reason_code'] ) ? (string) $item['reason_code'] : '';
349 $labels = array(
350 'absent_unprotected' => __( 'This listing was absent from the reconciliation snapshot and was not protected.', 'mlsimport' ),
351 'absent_import_task_missing' => __( 'This listing was absent from the reconciliation snapshot and its Import Task was unavailable.', 'mlsimport' ),
352 );
353
354 return esc_html( isset( $labels[ $reason_code ] ) ? $labels[ $reason_code ] : '' );
355 }
356
357 /**
358 * Default column renderer (fallback).
359 *
360 * @param array $item Row data.
361 * @param string $column_name Column slug.
362 * @return string
363 */
364 protected function column_default( $item, $column_name ): string {
365 return isset( $item[ $column_name ] ) ? esc_html( (string) $item[ $column_name ] ) : '';
366 }
367
368 /**
369 * Renders the empty-state message when no items are found.
370 *
371 * @return void
372 */
373 public function no_items(): void {
374 echo esc_html__( 'No activity recorded in the last 30 days.', 'mlsimport' );
375 }
376 }
377