Plugin Directory

Changeset 3420599


Ignore:
Timestamp:
12/16/2025 02:41:23 AM (5 weeks ago)
Author:
arothman
Message:

## Version 2.0 - Major Update

### New Features

Full Job Board Sync (Job Manager)

  • Complete job board API integration with automatic synchronization
  • Custom post type 'job' for native WordPress job management
  • Fulltext search index for high-performance keyword searches
  • Advanced filtering (keywords, location, department, job type, date posted, radius search)
  • Pagination controls with simple or numbered page styles
  • Optional job count display (e.g., "Showing 1-25 of 150 jobs")
  • Job detail pages with structured data (Schema.org JobPosting)
  • Social sharing widget with Email, Facebook, LinkedIn, X (Twitter), Bluesky, and copy link functionality
  • Internal-only job board support
  • Multi-database job board support
  • Per-category job pages
  • Expired job handling with configurable delete or display options
  • Optional job description inline style removal for consistent site design

Block Bindings Integration

  • Dynamic job data binding for WordPress block editor (6.5+)
  • Custom field mapping for flexible content display in Site Editor templates

SEO Enhancements

  • Automatic Schema.org JobPosting markup generation
  • Interactive schema mapper utility for custom field mapping
  • XML Sitemap integration for job posts
  • Customizable job title formats for browser tabs and SEO

Security

  • Secure API connection with configurable 256-char authentication key
  • Comprehensive input sanitization and output escaping throughout
  • PHPCS compliance with WordPress coding standards
  • Nonce verification for all admin actions
  • Prepared database queries

### Improvements

Code Architecture

  • Complete plugin restructure with proper class separation into /includes/ directory
  • Modular architecture with dedicated classes for Job Manager, Schema, SEO, Sitemap, Social Widget, Block Bindings, and Deactivation Handler
  • Prefixed all functions from pcr_ to pcrecruiter_ for naming consistency
  • Updated option names to use pcrecruiter_ prefix
  • Automatic migration from legacy v1.x settings and cron schedules

Admin Interface

  • Reorganized settings page with tabbed sections for Feed Settings and Job Board Sync
  • Manual feed update button with AJAX feedback
  • Enhanced error reporting with detailed status messages
  • Deactivation modal with granular cleanup options (settings, jobs, RSS feeds)
  • Improved field documentation and contextual help
  • Security key generator for jobboard sync
  • Job board page URL configuration for legacy redirects

Performance

  • Query optimization with transient-based caching system
  • Job listing cache with automatic invalidation on post updates
  • Reduced redundant API calls
  • Efficient database operations with proper indexing

Standards Compliance

  • WordPress coding standards (PHPCS) compliant throughout
  • Proper escaping with esc_html(), esc_attr(), esc_url(), esc_js()
  • Comprehensive sanitization with sanitize_text_field(), sanitize_textarea_field()
  • Internationalization ready with text domain 'pcrecruiter-extensions'
  • License updated to GPL3

### Bug Fixes

  • Fixed canonical link handling for iframe implementations to allow search engine indexing of individual job pages
  • Corrected file deletion to use wp_delete_file() instead of unlink() for WordPress compatibility
  • JSON RSS generates properly
  • Improved URL sanitization in iframe shortcode
  • Better handling of edge cases in feed generation
  • Fixed undefined index warnings in option callbacks
  • Resolved timezone handling issues with date display
  • Fixed mode radio button selection logic

### Technical Changes

  • Minimum requirement: WordPress 5.6+
  • PHP 7.4+ recommended
  • Added PCRECRUITER_EXTENSIONS_VERSION constant for version management
  • Enhanced error logging with WP_DEBUG integration
  • Proper plugin activation/deactivation hooks with cleanup options
  • Legacy URL redirect support (?recordid= to clean URLs)
  • Database fulltext index creation for efficient keyword search
  • Custom admin columns for job post type with sortable fields

### Migration Notes
Version 2.0 automatically migrates settings from 1.x installations. Existing iframe shortcodes and RSS feed configurations will continue to work without modification. New Job Manager features are opt-in and require configuration through Settings → PCRecruiter Extensions → Job Board Sync.

### Important
This is a major release with significant new functionality. Testing in a staging environment before updating production sites is recommended. The new Job Manager sync feature requires coordination with PCRecruiter support to configure the WordPress sync settings in your PCRecruiter database.

Location:
pcrecruiter-extensions/trunk
Files:
29 added
6 edited

Legend:

