PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / trunk
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings vtrunk
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 / includes / standalone / class-mlsimport-standalone-cpt.php

class-mlsimport-standalone-cpt.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings trunk, at includes/standalone/class-mlsimport-standalone-cpt.php

285 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Standalone (theme_id 990) custom post types and taxonomies.
4 *
5 * Registers the two standalone CPTs (mlsimport_property, mlsimport_agent) and
6 * the flat mlsimport_-prefixed taxonomies (§7). Per ADR-0003 these are ALWAYS
7 * registered with show_in_rest so the data layer works in every mode — but the
8 * admin UI AND the public URL surface (public/has_archive/rewrite) follow
9 * standalone mode. Outside 990 the CPTs must not own front-end URLs at all: a
10 * CPT archive rewrite out-ranks WordPress's page rule, so any base they claim
11 * is taken away from a page or theme CPT already using it (#206). Inside 990
12 * the property base is property_slug() — 'listing' by default and admin-
13 * settable, never the heavily contested 'properties'. maybe_flush_rewrites()
14 * keeps the cached rewrite_rules option in sync when the mode or base changes.
15 *
16 * @package Mlsimport
17 */
18
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23 /**
24 * Registers the standalone CPTs and taxonomies.
25 */
26 class Mlsimport_Standalone_Cpt {
27
28 /**
29 * The default property URL base. See property_slug() for why it is not
30 * 'properties'.
31 */
32 private const DEFAULT_PROPERTY_SLUG = 'listing';
33
34 /**
35 * The flat taxonomies (§7), all attached to mlsimport_property. Flat
36 * (hierarchical=false) — term trees confuse non-technical realtors.
37 */
38 private const TAXONOMIES = array(
39 'mlsimport_property_type' => 'Property Types',
40 'mlsimport_listing_type' => 'Listing Types',
41 'mlsimport_status' => 'Statuses',
42 'mlsimport_city' => 'Cities',
43 'mlsimport_area' => 'Areas',
44 'mlsimport_county' => 'Counties',
45 'mlsimport_state' => 'States',
46 'mlsimport_zip' => 'ZIP Codes',
47 'mlsimport_high_school_district' => 'High School Districts',
48 'mlsimport_feature' => 'Features',
49 'mlsimport_label' => 'Labels',
50 );
51
52 /**
53 * The plugin taxonomy slugs, after the mlsimport_taxonomies filter — the
54 * single source of truth for anything that needs to iterate the taxonomies
55 * (e.g. per-term settings).
56 *
57 * @return string[]
58 */
59 public static function taxonomy_slugs(): array {
60 return array_keys( (array) apply_filters( 'mlsimport_taxonomies', self::TAXONOMIES ) );
61 }
62
63 /**
64 * The plugin taxonomies as slug => label, after the mlsimport_taxonomies
65 * filter — the single source for anything needing a labelled taxonomy list
66 * (e.g. the Category widgets' taxonomy picker).
67 *
68 * @return array<string,string>
69 */
70 public static function taxonomy_labels(): array {
71 return (array) apply_filters( 'mlsimport_taxonomies', self::TAXONOMIES );
72 }
73
74 /**
75 * One-time repair for packed taxonomy terms (issue #290).
76 *
77 * Before 7.1.2 a multi-enum RESO field arriving as one comma-glued string
78 * ("Back Yard,Corners Marked") became ONE term whose sanitized slug glued
79 * every value together — a dead-end archive every feature chip linked to.
80 * The importer now splits such values at write time; this walks the terms
81 * that pre-fix imports already created and repairs them in place.
82 *
83 * Step by step, per plugin taxonomy:
84 * 1. Find terms whose name contains a comma (the packed ones).
85 * 2. For every post carrying a packed term, append the individual parts
86 * as their own terms (created on the fly by wp_set_object_terms).
87 * 3. Delete the packed term — wp_delete_term also detaches it everywhere.
88 *
89 * @return int Number of packed terms split (0 on a clean site).
90 */
91 public static function split_packed_terms(): int {
92 $split = 0;
93 foreach ( self::taxonomy_slugs() as $taxonomy ) {
94 // Step 1 - every term in the taxonomy, attached or not.
95 $terms = get_terms(
96 array(
97 'taxonomy' => $taxonomy,
98 'hide_empty' => false,
99 )
100 );
101 if ( ! is_array( $terms ) ) {
102 continue;
103 }
104 foreach ( $terms as $term ) {
105 // Only packed names need repair.
106 if ( false === strpos( $term->name, ',' ) ) {
107 continue;
108 }
109 // The individual values hidden inside the packed name.
110 $parts = array_filter( array_map( 'trim', explode( ',', $term->name ) ), 'strlen' );
111
112 // Step 2 - re-file every post carrying the packed term under the
113 // individual parts (append, so its other terms are kept).
114 $post_ids = get_objects_in_term( $term->term_id, $taxonomy );
115 foreach ( ( is_array( $post_ids ) ? $post_ids : array() ) as $post_id ) {
116 wp_set_object_terms( (int) $post_id, $parts, $taxonomy, true );
117 }
118
119 // Step 3 - remove the packed term (and its dead-end archive).
120 wp_delete_term( $term->term_id, $taxonomy );
121 $split++;
122 }
123 }
124 return $split;
125 }
126
127 /**
128 * Run split_packed_terms() once per site, guarded by an option so the term
129 * sweep doesn't repeat on every admin_init.
130 *
131 * @return void
132 */
133 public static function maybe_split_packed_terms(): void {
134 // Already repaired: nothing to do.
135 if ( get_option( 'mlsimport_packed_terms_split' ) ) {
136 return;
137 }
138 self::split_packed_terms();
139 // Remember the repair ran so this stays a one-time migration.
140 update_option( 'mlsimport_packed_terms_split', 1 );
141 }
142
143 /**
144 * The URL base the property archive and single permalinks sit on.
145 *
146 * Deliberately NOT 'properties' (#206): a CPT archive rewrite out-ranks
147 * WordPress's page rule, so that base silently swallows any page or theme
148 * property CPT already using it. 'listing' is the default; the admin can
149 * move it from the standalone settings when even that collides.
150 *
151 * @return string The rewrite slug, never empty.
152 */
153 public static function property_slug(): string {
154 // Step 1 - read the admin's choice, defaulting to the safe base.
155 $slug = (string) mlsimport_standalone_option( 'property_url_slug', self::DEFAULT_PROPERTY_SLUG );
156
157 // Step 2 - normalise it the way WordPress normalises any permalink part,
158 // so 'Homes For Sale' becomes a working base instead of a broken one.
159 $slug = sanitize_title( $slug );
160
161 // Step 3 - a setting that normalises to nothing would leave the archive
162 // with no base at all, so fall back rather than register it.
163 return '' !== $slug ? $slug : self::DEFAULT_PROPERTY_SLUG;
164 }
165
166 /**
167 * Register the CPTs and taxonomies. Hook to init in production.
168 *
169 * @return void
170 */
171 public static function register(): void {
172 // ADR-0003: always registered (REST/data layer), but both the admin UI
173 // and the front-end URL surface follow standalone mode. Outside 990 the
174 // CPTs must not be public and must pass rewrite => false: an archive
175 // rewrite beats WordPress's page rule and silently hijacks whatever
176 // page or theme CPT already sits on that base (#206).
177 $standalone = function_exists( 'mlsimport_is_standalone_mode' ) && mlsimport_is_standalone_mode();
178 $show_ui = $standalone;
179
180 // Same brand icon as the Import Tasks menu.
181 $menu_icon = defined( 'MLSIMPORT_PLUGIN_URL' ) ? MLSIMPORT_PLUGIN_URL . 'img/mlsimport_menu.png' : 'dashicons-admin-home';
182
183 /** Filter the property CPT registration args. @since 6.3 */
184 register_post_type(
185 'mlsimport_property',
186 apply_filters(
187 'mlsimport_property_post_type_args',
188 array(
189 'public' => $standalone,
190 'show_ui' => $show_ui,
191 'show_in_menu' => $show_ui,
192 'show_in_rest' => true,
193 'menu_icon' => $menu_icon,
194 'has_archive' => $standalone,
195 'rewrite' => $standalone ? array( 'slug' => self::property_slug() ) : false,
196 'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields', 'comments', 'author' ),
197 'labels' => array( 'name' => 'MLS Properties' ),
198 // Group above core Comments (25) so all MLSImport menus stay together.
199 'menu_position' => 22,
200 )
201 )
202 );
203
204 /** Filter the agent CPT registration args. @since 6.3 */
205 register_post_type(
206 'mlsimport_agent',
207 apply_filters(
208 'mlsimport_agent_post_type_args',
209 array(
210 'public' => $standalone,
211 'show_ui' => $show_ui,
212 'show_in_menu' => $show_ui,
213 'show_in_rest' => true,
214 'menu_icon' => $menu_icon,
215 'has_archive' => $standalone,
216 // Core keys rewrite generation off this arg, not off 'public'.
217 'rewrite' => $standalone,
218 'supports' => array( 'title', 'editor', 'thumbnail' ),
219 'labels' => array( 'name' => 'Real Estate Agents' ),
220 'menu_position' => 23,
221 )
222 )
223 );
224
225 /** Filter the standalone taxonomy list (slug => label). @since 6.3 */
226 $taxonomies = (array) apply_filters( 'mlsimport_taxonomies', self::TAXONOMIES );
227 // Register each taxonomy against the property CPT with the shared arg set.
228 foreach ( $taxonomies as $taxonomy => $label ) {
229 /** Filter a taxonomy's registration args. @since 6.3 */
230 register_taxonomy(
231 $taxonomy,
232 'mlsimport_property',
233 apply_filters(
234 'mlsimport_taxonomy_args',
235 array(
236 'public' => $standalone,
237 'show_ui' => $show_ui,
238 'show_in_menu' => $show_ui,
239 'show_in_rest' => true,
240 // Core keys rewrite generation off this arg, not off 'public'.
241 'rewrite' => $standalone,
242 'hierarchical' => false,
243 'labels' => array( 'name' => $label ),
244 ),
245 $taxonomy
246 )
247 );
248 }
249
250 /** Fires after the standalone CPTs + taxonomies are registered. @since 6.3 */
251 do_action( 'mlsimport_registered_cpts' );
252 }
253
254 /**
255 * Drop the cached rewrite rules whenever the stored mode signature no
256 * longer matches the current standalone mode (#206).
257 *
258 * The CPT archive rule only exists in standalone mode since the fix above,
259 * and its base is now a setting, but WordPress caches compiled rules in the
260 * rewrite_rules option — so a mode switch, a base change, or updating to
261 * this version on a site where the stale rule already hijacked a
262 * /properties/ page all leave wrong rules in the DB. Deleting the option makes WordPress lazily
263 * rebuild the rules on the next request, after init has registered the
264 * CPTs with the correct args for the current mode. Hooked to init after
265 * register(); on matching signatures (every ordinary request) it is a
266 * single get_option and does nothing.
267 *
268 * @return void
269 */
270 public static function maybe_flush_rewrites(): void {
271 // Signature = fix revision + current mode + the archive base. The slug is
272 // part of it because an admin changing the base from the settings screen
273 // changes the rules register() produces, exactly like a mode switch does.
274 // Bump 'v2' if rewrite-affecting args change again.
275 $standalone = function_exists( 'mlsimport_is_standalone_mode' ) && mlsimport_is_standalone_mode();
276 $signature = 'v2:' . ( $standalone ? '990' : 'other' ) . ':' . self::property_slug();
277
278 if ( get_option( 'mlsimport_rewrite_mode' ) === $signature ) {
279 return;
280 }
281 delete_option( 'rewrite_rules' );
282 update_option( 'mlsimport_rewrite_mode', $signature );
283 }
284 }
285