Changeset 3431406
- Timestamp:
- 01/03/2026 03:40:50 AM (2 weeks ago)
- Location:
- pcrecruiter-extensions/trunk
- Files:
-
- 8 edited
-
PCRecruiter-Extensions.php (modified) (13 diffs)
-
assets/js/pcr-frontend.js (modified) (5 diffs)
-
includes/class-deactivation-handler.php (modified) (28 diffs)
-
includes/class-job-manager.php (modified) (44 diffs)
-
includes/class-schema-mapper.php (modified) (7 diffs)
-
includes/class-seo-enhancements.php (modified) (8 diffs)
-
includes/class-sitemap-integration.php (modified) (17 diffs)
-
readme.txt (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
pcrecruiter-extensions/trunk/PCRecruiter-Extensions.php
r3423933 r3431406 5 5 * Plugin URI: https://www.pcrecruiter.net 6 6 * Description: Integrates PCRecruiter job boards with WordPress via iframe embed or full job sync with /job/ custom post type. 7 * Version: 2.0. 47 * Version: 2.0.5 8 8 * Requires at least: 5.6 9 9 * Requires PHP: 7.4 … … 19 19 * phpcs:set WordPress.WP.I18n text_domain[] pcrecruiter-extensions 20 20 */ 21 21 if ( ! defined( 'ABSPATH' ) ) { 22 exit; 23 } 22 24 // Public endpoint for PCRecruiter custom forms & job sync. 23 25 // Form is submitted from an external domain - WordPress nonce cannot be added. … … 306 308 $pcrecruiter_has_shortcode = (strpos($job_board_page, $url) !== false); 307 309 308 if ($pcrecruiter_has_shortcode && $_SERVER['REQUEST_METHOD'] === 'POST') {310 if ($pcrecruiter_has_shortcode && isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST') { 309 311 $rawData = file_get_contents('php://input'); 310 312 $data = json_decode($rawData, true); … … 445 447 $is_jobmanager_page = false; 446 448 $is_job_post = is_singular('job'); 449 $is_apply_frame = $is_job_post && isset($_GET['apply']); 447 450 448 451 if ($using_full_sync) { … … 495 498 // Register all scripts/styles 496 499 wp_register_script('pcr-iframe', 'https://www2.pcrecruiter.net/pcrimg/inc/pcrframehost.js', false, PCRECRUITER_EXTENSIONS_VERSION, false); 497 wp_register_script('pcr-jobboard-js', 'https://www2.pcrecruiter.net/pcrimg/js/wp_jobboard.js', false, PCRECRUITER_EXTENSIONS_VERSION, false); 498 wp_register_script('pcr-frontend', plugin_dir_url(__FILE__) . 'assets/js/pcr-frontend.js', array(), PCRECRUITER_EXTENSIONS_VERSION, true); 500 wp_register_script('pcr-frontend', plugin_dir_url(__FILE__) . 'assets/js/pcr-frontend.js', array(), PCRECRUITER_EXTENSIONS_VERSION, false); 499 501 wp_register_style('pcr-job-board', plugin_dir_url(__FILE__) . 'assets/css/pcr-job-board.css', array(), PCRECRUITER_EXTENSIONS_VERSION); 500 502 … … 505 507 506 508 // Load jobmanager-specific assets on job board page(s) and single job posts 507 if ($is_jobmanager_page || $is_job_post) { 508 wp_enqueue_script('pcr-jobboard-js'); 509 if ($is_jobmanager_page || $is_job_post || $is_apply_frame) { 509 510 wp_enqueue_script('pcr-frontend'); 510 511 wp_enqueue_style('pcr-job-board'); … … 516 517 const ref = '$hostreferer'; 517 518 if(ref.length > 0 && document.location.host?.indexOf(ref) === -1) { 518 localStorage['pcr ecruiter_referer'] = ref;519 localStorage['pcr_referer'] = ref; 519 520 } 520 521 … … 767 768 } 768 769 769 // Enqueue jobboard script for appendSrcToFrame() function770 wp_enqueue_script('pcr-jobboard-js');770 // Note: appendSrcToFrame() is now provided by pcr-frontend.js when in Full Sync mode 771 // For iframe/passthrough mode, this function may not be available but is also not needed 771 772 772 773 $version = pcrecruiter_get_plugin_version(); … … 781 782 */ 782 783 add_action('parse_request', 'pcrecruiter_handle_jobshare_redirect', 1); 783 784 784 function pcrecruiter_handle_jobshare_redirect($wp) 785 785 { … … 788 788 } 789 789 790 $query_string = wp_unslash($_SERVER['QUERY_STRING']);790 $query_string = sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ); 791 791 792 792 // Check if starts with JOBSHARE … … 820 820 if ($current_path && $job_board_path && rtrim($current_path, '/') === rtrim($job_board_path, '/')) { 821 821 $redirect_url = 'https://host.pcrecruiter.net/pcrbin/jobboard.aspx?JOBSHARE' . $jobshare_token; 822 wp_redirect($redirect_url, 301); 822 $redirect_url = esc_url_raw($redirect_url); 823 824 // Whitelist PCRecruiter host for this redirect 825 add_filter('allowed_redirect_hosts', function ($hosts) { 826 $hosts[] = 'host.pcrecruiter.net'; 827 return $hosts; 828 }); 829 830 wp_safe_redirect($redirect_url, 301); 823 831 exit; 824 832 } … … 1647 1655 } 1648 1656 if (isset($_POST['pcrecruiter_job_default_og_image'])) { 1649 update_option('pcrecruiter_job_default_og_image', esc_url_raw( $_POST['pcrecruiter_job_default_og_image']));1657 update_option('pcrecruiter_job_default_og_image', esc_url_raw( wp_unslash( $_POST['pcrecruiter_job_default_og_image'] ) )); 1650 1658 } 1651 1659 // Handle pcrecruiter_seo_enable_overrides (separate option) … … 1811 1819 $registered_by = get_post_type_object('job'); 1812 1820 if ($registered_by && !isset($registered_by->cap->edit_posts)) { 1813 echo '<p class="description" style="color: #d63638;"> ⚠️Warning: Another plugin has registered the "job" post type. Full sync may not work correctly.</p>';1821 echo '<p class="description" style="color: #d63638;">⚠︠Warning: Another plugin has registered the "job" post type. Full sync may not work correctly.</p>'; 1814 1822 } 1815 1823 } ?> -
pcrecruiter-extensions/trunk/assets/js/pcr-frontend.js
r3423324 r3431406 1 1 /** 2 2 * PCRecruiter Extensions - Frontend JavaScript 3 * Handles paginationand apply button interactions3 * Handles search form, pagination, sorting, radius search, and apply button interactions 4 4 */ 5 5 … … 12 12 document.addEventListener("DOMContentLoaded", function () { 13 13 initSearchForm(); 14 initClearButton(); 15 initRadiusDropdown(); 14 16 }); 15 17 … … 58 60 59 61 /** 62 * Initialize clear button handler 63 */ 64 function initClearButton() { 65 const clearButton = document.getElementById("clearButton"); 66 if (clearButton) { 67 clearButton.addEventListener("click", function (e) { 68 e.preventDefault(); 69 const resultsPage = document.getElementById("resultspage"); 70 if (resultsPage) { 71 resultsPage.value = 1; 72 } 73 // Redirect to clean URL (same page without query params) 74 window.location.href = window.location.pathname; 75 }); 76 } 77 } 78 79 /** 80 * Initialize radius dropdown autocomplete UI 81 */ 82 function initRadiusDropdown() { 83 const input = document.getElementById("Positions---Radius"); 84 if (!input) return; 85 86 const panel = document.getElementById("radius-dropdown-panel"); 87 const latLongInput = document.getElementById("Position---LatLong"); 88 if (!panel || !latLongInput) return; 89 90 let language = input.getAttribute("data-language") || "US"; 91 92 // Create spinner 93 const spinner = document.createElement("span"); 94 spinner.className = "loading-spinner"; 95 spinner.style.display = "none"; 96 input.parentNode.insertBefore(spinner, input.nextSibling); 97 98 let debounceTimer; 99 100 // Input handler with debounce 101 input.addEventListener("input", function () { 102 clearTimeout(debounceTimer); 103 debounceTimer = setTimeout(function () { 104 radiusDropdownSearch(input, panel, latLongInput, spinner, language); 105 }, 300); 106 }); 107 108 // Down arrow to focus first result 109 input.addEventListener("keydown", function (e) { 110 if (e.key === "ArrowDown" || e.which === 40 || e.keyCode === 40) { 111 const firstButton = panel.querySelector(".radius-button"); 112 if (firstButton) { 113 firstButton.focus(); 114 e.stopPropagation(); 115 e.preventDefault(); 116 } 117 } 118 }); 119 120 // Clear lat/long if input is cleared 121 input.addEventListener("blur", function () { 122 if (!input.value.trim()) { 123 latLongInput.value = ""; 124 } 125 }); 126 127 // Close panel when clicking outside 128 document.addEventListener("click", function (e) { 129 if ( 130 panel.style.display === "block" && 131 !panel.contains(e.target) && 132 e.target !== input 133 ) { 134 panel.style.display = "none"; 135 } 136 }); 137 } 138 139 /** 140 * Perform radius dropdown search 141 */ 142 function radiusDropdownSearch(input, panel, latLongInput, spinner, language) { 143 const query = input.value.trim(); 144 if (!query) { 145 panel.style.display = "none"; 146 return; 147 } 148 149 // Setup panel UI 150 panel.style.position = "absolute"; 151 panel.style.display = "block"; 152 panel.style.height = "160px"; 153 panel.style.width = "100%"; 154 panel.style.backgroundColor = "#fff"; 155 panel.style.border = "1px solid #ccc"; 156 panel.style.overflowY = "auto"; 157 panel.innerHTML = ""; 158 spinner.style.display = "inline-block"; 159 160 const url = 161 "https://maps.pcrecruiter.net/rest/uiapi/geolocations/quicksearch?location=" + 162 encodeURIComponent(query) + 163 "&language=" + 164 encodeURIComponent(language) + 165 "&rows=50"; 166 167 fetch(url) 168 .then(function (response) { 169 return response.json(); 170 }) 171 .then(function (data) { 172 spinner.style.display = "none"; 173 panel.innerHTML = ""; 174 175 if (data && data.length > 0) { 176 data.forEach(function (location) { 177 var name = location.name; 178 var region = location.region; 179 var country = location.country; 180 var latitude = location.latitude; 181 var longitude = location.longitude; 182 var zip = location.zip; 183 184 var displayText = 185 name + 186 ", " + 187 region.toUpperCase() + 188 ", " + 189 country + 190 (zip ? " " + zip : ""); 191 192 var item = document.createElement("div"); 193 item.innerHTML = 194 '<button class="radius-button" type="button">' + 195 displayText + 196 "</button>"; 197 item.style.padding = "5px"; 198 item.style.cursor = "pointer"; 199 200 item.addEventListener("click", function () { 201 input.value = displayText; 202 latLongInput.value = latitude + "," + longitude; 203 panel.style.display = "none"; 204 }); 205 206 panel.appendChild(item); 207 }); 208 } else { 209 panel.innerHTML = '<div style="padding:5px;">No results found</div>'; 210 } 211 }) 212 .catch(function (error) { 213 spinner.style.display = "none"; 214 panel.innerHTML = 215 '<div style="padding:5px;color:red;">Error: ' + 216 error.message + 217 "</div>"; 218 }); 219 } 220 221 /** 60 222 * Submit search form via GET redirect 61 223 */ … … 81 243 const formData = new FormData(searchForm); 82 244 245 // Check if radius search criteria have changed - if so, reset sortorigin 246 // so the auto-Distance sort will be applied 247 const currentParams = new URL(window.location.href).searchParams; 248 const currentLatLong = currentParams.get("Position---LatLong") || ""; 249 const newLatLong = formData.get("Position---LatLong") || ""; 250 const currentRadius = currentParams.get("radius-distance") || ""; 251 const newRadius = formData.get("radius-distance") || ""; 252 253 let resetSortOrigin = false; 254 if (newLatLong && (newLatLong !== currentLatLong || newRadius !== currentRadius)) { 255 // Radius criteria changed - reset to allow auto-Distance sort 256 resetSortOrigin = true; 257 } 258 83 259 url.search = ""; 84 260 85 261 for (let [key, value] of formData.entries()) { 86 262 if (value !== "" && key !== "action") { 87 url.searchParams.append(key, value); 263 // Override sortorigin if radius criteria changed 264 if (key === "sortorigin" && resetSortOrigin) { 265 url.searchParams.append(key, "default"); 266 } else { 267 url.searchParams.append(key, value); 268 } 88 269 } 89 270 } … … 100 281 /** 101 282 * Delegated click handler for dynamic elements 102 */ 103 document.addEventListener("click", function (e) { 104 // Handle pagination button clicks 105 if (e.target.classList.contains("btn-pagination")) { 106 e.preventDefault(); 107 const targetPage = e.target.getAttribute("data-page"); 108 if (targetPage) { 283 * Using capture phase (true) to intercept before other handlers 284 */ 285 document.addEventListener( 286 "click", 287 function (e) { 288 // Handle sort links 289 const sortLink = e.target.closest(".customsortfield"); 290 if (sortLink) { 291 e.preventDefault(); 292 e.stopPropagation(); 293 e.stopImmediatePropagation(); 294 295 const sortName = sortLink.getAttribute("data-sortname") || ""; 296 const sortDirRaw = sortLink.getAttribute("data-sortdirection") || ""; 297 const sortDirDefault = sortDirRaw ? sortDirRaw.toUpperCase() : "ASC"; 298 299 if (!sortName) { 300 return; 301 } 302 303 const searchForm = document.getElementById("searchForm"); 304 const sortfieldInput = searchForm 305 ? searchForm.querySelector('input[name="sortfield"]') 306 : document.querySelector('input[name="sortfield"]'); 307 const sortoriginInput = searchForm 308 ? searchForm.querySelector('input[name="sortorigin"]') 309 : document.querySelector('input[name="sortorigin"]'); 310 311 // Get current sort from hidden input 312 let currentSort = ""; 313 if (sortfieldInput && sortfieldInput.value) { 314 currentSort = sortfieldInput.value; 315 } 316 317 // If still empty, check URL params 318 if (!currentSort) { 319 const urlParams = new URL(window.location.href).searchParams; 320 currentSort = urlParams.get("sortfield") || ""; 321 } 322 323 // Determine next direction 324 let nextDir = sortDirDefault; 325 if (currentSort) { 326 const parts = currentSort.trim().split(/\s+/); 327 const currentField = parts[0] || ""; 328 const currentDir = (parts[1] || "").toUpperCase(); 329 if ( 330 currentField === sortName && 331 (currentDir === "ASC" || currentDir === "DESC") 332 ) { 333 nextDir = currentDir === "ASC" ? "DESC" : "ASC"; 334 } 335 } 336 337 if (sortfieldInput) { 338 sortfieldInput.value = sortName + (nextDir ? " " + nextDir : ""); 339 } 340 341 // Mark this as a user-initiated sort change 342 if (sortoriginInput) { 343 sortoriginInput.value = "user"; 344 } 345 346 if (searchForm) { 347 submitSearchForm(searchForm); 348 } else { 349 const url = new URL(window.location.href); 350 url.searchParams.set( 351 "sortfield", 352 sortName + (nextDir ? " " + nextDir : "") 353 ); 354 url.searchParams.set("sortorigin", "user"); 355 url.searchParams.set("search", "1"); 356 if (!url.searchParams.has("pg")) { 357 url.searchParams.set("pg", "1"); 358 } 359 window.location.href = url.toString(); 360 } 361 return; 362 } 363 364 // Handle pagination button clicks 365 if (e.target.classList.contains("btn-pagination")) { 366 e.preventDefault(); 367 const targetPage = e.target.getAttribute("data-page"); 368 if (targetPage) { 369 const url = new URL(window.location.href); 370 url.searchParams.set("pg", targetPage); 371 372 // Preserve sortfield if present in form 373 const sortfieldInput = document.querySelector( 374 'input[name="sortfield"]' 375 ); 376 if (sortfieldInput && sortfieldInput.value) { 377 url.searchParams.set("sortfield", sortfieldInput.value); 378 } 379 380 // Preserve sortorigin if present in form 381 const sortoriginInput = document.querySelector( 382 'input[name="sortorigin"]' 383 ); 384 if (sortoriginInput && sortoriginInput.value) { 385 url.searchParams.set("sortorigin", sortoriginInput.value); 386 } 387 388 window.location.href = url.toString(); 389 } 390 } 391 392 // Handle Apply button click 393 if (e.target.id === "btnApply" || e.target.closest("#btnApply")) { 394 e.preventDefault(); 109 395 const url = new URL(window.location.href); 110 url.searchParams.set("pg", targetPage); 111 112 // Preserve sortfield if present in form 113 const sortfieldInput = document.querySelector( 114 'input[name="sortfield"]' 115 ); 116 if (sortfieldInput && sortfieldInput.value) { 117 url.searchParams.set("sortfield", sortfieldInput.value); 118 } 119 396 url.searchParams.set("apply", "y"); 120 397 window.location.href = url.toString(); 121 398 } 122 } 123 124 // Handle Apply button click 125 if (e.target.id === "btnApply" || e.target.closest("#btnApply")) { 126 e.preventDefault(); 127 const url = new URL(window.location.href); 128 url.searchParams.set("apply", "y"); 129 window.location.href = url.toString(); 130 } 131 }); 399 }, 400 true 401 ); // Use capture phase to intercept before other handlers 402 403 /** 404 * Append referrer source to iframe (used by job detail apply frame) 405 * Global function called from inline onload handler 406 */ 407 window.appendSrcToFrame = function (frameobj) { 408 try { 409 var src = localStorage["pcr_referer"]; 410 if (src) { 411 var host = frameobj.getAttribute("host"); 412 if (host && host.indexOf("src=") < 0) { 413 frameobj.setAttribute( 414 "host", 415 host + "&src=" + encodeURIComponent(src) 416 ); 417 } 418 } 419 } catch (e) { 420 // Silently ignore localStorage errors 421 } 422 }; 132 423 })(); -
pcrecruiter-extensions/trunk/includes/class-deactivation-handler.php
r3423933 r3431406 6 6 * @since 2.0.0 7 7 */ 8 8 if (! defined('ABSPATH')) { 9 exit; 10 } 9 11 class PCR_Deactivation_Handler 10 12 { 11 12 13 /** 13 14 * Initialize the deactivation handler … … 19 20 add_action('admin_footer', [__CLASS__, 'render_deactivation_modal']); 20 21 } 21 22 22 /** 23 23 * Enqueue scripts and styles for deactivation modal … … 29 29 return; 30 30 } 31 32 31 wp_enqueue_style( 33 32 'pcr-deactivation-modal', … … 36 35 '1.0.0' 37 36 ); 38 39 37 wp_enqueue_script( 40 38 'pcr-deactivation-handler', … … 44 42 true 45 43 ); 46 47 44 wp_localize_script('pcr-deactivation-handler', 'pcrDeactivation', [ 48 45 'ajaxurl' => admin_url('admin-ajax.php'), … … 51 48 ]); 52 49 } 53 54 50 /** 55 51 * Render the deactivation modal HTML … … 69 65 <button type="button" class="pcr-modal-close" aria-label="Close">×</button> 70 66 </div> 71 72 67 <div class="pcr-modal-body"> 73 68 <p class="pcr-intro-text">Please select what data should be handled during deactivation:</p> 74 75 69 <form id="pcr-deactivation-form"> 76 70 <div class="pcr-option-section"> … … 81 75 <span class="pcr-option-description">Preserve all plugin settings for future use (recommended for temporary deactivation)</span> 82 76 </label> 83 84 77 <label class="pcr-option-label pcr-warning-option"> 85 78 <input type="radio" name="settings_action" value="delete"> … … 88 81 </label> 89 82 </div> 90 91 83 <div class="pcr-option-section"> 92 84 <h3>Job Postings</h3> … … 96 88 <span class="pcr-option-description">Preserve all synced job postings in WordPress (recommended)</span> 97 89 </label> 98 99 90 <label class="pcr-option-label pcr-danger-option"> 100 91 <input type="radio" name="jobs_action" value="delete"> … … 103 94 </label> 104 95 </div> 105 106 96 <div class="pcr-option-section"> 107 97 <h3>RSS Feed Files</h3> … … 111 101 <span class="pcr-option-description">Preserve pcrjobfeed.xml and pcrjobfeed.json files (recommended)</span> 112 102 </label> 113 114 103 <label class="pcr-option-label pcr-warning-option"> 115 104 <input type="radio" name="rss_action" value="delete"> … … 118 107 </label> 119 108 </div> 120 121 109 <div class="pcr-confirmation-section" id="pcr-confirmation-required" style="display:none;"> 122 110 <div class="pcr-warning-box"> … … 126 114 </div> 127 115 </div> 128 129 116 <div class="pcr-summary-section" id="pcr-summary"> 130 117 <h4>Summary:</h4> … … 133 120 </form> 134 121 </div> 135 136 122 <div class="pcr-modal-footer"> 137 123 <button type="button" class="button button-secondary pcr-cancel-deactivation">Cancel</button> … … 145 131 <?php 146 132 } 147 148 133 /** 149 134 * AJAX handler for cleanup actions … … 153 138 // Verify nonce 154 139 check_ajax_referer('pcr_deactivation_cleanup', 'nonce'); 155 156 140 // Check permissions 157 141 if (!current_user_can('activate_plugins')) { 158 142 wp_send_json_error(['message' => 'Insufficient permissions']); 159 143 } 160 161 144 $settings_action = isset($_POST['settings_action']) ? sanitize_text_field(wp_unslash($_POST['settings_action'])) : 'keep'; 162 145 $jobs_action = isset($_POST['jobs_action']) ? sanitize_text_field(wp_unslash($_POST['jobs_action'])) : 'keep'; 163 146 $rss_action = isset($_POST['rss_action']) ? sanitize_text_field(wp_unslash($_POST['rss_action'])) : 'keep'; 164 147 $confirmation = isset($_POST['confirmation']) ? sanitize_text_field(wp_unslash($_POST['confirmation'])) : ''; 165 166 148 // Validate confirmation for destructive actions 167 149 if (($settings_action === 'delete' || $jobs_action === 'delete' || $rss_action === 'delete') && $confirmation !== 'DELETE') { 168 150 wp_send_json_error(['message' => 'Invalid confirmation text']); 169 151 } 170 171 152 // Store cleanup actions for deactivation hook to execute 172 153 update_option('pcrecruiter_deactivation_actions', [ … … 176 157 'timestamp' => current_datetime()->getTimestamp() 177 158 ], false); 178 179 159 wp_send_json_success([ 180 160 'message' => 'Deactivation preferences saved. Proceeding with plugin deactivation...' … … 188 168 self::delete_plugin_settings(); 189 169 } 190 191 170 public static function execute_jobs_cleanup() 192 171 { 193 172 self::delete_all_jobs(); 194 173 } 195 196 174 public static function execute_rss_cleanup() 197 175 { … … 204 182 { 205 183 global $wpdb; 206 207 184 // List of all plugin options 208 185 $options = [ … … 228 205 'pcrecruiter_full_sync_enabled' 229 206 ]; 230 231 207 foreach ($options as $option) { 232 208 delete_option($option); 233 209 } 234 235 210 // Clear any transients 236 211 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching … … 241 216 ); 242 217 } 243 244 218 /** 245 219 * Delete all job posts … … 249 223 { 250 224 global $wpdb; 251 252 225 // Get all job post IDs 253 226 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching … … 258 231 ) 259 232 ); 260 261 233 $deleted_count = 0; 262 263 234 if (!empty($job_ids)) { 264 235 foreach ($job_ids as $job_id) { … … 268 239 } 269 240 } 270 271 241 // Clear job caches 272 242 if (class_exists('PCR_Job_Manager')) { … … 274 244 } 275 245 } 276 277 246 return $deleted_count; 278 247 } 279 280 248 /** 281 249 * Delete RSS feed files … … 285 253 { 286 254 $deleted_files = []; 287 288 255 $feed_files = [ 289 256 WP_CONTENT_DIR . '/uploads/pcrjobfeed.xml', 290 257 WP_CONTENT_DIR . '/uploads/pcrjobfeed.json' 291 258 ]; 292 293 259 foreach ($feed_files as $file) { 294 260 if (file_exists($file)) { … … 299 265 } 300 266 } 301 302 267 return $deleted_files; 303 268 } 304 269 } 305 306 270 // Initialize the deactivation handler 307 271 PCR_Deactivation_Handler::init(); -
pcrecruiter-extensions/trunk/includes/class-job-manager.php
r3423933 r3431406 8 8 * 9 9 * @package PCRecruiter_Extensions 10 * @since 2.0.010 * @since 1.0.0 11 11 */ 12 12 … … 168 168 return '[[' . strtolower($key) . ']]'; 169 169 } 170 171 172 170 public function update_wp_settings($data): string 173 171 { … … 220 218 try { 221 219 // Lookup all post IDs with matching databaseid using WordPress built-in function 220 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Required for job lookup by databaseid 222 221 $args = array( 223 222 'post_type' => 'job', … … 300 299 if ($isDeleted) { 301 300 //handle expired job based on settings 302 if ( strlen($existingpostid) > 0) {301 if (!empty($existingpostid)) { 303 302 $options = get_option('pcrecruiter_feed_options', array()); 304 303 $handling = isset($options['expired_job_handling']) ? $options['expired_job_handling'] : 'delete'; … … 362 361 $allowed_html['br'] = array(); 363 362 364 // Strip inline styles from content 363 // Strip inline styles from content (but preserve style on span.positionlogo and span.companylogo) 365 364 $options = get_option('pcrecruiter_feed_options', array()); 366 365 $strip_styles = isset($options['strip_inline_styles']) ? $options['strip_inline_styles'] : '1'; … … 369 368 370 369 if ($strip_styles === '1') { 371 $content = preg_replace('/\s*style\s*=\s*["\'][^"\']*["\']/i', '', $content); 372 } 373 370 $pattern = '/<([a-z0-9]+)([^>]*?)\sstyle\s*=\s*(["\'])(.*?)\3([^>]*?)>/i'; 371 372 $content = preg_replace_callback( 373 $pattern, 374 function ($m) { 375 $tag = strtolower($m[1]); 376 $before = $m[2]; 377 $after = $m[5]; 378 $full = $m[0]; 379 380 // Combine attributes around the style attr to inspect class 381 $attrs = $before . ' ' . $after; 382 383 // Keep style attribute only for span logos, but strip default-logo markers 384 if ($tag === 'span') { 385 $hasLogoClass = preg_match('/\bclass\s*=\s*["\'][^"\']*\b(positionlogo|companylogo)\b[^"\']*["\']/i', $attrs); 386 $hasDefaultLogo = preg_match('/\bdata-default-logo\s*=\s*["\']\s*true\s*["\']/i', $attrs); 387 388 if ($hasLogoClass) { 389 if ($hasDefaultLogo) { 390 // Remove entirely when it's a default placeholder 391 return ''; 392 } 393 // Preserve original tag (including style) for real logos 394 return $full; 395 } 396 } 397 398 // Otherwise remove the style attribute by rebuilding the tag without it 399 // Trim to avoid duplicate spaces 400 $reconstructed = '<' . $tag . preg_replace('/\s+/', ' ', trim($before . ' ' . $after)) . '>'; 401 return $reconstructed; 402 }, 403 $content 404 ); 405 } 406 407 // Sanitize content - allow form tags for apply buttons 374 408 $job_data['post_content'] = wp_kses($content, $allowed_html); 375 376 // Force all <a> tags to open in new tab, except apply links377 $job_data['post_content'] = preg_replace(378 '/<a\s+(?![^>]*target=)(?![^>]*href=["\'][^"\']*[?&]apply=y)([^>]*)>/i',379 '<a target="_blank" $1>',380 $job_data['post_content']381 );382 $job_data['post_content'] = preg_replace(383 '/<a\s+(?![^>]*href=["\'][^"\']*[?&]apply=y)([^>]*)target=["\'](?!_blank)[^"\']*["\']([^>]*)>/i',384 '<a $1 target="_blank" $2>',385 $job_data['post_content']386 );387 409 388 410 if ($existingpostid) { … … 444 466 } 445 467 468 // Populate Yoast SEO meta after all job meta is saved 469 if (class_exists('PCR_SEO_Enhancements')) { 470 PCR_SEO_Enhancements::populate_yoast_meta($post_id); 471 } 472 446 473 //return postid 447 474 return $post_id; … … 503 530 $searchmetafields = get_option("global_search_metafields"); 504 531 $resulthmetafields = get_option("global_result_metafields"); 505 506 507 532 //result options 508 533 $showjobs = get_option("global_result_showjobs"); … … 526 551 527 552 $search_template = PCR_Job_Manager::replace_search_fields($search_template, $searchmetafields); 528 529 530 553 $output = []; 531 554 … … 697 720 } 698 721 } 699 700 701 722 // Get pagination style from settings 702 723 $pcr_options = get_option('pcrecruiter_feed_options', []); … … 828 849 return [$beforeTbody, $tableRow, $afterTbody]; 829 850 } 830 831 832 851 public static function build_job_selection_query($showjobs, $resultsperpage, $page, $filter, $jobcategory = ''): string 833 852 { … … 852 871 $limitsql = esc_sql(($resultsperpage + 1)); 853 872 $iskeywordsearch = false; 854 $isradius = false;855 873 856 874 $pageoffset = (intval($page) - 1); … … 873 891 $defaultsortfield = PCR_Job_Manager::parseOrderBy(get_option('global_sort_field')); 874 892 875 // Check POST first, then GET for sortfield 893 // Check POST first, then GET for sortfield and sortorigin 876 894 $sortfield_raw = ''; 895 $sortorigin = 'default'; 877 896 if (isset($_POST['sortfield']) && $_POST['sortfield'] !== '') { 878 897 $sortfield_raw = sanitize_text_field(wp_unslash($_POST['sortfield'])); 898 $sortorigin = isset($_POST['sortorigin']) ? sanitize_text_field(wp_unslash($_POST['sortorigin'])) : 'default'; 879 899 } elseif (isset($_GET['sortfield']) && $_GET['sortfield'] !== '') { 880 900 $sortfield_raw = sanitize_text_field(wp_unslash($_GET['sortfield'])); 901 $sortorigin = isset($_GET['sortorigin']) ? sanitize_text_field(wp_unslash($_GET['sortorigin'])) : 'default'; 902 } 903 904 // Check if radius search is active (for Distance sort logic) 905 $isRadiusSearch = false; 906 if (isset($_POST['Position---LatLong']) && strlen(sanitize_text_field(wp_unslash($_POST['Position---LatLong']))) > 0) { 907 $isRadiusSearch = true; 908 } elseif (isset($_GET['Position---LatLong']) && strlen(sanitize_text_field(wp_unslash($_GET['Position---LatLong']))) > 0) { 909 $isRadiusSearch = true; 910 } 911 912 // Auto-apply Distance sort for radius searches ONLY if user hasn't explicitly chosen a sort 913 if ($isRadiusSearch && $sortorigin !== 'user') { 914 if (strpos($sortfield_raw, '[[Distance]]') === false) { 915 $sortfield_raw = '[[Distance]] ASC'; 916 $sortforced = true; 917 } 881 918 } 882 919 … … 889 926 890 927 $orderby = ''; 928 $sort_is_distance = false; 891 929 $sortdir = esc_sql($sortfield['ascdesc']); 892 930 … … 1055 1093 FROM {$wpdb->postmeta} pm 1056 1094 WHERE pm.post_id = wp.ID 1057 AND pm.meta_key = '[[position.zip _code]]'1095 AND pm.meta_key = '[[position.zip]]' 1058 1096 LIMIT 1 1059 1097 ) {$sortdir}, wp.post_title"; … … 1095 1133 LIMIT 1 1096 1134 ) {$sortdir}, wp.post_title"; 1135 break; 1136 case '[[Distance]]': 1137 // Distance sort only works with radius search 1138 if ($isRadiusSearch) { 1139 $orderby = "ORDER BY distance {$sortdir}"; 1140 $sort_is_distance = true; 1141 } else { 1142 // Fall back to default sort if no radius search active 1143 $orderby = "ORDER BY ( 1144 SELECT pm.meta_value 1145 FROM {$wpdb->postmeta} pm 1146 WHERE pm.post_id = wp.ID 1147 AND pm.meta_key = '[[position.date_posted]]' 1148 LIMIT 1 1149 ) DESC"; 1150 } 1097 1151 break; 1098 1152 … … 1134 1188 ) DESC"; 1135 1189 } 1136 1137 1138 1190 /////////////////////////////////////////////////////////////////////// 1139 1191 //BUILD WHERE CLAUSE … … 1173 1225 $whereclause[] = "EXISTS (SELECT 1 FROM {$wpdb->postmeta} pm WHERE meta_key = '[[position.city]]' AND meta_value = '{$match}' AND wp.ID = pm.post_id)"; 1174 1226 $whereclause[] = "OR EXISTS (SELECT 1 FROM {$wpdb->postmeta} pm WHERE meta_key = '[[position.state]]' AND meta_value = '{$match}' AND wp.ID = pm.post_id)"; 1175 $whereclause[] = "OR EXISTS (SELECT 1 FROM {$wpdb->postmeta} pm WHERE meta_key = '[[position.zip _code]]' AND meta_value = '{$match}' AND wp.ID = pm.post_id)";1227 $whereclause[] = "OR EXISTS (SELECT 1 FROM {$wpdb->postmeta} pm WHERE meta_key = '[[position.zip]]' AND meta_value = '{$match}' AND wp.ID = pm.post_id)"; 1176 1228 $whereclause[] = ")"; 1177 1229 } … … 1254 1306 $whereclause[] = "AND EXISTS (SELECT 1 FROM {$wpdb->postmeta} pm WHERE meta_key = '[[position.department]]' AND meta_value = '{$match}' AND wp.ID = pm.post_id)"; 1255 1307 } 1256 1257 //Position---LatLong (Radius)1258 if (isset($_POST['Position---LatLong']) && strlen(sanitize_text_field(wp_unslash($_POST['Position---LatLong']))) > 0) {1259 $isradius = true;1260 }1261 1262 1308 //Positions---Salary 1263 1309 if (isset($_POST['Positions---Salary']) && strlen(sanitize_text_field(wp_unslash($_POST['Positions---Salary']))) > 0) { … … 1322 1368 } 1323 1369 } 1324 1325 1326 1370 //jobcategory filter from shortcode parameter 1327 1371 if (!empty($jobcategory)) { … … 1331 1375 1332 1376 //radius search uses a different query 1333 if ($is radius) {1377 if ($isRadiusSearch) { 1334 1378 global $wpdb; 1335 1379 … … 1361 1405 $output[] = "HAVING distance <= {$radius}"; 1362 1406 //sort by 1363 if ($sortforced) { 1407 $sort_is_date = ($sortfield['fieldname'] === '[[Date_Posted]]' || $sortfield['fieldname'] === ''); 1408 if ($sortforced || $sort_is_date) { 1364 1409 $output[] = $orderby; 1365 1410 } else { … … 1375 1420 //keyword search return in context order 1376 1421 if ($iskeywordsearch == false || $sortforced) { 1422 if ($sort_is_distance) { 1423 $orderby = "ORDER BY ( 1424 SELECT pm.meta_value 1425 FROM {$wpdb->postmeta} pm 1426 WHERE pm.post_id = wp.ID 1427 AND pm.meta_key = '[[position.date_posted]]' 1428 LIMIT 1 1429 ) DESC"; 1430 } 1377 1431 $output[] = $orderby; 1378 1432 } … … 1407 1461 return $sql; 1408 1462 } 1409 1410 1411 1463 public static function draw_search_form($searchtemplate, $page): string 1412 1464 { 1413 //get sort if sort was overridden 1465 // Determine sortfield and sortorigin 1466 // sortorigin values: 'user' (clicked sort link), 'radius' (auto-applied), 'default' (global setting) 1414 1467 $sortfield = ''; 1415 1468 $sortorigin = 'default'; 1469 1470 // Check for user-provided sort (POST or GET) 1416 1471 if (isset($_POST['sortfield']) && strlen(sanitize_text_field(wp_unslash($_POST['sortfield']))) > 0) { 1417 1472 $sortfield = sanitize_text_field(wp_unslash($_POST['sortfield'])); 1473 // Check if this was a user click or auto-applied 1474 $sortorigin = isset($_POST['sortorigin']) ? sanitize_text_field(wp_unslash($_POST['sortorigin'])) : 'default'; 1418 1475 } elseif (isset($_GET['sortfield']) && strlen(sanitize_text_field(wp_unslash($_GET['sortfield']))) > 0) { 1419 1476 $sortfield = sanitize_text_field(wp_unslash($_GET['sortfield'])); 1420 } 1477 $sortorigin = isset($_GET['sortorigin']) ? sanitize_text_field(wp_unslash($_GET['sortorigin'])) : 'default'; 1478 } 1479 1480 // If no sortfield provided, use default 1481 if (empty($sortfield)) { 1482 $sortfield = get_option('global_sort_field', ''); 1483 $sortorigin = 'default'; 1484 } 1485 1486 // Check if radius search is active 1487 $isRadiusActive = false; 1488 if (isset($_POST['Position---LatLong']) && strlen(sanitize_text_field(wp_unslash($_POST['Position---LatLong']))) > 0) { 1489 $isRadiusActive = true; 1490 } elseif (isset($_GET['Position---LatLong']) && strlen(sanitize_text_field(wp_unslash($_GET['Position---LatLong']))) > 0) { 1491 $isRadiusActive = true; 1492 } 1493 1494 // Auto-apply Distance sort for radius searches ONLY if user hasn't explicitly chosen a sort 1495 if ($isRadiusActive && $sortorigin !== 'user') { 1496 // Only override if not already Distance 1497 if (strpos($sortfield, '[[Distance]]') === false) { 1498 $sortfield = '[[Distance]] ASC'; 1499 $sortorigin = 'radius'; 1500 } 1501 } 1502 1421 1503 // Build form action URL with current page parameter 1422 1504 $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : ''; … … 1433 1515 $output[] = ' <form id="searchForm" name="searchForm" autocomplete="off" method="post" action="' . esc_url($form_action) . '" title="Search Form">'; 1434 1516 $output[] = $searchtemplate; 1435 $output[] = ' <input name="action" id="action" type="hidden" value="SEARCHJOBS"></input>'; 1436 $output[] = ' <input name="resultspage" id="resultspage" type="hidden" value="' . esc_attr($page) . '"></input>'; 1437 $output[] = ' <input name="sortfield" id="sortfield" type="hidden" value="' . esc_attr($sortfield) . '"></input>'; 1517 $output[] = ' <input name="action" id="action" type="hidden" value="SEARCHJOBS">'; 1518 $output[] = ' <input name="resultspage" id="resultspage" type="hidden" value="' . esc_attr($page) . '">'; 1519 $output[] = ' <input name="sortfield" id="sortfield" type="hidden" value="' . esc_attr($sortfield) . '">'; 1520 $output[] = ' <input name="sortorigin" id="sortorigin" type="hidden" value="' . esc_attr($sortorigin) . '">'; 1438 1521 $output[] = ' </form>'; 1439 1522 $output[] = '</div>'; … … 1456 1539 public static function replace_search_fields($template, $metafields): string 1457 1540 { 1458 1459 1460 1541 // Handle null/empty metafields gracefully 1461 1542 if (empty($metafields) || !is_string($metafields)) { … … 1484 1565 $fielddic[$field['fieldname']] = $field; 1485 1566 } 1486 1487 1488 1567 $allfields = preg_split('/(\[\[[^\]]+\]\])/', $template, -1, PREG_SPLIT_DELIM_CAPTURE); 1489 1568 … … 1501 1580 return implode('', $allfields); 1502 1581 } 1503 1504 1505 1582 public static function replace_result_fields($post, $template, $metafields, $istable, $baseurl): string 1506 1583 { … … 1538 1615 $fieldvalue = str_replace(['http://', "https://"], ["", ""], esc_attr(get_permalink($post->ID))); 1539 1616 } 1540 1541 1617 if (strtolower($fieldname) == "[[position.date_posted]]") { 1542 1618 //reformat date from ISO to readable with timezone adjustment 1543 $fieldvalue = PCR_Job_Manager::adjustTimezoneUsingCookie($fieldvalue, self::get_date_format());1619 $fieldvalue = PCR_Job_Manager::adjustTimezoneUsingCookie($fieldvalue, "Y-m-d"); 1544 1620 } 1545 1621 1546 1622 if (strtolower($fieldname) == "[[position.last_modified]]") { 1547 1623 //reformat date from ISO to readable with timezone adjustment 1548 $fieldvalue = PCR_Job_Manager::adjustTimezoneUsingCookie($fieldvalue, self::get_date_format());1624 $fieldvalue = PCR_Job_Manager::adjustTimezoneUsingCookie($fieldvalue, "Y-m-d"); 1549 1625 } 1550 1626 … … 1561 1637 return $fieldvalue; 1562 1638 } 1563 1564 1565 1639 public static function replace_field($template, $field): string 1566 1640 { … … 1599 1673 1600 1674 //radius city/state/zip 1601 1602 1603 1675 1604 1676 $radius[] = '<div style="position: relative;">'; … … 1641 1713 $textbox = []; 1642 1714 1643 1644 1645 1715 $textbox[] = '<input autocomplete="on" class="form-control" 1646 1716 type="text" id="' . esc_attr(pcrecruiter_esc_form_var($fieldname)) . '" name="' . esc_attr(pcrecruiter_esc_form_var($fieldname)) . '" … … 1685 1755 $output = []; 1686 1756 1687 1688 1689 1757 $fieldvalue = isset($_POST[pcrecruiter_esc_form_var($fieldname)]) ? sanitize_text_field(wp_unslash($_POST[pcrecruiter_esc_form_var($fieldname)])) : ''; 1690 1691 1692 1758 $whereclause = " AND EXISTS (SELECT 1 FROM {$wpdb->postmeta} pm WHERE meta_key = '[[position.status]]' AND meta_value = '{$intext}' AND wp.post_id = pm.post_id)"; 1693 1759 … … 1750 1816 $query = "SELECT DISTINCT meta_value as dropdown FROM {$wpdb->postmeta} wp WHERE meta_key = '[[position.department]]' {$whereclause} ORDER BY meta_value"; 1751 1817 break; 1752 1753 1754 1818 case 'Positions.Date_Posted': 1755 1819 $output[] = '<option value="">' . 'Posted Within...</option>'; … … 1773 1837 $output[] = '<option ' . ($fieldvalue == '225000-300000' ? " selected " : "") . 'value="225000-300000">' . '225000 - 300000</option>'; 1774 1838 $output[] = '<option ' . ($fieldvalue == '300000-10000000000' ? " selected " : "") . 'value="300000-10000000000">' . '300000+</option>'; 1775 1776 1777 1839 return '<select id="' . esc_attr(pcrecruiter_esc_form_var($fieldname)) . '" name="' . esc_attr(pcrecruiter_esc_form_var($fieldname)) . '" size="1" class="form-control">' . implode("\r\n", $output) . '</select>'; 1778 1840 } … … 1817 1879 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $normalized from esc_sql(), $whereclause built with esc_sql() 1818 1880 $results = $wpdb->get_results($query); 1819 1820 1821 1881 $fieldvalues = isset($_POST[pcrecruiter_esc_form_var($fieldname)]) ? array_map('sanitize_text_field', wp_unslash($_POST[pcrecruiter_esc_form_var($fieldname)])) : []; 1822 1882 … … 1845 1905 { 1846 1906 $output = []; 1847 1848 1849 1907 $template_css_url = $baseurl . '/pcrbin/jobboard.aspx?action=templatecss&uid=' . $databaseid . '&ver=' . $hash; 1850 1908 wp_enqueue_style('pcr-template-css', $template_css_url, array(), $hash); … … 1852 1910 $template_js_url = $baseurl . '/pcrbin/jobboard.aspx?action=templatejs&uid=' . $databaseid . '&ver=' . $hash; 1853 1911 wp_enqueue_script('pcr-template-js', $template_js_url, array(), $hash, true); 1854 1855 1856 1912 // CSS and JS are now properly enqueued above, no inline tags needed 1857 1913 … … 1863 1919 $template = str_ireplace(["~", "~"], ["|", "|"], $template); 1864 1920 1865 // Add sort direction arrows 1866 // Check POST first, then GET, then fall back to default 1921 // Check if radius search is active 1922 $isradius = false; 1923 if (isset($_POST['Position---LatLong']) && strlen(sanitize_text_field(wp_unslash($_POST['Position---LatLong']))) > 0) { 1924 $isradius = true; 1925 } elseif (isset($_GET['Position---LatLong']) && strlen(sanitize_text_field(wp_unslash($_GET['Position---LatLong']))) > 0) { 1926 $isradius = true; 1927 } 1928 1929 // Convert bare [[field]] placeholders to clickable sort links 1930 // Match [[FieldName]] patterns that are NOT already inside HTML tags 1931 $template = preg_replace_callback( 1932 '/(?<=>|^)([^<]*)(\[\[([^\]]+)\]\])([^<]*)(?=<|$)/i', 1933 function ($matches) use ($isradius) { 1934 $before = $matches[1]; 1935 $fieldPlaceholder = $matches[2]; 1936 $fieldName = $matches[3]; 1937 $after = $matches[4]; 1938 1939 // Check if this is Distance field and no radius search 1940 $isDistance = stripos($fieldName, 'Distance') !== false; 1941 if ($isDistance && !$isradius) { 1942 // Return as plain text (non-clickable) with disabled styling 1943 $displayName = str_replace(['Position.', 'Positions.', '_'], ['', '', ' '], $fieldName); 1944 return $before . '<span class="sort-disabled" style="opacity: 0.5; cursor: not-allowed;" title="Distance sort requires a location search">' . esc_html($displayName) . '</span>' . $after; 1945 } 1946 1947 // Check if this is a sortable field 1948 $sortableFields = [ 1949 'Distance', 'Job_Title', 'Position.Job_Title', 'Date_Posted', 1950 'Position.Date_Posted', 'PCity', 'Position.City', 'PState', 1951 'Position.State', 'PCountry', 'Position.Country', 'PCounty', 1952 'Department', 'Positions.Company_Name', 'Position Id' 1953 ]; 1954 1955 $isSortable = false; 1956 foreach ($sortableFields as $sortField) { 1957 if (stripos($fieldName, $sortField) !== false) { 1958 $isSortable = true; 1959 break; 1960 } 1961 } 1962 1963 if ($isSortable) { 1964 // Create clickable sort link 1965 $fullFieldName = '[[' . $fieldName . ']]'; 1966 $displayName = str_replace(['Position.', 'Positions.', '_'], ['', '', ' '], $fieldName); 1967 $sortLink = '<a href="#" class="customsortfield" data-sortname="' . 1968 esc_attr($fullFieldName) . '" data-sortdirection="ASC">' . 1969 esc_html($displayName) . '</a>'; 1970 return $before . $sortLink . $after; 1971 } 1972 1973 // Return unchanged if not sortable 1974 return $matches[0]; 1975 }, 1976 $template 1977 ); 1978 1979 // Disable existing Distance sort links if no radius search 1980 if (!$isradius) { 1981 $template = preg_replace( 1982 '/(<a[^>]*data-sortname[^>]*["\'].*?Distance.*?["\'][^>]*>)(.*?)<\/a>/i', 1983 '<span class="sort-disabled" style="opacity: 0.5; cursor: not-allowed;" title="Distance sort requires a location search">$2</span>', 1984 $template 1985 ); 1986 } 1987 1988 // Determine current sort field (considering sortorigin for radius auto-sort) 1867 1989 $currentSortField = ''; 1990 $sortorigin = 'default'; 1991 1868 1992 if (isset($_POST['sortfield']) && $_POST['sortfield'] !== '') { 1869 1993 $currentSortField = sanitize_text_field(wp_unslash($_POST['sortfield'])); 1994 $sortorigin = isset($_POST['sortorigin']) ? sanitize_text_field(wp_unslash($_POST['sortorigin'])) : 'default'; 1870 1995 } elseif (isset($_GET['sortfield']) && $_GET['sortfield'] !== '') { 1871 1996 $currentSortField = sanitize_text_field(wp_unslash($_GET['sortfield'])); 1872 } else { 1997 $sortorigin = isset($_GET['sortorigin']) ? sanitize_text_field(wp_unslash($_GET['sortorigin'])) : 'default'; 1998 } 1999 2000 // If no sortfield provided, use default 2001 if (empty($currentSortField)) { 1873 2002 $currentSortField = get_option('global_sort_field', ''); 2003 $sortorigin = 'default'; 2004 } 2005 2006 // Auto-apply Distance sort for radius searches (same logic as draw_search_form) 2007 if ($isradius && $sortorigin !== 'user') { 2008 if (strpos($currentSortField, '[[Distance]]') === false) { 2009 $currentSortField = '[[Distance]] ASC'; 2010 } 1874 2011 } 1875 2012 … … 1878 2015 $currentDirection = strtoupper($sortInfo['ascdesc'] ?? ''); 1879 2016 1880 // Add sort direction arrows and toggle directions 2017 // Add arrows to indicate sort direction 2018 // Only show arrow if the field exists in the visible sort options 1881 2019 if (!empty($currentField)) { 1882 // Remove any existing arrows first 1883 $template = preg_replace("/\s*[↓↑]\s*/u", "", $template); 1884 1885 // Determine next direction (toggle) 1886 $nextDirection = ($currentDirection === "DESC") ? "ASC" : "DESC"; 1887 $arrow = ($currentDirection === "DESC") ? " ↓" : " ↑"; 1888 1889 // Find sort links with data-sortname matching current field 1890 $template = preg_replace_callback( 1891 '/(<a[^>]*data-sortname=["\']' . preg_quote($currentField, '/') . '["\'][^>]*data-sortdirection=["\'])([^"\']*)/i', 1892 function ($matches) use ($nextDirection, $arrow) { 1893 // Replace the direction attribute with the toggled direction 1894 return $matches[1] . $nextDirection; 1895 }, 1896 $template 1897 ); 1898 1899 // Add arrow to the link text 1900 $template = preg_replace( 1901 '/(<a[^>]*data-sortname=["\']' . preg_quote($currentField, '/') . '["\'][^>]*>)([^<]*)/i', 1902 '$1$2' . $arrow, 1903 $template 1904 ); 2020 // Remove any existing arrows first (HTML entities and Unicode) 2021 $template = preg_replace('/\s*(↓|↑|[\x{2193}\x{2191}])\s*/u', '', $template); 2022 2023 // Check if the current sort field exists as a clickable link in the template 2024 $fieldExistsInTemplate = (strpos($template, 'data-sortname="' . $currentField . '"') !== false) || 2025 (strpos($template, "data-sortname='" . $currentField . "'") !== false); 2026 2027 if ($fieldExistsInTemplate) { 2028 // Add appropriate arrow based on current sort direction 2029 $arrow = ($currentDirection === 'DESC') ? ' ↓' : ' ↑'; 2030 2031 // Try to find the current field in the template and add arrow using data-sortname 2032 $template = preg_replace( 2033 '/(<a[^>]*data-sortname\s*=\s*["\']' . preg_quote($currentField, '/') . '["\'][^>]*>)([^<]*)/i', 2034 '$1$2' . $arrow, 2035 $template 2036 ); 2037 } 2038 // If field doesn't exist in template (e.g., Distance sort with no Distance link), no arrow is shown 1905 2039 } 1906 2040 … … 2102 2236 { 2103 2237 $jobid = get_post_meta($post_id, '[[position.job_id]]', true); 2104 2105 2106 2238 // Try both possible cases for databaseid 2107 2239 $databaseid = get_post_meta($post_id, "[[databaseid]]", true); 2108 2109 2110 2240 $baseurl = get_post_meta($post_id, "[[baseurl]]", true); 2111 2112 2113 2241 $hash = get_option('global_template_hash', 'any_version'); 2114 2242 … … 2136 2264 2137 2265 try { 2138 // Try to parse the date - handle both ISO and US formats 2139 $date = false; 2140 2141 // First try: ISO 8601 format (2025-12-18T15:34:39Z) 2142 try { 2143 $date = new DateTime($datetime_string, new DateTimeZone('UTC')); 2144 } catch (Exception $e) { 2145 // Second try: US format with AM/PM (12/18/2025 12:51:18 PM) 2146 $date = DateTime::createFromFormat('m/d/Y h:i:s A', $datetime_string, new DateTimeZone('UTC')); 2147 } 2148 2149 if ($date === false) { 2150 throw new Exception('Unable to parse date'); 2151 } 2266 // Create DateTime object from the input (assume UTC/GMT) 2267 $date = new DateTime($datetime_string, new DateTimeZone('UTC')); 2152 2268 2153 2269 // If no cookie, display as GMT with indicator … … 2174 2290 } 2175 2291 } 2176 /** 2177 * Get the configured date format 2178 */ 2179 private static function get_date_format() 2180 { 2181 $options = get_option('pcrecruiter_feed_options', array()); 2182 $date_format = isset($options['date_format']) ? $options['date_format'] : 'wordpress'; 2183 2184 if ($date_format === 'custom') { 2185 return isset($options['date_format_custom']) && !empty($options['date_format_custom']) 2186 ? $options['date_format_custom'] 2187 : 'Y-m-d'; 2188 } 2189 2190 if ($date_format === 'wordpress') { 2191 return get_option('date_format'); 2192 } 2193 2194 return $date_format; 2195 } 2292 2196 2293 private static function time_ago($datetime, $full = false) 2197 2294 { … … 2513 2610 return; 2514 2611 } 2515 2516 2517 2612 if (!empty($job_board_page)) { 2518 2613 // Get the page ID from URL -
pcrecruiter-extensions/trunk/includes/class-schema-mapper.php
r3423933 r3431406 327 327 <h1>JobPosting Schema Mapper</h1> 328 328 329 <!-- Validation Status -->329 <!-- Validation Status --> 330 330 <div id="pcr-schema-status" class="pcr-schema-status"> 331 331 <div class="pcr-status-loading"> … … 334 334 </div> 335 335 336 <!-- Preview Section -->336 <!-- Preview Section --> 337 337 <div class="pcr-schema-preview-section"> 338 338 <h2>Schema Preview</h2> … … 367 367 368 368 <div class="pcr-schema-intro"> 369 <h2>Schema Mapping</h2>370 <p>Map your job post fields to <a href="https://schema.org/JobPosting" target="_blank" rel="noopener">schema.org JobPosting</a> properties. Jobs with incomplete required fields will not output schema.</p>369 <h2>Schema Mapping</h2> 370 <p>Map your job post fields to <a href="https://schema.org/JobPosting" target="_blank" rel="noopener">schema.org JobPosting</a> properties. Jobs with incomplete required fields will not output schema.</p> 371 371 <p><strong>Source Types:</strong></p> 372 372 <ul> … … 406 406 </form> 407 407 408 408 409 409 </div> 410 410 <?php … … 760 760 $full_key = $prop_key . '.' . $sub_key; 761 761 $label_prefix = !empty($parent_label) ? $parent_label . ' - ' : ''; 762 762 763 763 // Recursively call for nested objects 764 764 if ($sub_property['type'] === 'object' && !empty($sub_property['properties'])) { … … 1067 1067 // Check if it's HTML with background-image CSS 1068 1068 if (strpos($logo_value, 'background-image') !== false) { 1069 // If the element indicates it's a default logo, treat as empty 1070 if (preg_match('/data-default-logo\s*=\s*(["\']?)true\1/i', $logo_value)) { 1071 return ''; 1072 } 1073 1069 1074 // Extract URL from: background-image:url(/pcrbin/logo.exe?_=...) 1070 1075 if (preg_match('/background-image\s*:\s*url\s*\(\s*([^)]+)\s*\)/i', $logo_value, $matches)) { … … 1202 1207 // For description field specifically, clean up HTML 1203 1208 if (isset($property['label']) && $property['label'] === 'Job Description') { 1209 // Look for pcr-description-start marker (same logic as build_meta_description) 1210 if (preg_match('/<!--\s*pcr-description-start\s*-->(.*)/is', $value, $matches)) { 1211 // Found comment marker - use everything after it 1212 $value = $matches[1]; 1213 } elseif (preg_match("/<div[^>]*data-pcr-description=[\"']start[\"'][^>]*>(.*?)<\/div>/is", $value, $matches)) { 1214 // Found data attribute marker - use content inside the div 1215 $value = $matches[1]; 1216 } 1217 1204 1218 // Remove HTML comments 1205 1219 $value = preg_replace('/<!--(.|\s)*?-->/', '', $value); 1206 1220 1207 // Remove empty span/div tags 1208 $value = preg_replace('/<(span|div)[^>]*>\s*<\/\1>/', '', $value); 1209 1210 // Remove standalone template fragments like "| Location" 1211 $value = preg_replace('/\|\s*Location\s*/', '', $value); 1212 1213 // Sanitize HTML (keeps safe tags like p, h2, ul, li, strong, em) 1214 $value = wp_kses_post($value); 1221 // Remove script and style tags completely 1222 $value = preg_replace('/<(script|style)[^>]*>.*?<\/\1>/is', '', $value); 1223 1224 // Sanitize HTML - Google Jobs recognizes <p>, <ul>, <li>, <br> 1225 // Also allows header and character tags like <h1>, <strong>, <em> (though not used in formatting) 1226 $allowed_html = array( 1227 'p' => array(), 1228 'br' => array(), 1229 'ul' => array(), 1230 'ol' => array(), 1231 'li' => array(), 1232 'strong' => array(), 1233 'em' => array(), 1234 'b' => array(), 1235 'i' => array(), 1236 'u' => array(), 1237 'h1' => array(), 1238 'h2' => array(), 1239 'h3' => array(), 1240 'h4' => array(), 1241 'h5' => array(), 1242 'h6' => array(), 1243 'a' => array('href' => array()), 1244 'div' => array(), 1245 'span' => array(), 1246 ); 1247 $value = wp_kses($value, $allowed_html); 1248 1249 // Remove extra whitespace but preserve line breaks 1250 $value = preg_replace('/[ \t]+/', ' ', $value); 1251 $value = preg_replace('/\n\s*\n/', "\n", $value); 1215 1252 } 1216 1253 -
pcrecruiter-extensions/trunk/includes/class-seo-enhancements.php
r3423933 r3431406 54 54 $conflicts[] = array( 55 55 'name' => 'Yoast SEO', 56 'config' => 'SEO → Search Appearance → Content Types →Jobs'56 'config' => 'SEO → Search Appearance → Content Types → Jobs' 57 57 ); 58 58 } … … 61 61 $conflicts[] = array( 62 62 'name' => 'Rank Math', 63 'config' => 'Rank Math → Titles & Meta →Job Posts'63 'config' => 'Rank Math → Titles & Meta → Job Posts' 64 64 ); 65 65 } … … 68 68 $conflicts[] = array( 69 69 'name' => 'Divi', 70 'config' => 'Divi → Theme Options →SEO'70 'config' => 'Divi → Theme Options → SEO' 71 71 ); 72 72 } … … 82 82 $conflicts[] = array( 83 83 'name' => 'Avada', 84 'config' => 'Avada → Theme Options →SEO'84 'config' => 'Avada → Theme Options → SEO' 85 85 ); 86 86 } … … 89 89 $conflicts[] = array( 90 90 'name' => 'Astra', 91 'config' => 'Customizer → General →Blog / Archive'91 'config' => 'Customizer → General → Blog / Archive' 92 92 ); 93 93 } … … 134 134 /** 135 135 * Add comprehensive meta tags and Open Graph for job posts 136 * Only outputs when no SEO plugin is handling meta tags 136 137 */ 137 138 public static function add_job_meta_tags() 138 139 { 139 140 if (!is_singular('job')) { 141 return; 142 } 143 144 // Skip if Yoast or Rank Math is active - they handle meta tags 145 // and we populate their fields via populate_yoast_meta() 146 if (defined('WPSEO_VERSION') || defined('RANK_MATH_VERSION')) { 140 147 return; 141 148 } … … 394 401 // Found comment marker - use everything after it 395 402 $content = $matches[1]; 396 } elseif (preg_match( '/<[^>]*data-pcr-description=["\']start["\'][^>]*>(.*?)(?=<\/div>|$)/is', $content, $matches)) {403 } elseif (preg_match("/<div[^>]*data-pcr-description=[\"']start[\"'][^>]*>(.*?)<\/div>/is", $content, $matches)) { 397 404 // Found data attribute marker - use content inside the element 398 405 $content = $matches[1]; … … 745 752 return array(); 746 753 } 754 755 /** 756 * Populate Yoast SEO meta fields for job posts 757 * Called after job creation/update 758 * Only populates if fields are empty (preserves manual edits) 759 * 760 * @param int $job_id The job post ID 761 */ 762 /** 763 * Populate Yoast SEO meta fields for job posts 764 * Called after job creation/update 765 * Only populates if fields are empty (preserves manual edits) 766 * 767 * @param int $job_id The job post ID 768 */ 769 /** 770 * Populate Yoast SEO meta fields for job posts 771 * Called after job creation/update via save_post_job hook 772 * Only populates if fields are empty (preserves manual edits) 773 * 774 * @param int $job_id The job post ID 775 */ 776 public static function populate_yoast_meta($job_id) 777 { 778 // Only for job posts 779 if (get_post_type($job_id) !== 'job') { 780 return; 781 } 782 783 // Only if Yoast is active 784 if (!defined('WPSEO_VERSION')) { 785 return; 786 } 787 788 // Build optimized title and description 789 $job_title = get_the_title($job_id); 790 if (empty($job_title)) { 791 $job_title = get_post_meta($job_id, '[[position.job_title]]', true); 792 } 793 794 $seo_title = self::build_seo_title($job_id, $job_title); 795 $meta_description = self::build_meta_description($job_id); 796 797 // Only populate if Yoast fields are empty (preserves manual edits) 798 $existing_title = get_post_meta($job_id, '_yoast_wpseo_title', true); 799 if (empty($existing_title) && !empty($seo_title)) { 800 update_post_meta($job_id, '_yoast_wpseo_title', $seo_title); 801 } 802 803 $existing_desc = get_post_meta($job_id, '_yoast_wpseo_metadesc', true); 804 if (empty($existing_desc) && !empty($meta_description)) { 805 update_post_meta($job_id, '_yoast_wpseo_metadesc', $meta_description); 806 } 807 808 // Populate OpenGraph fields (Facebook) 809 $existing_og_title = get_post_meta($job_id, '_yoast_wpseo_opengraph-title', true); 810 if (empty($existing_og_title) && !empty($seo_title)) { 811 update_post_meta($job_id, '_yoast_wpseo_opengraph-title', $seo_title); 812 } 813 814 $existing_og_desc = get_post_meta($job_id, '_yoast_wpseo_opengraph-description', true); 815 if (empty($existing_og_desc) && !empty($meta_description)) { 816 update_post_meta($job_id, '_yoast_wpseo_opengraph-description', $meta_description); 817 } 818 819 // Populate Twitter Card fields 820 $existing_twitter_title = get_post_meta($job_id, '_yoast_wpseo_twitter-title', true); 821 if (empty($existing_twitter_title) && !empty($seo_title)) { 822 update_post_meta($job_id, '_yoast_wpseo_twitter-title', $seo_title); 823 } 824 825 $existing_twitter_desc = get_post_meta($job_id, '_yoast_wpseo_twitter-description', true); 826 if (empty($existing_twitter_desc) && !empty($meta_description)) { 827 update_post_meta($job_id, '_yoast_wpseo_twitter-description', $meta_description); 828 } 829 830 // Set OpenGraph image if available 831 $job_image = self::get_job_image($job_id); 832 if (!empty($job_image)) { 833 $existing_og_image = get_post_meta($job_id, '_yoast_wpseo_opengraph-image', true); 834 if (empty($existing_og_image)) { 835 update_post_meta($job_id, '_yoast_wpseo_opengraph-image', $job_image); 836 } 837 838 $existing_twitter_image = get_post_meta($job_id, '_yoast_wpseo_twitter-image', true); 839 if (empty($existing_twitter_image)) { 840 update_post_meta($job_id, '_yoast_wpseo_twitter-image', $job_image); 841 } 842 } 843 } 747 844 } -
pcrecruiter-extensions/trunk/includes/class-sitemap-integration.php
r3423933 r3431406 1 1 <?php 2 2 3 /** 3 4 * PCRecruiter Sitemap Integration … … 7 8 * @since 2.0.0 8 9 */ 9 10 10 if (!defined('ABSPATH')) { 11 11 exit; 12 12 } 13 14 13 class PCR_Sitemap_Integration 15 14 { … … 23 22 add_filter('wp_sitemaps_posts_entry', array(__CLASS__, 'filter_wp_sitemap_entry'), 10, 3); 24 23 add_filter('wp_sitemaps_posts_query_args', array(__CLASS__, 'filter_wp_sitemap_query'), 10, 2); 25 26 24 // Yoast SEO 27 25 add_filter('wpseo_sitemap_entry', array(__CLASS__, 'filter_yoast_sitemap_entry'), 10, 3); 28 26 add_filter('wpseo_exclude_from_sitemap_by_post_ids', array(__CLASS__, 'exclude_internal_jobs_yoast')); 29 30 27 // RankMath 31 28 add_filter('rank_math/sitemap/entry', array(__CLASS__, 'filter_rankmath_sitemap_entry'), 10, 3); 32 29 add_filter('rank_math/sitemap/exclude_post', array(__CLASS__, 'exclude_internal_jobs_rankmath'), 10, 2); 33 30 } 34 35 31 /** 36 32 * Add job post type to native WordPress sitemap … … 46 42 return $post_types; 47 43 } 48 49 44 // Check if job post type exists and is registered 50 45 $job_post_type = get_post_type_object('job'); 51 52 46 if (!$job_post_type) { 53 47 return $post_types; 54 48 } 55 56 49 // Use the actual post type object to ensure all properties are correct 57 50 $post_types['job'] = $job_post_type; 58 59 51 return $post_types; 60 52 } 61 62 53 /** 63 54 * Filter WordPress sitemap entries for jobs … … 73 64 return $entry; 74 65 } 75 76 66 // Exclude internal jobs 77 67 if (self::is_internal_job($post->ID)) { 78 68 return false; 79 69 } 80 81 70 // Set priority, change frequency, and last modified date 82 71 $entry['priority'] = apply_filters('pcrecruiter_sitemap_priority', 0.6, $post->ID); 83 72 $entry['changefreq'] = apply_filters('pcrecruiter_sitemap_changefreq', 'weekly', $post->ID); 84 73 $entry['lastmod'] = get_the_modified_date('c', $post); 85 86 74 return $entry; 87 75 } 88 89 76 /** 90 77 * Filter WordPress sitemap query to exclude internal jobs … … 99 86 return $args; 100 87 } 101 102 88 // Exclude internal jobs via meta query 89 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Required for filtering by job status 103 90 if (!isset($args['meta_query'])) { 104 91 $args['meta_query'] = array(); 105 92 } 106 107 93 $args['meta_query'][] = array( 108 94 'key' => '[[position.status]]', … … 110 96 'compare' => '!=', 111 97 ); 112 113 98 return $args; 114 99 } 115 116 100 /** 117 101 * Filter Yoast SEO sitemap entries for jobs … … 127 111 return $url; 128 112 } 129 130 113 // Exclude internal jobs 131 114 if (self::is_internal_job($object->ID)) { 132 115 return false; 133 116 } 134 135 117 // Set priority and change frequency if not manually set 136 118 if (!isset($url['pri']) || $url['pri'] == 0.5) { // Yoast default is 0.5 137 119 $url['pri'] = apply_filters('pcrecruiter_sitemap_priority', 0.6, $object->ID); 138 120 } 139 140 121 if (!isset($url['chf']) || $url['chf'] == 'weekly') { 141 122 $url['chf'] = apply_filters('pcrecruiter_sitemap_changefreq', 'weekly', $object->ID); 142 123 } 143 144 124 // Set last modified date 145 125 $url['mod'] = get_the_modified_date('c', $object); 146 147 126 return $url; 148 127 } 149 150 128 /** 151 129 * Exclude internal jobs from Yoast sitemap by ID … … 157 135 { 158 136 $internal_jobs = self::get_internal_job_ids(); 159 160 137 if (!empty($internal_jobs)) { 161 138 $excluded_ids = array_merge($excluded_ids, $internal_jobs); 162 139 } 163 164 140 return $excluded_ids; 165 141 } 166 167 142 /** 168 143 * Filter RankMath sitemap entries for jobs … … 178 153 return $entry; 179 154 } 180 181 155 // Exclude internal jobs 182 156 if (self::is_internal_job($object->ID)) { 183 157 return false; 184 158 } 185 186 159 // Set priority, change frequency, and last modified date 187 160 $entry['priority'] = apply_filters('pcrecruiter_sitemap_priority', 0.6, $object->ID); 188 161 $entry['changefreq'] = apply_filters('pcrecruiter_sitemap_changefreq', 'weekly', $object->ID); 189 162 $entry['lastmod'] = get_the_modified_date('c', $object); 190 191 163 return $entry; 192 193 } 194 164 } 195 165 /** 196 166 * Exclude internal jobs from RankMath sitemap … … 205 175 return $exclude; 206 176 } 207 208 177 // Exclude if internal job 209 178 if (self::is_internal_job($post->ID)) { 210 179 return true; 211 180 } 212 213 181 return $exclude; 214 182 } 215 216 183 /** 217 184 * Check if a job is internal-only … … 225 192 return strtolower($status) === 'internalonly'; 226 193 } 227 228 194 /** 229 195 * Get all internal job IDs … … 239 205 return $cached; 240 206 } 241 242 207 // Query for internal jobs 208 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Required for filtering internal jobs 243 209 $args = array( 244 210 'post_type' => 'job', … … 253 219 ), 254 220 ); 255 256 221 $internal_ids = get_posts($args); 257 258 222 // Cache for 1 hour 259 223 set_transient('pcr_internal_job_ids', $internal_ids, HOUR_IN_SECONDS); 260 261 224 return $internal_ids; 262 225 } 263 264 226 /** 265 227 * Clear internal job IDs cache when jobs are updated … … 270 232 delete_transient('pcr_internal_job_ids'); 271 233 } 272 273 234 /** 274 235 * Get debug information about sitemap configuration … … 280 241 { 281 242 $info = array(); 282 283 243 // Check if job post type is registered 284 244 $job_post_type = get_post_type_object('job'); 285 245 $info['post_type_registered'] = !empty($job_post_type); 286 287 246 if ($job_post_type) { 288 247 $info['post_type_public'] = $job_post_type->public; … … 290 249 $info['post_type_has_archive'] = $job_post_type->has_archive; 291 250 } 292 293 251 // Check if job sync is configured 294 252 $options = get_option('pcrecruiter_feed_options', array()); 295 253 $info['sync_configured'] = !empty($options['jobboard_security_key']); 296 297 254 // Count jobs 298 255 $info['total_jobs'] = wp_count_posts('job')->publish ?? 0; 299 256 $info['internal_jobs'] = count(self::get_internal_job_ids()); 300 301 257 // Check which sitemap plugins are active 302 258 $info['wp_core_sitemaps'] = function_exists('wp_sitemaps_get_server'); 303 259 $info['yoast_active'] = defined('WPSEO_VERSION'); 304 260 $info['rankmath_active'] = defined('RANK_MATH_VERSION'); 305 306 261 // Check if sitemaps are enabled 307 262 if (function_exists('get_option')) { 308 263 $info['wp_sitemaps_enabled'] = (bool) get_option('blog_public'); 309 264 } 310 311 265 return $info; 312 266 } -
pcrecruiter-extensions/trunk/readme.txt
r3423933 r3431406 5 5 Tested up to: 6.9 6 6 Requires PHP: 7.4 7 Stable tag: 2.0. 47 Stable tag: 2.0.5 8 8 License: GPLv3 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-3.0.html … … 123 123 124 124 == Changelog == 125 = 2.0.5 - 2026-01-02 = 126 * Improved handling of company and position logos 127 * Radius search sort by distance 128 * Yoast fields generation 129 * Exempt PCR frames/js from interference by cache and optimization defer/lazyload plugins 130 * Performance optimizations 125 131 126 = 2.0.4 - 202 4-19-10=132 = 2.0.4 - 2025-12-19 = 127 133 * Improved handling of v1 URLs 128 134 * Performance optimizations … … 130 136 * Zipcode field handler 131 137 132 = 2.0.3 - 202 4-18-10=138 = 2.0.3 - 2025-12-18 = 133 139 * Additional checks and safeguards for pre-existing custom post type 134 140 * Enhanced meta Description tagging … … 138 144 * Multi-check search fields fix 139 145 140 = 2.0.2 - 202 4-16-10=146 = 2.0.2 - 2025-12-16 = 141 147 * Accomodate other /job/ plugins in iframe-only mode 142 148 143 = 2.0.1 - 202 4-16-10=149 = 2.0.1 - 2025-12-16 = 144 150 * Share widget width adjustment 145 151 146 = 2.0.0 - 202 4-12-10 =152 = 2.0.0 - 2025-12-10 = 147 153 * Initial public release of full sync mode 148 154 * Added block bindings support
Note: See TracChangeset
for help on using the changeset viewer.