Unmodified
Added
Removed
  • pcrecruiter-extensions/trunk/PCRecruiter-Extensions.php

    r3400600 r3420599  
    11<?php
     2
     3/**
     4 * Plugin Name: PCRecruiter Extensions
     5 * Plugin URI: https://www.pcrecruiter.net
     6 * Description: Embeds PCRecruiter forms and iframe content via shortcodes, facilitates RSS/XML Job Feeds, syncs job board API content.
     7 * Version: 2.0
     8 * Author: Main Sequence Technology, Inc.
     9 * Author URI: https://www.pcrecruiter.net
     10 * License: GPL3
     11 * Text Domain: pcrecruiter-extensions
     12 *
     13 * @package PCRecruiter_Extensions
     14 * phpcs:set WordPress.NamingConventions.PrefixAllGlobals prefixes[] pcrecruiter
     15 * phpcs:set WordPress.WP.I18n text_domain[] pcrecruiter-extensions
     16 */
     17
     18// Public endpoint for PCRecruiter custom forms & job sync.
     19// Form is submitted from an external domain - WordPress nonce cannot be added.
     20// All inputs are sanitized with sanitize_text_field() / sanitize_textarea_field().
     21// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended
     22
     23function pcrecruiter_maybe_migrate_v1_settings()
     24{
     25    // Check if old options exist and new ones don't
     26    $old_options = get_option('pcr_feed_options');
     27    $new_options = get_option('pcrecruiter_feed_options');
     28
     29    if ($old_options && !$new_options) {
     30        // Migrate settings
     31        update_option('pcrecruiter_feed_options', $old_options);
     32
     33        // Clear old cron and set up new one if activated
     34        wp_clear_scheduled_hook('pcr_feed');
     35        if (!empty($old_options['activation'])) {
     36            $frequency = $old_options['frequency'] ?? 'daily';
     37            wp_schedule_event(time(), $frequency, 'pcrecruiter_feed');
     38        }
     39    }
     40}
     41add_action('plugins_loaded', 'pcrecruiter_maybe_migrate_v1_settings', 5);
     42
     43function pcrecruiter_define_version_constant()
     44{
     45    if (defined('PCRECRUITER_EXTENSIONS_VERSION')) {
     46        return;
     47    }
     48
     49    if (!function_exists('get_plugin_data')) {
     50        require_once ABSPATH . 'wp-admin/includes/plugin.php';
     51    }
     52
     53    $plugin_data = get_plugin_data(__FILE__);
     54    $version = $plugin_data['Version'] ?? '2.0';
     55
     56    define('PCRECRUITER_EXTENSIONS_VERSION', $version);
     57}
     58
     59function pcrecruiter_get_plugin_version()
     60{
     61    pcrecruiter_define_version_constant();
     62    return PCRECRUITER_EXTENSIONS_VERSION;
     63}
     64
     65add_action('init', 'pcrecruiter_define_version_constant', 1);
     66
    267/*
    3 Plugin Name: PCRecruiter Extensions
    4 Plugin URI: https://www.pcrecruiter.net
    5 Description: Embeds PCRecruiter forms and iframe content via shortcodes, and facilitiates RSS/XML Job Feeds.
    6 Version: 1.4.37
    7 Author: Main Sequence Technology, Inc.
    8 Author URI: https://www.pcrecruiter.net
    9 License: GPLv2 or later
    10 License URI: https://www.gnu.org/licenses/gpl-2.0.html
     68JOB MANAGER
    1169*/
     70function pcrecruiter_load_plugin()
     71{
     72    require_once plugin_dir_path(__FILE__) . 'includes/class-job-manager.php';
     73    require_once plugin_dir_path(__FILE__) . 'includes/class-social-widget.php';
     74    require_once plugin_dir_path(__FILE__) . 'includes/class-deactivation-handler.php';
     75    require_once plugin_dir_path(__FILE__) . 'includes/class-block-bindings.php';
     76    require_once plugin_dir_path(__FILE__) . 'includes/class-schema-mapper.php';
     77    require_once plugin_dir_path(__FILE__) . 'includes/class-schema-frontend.php';
     78    require_once plugin_dir_path(__FILE__) . 'includes/class-seo-enhancements.php';
     79    require_once plugin_dir_path(__FILE__) . 'includes/class-sitemap-integration.php';
     80}
     81add_action('plugins_loaded', 'pcrecruiter_load_plugin');
     82
     83
     84/**
     85 * Activation hook for PCR_Job_Manager.
     86 * Registers CPT and creates fulltext index on wp_posts for keyword search performance.
     87 * Direct DB query required "" dbDelta() does not support FULLTEXT indexes.
     88 */
     89function pcrecruiter_activate_job_manager_plugin()
     90{
     91    require_once plugin_dir_path(__FILE__) . 'includes/class-job-manager.php';
     92   
     93    PCR_Job_Manager::register_job_post_type();
     94    pcrecruiter_ensure_fulltext_index();
     95    flush_rewrite_rules();
     96}
     97register_activation_hook(__FILE__, 'pcrecruiter_activate_job_manager_plugin');
     98
     99/**
     100 * Create fulltext index on wp_posts if missing.
     101 * Required for efficient keyword search on job titles/content.
     102 * Uses direct query because WP_Query lacks native fulltext support.
     103 */
     104function pcrecruiter_ensure_fulltext_index()
     105{
     106    global $wpdb;
     107
     108    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Activation: one-time setup, no caching needed
     109    $index_exists = $wpdb->get_var(
     110        $wpdb->prepare(
     111            "SHOW INDEX FROM {$wpdb->posts} WHERE Key_name = %s",
     112            'ft_wp_posts_content'
     113        )
     114    );
     115
     116    if (! $index_exists) {
     117        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Activation: one-time fulltext index creation (dbDelta unsupported; standard for search plugins)
     118        $result = $wpdb->query(
     119            "ALTER TABLE {$wpdb->posts} ADD FULLTEXT ft_wp_posts_content (post_title, post_content)"
     120        );
     121        if (false === $result) {
     122            // Log error for debugging
     123            if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
     124                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
     125                error_log('PCRecruiter: Failed to create fulltext index - ' . $wpdb->last_error);
     126            }
     127            // Store error for admin notice
     128            add_option('pcrecruiter_fulltext_index_error', $wpdb->last_error, '', 'no');
     129        } else {
     130            // Success "" clear any previous error
     131            delete_option('pcrecruiter_fulltext_index_error');
     132        }
     133    }
     134}
     135function pcrecruiter_deactivate_job_manager_plugin()
     136{
     137    // Get user's cleanup choices from modal
     138    $deactivation_actions = get_option('pcrecruiter_deactivation_actions', []);
     139
     140    // Execute cleanup based on user's choices
     141    if (!empty($deactivation_actions)) {
     142        // Settings cleanup
     143        if (($deactivation_actions['settings'] ?? 'keep') === 'delete') {
     144            PCR_Deactivation_Handler::execute_settings_cleanup();
     145        }
     146
     147        // Jobs cleanup
     148        if (($deactivation_actions['jobs'] ?? 'keep') === 'delete') {
     149            PCR_Deactivation_Handler::execute_jobs_cleanup();
     150        }
     151
     152        // RSS cleanup
     153        if (($deactivation_actions['rss'] ?? 'keep') === 'delete') {
     154            PCR_Deactivation_Handler::execute_rss_cleanup();
     155        }
     156    }
     157
     158    // Always cleanup
     159    PCR_Job_Manager::unregister_job_post_type();
     160    flush_rewrite_rules();
     161    delete_option('pcrecruiter_deactivation_actions');
     162}
     163register_deactivation_hook(__FILE__, 'pcrecruiter_deactivate_job_manager_plugin');
     164// Hook to initialize the custom post type
     165add_action('init', ['PCR_Job_Manager', 'register_job_post_type']);
     166
     167// Add columns to admin area
     168add_action('init', ['PCR_Job_Manager', 'setup_admin_columns']);
     169
     170// Clear job cache when jobs are updated or deleted
     171add_action('save_post_job', ['PCR_Job_Manager', 'clear_all_job_caches']);
     172add_action('delete_post', 'pcrecruiter_clear_job_cache_on_delete');
     173
     174function pcrecruiter_clear_job_cache_on_delete($post_id)
     175{
     176    if (get_post_type($post_id) === 'job') {
     177        PCR_Job_Manager::clear_all_job_caches();
     178    }
     179}
     180
     181/**
     182 * Optional: Add admin notice if schema mapper is not enabled
     183 * This reminds admins that the feature exists
     184 */
     185function pcrecruiter_schema_mapper_notice()
     186{
     187    // Only show on Jobs admin pages
     188    $screen = get_current_screen();
     189    if (!$screen || $screen->post_type !== 'job') {
     190        return;
     191    }
     192
     193    // Check if schema mapper is enabled
     194    if (class_exists('PCR_Schema_Mapper')) {
     195        return;
     196    }
     197
     198    // Check if user has already dismissed this notice
     199    $user_id = get_current_user_id();
     200    if (get_user_meta($user_id, 'pcr_schema_mapper_notice_dismissed', true)) {
     201        return;
     202    }
     203
     204?>
     205    <div class="notice notice-info is-dismissible" data-dismissible="pcr-schema-mapper-notice">
     206        <p>
     207            <strong>PCRecruiter Extensions:</strong>
     208            Custom schema mapping is available but not enabled.
     209            This allows you to create JobPosting schema even when PCR doesn't provide it.
     210            <a href="<?php echo esc_url(admin_url('edit.php?post_type=job&page=pcr-schema-mapper')); ?>">Configure Schema Mapping</a>
     211        </p>
     212    </div>
     213    <script>
     214        jQuery(function($) {
     215            $(document).on('click', '.notice[data-dismissible="pcr-schema-mapper-notice"] .notice-dismiss', function() {
     216                $.post(ajaxurl, {
     217                    action: 'pcr_dismiss_schema_notice',
     218                    nonce: '<?php echo esc_js(wp_create_nonce('pcr_dismiss_schema_notice')); ?>'
     219                });
     220            });
     221        });
     222    </script>
     223    <?php
     224}
     225// Uncomment to enable the notice:
     226// add_action('admin_notices', 'pcrecruiter_schema_mapper_notice');
     227
     228/**
     229 * Handle dismissal of schema mapper notice
     230 */
     231function pcrecruiter_dismiss_schema_notice()
     232{
     233    check_ajax_referer('pcr_dismiss_schema_notice', 'nonce');
     234    $user_id = get_current_user_id();
     235    update_user_meta($user_id, 'pcr_schema_mapper_notice_dismissed', true);
     236    wp_send_json_success();
     237}
     238add_action('wp_ajax_pcr_dismiss_schema_notice', 'pcrecruiter_dismiss_schema_notice');
     239
     240
     241
     242
     243/*
     244END JOB MANAGER
     245*/
     246
    12247
    13248/*
     
    15250*/
    16251
    17 function pcr_assets() {
    18     wp_register_script( 'pcr-iframe', 'https://www2.pcrecruiter.net/pcrimg/inc/pcrframehost.js', false, false, false);
    19     wp_enqueue_script( 'pcr-iframe' );
    20     wp_enqueue_style( 'pcr-styles', 'https://www2.pcrecruiter.net/pcrimg/inc/pcrframehost.css', array(), null );
    21 }
    22 add_action( 'wp_enqueue_scripts', 'pcr_assets' );
    23 function sanitize_loadurl($urlparam)
    24     {
    25         // Allow only letters, numbers, periods, equals, colons, hyphens, question marks, hyphens, forward slashes, percent signs, spaces, and ampersands
    26         return preg_replace('/[^a-zA-Z0-9\.\=\:\?\/%\s&\-_]/', '', $urlparam);
    27     }
    28 
    29 function pcr_frame($atts)
    30 {
    31     $a = shortcode_atts([
     252function pcrecruiter_plugin_controller()
     253{
     254    global $pcrecruiter_has_shortcode;
     255
     256    $options = get_option('pcrecruiter_feed_options', []);
     257    $job_board_page = $options['job_board_page'] ?? '';
     258
     259    if (empty($job_board_page)) {
     260        return;
     261    }
     262    // Get current URL (sanitize and unslash server input)
     263    $current_path = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
     264    $url = $current_path;
     265    $pcrecruiter_has_shortcode = (strpos($job_board_page, $url) !== false);
     266
     267    if ($pcrecruiter_has_shortcode) {
     268        $rawData = file_get_contents('php://input');
     269        $data = json_decode($rawData, true);
     270        $action = "";
     271
     272        // Internal job manager cookie
     273        if (stripos($current_path, 'internaljobmanager') !== false) {
     274            PCR_Job_Manager::write_internal_cookie();
     275            add_action('wp_head', ['PCR_Job_Manager', 'write_no_index_header']);
     276        }
     277
     278        if (isset($data['action'])) {
     279            $action = $data['action'];
     280        }
     281
     282        // Check if there was an error during decoding
     283        if (json_last_error() === JSON_ERROR_NONE) {
     284            switch ($action) {
     285                case 'DELETEALL':
     286                    $job_manager = new PCR_Job_Manager();
     287                    $rslt = $job_manager->delete_jobs($data);
     288                    $status = $rslt ? 200 : 500;
     289
     290                    header("Content-Type: application/json");
     291
     292                    if ($rslt) {
     293                        exit(json_encode([
     294                            'status' => $status,
     295                            'message' => 'Jobs were deleted successfully.',
     296                            'content' => 'Jobs were deleted successfully.'
     297                        ]));
     298                    } else {
     299                        exit(json_encode([
     300                            'status' => $status,
     301                            'message' => 'Jobs could not be deleted',
     302                            'content' => 'Error: jobs could not be deleted'
     303                        ]));
     304                    }
     305                    break;
     306
     307                case 'UPDATEJOB':
     308                    $job_manager = new PCR_Job_Manager();
     309                    $validfields = true;
     310                    $postid = "";
     311                    $status = 200;
     312                    $content = 'Job was updated successfully.';
     313
     314                    // Check for missing or empty fields
     315                    if (empty($data['title'])) {
     316                        $validfields = false;
     317                    }
     318                    if (empty($data['description'])) {
     319                        $validfields = false;
     320                    }
     321
     322                    if ($validfields) {
     323                        $postid = $job_manager->create_Update_job($data);
     324                        if (!$postid) {
     325                            $validfields = false;
     326                        }
     327                    }
     328
     329                    header("Content-Type: application/json");
     330
     331                    if ($validfields) {
     332                        if (strpos($postid, 'Error') !== false) {
     333                            $status = 410;
     334                            $content = $postid;
     335                        }
     336
     337                        exit(json_encode([
     338                            'status' => $status,
     339                            'message' => 'The JSON action is UPDATEJOB.',
     340                            'content' => $content,
     341                            'shortcode' => $pcrecruiter_has_shortcode,
     342                            'permalink' => get_permalink($postid)
     343                        ]));
     344                    } else {
     345                        exit(json_encode([
     346                            'status' => 400,
     347                            'message' => 'Missing or Invalid Fields',
     348                            'content' => $content,
     349                            'shortcode' => $pcrecruiter_has_shortcode
     350                        ]));
     351                    }
     352                    break;
     353
     354                case 'UPDATESETTING':
     355                    if (isset($data['resulttemplate'], $data['searchtemplate'], $data['hash'])) {
     356                        $job_manager = new PCR_Job_Manager();
     357                        $resultcode = $job_manager->update_wp_settings($data);
     358                        $status = 200;
     359                        $content = 'Settings Updated';
     360
     361                        if (strpos($resultcode, 'Error') !== false) {
     362                            $status = 410;
     363                            $content = $resultcode;
     364                        }
     365
     366                        header("Content-Type: application/json");
     367                        exit(json_encode([
     368                            'status' => $status,
     369                            'message' => $content
     370                        ]));
     371                    } else {
     372                        header("Content-Type: application/json");
     373                        exit(json_encode([
     374                            'status' => 400,
     375                            'message' => 'Missing Setting Data'
     376                        ]));
     377                    }
     378                    break;
     379
     380                default:
     381                    header("Content-Type: application/json");
     382                    exit(json_encode([
     383                        'status' => 400,
     384                        'message' => 'The JSON action is unknown.'
     385                    ]));
     386            }
     387        }
     388    }
     389}
     390add_action('wp', 'pcrecruiter_plugin_controller');
     391
     392function pcrecruiter_assets()
     393{
     394    global $post;
     395
     396    // Get job board page configuration
     397    $options = get_option('pcrecruiter_feed_options', []);
     398    $job_board_page = isset($options['job_board_page']) ? trim($options['job_board_page']) : '';
     399
     400    // Determine if we're using Full Sync (jobmanager) or just iframes
     401    $using_full_sync = !empty($job_board_page);
     402
     403    // Determine if current page should load jobmanager assets
     404    $is_jobmanager_page = false;
     405    $is_job_post = is_singular('job');
     406
     407    if ($using_full_sync) {
     408
     409        // Check if job_board_page is a URL or page ID(s)
     410        if (filter_var($job_board_page, FILTER_VALIDATE_URL)) {
     411            // It's a URL - check if current URL matches
     412            $current_url = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
     413            $current_path = wp_parse_url($current_url, PHP_URL_PATH);
     414            $is_jobmanager_page = ($current_path && strpos($job_board_page, $current_path) !== false);
     415        } elseif ($post) {
     416            // It's page ID(s) - convert to array and check current page
     417            $page_ids = array_map('intval', array_map('trim', explode(',', $job_board_page)));
     418            $is_jobmanager_page = in_array($post->ID, $page_ids, true);
     419        }
     420    }
     421
     422    $hostreferer = "";
     423    $host = "";
     424
     425    // URL referer
     426    if (isset($_SERVER['HTTP_REFERER'])) {
     427        $referer = sanitize_url(wp_unslash($_SERVER['HTTP_REFERER']));
     428        $parsed = wp_parse_url($referer);
     429        $host = isset($parsed['host']) ? $parsed['host'] : "";
     430        $hostreferer = "referer||website||" . $host;
     431    }
     432
     433    // User-provided source
     434    if (isset($_GET['src'])) {
     435        $src = sanitize_text_field(wp_unslash($_GET['src']));
     436        if (strpos($src, "||") == false) {
     437            $hostreferer = $src . "||website||" . $host;
     438        } else {
     439            $hostreferer = $src;
     440        }
     441    }
     442
     443    // Google Tag Manager
     444    if (isset($_GET['utm_source'])) {
     445        $utm_source = sanitize_text_field(wp_unslash($_GET['utm_source']));
     446        $utm_medium = isset($_GET['utm_medium']) ? sanitize_text_field(wp_unslash($_GET['utm_medium'])) : $host;
     447        $utm_campaign = isset($_GET['utm_campaign']) ? sanitize_text_field(wp_unslash($_GET['utm_campaign'])) : "website";
     448        $hostreferer = $utm_source . "||" . $utm_campaign . "||" . $utm_medium;
     449    }
     450
     451    $hostreferer = esc_js(sanitize_text_field(preg_replace('/[\x{0022}\x{0027}\x{2018}\x{2019}\x{201C}\x{201D}\x{005C}]/u', '', $hostreferer)));
     452
     453
     454    // Register all scripts/styles
     455    wp_register_script('pcr-iframe', 'https://www2.pcrecruiter.net/pcrimg/inc/pcrframehost.js', false, PCRECRUITER_EXTENSIONS_VERSION, false);
     456    wp_register_script('pcr-jobboard-js', 'https://www2.pcrecruiter.net/pcrimg/js/wp_jobboard.js', false, PCRECRUITER_EXTENSIONS_VERSION, false);
     457    wp_register_script('pcr-frontend', plugin_dir_url(__FILE__) . 'assets/js/pcr-frontend.js', array(), PCRECRUITER_EXTENSIONS_VERSION, true);
     458    wp_register_style('pcr-job-board', plugin_dir_url(__FILE__) . 'assets/css/pcr-job-board.css', array(), PCRECRUITER_EXTENSIONS_VERSION);
     459
     460    // Load based on configuration
     461    if ($using_full_sync) {
     462        // Full Sync mode: Load tracking on all pages, jobmanager assets on specific pages
     463        wp_enqueue_script('pcr-iframe');
     464
     465        // Load jobmanager-specific assets on job board page(s) and single job posts
     466        if ($is_jobmanager_page || $is_job_post) {
     467            wp_enqueue_script('pcr-jobboard-js');
     468            wp_enqueue_script('pcr-frontend');
     469            wp_enqueue_style('pcr-job-board');
     470        }
     471
     472        // Inline script for PHP-dependent variables (referer tracking and timezone)
     473        // Only in Full Sync mode - CORS prevents iframe access to localStorage
     474        wp_add_inline_script('pcr-iframe', "
     475            const ref = '$hostreferer';
     476            if(ref.length > 0 && document.location.host?.indexOf(ref) === -1) {
     477                localStorage['pcrecruiter_referer'] = ref;
     478            }
     479           
     480            // Set timezone cookie
     481            try {
     482                const offset = new Date().getTimezoneOffset();
     483                document.cookie = 'wp_timezone_offset=' + offset + '; path=/; secure; samesite=lax';
     484            } catch(e) {
     485                // Silently fail if cookie setting fails
     486            }
     487        ");
     488    } else {
     489        // Iframe-only mode: Only load iframe scripts when shortcode is present
     490        // Scripts will be conditionally loaded by pcrecruiter_frame() when executed
     491        wp_enqueue_script('pcr-iframe');
     492    }
     493}
     494add_action('wp_enqueue_scripts', 'pcrecruiter_assets');
     495
     496// Register 'pg' query variable for pagination
     497function pcrecruiter_register_query_vars($vars)
     498{
     499    $vars[] = 'pg';
     500    return $vars;
     501}
     502add_filter('query_vars', 'pcrecruiter_register_query_vars');
     503
     504/**
     505 * Disable wpautop for job posts using proper filter management
     506 */
     507function pcrecruiter_manage_wpautop_for_jobs($content)
     508{
     509    if (get_post_type() !== 'job') {
     510        return $content;
     511    }
     512
     513    $wpautop_priority = has_filter('the_content', 'wpautop');
     514
     515    if ($wpautop_priority !== false) {
     516        remove_filter('the_content', 'wpautop', $wpautop_priority);
     517
     518        // Store priority globally for restoration
     519        global $pcrecruiter_wpautop_priority;
     520        $pcrecruiter_wpautop_priority = $wpautop_priority;
     521
     522        add_filter('the_content', 'pcrecruiter_restore_wpautop_after_job', 999);
     523    }
     524
     525    return $content;
     526}
     527
     528/**
     529 * Restore wpautop after job content is processed
     530 */
     531function pcrecruiter_restore_wpautop_after_job($processed_content)
     532{
     533    global $pcrecruiter_wpautop_priority;
     534    if (isset($pcrecruiter_wpautop_priority)) {
     535        add_filter('the_content', 'wpautop', $pcrecruiter_wpautop_priority);
     536        unset($pcrecruiter_wpautop_priority);
     537    }
     538    return $processed_content;
     539}
     540add_filter('the_content', 'pcrecruiter_manage_wpautop_for_jobs', 8);
     541
     542/**
     543 * Insert custom job content into the post body.
     544 * Handles Avada and other page builders that call the_content filter multiple times.
     545 */
     546function pcrecruiter_insert_custom_content($content)
     547{
     548    if (get_post_type() !== 'job') {
     549        return $content;
     550    }
     551   
     552    if (!is_singular('job')) {
     553        return $content;
     554    }
     555   
     556    // Get the actual post content from database
     557    global $post;
     558    if (!$post instanceof WP_Post) {
     559        return $content;
     560    }
     561   
     562    $raw_post_content = $post->post_content ?? '';
     563   
     564    // Some builders (like Avada) store content base64 encoded
     565    $decoded = base64_decode($raw_post_content, true);
     566    if ($decoded !== false && mb_check_encoding($decoded, 'UTF-8')) {
     567        $raw_post_content = $decoded;
     568    }
     569   
     570    // Only process if current content matches the actual post content
     571    // This prevents processing header/footer/sidebar template sections
     572    $is_main_content = false;
     573    if ($content === $raw_post_content) {
     574        $is_main_content = true;
     575    } elseif (strlen($raw_post_content) >= 100) {
     576        $is_main_content = strpos($content, substr($raw_post_content, 0, 100)) !== false;
     577    } elseif (strlen($raw_post_content) > 0) {
     578        $is_main_content = strpos($content, $raw_post_content) !== false;
     579    }
     580   
     581    if (!$is_main_content) {
     582        return $content;
     583    }
     584   
     585    $post_id = get_the_ID();
     586    return PCR_Job_Manager::Render_Job_Posting($post_id, $content);
     587}
     588add_filter('the_content', 'pcrecruiter_insert_custom_content', 20);
     589
     590// CSS class by status
     591add_filter('body_class', 'pcrecruiter_add_expired_job_body_class');
     592function pcrecruiter_add_expired_job_body_class($classes)
     593{
     594    if (is_singular('job')) {
     595        $job_id = get_the_ID();
     596        $is_expired = get_post_meta($job_id, '_pcr_expired', true);
     597
     598        if ($is_expired) {
     599            $classes[] = 'pcr-expired-job';
     600            $classes[] = 'job-status-expired';
     601        } else {
     602            $classes[] = 'job-status-active';
     603        }
     604    }
     605    return $classes;
     606}
     607add_filter('post_class', 'pcrecruiter_add_expired_job_post_class', 10, 3);
     608function pcrecruiter_add_expired_job_post_class($classes, $class, $post_id)
     609{
     610    if (get_post_type($post_id) === 'job') {
     611        $is_expired = get_post_meta($post_id, '_pcr_expired', true);
     612
     613        if ($is_expired) {
     614            $classes[] = 'pcr-expired-job';
     615            $classes[] = 'job-status-expired';
     616        } else {
     617            $classes[] = 'job-status-active';
     618        }
     619    }
     620    return $classes;
     621}
     622
     623
     624// Custom 404 handler for job post type
     625function pcrecruiter_custom_job_404_redirect()
     626{
     627    if (is_404()) {
     628        global $wp_query;
     629
     630        if (isset($wp_query->query_vars['post_type']) && $wp_query->query_vars['post_type'] === 'job') {
     631            $pcrecruiter_feed_options = get_option('pcrecruiter_feed_options');
     632            $job_404_page = isset($pcrecruiter_feed_options['job_404_page']) ? trim($pcrecruiter_feed_options['job_404_page']) : '';
     633
     634            if (!empty($job_404_page)) {
     635                if (strpos($job_404_page, '/') !== 0) {
     636                    $job_404_page = '/' . $job_404_page;
     637                }
     638
     639                wp_safe_redirect(home_url($job_404_page), 302);
     640                exit;
     641            }
     642        }
     643    }
     644}
     645add_action('template_redirect', 'pcrecruiter_custom_job_404_redirect');
     646
     647function pcrecruiter_sanitize_loadurl($urlparam)
     648{
     649    return preg_replace('/[^a-zA-Z0-9\.\=\:\?\/%\s&\-_]/', '', $urlparam);
     650}
     651
     652function pcrecruiter_frame($atts)
     653{
     654    // Register the framehost CSS so it can be enqueued conditionally below
     655    wp_register_style('pcr-framehost', 'https://www2.pcrecruiter.net/pcrimg/inc/pcrframehost.css', array(), PCRECRUITER_EXTENSIONS_VERSION);
     656
     657    $a = shortcode_atts(array(
    32658        'link' => 'about:blank',
    33659        'background' => 'transparent',
    34660        'initialheight' => '640',
    35         'form' => ''
    36     ], $atts);
    37 
    38     $sid = intval($a['form']);
     661        'analytics' => '',
     662        'form' => '',
     663        'jobcategory' => ''
     664    ), $atts);
     665
     666    $sid = isset($a['form']) && $a['form'] !== '' ? intval($a['form']) : null;
    39667    $loadurl = $a['link'];
    40668    $loadurl = htmlspecialchars_decode($loadurl, ENT_QUOTES);
    41     $loadurl = sanitize_loadurl($loadurl);
    42    
    43 
     669    $loadurl = pcrecruiter_sanitize_loadurl($loadurl);
    44670
    45671    $initialheight = intval($a['initialheight']);
    46672    $background = preg_match('/^#[a-fA-F0-9]{3,6}$|^transparent$/', $a['background']) ? $a['background'] : 'transparent';
    47673
    48     // Modify the URL when needed
    49     if (strpos($loadurl,'jobboard.') > -1 || strpos($loadurl,'temphrs.') > -1 || strpos($loadurl,'employer.') > -1 || strpos($loadurl,'/reg') > -1 || strpos($loadurl,'reg') > -1) {
    50        
    51     } else {
    52         if ($sid < 1 || strpos($loadurl, '.asp?') === false || strpos($loadurl, '.exe?') === false || strpos($loadurl, '.aspx?') === false) {
    53          $loadurl = 'jobboard.aspx?uid=' . $loadurl;
    54          $pcrframecss = '';
    55         }
    56     }
    57 
    58     // If a custom form is specified
    59     if ($sid && $loadurl !== "about:blank") {
    60         // Create the <script> tag with DOMDocument
    61         $doc = new DOMDocument('1.0', 'UTF-8');
    62         $script = $doc->createElement('script');
    63         $loadurl = urldecode($loadurl);
    64         $script->setAttribute('src', "https://host.pcrecruiter.net/pcrbin/{$loadurl}&action=opencustomform&sid={$sid}");
    65         $doc->appendChild($script);
    66        
    67         return "<!-- Start PCRecruiter Form -->"
    68             . $doc->saveHTML()
    69             . "<!-- End PCRecruiter Form -->";
    70     }
    71 
    72     // Prepend URL based on the link
    73     if (substr($loadurl, 0, 4) !== "http") {
    74         $prefix = (substr($loadurl, 0, 8) === "jobboard")
    75             ? 'https://host.pcrecruiter.net/pcrbin/'
    76             : 'https://www2.pcrecruiter.net/pcrbin/';
    77         $loadurl = $prefix . $loadurl;
    78     }
    79 
    80     $loadurl = str_replace('http:','https:',$loadurl);
    81     $loadurl = str_replace('.exe?','.aspx?',$loadurl);
    82 
    83     // Create iframe with DOMDocument
    84     $doc = new DOMDocument('1.0', 'UTF-8');
    85     $iframe = $doc->createElement('iframe');
    86     $iframe->setAttribute('frameborder', '0');
    87     $iframe->setAttribute('host', $loadurl);
    88     $iframe->setAttribute('id', 'pcrframe');
    89     $iframe->setAttribute('name', 'pcrframe');
    90     $iframe->setAttribute('src', 'about:blank');
    91     $iframe->setAttribute('style', "height:{$a['initialheight']}px;width:100%;background-color:{$a['background']};border:0;margin:0;padding:0");
    92     $iframe->setAttribute('onload', 'pcrframeurl();');
    93 
    94     $doc->appendChild($iframe);
    95 
    96     return "<!-- Start PCRecruiter WP 1.4.36-->"
    97         . $doc->saveHTML()
    98         . "<!-- End PCRecruiter WP -->";
    99 }
    100 
    101 add_shortcode('PCRecruiter', 'pcr_frame');
    102 
     674    $analytics = $a['analytics'];
     675    if ($a['analytics'] != '') {
     676        $analytics = ' analytics="true" ';
     677    };
     678
     679    if ($loadurl == "jobmanager" || $loadurl == "internaljobmanager") {
     680        $job_manager = new PCR_Job_Manager();
     681        $jobcategory = isset($a['jobcategory']) && $a['jobcategory'] !== '' ? sanitize_text_field($a['jobcategory']) : '';
     682        return $job_manager->job_listings($loadurl, $jobcategory);
     683    }
     684
     685    $needs_css = true;
     686    if (strpos($loadurl, '.asp?') === false && strpos($loadurl, '.exe?') === false && strpos($loadurl, '.aspx?') === false) {
     687        // Parse URL to extract base UID and any additional parameters
     688        // This handles: "pcr demo.pcrdemo", "pcr%20demo.pcrdemo", and "pcr%20demo.pcrdemo&filter=value"
     689        $parts = explode('&', $loadurl, 2);
     690        $uid = $parts[0]; // Base UID (may have spaces or %20)
     691        $extra_params = isset($parts[1]) ? '&' . $parts[1] : ''; // Additional params like &filter=value
     692
     693        // Decode then encode only the UID part to normalize encoding
     694        $loadurl = 'jobboard.aspx?uid=' . rawurlencode(rawurldecode($uid)) . $extra_params;
     695        $needs_css = false;
     696    }
     697
     698    if (is_numeric($sid) && $loadurl !== "about:blank") {
     699        $script_url = 'https://www2.pcrecruiter.net/pcrbin/' . $loadurl . '&action=opencustomform&sid=' . rawurlencode($sid);
     700
     701        $handle = 'pcrecruiter-customform-' . $sid;
     702
     703        wp_register_script($handle, esc_url_raw($script_url), [], $sid, true);
     704        wp_enqueue_script($handle);
     705
     706        // Prevent duplicate loading in footer
     707        wp_dequeue_script($handle);
     708        wp_deregister_script($handle);
     709
     710        // Required for PCRecruiter's DOM-based form injection
     711        // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript
     712        return '<script src="' . esc_url($script_url) . '"></script>';
     713    } else {
     714        if (substr($loadurl, 0, 4) !== "http" && substr($loadurl, 0, 8) !== "jobboard") {
     715            $aspurl = 'https://www2.pcrecruiter.net/pcrbin/' . $loadurl;
     716            $loadurl = $aspurl;
     717        };
     718        if (substr($loadurl, 0, 4) !== "http" && substr($loadurl, 0, 8) == "jobboard") {
     719            $needs_css = false;
     720            $aspurl = 'https://host.pcrecruiter.net/pcrbin/' . $loadurl;
     721            $loadurl = $aspurl;
     722        };
     723
     724        if ($needs_css) {
     725            wp_enqueue_style('pcr-framehost');
     726        }
     727
     728        // Enqueue jobboard script for appendSrcToFrame() function
     729        wp_enqueue_script('pcr-jobboard-js');
     730
     731        $version = pcrecruiter_get_plugin_version();
     732
     733        return "<!-- Start PCRecruiter WP {$version}--><iframe frameborder=\"0\" host=\"{$loadurl}\" id=\"pcrframe\" name=\"pcrframe\" src=\"about:blank\" style=\"height:{$initialheight}px;width:100%;background-color:{$background};border:0;margin:0;padding:0\" {$analytics} onload=\"pcrframeurl();\"></iframe><!-- End PCRecruiter WP -->";
     734    }
     735}
     736add_shortcode('PCRecruiter', 'pcrecruiter_frame');
     737
     738/**
     739 * Redirect legacy PCR iframe job detail links to synced WP job URLs
     740 */
     741add_action('template_redirect', 'pcrecruiter_redirect_legacy_job_links');
     742
     743function pcrecruiter_redirect_legacy_job_links()
     744{
     745    if (is_admin()) {
     746        return;
     747    }
     748
     749    // Only redirect if sync is configured (has security key)
     750    $options = get_option('pcrecruiter_feed_options', []);
     751    if (empty($options['jobboard_security_key'])) {
     752        return;
     753    }
     754
     755    if (empty($_GET['recordid'])) {
     756        return;
     757    }
     758
     759    $recordid = '';
     760
     761    if (isset($_GET['recordid'])) {
     762        $recordid = preg_replace('/\D+/', '', sanitize_text_field(wp_unslash($_GET['recordid'])));
     763    }
     764
     765    if ('' === $recordid) {
     766        return;
     767    }
     768
     769    if (is_singular('job')) {
     770        return;
     771    }
     772
     773    $match = get_posts([
     774        'post_type'      => 'job',
     775        'posts_per_page' => 1,
     776        'fields'         => 'ids',
     777        'meta_key'       => '[[position.job_id]]',
     778        'meta_value'     => $recordid,
     779        'no_found_rows'  => true,
     780        'update_post_meta_cache' => false,
     781        'update_post_term_cache' => false,
     782    ]);
     783
     784    if (!$match) {
     785        return;
     786    }
     787
     788    $url = get_permalink($match[0]);
     789    if (!empty($url) && $url !== home_url(add_query_arg([]))) {
     790        wp_safe_redirect($url, 301);
     791        exit;
     792    }
     793}
    103794
    104795/**
     
    106797 * Prevents search engines from consolidating ?recordid= variations into one page
    107798 */
    108 function pcrecruiter_remove_canonical_for_iframe() {
     799function pcrecruiter_remove_canonical_for_iframe()
     800{
    109801    global $post;
    110    
     802
    111803    if (!is_a($post, 'WP_Post')) {
    112804        return;
    113805    }
    114    
     806
    115807    // Only proceed if this page has the PCRecruiter shortcode
    116808    if (!has_shortcode($post->post_content, 'PCRecruiter')) {
    117809        return;
    118810    }
    119    
     811
    120812    // Check if it's an iframe installation (not jobmanager)
    121813    $pattern = get_shortcode_regex(['PCRecruiter']);
     
    128820        }
    129821    }
    130    
     822
    131823    // This is an iframe page - remove canonical to allow search engines
    132824    // to index individual ?recordid= URLs as separate pages
     
    137829
    138830/*
    139 
    140831BEGIN Get PCR Feed
    141 
    142832*/
    143833
    144 // checkop() to see if options exist. If not, set the defaults
    145 function checkop() {
    146     //check if option is already present
    147     //option key is pcr_feed_options
    148     if(!get_option('pcr_feed_options')) {
    149         //not present, so add
    150         $pcr_options = array(
    151             //'activation'    =>  'true',
    152             'frequency'     =>  'daily',
    153             'query'         =>  'Status%20eq%20Available%20and%20ShowOnWeb%20eq%20true%20and%20%28NumberOfOpenings%20ne%200%20OR%20NumberOfOpenings%20eq%20%27%27)',
    154             'mode'          =>  'job',
    155         );
    156         add_option('pcr_feed_options', $pcr_options);
    157     }
    158 }
    159 
    160 register_activation_hook(__FILE__, 'pcr_feed_activation');
    161 
    162 function pcr_feed_activation(){
    163     // set the defaults with checkop()
    164     checkop();
    165     pcr_feed_func();
    166 }
    167 add_action('pcr_feed', 'pcr_feed_activation');
    168 
    169 // Register hook for admin notices
    170 add_action('admin_notices', 'pcr_admin_notices');
     834function pcrecruiter_checkop()
     835{
     836    if (!get_option('pcrecruiter_feed_options')) {
     837        $pcrecruiter_options = array(
     838            'frequency' => 'daily',
     839            'query' => 'Status%20eq%20Available%20and%20ShowOnWeb%20eq%20true%20and%20%28NumberOfOpenings%20ne%200%20OR%20NumberOfOpenings%20eq%20%27%27)',
     840            'mode' => 'job',
     841        );
     842        add_option('pcrecruiter_feed_options', $pcrecruiter_options);
     843    }
     844}
     845
     846register_activation_hook(__FILE__, 'pcrecruiter_feed_activation');
     847
     848function pcrecruiter_feed_activation()
     849{
     850    pcrecruiter_checkop();
     851    pcrecruiter_feed_func();
     852}
     853add_action('pcrecruiter_feed', 'pcrecruiter_feed_activation');
     854
     855function pcrecruiter_feed_func($force = false, &$error = null)
     856{
     857    $pcrecruiter_feed_options = get_option('pcrecruiter_feed_options', array());
     858    $pcrecruiter_customfields = str_replace(' ', '%20', $pcrecruiter_feed_options['custom_fields'] ?? '');
     859    $pcrecruiter_standardfields = str_replace(' ', '%20', $pcrecruiter_feed_options['standard_fields'] ?? '');
     860    $pcrecruiter_id_number = $pcrecruiter_feed_options['id_number'] ?? '';
     861    $pcrecruiter_activation = $pcrecruiter_feed_options['activation'] ?? '';
     862    $url = "https://host.pcrecruiter.net/pcrbin/feeds.aspx?action=customfeed&query=" . $pcrecruiter_feed_options['query'] . "&xtransform=RSS2&SessionId=" . $pcrecruiter_id_number . "&FieldsPlus=" . $pcrecruiter_standardfields . "&custom=" . $pcrecruiter_customfields . "&mode=" . $pcrecruiter_feed_options['mode'];
     863
     864    if (! $force && ! $pcrecruiter_activation) {
     865        return false;
     866    }
     867
     868    $buffer = file_get_contents($url);
     869
     870    if ($buffer === false) {
     871        $error = 'Feed download failed (check SessionID/query/mode/network).';
     872        return false;
     873    }
     874
     875    $buffer = str_replace(array("\n", "\r", "\t"), '', $buffer);
     876    $xml = simplexml_load_string($buffer, null, LIBXML_NOCDATA);
     877
     878    if ($xml === false) {
     879        $error = 'Feed XML could not be parsed.';
     880        return false;
     881    }
     882
     883    // Convert XML to array
     884    $array = json_decode(json_encode($xml), true);
     885
     886    // Recursively trim whitespace from all string values
     887    $array = pcrecruiter_array_trim_recursive($array);
     888
     889    $json = json_encode($array, JSON_PRETTY_PRINT);
     890    file_put_contents(WP_CONTENT_DIR . '/uploads/pcrjobfeed.json', $json);
     891    copy($url, WP_CONTENT_DIR . "/uploads/pcrjobfeed.xml");
     892
     893    return true;
     894}
    171895
    172896/**
    173  * Display admin notices for PCRecruiter feed status
     897 * Recursively trim whitespace from all string values in an array
    174898 */
    175 function pcr_admin_notices() {
    176     // Only show on the PCRecruiter settings page
    177     $screen = get_current_screen();
    178     if ($screen->id !== 'settings_page_pcr-ext-setting-admin') {
    179         return;
    180     }
    181    
    182     // Check if we have a feed status to display
    183     $feed_status = get_option('pcr_feed_status');
    184     if (!$feed_status) {
    185         return;
    186     }
    187    
    188     // Format the time for display
    189     $status_time = !empty($feed_status['time']) ?
    190         ' (' . human_time_diff(strtotime($feed_status['time']), current_time('timestamp')) . ' ago)' : '';
    191        
    192     // Display success or error message
    193     if ($feed_status['success']) {
    194         echo '<div class="notice notice-success is-dismissible">';
    195         echo '<p><strong>Feed:</strong> ' . esc_html($feed_status['message']) . esc_html($status_time) . '</p>';
    196         echo '</div>';
    197     } else {
    198         echo '<div class="notice notice-error is-dismissible">';
    199         echo '<p><strong>Feed Error:</strong> ' . esc_html($feed_status['message']) . esc_html($status_time) . '</p>';
    200         echo '</div>';
    201     }
    202 }
    203 
    204 
    205 function pcr_feed_func() {
    206     // do this via cron
    207     $pcr_feed_options = get_option('pcr_feed_options', array());
    208     $pcr_customfields = str_replace(' ', '%20', $pcr_feed_options['custom_fields'] ?? '');
    209     $pcr_standardfields = str_replace(' ', '%20', $pcr_feed_options['standard_fields'] ?? '');
    210     $pcr_id_number = $pcr_feed_options['id_number'] ?? '';
    211     $pcr_activation = $pcr_feed_options['activation'] ?? '';
    212     $url = "https://host.pcrecruiter.net/pcrbin/feeds.aspx?action=customfeed&query=". $pcr_feed_options['query'] ."&xtransform=RSS2&SessionId=" . $pcr_id_number . "&FieldsPlus=" . $pcr_standardfields . "&custom=" . $pcr_customfields . "&mode=" . $pcr_feed_options['mode'];
    213 
    214     if($pcr_activation) {
    215         // First, try to safely get the content from the URL
    216         $buffer = @file_get_contents($url);
    217        
    218         // Check if we actually got content
    219         if ($buffer === false) {
    220             // Log the error and store the status message
    221             $error_message = 'Failed to fetch XML feed. Please check your PCRecruiter SessionID and internet connection.';
    222             error_log('PCRecruiter Extensions: ' . $error_message);
    223             update_option('pcr_feed_status', array(
    224                 'success' => false,
    225                 'message' => $error_message,
    226                 'time' => current_time('mysql')
    227             ));
    228             return false;
    229         }
    230        
    231         // Clean the buffer
    232         $buffer = str_replace(array("\n", "\r", "\t"), '', $buffer);
    233        
    234         // Try to load the XML with error suppression
    235         libxml_use_internal_errors(true); // Suppress XML errors
    236         $xml = @simplexml_load_string($buffer, null, LIBXML_NOCDATA);
    237        
    238         // Check if XML loading was successful
    239         if ($xml === false) {
    240             $errors = libxml_get_errors();
    241             libxml_clear_errors();
    242            
    243             $error_message = 'XML load from '.$xml.' failed. Please verify your PCR SessionID and that the feed data is valid.';
    244             if (count($errors) > 0) {
    245                 $error_message .= ' Error: ' . $errors[0]->message;
    246             }
    247            
    248             // Log the error and store the status message
    249             error_log('PCRecruiter Extensions: ' . $error_message);
    250             update_option('pcr_feed_status', array(
    251                 'success' => false,
    252                 'message' => $error_message,
    253                 'time' => current_time('mysql')
    254             ));
    255             return false;
    256         }
    257        
    258         // Try to get namespaces safely
    259         try {
    260             $namespaces = $xml->getDocNamespaces(true);
    261            
    262             $array = [];
    263             if (!empty($namespaces)) {
    264                 foreach ($namespaces as $prefix => $namespace) {
    265                     if (is_string($namespace)) {
    266                         $nsXml = @simplexml_load_string($buffer, null, LIBXML_NOCDATA, $prefix);
    267                         if ($nsXml !== false) {
    268                             $array = array_merge_recursive($array, (array) $nsXml);
    269                         }
    270                     }
    271                 }
    272             } else {
    273                 // If no namespaces, just use the base XML
    274                 $array = (array) $xml;
    275             }
    276            
    277             // Encode to JSON
    278             $json = json_encode($array, JSON_PRETTY_PRINT);
    279             if ($json === false) {
    280                 $error_message = 'JSON encoding failed: ' . json_last_error_msg();
    281                 error_log('PCRecruiter Extensions: ' . $error_message);
    282                 update_option('pcr_feed_status', array(
    283                     'success' => false,
    284                     'message' => $error_message,
    285                     'time' => current_time('mysql')
    286                 ));
    287                 return false;
    288             }
    289            
    290             // Save the files
    291             $json_result = @file_put_contents(WP_CONTENT_DIR . '/uploads/pcrjobfeed.json', $json);
    292             $xml_result = @copy($url, WP_CONTENT_DIR . "/uploads/pcrjobfeed.xml");
    293            
    294             if ($json_result === false || $xml_result === false) {
    295                 $error_message = 'Failed to save feed file. Please check folder permissions.';
    296                 error_log('PCRecruiter Extensions: ' . $error_message);
    297                 update_option('pcr_feed_status', array(
    298                     'success' => false,
    299                     'message' => $error_message,
    300                     'time' => current_time('mysql')
    301                 ));
    302                 return false;
    303             }
    304            
    305             // Update status with success message
    306             update_option('pcr_feed_status', array(
    307                 'success' => true,
    308                 'message' => 'Feed successfully updated.',
    309                 'time' => current_time('mysql')
    310             ));
    311            
    312             return true;
    313            
    314         } catch (Exception $e) {
    315             $error_message = 'Exception occurred: ' . $e->getMessage();
    316             error_log('PCRecruiter Extensions: ' . $error_message);
    317             update_option('pcr_feed_status', array(
    318                 'success' => false,
    319                 'message' => $error_message,
    320                 'time' => current_time('mysql')
    321             ));
    322             return false;
    323         }
    324     }
    325    
    326     return true;
    327 }
    328 
    329 // deactivate hook
    330 register_deactivation_hook(__FILE__, 'pcr_deactivation');
    331 function pcr_deactivation(){
    332     wp_clear_scheduled_hook('pcr_feed');
    333 }
    334 /**/
     899function pcrecruiter_array_trim_recursive($array)
     900{
     901    if (!is_array($array)) {
     902        return is_string($array) ? trim($array) : $array;
     903    }
     904
     905    return array_map('pcrecruiter_array_trim_recursive', $array);
     906}
     907
     908register_deactivation_hook(__FILE__, 'pcrecruiter_deactivation');
     909function pcrecruiter_deactivation()
     910{
     911    wp_clear_scheduled_hook('pcrecruiter_feed');
     912}
     913
    335914/*
    336915END Get PCR Feed
    337916*/
     917
    338918/*
    339919Settings for the PCRecruiter Extensions Plugin
    340920*/
    341 class PcrSettingsPage
    342 {
    343     /**
    344     * Holds the values to be used in the fields callbacks
    345     */
     921class PCRecruiter_Extensions_Settings
     922{
    346923    private $options;
    347924
    348     /**
    349     * Start up
    350     */
    351925    public function __construct()
    352926    {
    353         add_action( 'admin_menu', array( $this, 'add_plugin_page' ) );
    354         add_action( 'admin_init', array( $this, 'page_init' ) );
    355     }
    356 
    357     /**
    358     * Add options page
    359     */
     927        $this->options = get_option('pcrecruiter_feed_options', []);
     928        if (!is_array($this->options)) {
     929            $this->options = [];
     930        }
     931
     932        add_action('admin_menu', [$this, 'add_plugin_page']);
     933        add_action('admin_init', [$this, 'page_init']);
     934    }
     935
    360936    public function add_plugin_page()
    361937    {
    362         // This page will be under "Settings"
    363938        add_options_page(
    364939            'Settings Admin',
     
    366941            'manage_options',
    367942            'pcr-ext-setting-admin',
    368             array( $this, 'create_admin_page' )
    369         );
    370     }
    371 
    372     /**
    373     * Options page callback
    374     */
     943            array($this, 'create_admin_page')
     944        );
     945    }
     946
     947    public function job_404_page_callback()
     948    {
     949        $options = get_option('pcrecruiter_feed_options');
     950        $job_404_page = isset($options['job_404_page']) ? esc_attr($options['job_404_page']) : '';
     951    ?>
     952        <input type="text" id="job_404_page" name="pcrecruiter_feed_options[job_404_page]" value="<?php echo esc_attr($job_404_page); ?>" size="60" placeholder="/job-not-found/" />
     953        <p class="description">Enter the URL path (e.g., /job-not-found/) to redirect users when a job posting is not found. Leave blank to use WordPress default 404 page.</p>
     954    <?php
     955    }
     956
     957    public function expired_job_handling_callback()
     958    {
     959        $options = get_option('pcrecruiter_feed_options');
     960        $handling = isset($options['expired_job_handling']) ? $options['expired_job_handling'] : 'delete';
     961    ?>
     962        <fieldset>
     963            <label>
     964                <input type="radio" name="pcrecruiter_feed_options[expired_job_handling]" value="delete" <?php checked($handling, 'delete'); ?> />
     965                <strong>Delete expired jobs</strong>
     966            </label>
     967            <p class="description" style="margin-left: 25px;">Permanently remove expired/filled jobs from WordPress when PCR marks them as deleted. URLs will become 404s.</p>
     968
     969            <br>
     970
     971            <label>
     972                <input type="radio" name="pcrecruiter_feed_options[expired_job_handling]" value="keep" <?php checked($handling, 'keep'); ?> />
     973                <strong>Keep expired jobs visible</strong>
     974            </label>
     975            <p class="description" style="margin-left: 25px;">Keep expired jobs in search results and listings, but mark them as "Position Filled" and disable apply functionality. Jobs get special CSS classes for custom styling.</p>
     976
     977            <p class="description" style="margin-top: 10px;"><em>Note: Keeping expired jobs visible helps maintain SEO value and provides better transparency to job seekers. Expired jobs will be marked with the "pcr-expired-job" class for custom styling.</em></p>
     978        </fieldset>
     979    <?php
     980    }
     981    public function job_title_format_callback()
     982    {
     983        $options = get_option('pcrecruiter_feed_options');
     984        $title_fields = isset($options['job_title_fields']) ? $options['job_title_fields'] : array(
     985            'job_title' => '1',
     986            'location' => '1',
     987            'company' => '1'
     988        );
     989        $force_titles = get_option('pcrecruiter_seo_enable_overrides', false);
     990    ?>
     991        <fieldset>
     992            <p class="description">Choose which fields to include in job page titles (browser tabs and search results).</p>
     993            <p class="description" style="margin-bottom: 10px;"><em>Note: Affects &lt;title&gt; tag only, not URL slugs. WordPress automatically appends site name.</em></p>
     994
     995
     996
     997            <div style="margin: 20px 0; padding: 20px; background: #f9f9f9; border-left: 4px solid #2271b1;">
     998                <h4 style="margin-top: 0;">Title Tag Components (in order)</h4>
     999                <p class="description" style="margin-bottom: 15px;">Fields are joined with " - ". Empty fields are automatically skipped.</p>
     1000
     1001                <label style="display: block; margin: 10px 0; padding: 8px; background: #fff; border: 1px solid #ddd; border-radius: 3px;">
     1002                    <input type="checkbox" name="pcrecruiter_feed_options[job_title_fields][job_title]" value="1" checked disabled style="margin-right: 8px;" />
     1003                    <strong>Job Title</strong> <em>(always included)</em>
     1004                </label>
     1005                <!-- Hidden field to ensure job_title is always saved -->
     1006                <input type="hidden" name="pcrecruiter_feed_options[job_title_fields][job_title]" value="1" />
     1007
     1008                <label style="display: block; margin: 10px 0; padding: 8px; background: #fff; border: 1px solid #ddd; border-radius: 3px;">
     1009                    <input type="checkbox" name="pcrecruiter_feed_options[job_title_fields][city]" value="1" <?php checked(!empty($title_fields['city']), true); ?> style="margin-right: 8px;" />
     1010                    <strong>City</strong>
     1011                </label>
     1012
     1013                <label style="display: block; margin: 10px 0; padding: 8px; background: #fff; border: 1px solid #ddd; border-radius: 3px;">
     1014                    <input type="checkbox" name="pcrecruiter_feed_options[job_title_fields][state]" value="1" <?php checked(!empty($title_fields['state']), true); ?> style="margin-right: 8px;" />
     1015                    <strong>State</strong>
     1016                </label>
     1017
     1018                <label style="display: block; margin: 10px 0; padding: 8px; background: #fff; border: 1px solid #ddd; border-radius: 3px;">
     1019                    <input type="checkbox" name="pcrecruiter_feed_options[job_title_fields][country]" value="1" <?php checked(!empty($title_fields['country']), true); ?> style="margin-right: 8px;" />
     1020                    <strong>Country</strong>
     1021                </label>
     1022
     1023                <label style="display: block; margin: 10px 0; padding: 8px; background: #fff; border: 1px solid #ddd; border-radius: 3px;">
     1024                    <input type="checkbox" name="pcrecruiter_feed_options[job_title_fields][position_id]" value="1" <?php checked(!empty($title_fields['position_id']), true); ?> style="margin-right: 8px;" />
     1025                    <strong>Position ID</strong> <span class="description">(Requisition number)</span>
     1026                </label>
     1027
     1028                <label style="display: block; margin: 10px 0; padding: 8px; background: #fff; border: 1px solid #ddd; border-radius: 3px;">
     1029                    <input type="checkbox" name="pcrecruiter_feed_options[job_title_fields][job_type]" value="1" <?php checked(!empty($title_fields['job_type']), true); ?> style="margin-right: 8px;" />
     1030                    <strong>Job Type</strong> <span class="description">(Full-time, Part-time, etc.)</span>
     1031                </label>
     1032                <label style="display: block; margin: 10px 0; padding: 8px; background: #fff; border: 1px solid #ddd; border-radius: 3px;">
     1033                    <input type="checkbox" name="pcrecruiter_feed_options[job_title_fields][company]" value="1" <?php checked(!empty($title_fields['company']), true); ?> style="margin-right: 8px;" />
     1034                    <strong>Company Name</strong> (Synced from PCR) <span class="description">
     1035                        <p><strong>Note:</strong> WordPress commonly adds " - <?php echo esc_html(get_bloginfo('name')); ?>" to the end of page titles.</p>
     1036                    </span>
     1037                </label>
     1038            </div>
     1039
     1040            <div style="margin: 10px 0; padding: 20px; background: #f9f9f9; border-left: 4px solid #ffc107;">
     1041                <label>
     1042                    <input type="checkbox" name="pcrecruiter_seo_enable_overrides" value="1" <?php checked($force_titles, true); ?> style="margin-right: 8px;" />
     1043                    Force Titles
     1044                </label>
     1045                <p class="description" style="margin: 8px 0 0 24px;">
     1046                    Some site themes and builders do not honor the default WordPress document title tags.
     1047                    Check this box if your jobs are not showing your selected Title Tag Components in the page title,
     1048                    or use your theme or plugin's provided tools to define the &lt;title&gt; tag.
     1049                </p>
     1050                <?php
     1051                $conflicts = PCR_SEO_Enhancements::get_detected_conflicts();
     1052                if (!empty($conflicts)): ?>
     1053                    <div style="margin-top: 15px; padding: 10px; background: #fff; border: 1px solid #ddd;">
     1054                        <strong>Detected SEO plugins/themes:</strong>
     1055                        <ul style="list-style: disc; margin: 5px 0 0 20px;">
     1056                            <?php foreach ($conflicts as $conflict): ?>
     1057                                <li><strong><?php echo esc_html($conflict['name']); ?>:</strong> <?php echo esc_html($conflict['config']); ?></li>
     1058                            <?php endforeach; ?>
     1059                        </ul>
     1060                    </div>
     1061                <?php endif; ?>
     1062            </div>
     1063
     1064
     1065            <div style="margin: 10px 0; padding: 20px; background: #f9f9f9; border-left: 4px solid #ffc107;">
     1066
     1067                <label>
     1068                    <input type="checkbox" name="pcrecruiter_seo_use_pcr_logos" value="1"
     1069                        <?php checked(get_option('pcrecruiter_seo_use_pcr_logos'), 1); ?> />
     1070                    Use Position/Company logos from PCR as <a href="https://ogp.me/" target="_blank">Open Graph</a> images.
     1071                </label>
     1072                <p class="description">
     1073                    When unchecked, your theme or SEO plugin will control social sharing images.
     1074                    When checked, PCR Position logos (or Company logos as fallback) will be used. NOTE: Images should be at least 1200x630px for optimal social sharing, 1.91:1 aspect ratio.
     1075                </p>
     1076            </div>
     1077
     1078
     1079        </fieldset>
     1080    <?php
     1081    }
     1082
     1083    public function strip_inline_styles_callback()
     1084    {
     1085        $options = get_option('pcrecruiter_feed_options');
     1086        $strip_styles = isset($options['strip_inline_styles']) ? $options['strip_inline_styles'] : '1';
     1087    ?>
     1088        <label>
     1089            <input type="checkbox" name="pcrecruiter_feed_options[strip_inline_styles]" value="1" <?php checked($strip_styles, '1'); ?> />
     1090            Remove inline styles from job descriptions
     1091        </label>
     1092        <p class="description">When enabled, removes style="" attributes from job content so jobs match your site's fonts and colors. Recommended for consistent styling.</p>
     1093    <?php
     1094    }
     1095
     1096
    3751097    public function create_admin_page()
    3761098    {
    3771099        $this->show_cron_status();
    378         // Set class property
    379         $this->options = get_option( 'pcr_feed_options' );
    380         ?>
     1100        $this->options = get_option('pcrecruiter_feed_options');
     1101        if (!is_array($this->options)) {
     1102            $this->options = [];
     1103        }
     1104    ?>
    3811105        <div class="wrap">
    3821106            <h1>PCRecruiter Extensions Settings</h1>
    3831107            <form method="post" onsubmit="return validatePCRfeed()" action="options.php">
    384             <?php
    385                 // This prints out all hidden setting fields
    386                 settings_fields( 'pcr_ext_option_group' );
    387                 do_settings_sections( 'pcr-ext-setting-admin' );
    388              ?>
    389 
    390                 <?php submit_button();?>
     1108                <?php
     1109                settings_fields('pcrecruiter_ext_option_group');
     1110                do_settings_sections('pcr-ext-setting-admin');
     1111                submit_button();
     1112                ?>
    3911113            </form>
    3921114        </div>
    3931115        <?php
    3941116    }
    395     /**
    396      * Gets the status of cron functionality on the site by performing a test spawn. Cached for one hour when all is well.
    397      *
    398      * @param bool $cache Whether to use the cached result from previous calls.
    399      * @return true|WP_Error Boolean true if the cron spawner is working as expected, or a WP_Error object if not.
    400      */
    401     public function test_cron_spawn( $cache = true ) {
     1117
     1118    public function test_cron_spawn($cache = true)
     1119    {
    4021120        global $wp_version;
    4031121
    404         if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
    405             /* translators: 1: The name of the PHP constant that is set. */
    406             return new WP_Error( 'cron_info', sprintf( __( 'The %s constant is set to true. WP-Cron spawning is disabled.', 'pcrecruiter-extensions' ), 'DISABLE_WP_CRON' ) );
    407         }
    408 
    409         if ( defined( 'ALTERNATE_WP_CRON' ) && ALTERNATE_WP_CRON ) {
    410             /* translators: 1: The name of the PHP constant that is set. */
    411             return new WP_Error( 'cron_info', sprintf( __( 'The %s constant is set to true.', 'pcrecruiter-extensions' ), 'ALTERNATE_WP_CRON' ) );
    412         }
    413 
    414         $cached_status = get_transient( 'wp-cron-test-ok' );
    415 
    416         if ( $cache && $cached_status ) {
     1122        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- WordPress core constant check
     1123        if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
     1124            return new WP_Error('cron_info', sprintf('The %s constant is set to true. WP-Cron spawning is disabled.', 'DISABLE_WP_CRON'));
     1125        }
     1126
     1127        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- WordPress core constant check
     1128        if (defined('ALTERNATE_WP_CRON') && ALTERNATE_WP_CRON) {
     1129            return new WP_Error('cron_info', sprintf('The %s constant is set to true. WP-Cron spawning is handled externally.', 'ALTERNATE_WP_CRON'));
     1130        }
     1131
     1132        $cached_status = get_transient('wp-cron-test-ok');
     1133
     1134        if ($cache && $cached_status) {
    4171135            return true;
    4181136        }
    4191137
    420         $sslverify     = version_compare( $wp_version, 4.0, '<' );
    421         $doing_wp_cron = sprintf( '%.22F', microtime( true ) );
    422 
    423         $cron_request = apply_filters( 'cron_request', array(
    424             'url'  => site_url( 'wp-cron.php?doing_wp_cron=' . $doing_wp_cron ),
    425             'key'  => $doing_wp_cron,
     1138        $sslverify = version_compare($wp_version, 4.0, '<');
     1139        $doing_wp_cron = sprintf('%.22F', microtime(true));
     1140
     1141        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Using WordPress core hook
     1142        $cron_request = apply_filters('cron_request', array(
     1143            'url' => site_url('wp-cron.php?doing_wp_cron=' . $doing_wp_cron),
     1144            'key' => $doing_wp_cron,
    4261145            'args' => array(
    427                 'timeout'   => 3,
    428                 'blocking'  => true,
    429                 'sslverify' => apply_filters( 'https_local_ssl_verify', $sslverify ),
     1146                'timeout' => 3,
     1147                'blocking' => true,
     1148                // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Using WordPress core hook
     1149                'sslverify' => apply_filters('https_local_ssl_verify', $sslverify),
    4301150            ),
    431         ) );
     1151        ));
    4321152
    4331153        $cron_request['args']['blocking'] = true;
    4341154
    435         $result = wp_remote_post( $cron_request['url'], $cron_request['args'] );
    436 
    437         if ( is_wp_error( $result ) ) {
     1155        $result = wp_remote_post($cron_request['url'], $cron_request['args']);
     1156
     1157        if (is_wp_error($result)) {
    4381158            return $result;
    439         } elseif ( wp_remote_retrieve_response_code( $result ) >= 300 ) {
    440             return new WP_Error( 'unexpected_http_response_code', sprintf(
    441                 /* translators: 1: The HTTP response code. */
    442                 __( 'Unexpected HTTP response code: %s', 'pcrecruiter-extensions' ),
    443                 intval( wp_remote_retrieve_response_code( $result ) )
    444             ) );
     1159        } elseif (wp_remote_retrieve_response_code($result) >= 300) {
     1160            return new WP_Error('unexpected_http_response_code', sprintf(
     1161                'Unexpected HTTP response code: %s',
     1162                intval(wp_remote_retrieve_response_code($result))
     1163            ));
    4451164        } else {
    446             set_transient( 'wp-cron-test-ok', 1, 3600 );
     1165            set_transient('wp-cron-test-ok', 1, 3600);
    4471166            return true;
    4481167        }
    449 
    450     }
    451     /**
    452      * Shows the status of cron functionality on the site. Only displays a message when there's a problem.
    453      */
    454     public function show_cron_status() {
    455 
     1168    }
     1169
     1170    public function show_cron_status()
     1171    {
    4561172        $status = $this->test_cron_spawn();
    4571173
    458         if ( is_wp_error( $status ) ) {
    459             if ( 'cron_info' === $status->get_error_code() ) {
    460                 ?>
     1174        if (is_wp_error($status)) {
     1175            if ('cron_info' === $status->get_error_code()) {
     1176        ?>
    4611177                <div id="cron-status-notice" class="notice notice-info">
    462                     <p><?php echo esc_html( $status->get_error_message() ); ?></p>
     1178                    <p><?php echo esc_html($status->get_error_message()); ?></p>
    4631179                </div>
    464                 <?php
     1180            <?php
    4651181            } else {
    466                 ?>
     1182            ?>
    4671183                <div id="cron-status-error" class="error">
    4681184                    <p>
    4691185                        <?php
    4701186                        printf(
    471                             /* translators: 1: Error message text. */
    472                             esc_html__( 'There was a problem with your cron configuration. The cron events on your site may not work. The problem was: %s', 'pcrecruiter-extensions' ),
    473                             '<br><strong>' . esc_html( $status->get_error_message() ) . '</strong>'
     1187                            'There was a problem with your cron configuration. The cron events on your site may not work. The problem was: %s',
     1188                            '<br><strong>' . esc_html($status->get_error_message()) . '</strong>'
    4741189                        );
    4751190                        ?>
    4761191                    </p>
    4771192                </div>
    478                 <?php
     1193        <?php
    4791194            }
    4801195        }
    4811196    }
    4821197
    483     /**
    484      * Register and add settings
    485      */
    486 
    487 
    488     /**
    489      * Sanitize each setting field as needed
    490      *
    491      * @param array $input Contains all settings fields as array keys
    492      */
    493     public function sanitize($input)
    494 {
    495     $new_input = array();
    496 
    497     // Activation should be a boolean
    498     if (isset($input['activation'])) {
    499         $new_input['activation'] = (bool)($input['activation']);
    500     }
    501 
    502     // Frequency should be one of the predefined options
    503     if (isset($input['frequency'])) {
    504         $allowed_frequencies = array('daily', 'hourly', 'twicedaily');
    505         $new_input['frequency'] = in_array($input['frequency'], $allowed_frequencies)
    506             ? $input['frequency']
    507             : 'daily'; // Default to daily if invalid
    508     }
    509 
    510     // ID number should be sanitized as text field
    511     if (isset($input['id_number'])) {
    512         $new_input['id_number'] = sanitize_text_field($input['id_number']);
    513     }
    514 
    515     // Standard fields should be sanitized as text
    516     if (isset($input['standard_fields'])) {
    517         $new_input['standard_fields'] = sanitize_text_field($input['standard_fields']);
    518     }
    519 
    520     // Custom fields should be sanitized as text
    521     if (isset($input['custom_fields'])) {
    522         $new_input['custom_fields'] = sanitize_text_field($input['custom_fields']);
    523     }
    524 
    525     if (isset($input['query'])) {
    526         // Expanded character set to handle all PCRecruiter query parameters safely
    527         $new_input['query'] = preg_replace('/[^\w\s%\(\)\=\-\'\"\.\_\+\!\*\,\;\:\[\]\{\}\<\>\?\/\&\|]+/', '', $input['query']);
    528     }
    529 
    530     // Mode should be either 'job' or 'apply'
    531     if (isset($input['mode'])) {
    532         $new_input['mode'] = in_array($input['mode'], array('job', 'apply'))
    533             ? $input['mode']
    534             : 'job'; // Default to job if invalid
    535     }
    536    
    537     // If activation is enabled, check the feed immediately
    538     if (isset($new_input['activation']) && $new_input['activation']) {
    539         // Schedule a one-time event to check the feed after settings are saved
    540         wp_schedule_single_event(time() + 2, 'pcr_check_feed_once');
    541     }
    542 
    543     return $new_input;
    544 }
    545 
    5461198    public function page_init()
    5471199    {
    548 
    5491200        register_setting(
    550             'pcr_ext_option_group', // Option group
    551             'pcr_feed_options', // Option name
    552             array( $this, 'sanitize' ) // Sanitize
    553         );
    554 
     1201            'pcrecruiter_ext_option_group',
     1202            'pcrecruiter_feed_options',
     1203            array($this, 'sanitize')
     1204        );
     1205
     1206        // Register separate SEO settings
     1207        register_setting(
     1208            'pcrecruiter_ext_option_group',
     1209            'pcrecruiter_seo_enable_overrides',
     1210            array(
     1211                'type' => 'boolean',
     1212                'default' => false,
     1213                'sanitize_callback' => array($this, 'sanitize_checkbox')
     1214            )
     1215        );
     1216        register_setting(
     1217            'pcrecruiter_ext_option_group',  // ← Match the form
     1218            'pcrecruiter_seo_use_pcr_logos',
     1219            array(
     1220                'type' => 'boolean',
     1221                'default' => false,
     1222                'sanitize_callback' => array($this, 'sanitize_checkbox')
     1223            )
     1224        );
    5551225        add_settings_section(
    556             'setting_section_id', // ID
    557             'PCRecruiter Feed Settings (Optional)', // Title
    558             array( $this, 'print_section_info' ), // Callback
    559             'pcr-ext-setting-admin' // Page
     1226            'jobboard_section_id',
     1227            'Job Board Sync Settings',
     1228            array($this, 'print_jobboard_section_info'),
     1229            'pcr-ext-setting-admin'
     1230        );
     1231
     1232        add_settings_field(
     1233            'jobboard_security_key',
     1234            'Job Board Sync Token',
     1235            array($this, 'jobboard_security_key_callback'),
     1236            'pcr-ext-setting-admin',
     1237            'jobboard_section_id'
     1238        );
     1239
     1240        add_settings_field(
     1241            'pagination_style',
     1242            'Pagination Style',
     1243            array($this, 'pagination_style_callback'),
     1244            'pcr-ext-setting-admin',
     1245            'jobboard_section_id'
     1246        );
     1247        add_settings_field(
     1248            'job_board_page',
     1249            'Primary Job Board Page',
     1250            array($this, 'job_board_page_callback'),
     1251            'pcr-ext-setting-admin',
     1252            'jobboard_section_id'
     1253        );
     1254        add_settings_field(
     1255            'job_404_page',
     1256            'Job Not Found Page',
     1257            array($this, 'job_404_page_callback'),
     1258            'pcr-ext-setting-admin',
     1259            'jobboard_section_id'
     1260        );
     1261        add_settings_field(
     1262            'expired_job_handling',
     1263            'Expired Job Handling',
     1264            array($this, 'expired_job_handling_callback'),
     1265            'pcr-ext-setting-admin',
     1266            'jobboard_section_id'
     1267        );
     1268
     1269        add_settings_field(
     1270            'job_title_format',
     1271            'Job Title &amp; Image Tags',
     1272            array($this, 'job_title_format_callback'),
     1273            'pcr-ext-setting-admin',
     1274            'jobboard_section_id'
     1275        );
     1276        add_settings_field(
     1277            'strip_inline_styles',
     1278            'Strip Inline Styles',
     1279            array($this, 'strip_inline_styles_callback'),
     1280            'pcr-ext-setting-admin',
     1281            'jobboard_section_id'
     1282        );
     1283        add_settings_section(
     1284            'setting_section_id',
     1285            'RSS Feed Settings (Optional)',
     1286            array($this, 'print_rss_section_info'),
     1287            'pcr-ext-setting-admin'
    5601288        );
    5611289
     
    5631291            'activation',
    5641292            'Job Feed Enabled',
    565             array( $this, 'activation_callback' ),
     1293            array($this, 'activation_callback'),
    5661294            'pcr-ext-setting-admin',
    5671295            'setting_section_id'
     
    5711299            'frequency',
    5721300            'Frequency of Update',
    573             array( $this, 'frequency_callback' ),
     1301            array($this, 'frequency_callback'),
    5741302            'pcr-ext-setting-admin',
    5751303            'setting_section_id'
     
    5771305
    5781306        add_settings_field(
    579             'id_number', // ID
    580             'PCR SessionID', // Title
    581             array( $this, 'id_number_callback' ), // Callback
    582             'pcr-ext-setting-admin', // Page
    583             'setting_section_id' // Section
     1307            'id_number',
     1308            'PCR SessionID',
     1309            array($this, 'id_number_callback'),
     1310            'pcr-ext-setting-admin',
     1311            'setting_section_id'
    5841312        );
    5851313
    5861314        add_settings_field(
    587             'standard_fields', // ID
    588             'Standard Fields', // Title
    589             array( $this, 'standard_fields_callback' ), // Callback
    590             'pcr-ext-setting-admin', // Page
    591             'setting_section_id' // Section
     1315            'standard_fields',
     1316            'Standard Fields',
     1317            array($this, 'standard_fields_callback'),
     1318            'pcr-ext-setting-admin',
     1319            'setting_section_id'
    5921320        );
    5931321
    5941322        add_settings_field(
    595             'custom_fields', // ID
    596             'Custom Fields', // Title
    597             array( $this, 'custom_fields_callback' ), // Callback
    598             'pcr-ext-setting-admin', // Page
    599             'setting_section_id' // Section
     1323            'custom_fields',
     1324            'Custom Fields',
     1325            array($this, 'custom_fields_callback'),
     1326            'pcr-ext-setting-admin',
     1327            'setting_section_id'
    6001328        );
    6011329
    6021330        add_settings_field(
    603             'query', // ID
    604             'Query', // Title
    605             array( $this, 'query_callback' ), // Callback
    606             'pcr-ext-setting-admin', // Page
    607             'setting_section_id' // Section
    608         );
    609 
     1331            'query',
     1332            'Query',
     1333            array($this, 'query_callback'),
     1334            'pcr-ext-setting-admin',
     1335            'setting_section_id'
     1336        );
    6101337        add_settings_field(
    611             'mode', // ID
    612             'Mode', // Title
    613             array( $this, 'mode_callback' ), // Callback
    614             'pcr-ext-setting-admin', // Page
    615             'setting_section_id' // Section
    616         );
    617     }
    618 
    619 
    620     /**
    621      * Print the Section text
    622      */
    623     public function print_section_info()
    624     {
    625         echo '<p style="font-size:1.25em;"><strong style="text-transform:uppercase">NOTE: These settings are NOT involved in typical PCR Job Board implementations.</strong></p><p style="font-size:1em;">When this optional function is enabled, the plugin will duplicate PCR\'s dynamic RSS feed as a static file at <a target="_blank" href="'. esc_url( site_url() ) .'/wp-content/uploads/pcrjobfeed.xml">'. esc_url( site_url() ) .'/wp-content/uploads/pcrjobfeed.xml</a>. You may use this data as a source for plugins and other third-party feed utilities.</p>';
    626          // Check to see if "Store Local Feed" is active. If it is, show the manual save button
    627                 if($this->options['activation'] ?? false){
    628                     $filename = 'pcrjobfeed.xml';
    629                     $fname = WP_CONTENT_DIR . "/uploads/".$filename;
    630                     if (file_exists($fname)) {
    631                         $d = gmdate("F d Y H:i:s", filectime($fname));
    632                         echo "<em style=\"font-weight:bold\">" . esc_html( $filename ) . " last updated: " . esc_html( $d ) . " (UTC).</em>";
    633                     } else {
    634                         echo "<i>File " . esc_html( $fname ) . " doesn't exist...</i>";
    635                     }
     1338            'mode',
     1339            'Mode',
     1340            array($this, 'mode_callback'),
     1341            'pcr-ext-setting-admin',
     1342            'setting_section_id'
     1343        );
     1344        add_settings_field(
     1345            'show_attribution',
     1346            'Attribution Link',
     1347            array($this, 'show_attribution_callback'),
     1348            'pcr-ext-setting-admin',
     1349            'jobboard_section_id'
     1350        );
     1351    }
     1352
     1353    public function sanitize($input)
     1354    {
     1355        $existing_options = get_option('pcrecruiter_feed_options', array());
     1356        $new_input = $existing_options;
     1357
     1358        if (isset($input['pagination_style'])) {
     1359            $new_input['pagination_style'] = sanitize_text_field($input['pagination_style']);
     1360            $new_input['show_job_count'] = isset($input['show_job_count']) ? '1' : '0';
     1361        }
     1362
     1363        if (isset($input['jobboard_security_key'])) {
     1364            $new_input['jobboard_security_key'] = sanitize_text_field($input['jobboard_security_key']);
     1365        }
     1366
     1367        if (isset($input['job_404_page'])) {
     1368            $new_input['job_404_page'] = sanitize_text_field($input['job_404_page']);
     1369        }
     1370        if (isset($input['expired_job_handling'])) {
     1371            $new_input['expired_job_handling'] = in_array($input['expired_job_handling'], array('delete', 'keep'))
     1372                ? $input['expired_job_handling']
     1373                : 'delete';
     1374        }
     1375        if (isset($input['strip_inline_styles'])) {
     1376            $new_input['strip_inline_styles'] = '1';
     1377        } else {
     1378            $new_input['strip_inline_styles'] = '0';
     1379        }
     1380        if (isset($input['job_title_fields'])) {
     1381            $allowed_fields = array('job_title', 'city', 'state', 'country', 'position_id', 'job_type', 'company');
     1382            $new_input['job_title_fields'] = array();
     1383
     1384            foreach ($allowed_fields as $field) {
     1385                $new_input['job_title_fields'][$field] = isset($input['job_title_fields'][$field]) ? '1' : '0';
     1386            }
     1387
     1388            // Job title is always required
     1389            $new_input['job_title_fields']['job_title'] = '1';
     1390        }
     1391
     1392        if (isset($input['frequency'])) {
     1393            $new_input['activation'] = isset($input['activation']) ? '1' : '';
     1394            $new_input['frequency'] = sanitize_text_field($input['frequency']);
     1395        }
     1396
     1397        if (isset($input['id_number'])) {
     1398            $new_input['id_number'] = $input['id_number'];
     1399        }
     1400
     1401        if (isset($input['standard_fields'])) {
     1402            $new_input['standard_fields'] = sanitize_text_field($input['standard_fields']);
     1403        }
     1404
     1405        if (isset($input['custom_fields'])) {
     1406            $new_input['custom_fields'] = sanitize_text_field($input['custom_fields']);
     1407        }
     1408
     1409        if (isset($input['query'])) {
     1410            $new_input['query'] = $input['query'];
     1411        }
     1412
     1413        if (isset($input['mode'])) {
     1414            $new_input['mode'] = $input['mode'];
     1415        }
     1416        if (isset($input['pagination_style'])) {
     1417            $new_input['pagination_style'] = sanitize_text_field($input['pagination_style']);
     1418            $new_input['show_job_count'] = isset($input['show_job_count']) ? '1' : '0';
     1419            $new_input['show_attribution'] = isset($input['show_attribution']) ? '1' : '0';
     1420        }
     1421        if (isset($input['job_board_page'])) {
     1422            $url = esc_url_raw($input['job_board_page']);
     1423            $new_input['job_board_page'] = $url;
     1424        }
     1425        // Handle pcrecruiter_seo_use_pcr_logos (separate option, not in feed_options array)
     1426        if (isset($_POST['pcrecruiter_seo_use_pcr_logos'])) {
     1427            update_option('pcrecruiter_seo_use_pcr_logos', '1');
     1428        } else {
     1429            delete_option('pcrecruiter_seo_use_pcr_logos'); // or update_option(..., '0')
     1430        }
     1431
     1432        // Handle pcrecruiter_seo_enable_overrides (separate option)
     1433        if (isset($_POST['pcrecruiter_seo_enable_overrides'])) {
     1434            update_option('pcrecruiter_seo_enable_overrides', true);
     1435        } else {
     1436            update_option('pcrecruiter_seo_enable_overrides', false);
     1437        }
     1438        return $new_input;
     1439    }
     1440
     1441    public function print_jobboard_section_info()
     1442    {
     1443        echo '<div class="pcr-section-primary">';
     1444        echo '<p>This sync token connects PCRecruiter directly to WordPress, creating a "Jobs" custom post type and actively syncing job data and settings from the PCR Job Board configuration to your WordPress database.</p>';
     1445        echo '<p><strong>This requires admin-level access to the PCR database System menu.</strong> Once configured, job postings will automatically sync to WordPress. For more information, please contact a PCR support representative.</p>';
     1446        echo '</div>';
     1447    }
     1448
     1449    public function sanitize_checkbox($input)
     1450    {
     1451        return $input ? true : false;
     1452    }
     1453
     1454    public function print_rss_section_info()
     1455    {
     1456        echo '<div class="pcr-section-secondary">';
     1457        echo '<p>When enabled, this feature will duplicate PCRecruiter\'s dynamic RSS feed as a static file at <a target="_blank" href="' . esc_attr(site_url()) . '/wp-content/uploads/pcrjobfeed.xml">' . esc_html(site_url()) . '/wp-content/uploads/pcrjobfeed.xml</a>. You may use this data as a source for plugins and other third-party feed utilities. <strong>The RSS function is NOT required for standard PCRecruiter Job Board use.</strong> Checking the "Job Feed Enabled" box below without proper values in the rest of this form may introduce errors into your website. Please <a target="_blank" href="https://help.pcrecruiter.com">contact PCRecruiter Support</a> for guidance if you wish to enable this feature.</p>';
     1458
     1459        if ($this->options['activation'] ?? false) {
     1460            $filename = 'pcrjobfeed.xml';
     1461            $fname = WP_CONTENT_DIR . "/uploads/" . $filename;
     1462            if (file_exists($fname)) {
     1463                $mtime = filemtime($fname);
     1464                echo "<p><em style=\"font-weight:bold\">" . esc_html($filename) . " last updated: " . esc_html(gmdate("F d Y H:i:s", $mtime)) . " (UTC).</em></p>";
     1465            } else {
     1466                echo "<p><em>File " . esc_html($fname) . " doesn't exist...</em></p>";
     1467            }
     1468        }
     1469        echo '</div>';
     1470    }
     1471
     1472    public function activation_callback()
     1473    {
     1474        $manual_update_url = wp_nonce_url(
     1475            admin_url('admin-post.php?action=pcrecruiter_manual_feed_update'),
     1476            'pcrecruiter_manual_feed_update'
     1477        );
     1478
     1479        printf(
     1480            '<label><input id="activation" name="pcrecruiter_feed_options[activation]" type="checkbox" %s /> Enable scheduled job feed</label><br><br>',
     1481            checked(!empty($this->options['activation']), true, false)
     1482        );
     1483
     1484        echo '<a class="button" href="' . esc_url($manual_update_url) . '">Update Feed Now</a>';
     1485        echo '<p class="description">Runs a one-time feed download immediately.</p>';
     1486    }
     1487
     1488
     1489    public function frequency_callback()
     1490    {
     1491        $freq = $this->options['frequency'] ?? '';   // default empty
     1492        $items = ['daily', 'hourly', 'twicedaily'];
     1493
     1494        echo "<select id='frequency' name='pcrecruiter_feed_options[frequency]'>";
     1495        foreach ($items as $item) {
     1496            $selected = ($freq === $item) ? 'selected="selected"' : '';
     1497            echo "<option value='" . esc_attr($item) . "' " . esc_attr($selected) . ">" . esc_html($item) . "</option>";
     1498        }
     1499        echo "</select>";
     1500    }
     1501
     1502    public function id_number_callback()
     1503    {
     1504        printf(
     1505            '<input type="text" id="id_number" name="pcrecruiter_feed_options[id_number]" value="%s" size="60" />',
     1506            isset($this->options['id_number']) ? esc_attr($this->options['id_number']) : ''
     1507        );
     1508    }
     1509
     1510    public function standard_fields_callback()
     1511    {
     1512        printf(
     1513            '<input type="text" id="standard_fields" name="pcrecruiter_feed_options[standard_fields]" value="%s" size="60" /><br /><span style="font-size:.8em;">Comma separated.</span>',
     1514            isset($this->options['standard_fields']) ? esc_attr($this->options['standard_fields']) : ''
     1515        );
     1516    }
     1517
     1518    public function custom_fields_callback()
     1519    {
     1520        printf(
     1521            '<input type="text" id="custom_fields" name="pcrecruiter_feed_options[custom_fields]" value="%s" size="60" /><br /><span style="font-size:.8em;">Comma separated.</span>',
     1522            isset($this->options['custom_fields']) ? esc_attr($this->options['custom_fields']) : ''
     1523        );
     1524    }
     1525
     1526    public function query_callback()
     1527    {
     1528        printf(
     1529            '<input type="text" id="query" name="pcrecruiter_feed_options[query]" value="%s" rows="4" size="60" />',
     1530            isset($this->options['query']) ? esc_attr($this->options['query']) : ''
     1531        );
     1532    }
     1533    public function mode_callback()
     1534    {
     1535        $mode = $this->options['mode'] ?? 'job';  // default to job
     1536        $check1 = $mode === 'job' ? 'checked' : '';
     1537        $check2 = $mode === 'apply' ? 'checked' : '';
     1538
     1539        printf('<input type="radio" id="job" name="pcrecruiter_feed_options[mode]" value="job" %s /> Job Link<br />', esc_attr($check1));
     1540        printf('<input type="radio" id="apply" name="pcrecruiter_feed_options[mode]" value="apply" %s /> Apply Link<br />', esc_attr($check2));
     1541    }
     1542
     1543    public function pagination_style_callback()
     1544    {
     1545        $options = get_option('pcrecruiter_feed_options');
     1546        $style = isset($options['pagination_style']) ? $options['pagination_style'] : 'paged';
     1547        $show_count = isset($options['show_job_count']) ? $options['show_job_count'] : '1';
     1548        ?>
     1549        <select id="pagination_style" name="pcrecruiter_feed_options[pagination_style]">
     1550            <option value="paged" <?php selected($style, 'paged'); ?>>Paged (Show Page Numbers)</option>
     1551            <option value="simple" <?php selected($style, 'simple'); ?>>Simple (Next/Previous Only)</option>
     1552        </select>
     1553        <p class="description">Choose how pagination is displayed in job listings.</p>
     1554
     1555        <br><br>
     1556        <label>
     1557            <input type="checkbox" id="show_job_count" name="pcrecruiter_feed_options[show_job_count]" value="1" <?php checked($show_count, '1'); ?> />
     1558            Show job count (e.g., "Showing 1-25 of 150 jobs")
     1559        </label>
     1560        <p class="description">Display the result count above pagination controls.</p>
     1561    <?php
     1562    }
     1563    public function show_attribution_callback()
     1564    {
     1565        $options = get_option('pcrecruiter_feed_options');
     1566        $show_attribution = isset($options['show_attribution']) ? $options['show_attribution'] : '1';
     1567    ?>
     1568        <label>
     1569            <input type="checkbox" id="show_attribution" name="pcrecruiter_feed_options[show_attribution]" value="1" <?php checked($show_attribution, '1'); ?> />
     1570            PCRecruiter Attribution
     1571        </label>
     1572        <p class="description">Give PCRecruiter some search engine love by displaying a small attribution link below job listings. Thank you!</p>
     1573    <?php
     1574    }
     1575    public function job_board_page_callback()
     1576    {
     1577        $options = get_option('pcrecruiter_feed_options');
     1578        $job_board_page = isset($options['job_board_page']) ? esc_attr($options['job_board_page']) : '';
     1579    ?>
     1580        <input type="text" id="job_board_page" name="pcrecruiter_feed_options[job_board_page]" value="<?php echo esc_attr($job_board_page); ?>" size="60" placeholder="Ex: <?php echo esc_attr(home_url('/careers/')); ?>" />
     1581        <p class="description">Enter the full URL of your primary job board page (where the <code>[PCRecruiter link="jobmanager"]</code> shortcode is). Visitors attempting to load the /job/ directory will be redirected to this URL.</p>
     1582    <?php
     1583    }
     1584
     1585    public function jobboard_security_key_callback()
     1586    {
     1587        $options = get_option('pcrecruiter_feed_options');
     1588        $key = isset($options['jobboard_security_key']) ? esc_textarea($options['jobboard_security_key']) : '';
     1589    ?>
     1590        <textarea placeholder="Click 'Generate Key' and 'Save Changes' to create a new key" id="jobboard_security_key" name="pcrecruiter_feed_options[jobboard_security_key]" rows="5" cols="80"><?php echo esc_html($key); ?></textarea>
     1591        <br>
     1592        <button type="button" class="button" onclick="generateJobboardKey()">Generate Key</button>
     1593
     1594        <script type="text/javascript">
     1595            function generateJobboardKey() {
     1596                const keyLength = 256;
     1597                const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
     1598                const array = new Uint32Array(keyLength);
     1599                window.crypto.getRandomValues(array);
     1600
     1601                let result = '';
     1602                for (let i = 0; i < keyLength; i++) {
     1603                    result += chars.charAt(array[i] % chars.length);
    6361604                }
    637     }
    638 
    639     /**
    640      * Get the settings option array and print one of its values
    641      */
    642 
    643     public function activation_callback()
    644     {
    645         printf(
    646             '<input id="activation" name="pcr_feed_options[activation]" type="checkbox" %2$s />',
    647             'activation',
    648             checked( isset( $this->options['activation'] ), true, false )
    649         );
    650     }
    651 
    652     public function frequency_callback()
    653     {
    654         $options = $this->options['frequency'];
    655         $items = array("daily", "hourly", "twicedaily");
    656         echo "<select id='frequency' name='pcr_feed_options[frequency]'>";
    657         foreach($items as $item) {
    658             $selected = ($this->options['frequency'] == $item) ? 'selected="selected"' : '';
    659             echo "<option value='" . esc_attr( $item ) . "' " . esc_attr( $selected ) . ">" . esc_html( $item ) . "</option>";
    660         }
    661         echo "</select>";
    662     }
    663 
    664     public function id_number_callback()
    665     {
    666         printf(
    667             '<input type="text" id="id_number" name="pcr_feed_options[id_number]" value="%s" size="60" placeholder="Please contact PCR support for your unique session-id value." />',
    668             isset( $this->options['id_number'] ) ? esc_attr( $this->options['id_number']) : ''
    669         );
    670     }
    671 
    672     public function standard_fields_callback()
    673     {
    674         printf(
    675             '<input type="text" id="standard_fields" name="pcr_feed_options[standard_fields]" value="%s" size="60" /><br /><span style="font-size:.8em;">Comma separated.</span>',
    676             isset( $this->options['standard_fields'] ) ? esc_attr( $this->options['standard_fields']) : ''
    677         );
    678     }
    679 
    680     public function custom_fields_callback()
    681     {
    682 
    683         printf(
    684             '<input type="text" id="custom_fields" name="pcr_feed_options[custom_fields]" value="%s" size="60" /><br /><span style="font-size:.8em;">Comma separated. Be sure to replace any %%20 characters with spaces.</span>',
    685             isset( $this->options['custom_fields'] ) ? esc_attr( $this->options['custom_fields']) : ''
    686         );
    687     }
    688 
    689     public function query_callback()
    690     {
    691         printf(
    692             '<input type="text" id="query" name="pcr_feed_options[query]" value="%s" rows="4" size="60" />',
    693             isset( $this->options['query'] ) ? esc_attr( $this->options['query']) : ''
    694         );
    695     }
    696 
    697     public function mode_callback()
    698     {
    699         if($this->options['mode'] == "job"){
    700             $check1 = "checked";
    701             $check2 = "";
    702         } else if($this->options['mode'] == "apply"){
    703             $check2 = "checked";
    704             $check1 = "";
    705         } else {
    706             $check1 = "checked";
    707             $check2 = "";
    708         }
    709     printf('<input type="radio" id="%s" name="pcr_feed_options[mode]" value="job" %s /> %s<br />',
    710         esc_attr('job'),
    711         esc_attr($check1),
    712         esc_html('Job Link')
     1605
     1606                document.getElementById('jobboard_security_key').value = result;
     1607            }
     1608        </script>
     1609<?php
     1610    }
     1611}
     1612
     1613
     1614function pcrecruiter_do_after_update($old, $new)
     1615{
     1616    $pcrecruiter_feed_options = get_option('pcrecruiter_feed_options', array());
     1617    $current_frequency = $pcrecruiter_feed_options['frequency'];
     1618    $activated = $pcrecruiter_feed_options['activation'] ?? '';
     1619    wp_clear_scheduled_hook('pcrecruiter_feed');
     1620
     1621    if ($activated) {
     1622        wp_schedule_event(time(), $current_frequency, 'pcrecruiter_feed');
     1623        pcrecruiter_feed_func();
     1624    } else {
     1625        $feedlinkxml = WP_CONTENT_DIR . "/uploads/pcrjobfeed.xml";
     1626        $feedlinkjson = WP_CONTENT_DIR . "/uploads/pcrjobfeed.json";
     1627
     1628        if (file_exists($feedlinkxml)) {
     1629            wp_delete_file($feedlinkxml);
     1630        }
     1631
     1632        if (file_exists($feedlinkjson)) {
     1633            wp_delete_file($feedlinkjson);
     1634        }
     1635    }
     1636}
     1637add_action('update_option_pcrecruiter_feed_options', 'pcrecruiter_do_after_update', 10, 2);
     1638
     1639function pcrecruiter_migrate_legacy_options()
     1640{
     1641    $legacy  = get_option('pcr_feed_options', null);
     1642    $current = get_option('pcrecruiter_feed_options', null);
     1643
     1644    // Migrate whenever the legacy option exists and the new one is unset
     1645    if (!is_null($legacy) && is_null($current)) {
     1646        update_option('pcrecruiter_feed_options', $legacy);
     1647        delete_option('pcr_feed_options'); // optional
     1648    }
     1649}
     1650add_action('admin_init', 'pcrecruiter_migrate_legacy_options');
     1651
     1652function pcrecruiter_manual_feed_update_handler()
     1653{
     1654    if (! current_user_can('manage_options')) {
     1655        wp_die('You do not have permission to do this.', '', array('response' => 403));
     1656    }
     1657    check_admin_referer('pcrecruiter_manual_feed_update');
     1658
     1659    // Ensure defaults exist, then run the feed once
     1660    pcrecruiter_checkop();
     1661    $error = null;
     1662    $ok = pcrecruiter_feed_func(true, $error);
     1663
     1664    $redirect = wp_get_referer() ?: admin_url('options-general.php?page=pcr-ext-setting-admin');
     1665    $redirect = add_query_arg('pcr_feed_updated', $ok ? '1' : '0', $redirect);
     1666    if (! $ok && $error) {
     1667        $redirect = add_query_arg('pcr_feed_error', rawurlencode($error), $redirect);
     1668    }
     1669    wp_safe_redirect($redirect);
     1670    exit;
     1671}
     1672add_action('admin_post_pcrecruiter_manual_feed_update', 'pcrecruiter_manual_feed_update_handler');
     1673
     1674function pcrecruiter_manual_feed_update_notice()
     1675{
     1676    $updated_flag = filter_input(INPUT_GET, 'pcr_feed_updated', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
     1677
     1678    if ($updated_flag === '1') {
     1679        echo '<div class="notice notice-success is-dismissible"><p>Job feed updated successfully.</p></div>';
     1680    } elseif ($updated_flag === '0') {
     1681        $msg = 'Feed update failed.';
     1682        $raw_error = filter_input(INPUT_GET, 'pcr_feed_error', FILTER_UNSAFE_RAW);
     1683        if ($raw_error !== null) {
     1684            $decoded = rawurldecode(wp_unslash($raw_error));
     1685            $sanitized = sanitize_text_field($decoded);
     1686            $msg = mb_substr($sanitized, 0, 300);
     1687        }
     1688        echo '<div class="notice notice-error is-dismissible"><p>' . esc_html($msg) . '</p></div>';
     1689    }
     1690}
     1691add_action('admin_notices', 'pcrecruiter_manual_feed_update_notice');
     1692
     1693if (is_admin()) {
     1694    $pcrecruiter_settings_page = new PCRecruiter_Extensions_Settings();
     1695}
     1696
     1697function pcrecruiter_settings_scripts()
     1698{
     1699    wp_enqueue_media();
     1700
     1701    wp_register_script(
     1702        'pcr-settings-scripts',
     1703        plugin_dir_url(__FILE__) . 'assets/js/pcr-admin.js',
     1704        array('jquery'),
     1705        '2.0.0',
     1706        true
    7131707    );
    714     printf('<input type="radio" id="%s" name="pcr_feed_options[mode]" value="apply" %s /> %s<br />',
    715         esc_attr('apply'),
    716         esc_attr($check2),
    717         esc_html('Apply Link')
     1708    wp_enqueue_script('pcr-settings-scripts');
     1709
     1710    wp_enqueue_style(
     1711        'pcr-settings-style',
     1712        plugin_dir_url(__FILE__) . 'assets/css/pcr-settings.css',
     1713        array(),
     1714        '1.0.0'
    7181715    );
    719     }
    720 }
    721 
    722 
    723 
    724 /* Begin update cron schedule if frequency changes */
    725 
    726 function do_after_update($old, $new) {
    727     // Do the stuff here
    728     $pcr_feed_options = get_option('pcr_feed_options', array());
    729     $current_frequency = $pcr_feed_options['frequency'];
    730     $activated = $pcr_feed_options['activation'] ?? '';
    731     wp_clear_scheduled_hook('pcr_feed');
    732     if($activated){
    733         wp_schedule_event( time(), $current_frequency, 'pcr_feed' );
    734         pcr_feed_func();
    735     } else {
    736         $feedlinkxml = WP_CONTENT_DIR . "/uploads/pcrjobfeed.xml";
    737         $feedlinkjson = WP_CONTENT_DIR . "/uploads/pcrjobfeed.json";
    738         if (file_exists($feedlinkxml)) {
    739         unlink(WP_CONTENT_DIR . "/uploads/pcrjobfeed.xml");
    740         } else {}
    741         if (file_exists($feedlinkjson)) {
    742         unlink(WP_CONTENT_DIR . "/uploads/pcrjobfeed.json");
    743         } else {}
    744     }
    745 }
    746 add_action('update_option_pcr_feed_options','do_after_update', 10, 2);
    747 /* End update cron schedule if frequency changes */
    748 
    749 
    750 if( is_admin() )
    751     $my_settings_page = new PcrSettingsPage();
    752 
    753 // JS for Admin Panel
    754 function pcr_settings_scripts(){
    755     wp_enqueue_media();
    756     wp_register_script('pcr-settings-scripts',plugin_dir_url( __DIR__ ) .'pcrecruiter-extensions/assets/js/pcr-admin.js', array('jquery'), '1.0.0', true);
    757     wp_enqueue_script('pcr-settings-scripts');
    758 }
    759 add_action( 'admin_enqueue_scripts', 'pcr_settings_scripts' );
     1716}
     1717add_action('admin_enqueue_scripts', 'pcrecruiter_settings_scripts');
  • pcrecruiter-extensions/trunk/assets/icon.svg

    r1173648 r3420599  
    1 <?xml version="1.0" encoding="utf-8"?>
    2 <!-- Generator: Adobe Illustrator 15.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
    3 <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
    4 <svg version="1.1" id="Layer_6" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
    5      width="95px" height="95px" viewBox="0 0 95 95" enable-background="new 0 0 95 95" xml:space="preserve">
    6 <g>
    7     <polygon fill="#19254C" points="6.712,71.048 47.5,94.598 47.5,47.499    "/>
    8     <polygon fill="#041726" points="47.5,0.402 47.5,47.5 88.288,23.951  "/>
    9     <polygon fill="#19488F" points="6.712,71.048 47.5,47.499 6.712,23.951   "/>
    10     <polygon fill="#1A623C" points="88.288,71.049 47.5,47.5 88.288,23.951   "/>
    11     <polygon fill="#73C3C8" points="47.5,0.402 6.713,23.951 47.5,47.5   "/>
    12     <polygon fill="#3B9C37" points="47.5,94.598 88.288,71.048 47.5,47.499   "/>
    13 </g>
     1<?xml version="1.0" encoding="UTF-8"?>
     2<svg id="a" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 640 640">
     3  <!-- Generator: Adobe Illustrator 30.0.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 123)  -->
     4  <defs>
     5    <style>
     6      .st0 {
     7        fill: #a460da;
     8      }
     9
     10      .st1 {
     11        fill: #21a777;
     12      }
     13
     14      .st2 {
     15        fill: #308add;
     16      }
     17
     18      .st3 {
     19        fill: #d9beef;
     20      }
     21
     22      .st4 {
     23        fill: #3660cd;
     24      }
     25
     26      .st5 {
     27        fill: #79d2b2;
     28      }
     29    </style>
     30  </defs>
     31  <polygon class="st2" points="320 640 597.1 480 320 320 320 640"/>
     32  <polygon class="st3" points="320 0 42.9 160 320 320 320 0"/>
     33  <polygon class="st1" points="597.1 480 320 320 597.1 160 597.1 480"/>
     34  <polygon class="st0" points="42.9 480 320 320 42.9 160 42.9 480"/>
     35  <polygon class="st5" points="320 0 320 320 597.1 160 320 0"/>
     36  <polygon class="st4" points="42.9 480 320 640 320 320 42.9 480"/>
    1437</svg>
  • pcrecruiter-extensions/trunk/assets/js/pcr-admin.js

    r2206149 r3420599  
    44        if(id_length < 40 && jQuery('#activation').prop('checked')){
    55            jQuery('#id_number').css('border-color','red');
    6             jQuery('#activation').prop('checked',false)
     6            jQuery('#activation').prop('checked',false);
    77            return false;
    88        } else {
  • pcrecruiter-extensions/trunk/readme.txt

    r3400600 r3420599  
    11=== PCRecruiter Extensions ===
    2 Contributors: arothman, mstdev
    3 Tags: Recruiting, Staffing, Applicant Tracking
    4 Requires at least: 3.0
    5 Tested up to: 6.8
    6 Stable tag: 1.4.37
    7 License: GPLv2 or later
    8 License URI: https://www.gnu.org/licenses/gpl-2.0.html
    9 Text Domain: pcrecruiter-extensions
     2Contributors: Main Sequence Technology, Inc.
     3Tags: recruiting, staffing, applicant tracking, job board, job posting
     4Requires at least: 5.6
     5Tested up to: 6.9
     6Requires PHP: 7.4
     7Stable tag: 2.0
     8License: GPLv3 or later
     9License URI: https://www.gnu.org/licenses/gpl-3.0.html
    1010
    11 PCR Job Board iframe embed, PCR Custom Form scripts, and PCR RSS download handler. Note: This plugin does not interact with the WordPress database.
     11Integrates your WordPress site with PCRecruiter (PCR) to embed job boards or sync live job postings as native WordPress content.
    1212
     13== Description ==
    1314
    14 == Job Board Installation ==
     15PCRecruiter Extensions provides two primary integration methods. The setup is typically done with the direct involvement of a PCR consultant; it is advised that you contact [email protected] before proceeding.
    1516
    16 IMPORTANT!!! You do NOT need to touch the SETTINGS panel for standard PCRecruiter Job Board installations.
     17**1. Full WordPress Job Sync**
     18Sync active jobs from PCRecruiter into WordPress as native custom post types (job).
     19Includes keyword search, radius search, structured data (JobPosting), social sharing, expired-job handling, and support for WordPress native styling and control.
    1720
    18 1.  Click 'Add New' from the 'Plugins' menu in your WordPress admin panel.
    19 2.  Use the 'Upload Plugin' option and browse to the PCRecruiter-Extensions.zip file.
    20 3.  Activate the plugin.
    21 4.  Edit the page where you want to display the PCRecruiter content and paste in the following:
     21Additional Features:
     22* Block bindings for WordPress 6.5+
     23* SEO-friendly URLs and optional job title customization
     24* Smart caching for improved performance
     25* Internal-only job board support
     26* Optional inline-style stripping for consistent site design
     27* Accessibility-friendly pagination
     28* Backward compatibility with legacy PCRecruiter usage
    2229
    23     [PCRecruiter link=""]
     30**2. Simple iFrame Embed**
     31Embed the hosted PCRecruiter job board using a simple shortcode. Ideal for quick and simple installs or sites that prefer PCR-rendered pages, or sites where SEO and link friendliness are of less importance.
    2432
    25     Place the shortcode content provided to you by Main Sequence between the quotes. If the URL does not begin with 'http', the plugin will assume you intend to load a URL from the PCRecruiter ASP hosting system and will prepend the correct URL.
     33Also: PCRecruiter job XML/JSON feed downloading for integrations or imports.
    2634
    27 Optional parameters:
     35Full documentation:
     36<a href="https://learning.pcrecruiter.net/site/docs/wordpress/">https://learning.pcrecruiter.net/site/docs/wordpress/</a>
    2837
    29 *   initialheight="" (defaults to 640 pixels if omitted)
    30 *   background="" (defaults to transparent if omitted)
    31 *   form="" (insert the 15-digit ID of a custom form)
     38Support:
     39[email protected]
    3240
    33 == XML Feed Setup ==
     41== Installation ==
    3442
    35 From the Settings > PCRecruiter Extensions panel, you may configure the plugin to duplicate a dynamic RSS job feed from PCRecruiter to a static copy on your WordPress server. This may be advantageous for running RSS-based display widgets, feed-based distribution services, or other functions that require a static XML/RSS feed.
     431. Upload the plugin ZIP via Plugins → Add New → Upload Plugin, or install from WordPress.org
     442. Activate the plugin.
    3645
    37 IMPORTANT!!! You do NOT need to touch the Settings panel to use this plugin for standard job board setups. Enabling the feed without correct settings values may cause errors or break your website. Please contact a Main Sequence Technology support representative for the settings required for use of this function.
     46== Quick Start ==
    3847
    39 * Job Feed Enabled: Checking this box activates the feed. Unchecking will deactivate and delete the XML file.
     48Iframe Job Board:
    4049
    41 * Frequency of Update: The feed can be set to refresh daily, hourly, or twice daily.
     50No plugin settings are required for iframe installations! Just insert the shortcode into your jobs page:
    4251
    43 * PCR SessionID: This encoded string identifies which PCRecruiter database to load the content from. Your support contact will provide the appropriate value for this field.
     52[PCRecruiter link="yourdatabase.yourprofile"]
    4453
    45 * Standard Fields: The feed will contain the job link, date entered, title, and description. To include additional fields, enter them in comma-separated form in this box. The list of values that are accepted can be found in the API documentation at https://www.pcrecruiter.net/apidocs_v2/#!/positions/GetPosition_get_0.
     54Contact your support representative for your shortcode.
    4655
    47 * Custom Fields: Custom fields can be included in comma-separated form as well.
     56Full WordPress Job Sync:
     571. Create a page and add:
     58   [PCRecruiter link="jobmanager"]
     592. Go to Settings → PCRecruiter Extensions → Job Board Sync.
     603. Generate a Sync Token and enter it into the PCR “WordPress Sync Settings” panel.
    4861
    49 * Query: By default, the feed will contain jobs that are set to Available/Open status, have the Show On Web field set to "Show", and have a Number of Openings that is 1 or greater. Contact Main Sequence Technology for assistance if alternate queries are needed.
     62== Features ==
    5063
    51 * Mode: This setting dictates whether the job links in the feed point to the job details page or to the apply screen for the job. The 'apply' mode would be useful in scenarios where the candidate will have already seen the description and should be directed straight to the first step of self-entry.
     64* Full job sync via secure token authentication
     65* Jobs stored as native custom posts (job)
     66* Keyword and radius search
     67* Structured data (schema.org/JobPosting)
     68* Internal-only job view support
     69* Social sharing widget
     70* Accessible pagination
     71* Expired-job handling (delete or keep visible)
     72* Customizable SEO job title formats
     73* Optional inline-style removal
     74* Automatic legacy URL redirection (?recordid= to clean URLs)
     75* Yoast SEO schema suppression to prevent duplication
     76* Block bindings for dynamic Site Editor templates
     77* Static XML/JSON feed mirroring (optional)
     78* Cron-based automatic feed updates
     79* Deactivation cleanup with keep/delete options
     80* Secure coding practices: sanitization, escaping, nonces, prepared queries
     81
     82== Example Shortcodes ==
     83
     84Iframe Job Board:
     85[PCRecruiter link="my%20data.mycompany"]
     86
     87Full Sync Job Board:
     88[PCRecruiter link="jobmanager"]
     89
     90Optional filtering:
     91[PCRecruiter link="jobmanager" jobcategory="Engineering"]
     92
     93Internal Job Board:
     94[PCRecruiter link="internaljobmanager"]
     95
     96== Frequently Asked Questions ==
     97
     98= Which method should I use: sync or iframe? =
     99Use full sync for SEO-friendly, WordPress-native jobs, structured data, and customization. This method requires advanced skills and should be done with the help of an experienced WordPress developer and/or PCRecruiter support.
     100Use the simple iframe method for quick installation and no-code rendering.
     101
     102= Do I need to edit any settings when using the iframe job board mode? =
     103No. The iframe mode works with only the shortcode. This method doesn't interact with the WordPress database or software.
     104
     105= Can I run multiple full-sync job boards on different pages? =
     106Yes. Use the jobcategory parameter to filter the listings by "job_category" Position custom field.
     107
     108= Can I combine multiple databases into a single job board? =
     109Yes, when using the full-sync multiple databases can be fed to the same WordPress site. With the simple iframe method, the search/display can only show one database's jobs at a time.
     110
     111= How are expired jobs handled? =
     112Typically, jobs that are no longer open are deleted automatically. You may choose to keep expired jobs visible with the full-sync mode - these will display a “Filled” badge and will have no apply button.
     113
     114= Can I customize how job titles display in browser tabs? =
     115Yes, with the full-sync you may configure this in Settings → PCRecruiter Extensions → Job Title Format.
     116
     117= Does the plugin work with Google Jobs? =
     118Yes. The plugin supports the required Schema for Google's jobs results. It will suppress Yoast Schema automatically if this feature is configured.
     119
     120= Where can I find the full setup guide? =
     121All documentation is online at:
     122<a href="https://learning.pcrecruiter.net/site/docs/wordpress/">https://learning.pcrecruiter.net/site/docs/wordpress/</a>
     123
     124== Changelog ==
     125
     126= 2.0.0 - 2024-12-10 =
     127* Initial public release of full sync mode
     128* Added block bindings support
     129* Added pagination and job count options
     130* Added schema utility
     131* Added expired job handling
     132* Added social sharing widget
     133* Added customizable title formats
     134* Added legacy URL redirect support
     135* Added feed update button and improved feed options
     136* Added deactivation cleanup wizard
     137* Security and sanitization improvements
     138* Caching improvements and timezone fixes
     139
     140= 1.x =
     141* Simple iframe and RSS/JSON feed functionality
Note: See TracChangeset for help on using the changeset viewer.