PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.0.4
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.0.4
7.2.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 All 37 releases
mlsimport / enviroment / StandaloneClass.php

StandaloneClass.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.0.4, at enviroment/StandaloneClass.php

358 lines 13.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; // Exit if accessed directly.
4 }
5
6 // Pull in the standalone helpers this adapter delegates to: the §9 RESO routing map,
7 // the derivation helpers, and the flat-table schema + row read/write classes.
8 require_once __DIR__ . '/../includes/standalone/class-mlsimport-standalone-reso-map.php';
9 require_once __DIR__ . '/../includes/standalone/class-mlsimport-standalone-derive.php';
10 require_once __DIR__ . '/../includes/standalone/class-mlsimport-standalone-table.php';
11 require_once __DIR__ . '/../includes/standalone/class-mlsimport-standalone-row.php';
12
13 /**
14 * Standalone (theme_id 990) write adapter.
15 *
16 * Implements the same adapter contract as ResidenceClass, but RESO-anchored: it
17 * routes a raw RESO property ($property['extra_meta'], PascalCase) into the
18 * mlsimport_property post (meta + 9 taxonomies + content) and the mlsimport_listings
19 * flat-table row, using the §9 routing map and the derivation helpers. The flat-table
20 * upsert happens here (this method has $property), not correlationUpdateAfter (ADR-0002).
21 */
22 class StandaloneClass {
23
24 /**
25 * Property CPT slug.
26 *
27 * @return string
28 */
29 public function get_property_post_type() {
30 return 'mlsimport_property';
31 }
32
33 /**
34 * Agent CPT slug.
35 *
36 * @return string
37 */
38 public function get_agent_post_type() {
39 return 'mlsimport_agent';
40 }
41
42 /**
43 * Featured-image hook (no extra work needed for standalone).
44 *
45 * @param int $property_id Post ID.
46 * @param int $attach_id Attachment ID.
47 * @return void
48 */
49 public function enviroment_image_save( $property_id, $attach_id ) {
50 }
51
52 /**
53 * Persist the gallery attachment IDs.
54 *
55 * @param int $property_id Post ID.
56 * @param array $post_attachments Attachment IDs.
57 * @return void
58 */
59 public function enviroment_image_save_gallery( $property_id, $post_attachments ) {
60 update_post_meta( $property_id, 'mlsimport_gallery', $post_attachments );
61 }
62
63 /**
64 * Route the raw RESO property into post meta / taxonomies / content and the
65 * mlsimport_listings row.
66 *
67 * @param int $property_id Post ID.
68 * @param array $property Pipeline property; reads $property['extra_meta'].
69 * @return array{property_history:string}
70 */
71 public function mlsimportSaasSetExtraMeta( $property_id, $property ) {
72 // Nothing to route without a RESO extra_meta array.
73 if ( ! isset( $property['extra_meta'] ) || ! is_array( $property['extra_meta'] ) ) {
74 return array( 'property_history' => '' );
75 }
76
77 // Raw RESO fields plus the accumulators used while routing them.
78 $extra = $property['extra_meta'];
79 $terms_by_tax = array();
80 $opted_in = $this->opted_in_fields();
81 $listing_key = isset( $extra['ListingKey'] ) ? (string) $extra['ListingKey'] : '';
82 $incoming_mod = isset( $extra['ModificationTimestamp'] ) ? (string) $extra['ModificationTimestamp'] : '';
83
84 /** Fires before a property is written (or skipped). @since 6.3 */
85 do_action( 'mlsimport_before_save_property', $property_id, $property );
86
87 // Sole change signal (ADR-0002): skip the whole write when the stored row
88 // is at least as fresh as the incoming ModificationTimestamp. The verdict is
89 // filterable so add-ons can force or suppress a write.
90 $skip = '' !== $listing_key && '' !== $incoming_mod && $this->is_unchanged( $listing_key, $incoming_mod );
91 /** Filter whether to skip writing this property. @since 6.3 */
92 if ( apply_filters( 'mlsimport_property_skip_write', $skip, $property_id, $incoming_mod, $property ) ) {
93 return array( 'property_history' => 'skipped: not modified since last import' );
94 }
95
96 // Route every incoming RESO field to its configured target(s).
97 foreach ( $extra as $field => $value ) {
98 $is_array = is_array( $value );
99
100 // A field may map to multiple targets (meta, tax, feature...).
101 foreach ( Mlsimport_Standalone_Reso_Map::targets_for( $field ) as $target ) {
102 // 'skip'/'content' are handled elsewhere (or not at all) here.
103 if ( 'skip' === $target || 'content' === $target ) {
104 continue;
105 }
106
107 // Target format is "<kind>:<name>" (e.g. "meta:x_foo", "tax:mlsimport_view").
108 $parts = explode( ':', $target, 2 );
109 $kind = $parts[0];
110 $name = isset( $parts[1] ) ? $parts[1] : '';
111
112 // Target kind 1: post meta.
113 if ( 'meta' === $kind ) {
114 // Meta key is namespaced; an "x_" name marks a passthrough field.
115 $meta_key = 'mlsimport_' . $name;
116 $is_passthrough = 0 === strpos( $name, 'x_' );
117 $raw = $value;
118
119 if ( $is_array ) {
120 // A multi-value RESO field (Flooring, Cooling, PoolFeatures...)
121 // stores as a comma-joined string, mapped or passthrough alike —
122 // that string is what the property page prints as the field's row.
123 // Arrays of records (Rooms, Media) have no scalar members and so
124 // store nothing.
125 $raw = implode( ', ', array_filter( $value, 'is_scalar' ) );
126 if ( '' === $raw ) {
127 continue;
128 }
129 }
130
131 /** Filter a property meta value before write. @since 6.3 */
132 $meta_value = apply_filters( 'mlsimport_property_meta_value', $raw, $meta_key, $property_id, $property );
133
134 if ( $is_passthrough ) {
135 // Passthrough: write only when the field is opted in via the selector.
136 if ( isset( $opted_in[ $field ] ) && 1 === intval( $opted_in[ $field ] ) ) {
137 update_post_meta( $property_id, $meta_key, $meta_value );
138 }
139 continue;
140 }
141 update_post_meta( $property_id, $meta_key, $meta_value );
142 // Target kind 2: taxonomy terms.
143 } elseif ( 'tax' === $kind ) {
144 // Multi-enum arrays (Appliances, View...) -> one term per value.
145 foreach ( ( $is_array ? $value : array( $value ) ) as $term ) {
146 // Collect non-empty term names under their taxonomy.
147 if ( '' !== (string) $term ) {
148 $terms_by_tax[ $name ][] = (string) $term;
149 }
150 }
151 // Target kind 3: boolean "feature" flag -> label term when true.
152 } elseif ( 'feature' === $kind && ! $is_array && $this->is_truthy( $value ) ) {
153 // feature:<Label> -> add the label term only when the YN is true.
154 /** Filter the feature label term. @since 6.3 */
155 $terms_by_tax['mlsimport_feature'][] = (string) apply_filters( 'mlsimport_property_feature_label', $name, $field, $value );
156 }
157 }
158 }
159
160 // Flat-table columns (§9 column targets + derivations) — shared with reindex.
161 $row = Mlsimport_Standalone_Row::build_columns( $extra );
162
163 // VirtualTourURLUnbranded arrives as an <iframe> blob -> store the clean URL.
164 if ( isset( $extra['VirtualTourURLUnbranded'] ) ) {
165 $tour = Mlsimport_Standalone_Derive::extract_tour_src( (string) $extra['VirtualTourURLUnbranded'] );
166 // Only store when a src URL was successfully extracted.
167 if ( null !== $tour ) {
168 update_post_meta( $property_id, 'mlsimport_virtual_tour', $tour );
169 }
170 }
171
172 // Write the accumulated terms, one taxonomy at a time (replace, not append).
173 foreach ( $terms_by_tax as $taxonomy => $terms ) {
174 wp_set_object_terms( $property_id, $terms, $taxonomy, false );
175 }
176
177 // PublicRemarks arrives promoted to the top level (theme_id 990) -> post body.
178 // MLS feeds send remarks as one unbroken block, so break it into readable
179 // paragraphs before write; add-ons can still override via the filter.
180 $remarks = Mlsimport_Standalone_Derive::paragraphs( isset( $property['content'] ) ? (string) $property['content'] : '' );
181 /** Filter the post body before write. @since 6.3 */
182 $content = (string) apply_filters( 'mlsimport_property_post_content', $remarks, $property );
183 if ( '' !== $content ) {
184 wp_update_post(
185 array(
186 'ID' => $property_id,
187 'post_content' => $content,
188 )
189 );
190 }
191
192 // FULLTEXT source: title + remarks + address (§8).
193 // Combine the searchable pieces into the row's FULLTEXT column.
194 $search_parts = array(
195 get_the_title( $property_id ),
196 isset( $property['content'] ) ? (string) $property['content'] : '',
197 isset( $property['adr_title'] ) ? (string) $property['adr_title'] : '',
198 isset( $extra['UnparsedAddress'] ) ? (string) $extra['UnparsedAddress'] : '',
199 );
200 $row['search_text'] = trim( implode( ' ', array_filter( $search_parts ) ) );
201
202 // Upsert the flat-table row only when we have a stable ListingKey.
203 if ( '' !== $listing_key ) {
204 Mlsimport_Standalone_Row::upsert( $property_id, $listing_key, $row );
205 }
206
207 /** Fires after a property has been written. @since 6.3 */
208 do_action( 'mlsimport_after_save_property', $property_id, $property );
209
210 return array( 'property_history' => '' );
211 }
212
213 /**
214 * Whether the stored row is already at least as fresh as the incoming
215 * ModificationTimestamp. Compared via timestamps so RESO ISO-8601 and the
216 * stored DATETIME format interoperate. No existing row => not unchanged.
217 *
218 * @param string $listing_key RESO ListingKey.
219 * @param string $incoming_mod Incoming ModificationTimestamp.
220 * @return bool
221 */
222 private function is_unchanged( $listing_key, $incoming_mod ) {
223 global $wpdb;
224
225 // Look up the stored modification timestamp for this listing.
226 $table = Mlsimport_Standalone_Table::table_name();
227 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- fixed prefixed table name; value binds via prepare().
228 $stored = $wpdb->get_var( $wpdb->prepare( "SELECT modification_timestamp FROM {$table} WHERE listing_key = %s", $listing_key ) );
229
230 // No stored row -> treat as changed (needs a write).
231 if ( null === $stored ) {
232 return false;
233 }
234
235 // Unchanged when the stored timestamp is at least as fresh as the incoming one.
236 return strtotime( $stored ) >= strtotime( $incoming_mod );
237 }
238
239 /**
240 * The field-selector opt-in map (RESO field => 1/0). Passthrough meta is
241 * written only for fields the user opted in.
242 *
243 * @return array
244 */
245 private function opted_in_fields() {
246 // Read the field selector; return its opt-in map or an empty array.
247 $selected = get_option( 'mlsimport_admin_fields_select' );
248 return isset( $selected['mls-fields'] ) && is_array( $selected['mls-fields'] ) ? $selected['mls-fields'] : array();
249 }
250
251 /**
252 * Whether a RESO boolean/YN value should count as true.
253 *
254 * @param mixed $value Raw YN value.
255 * @return bool
256 */
257 private function is_truthy( $value ) {
258 // A real boolean is returned as-is.
259 if ( is_bool( $value ) ) {
260 return $value;
261 }
262 // Otherwise accept common truthy string spellings.
263 return in_array( strtolower( trim( (string) $value ) ), array( '1', 'true', 'yes', 'y' ), true );
264 }
265
266 /**
267 * Removes the mlsimport_listings row when a property post is deleted
268 * (before_delete_post). Covers reconciliation, import-removal and manual
269 * admin deletes (ADR-0002). Gated to mlsimport_property.
270 *
271 * @param int $post_id Post being deleted.
272 * @return void
273 */
274 public static function cleanup_on_delete( $post_id ) {
275 // Only act on our own property post type.
276 if ( 'mlsimport_property' !== get_post_type( $post_id ) ) {
277 return;
278 }
279 // Remove the associated flat-table row.
280 Mlsimport_Standalone_Row::delete( (int) $post_id );
281 }
282
283 /**
284 * Drop the mlsimport_listings row when a property leaves the published state
285 * (trashed, drafted, set pending/private). before_delete_post only fires on a
286 * permanent delete, so this covers a manual or reconciliation trash that keeps
287 * the post around — keeping the index to published listings only.
288 *
289 * @param string $new_status New post status.
290 * @param string $old_status Old post status.
291 * @param WP_Post $post The post.
292 * @return void
293 */
294 public static function cleanup_on_status_change( $new_status, $old_status, $post ) {
295 // Only act on our own property posts.
296 if ( ! $post instanceof WP_Post || 'mlsimport_property' !== $post->post_type ) {
297 return;
298 }
299 // Any non-published state drops the row (keeps the index published-only).
300 if ( 'publish' !== $new_status ) {
301 Mlsimport_Standalone_Row::delete( (int) $post->ID );
302 }
303 }
304
305 /**
306 * Post-insert hook. The flat-table upsert is NOT done here (ADR-0002) — this
307 * runs without $property. Editorial taxonomies are never touched.
308 *
309 * Assigns the agent the user picked in the import task (mlsimport_item_agent,
310 * a mlsimport_agent post ID) to the property, and records whether the task
311 * opted to use the MLS feed's own listing agent instead. The display layer
312 * (mlsimport_property_agent) reads mlsimport_use_mls_agent to choose between
313 * the linked agent post and the feed's ListAgent* meta. Agents are never
314 * created from the feed here.
315 *
316 * @param string $is_insert 'yes' on insert.
317 * @param int $property_id Post ID.
318 * @param array $global_extra_fields Carries 'use_mls_agent' (bool) from the task.
319 * @param mixed $new_agent Selected mlsimport_agent post ID, or empty.
320 * @return void
321 */
322 public function correlationUpdateAfter( $is_insert, $property_id, $global_extra_fields, $new_agent ) {
323 // Record whether the task chose to use the feed's own listing agent.
324 $use_mls_agent = ! empty( $global_extra_fields['use_mls_agent'] );
325 update_post_meta( $property_id, 'mlsimport_use_mls_agent', $use_mls_agent ? 1 : 0 );
326
327 // Link the selected agent post to the property when one was picked.
328 $agent_id = (int) $new_agent;
329 if ( $agent_id > 0 ) {
330 update_post_meta( $property_id, 'mlsimport_list_agent_id', $agent_id );
331 }
332 }
333
334 /**
335 * Theme custom-fields hook — intentionally empty for standalone.
336 *
337 * The other adapters mirror the field selector into a theme-owned option here
338 * because their themes read labels from their own settings. Standalone has no
339 * such option: mlsimport_property_selected_fields() reads
340 * mlsimport_admin_fields_select directly at render time, so there is nothing to
341 * sync and no stale copy to keep in step.
342 *
343 * @param string $option_name Option name.
344 * @return void
345 */
346 public function enviroment_custom_fields( $option_name ) {
347 }
348
349 /**
350 * Standalone sends theme_id 990, so there is no theme schema to return.
351 *
352 * @return string
353 */
354 public function return_theme_schema() {
355 return '';
356 }
357 }
358