CountyCollective Reference
Beacon GIS Boundary Import
Original Beacon GIS layer, WKT conversion, generated file, composite, and manifest logic.
<?php
/**
* County GOP Core — GIS Beacon boundary import and GeoJSON generator.
*
* Extracted from theme/cgop-theme/inc/gis-boundary-import.php in Pass 08.
* Regenerates local GeoJSON files from configured Beacon vector layers. This is
* an admin-only import workflow; public pages consume generated static files.
*
* Python WKT converter moved from theme/tools/gis/ to plugin/tools/gis/ per
* Pass 08 decision P08-001-A.
*
* Option keys, function names, admin slugs, and action names are frozen legacy
* identifiers — do not rename without explicit owner approval.
* GIS-001 county option scoping is deferred; options remain site-global.
*
* @package CountyGOPCore
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'CGOP_GIS_IMPORT_CAP', 'manage_options' );
define( 'CGOP_GIS_IMPORT_SETTINGS_OPTION', 'CGOP_gis_boundary_import_settings' );
define( 'CGOP_GIS_IMPORT_STATUS_OPTION', 'CGOP_gis_boundary_import_status' );
define( 'CGOP_GIS_IMPORT_CUSTOM_LAYERS_OPTION', 'CGOP_gis_boundary_import_custom_layers' );
define( 'CGOP_GIS_COMPOSITE_MAPS_OPTION', 'CGOP_gis_composite_maps' );
define( 'CGOP_GIS_DIRECT_IMPORTS_OPTION', 'CGOP_gis_direct_geojson_imports' );
/**
* Return the configured Beacon layer registry.
*
* @return array[]
*/
function CGOP_gis_get_layer_registry() {
return CGOP_gis_get_custom_layer_registry();
}
/**
* Return administrator-added Beacon layers.
*
* @return array[]
*/
function CGOP_gis_get_custom_layer_registry() {
$custom = get_option( CGOP_GIS_IMPORT_CUSTOM_LAYERS_OPTION, array() );
return is_array( $custom ) ? $custom : array();
}
/**
* Generate a unique internal Beacon layer ID.
*
* @return string
*/
function CGOP_gis_generate_layer_id() {
$existing = CGOP_gis_get_custom_layer_registry();
for ( $i = 0; $i < 20; $i++ ) {
$id = wp_generate_uuid4();
if ( ! isset( $existing[ $id ] ) ) {
return $id;
}
}
return 'layer-' . wp_generate_uuid4();
}
/**
* Normalize a Beacon layer configuration.
*
* @param array $raw Raw layer data.
* @return array
*/
function CGOP_gis_normalize_layer_config( array $raw ) {
$label = sanitize_text_field( $raw['label'] ?? '' );
$id = ! empty( $raw['id'] ) ? sanitize_text_field( $raw['id'] ) : CGOP_gis_generate_layer_id();
$output_dir = ! empty( $raw['output_dir'] ) ? sanitize_title( $raw['output_dir'] ) : sanitize_title( $label );
if ( ! $output_dir ) {
$output_dir = sanitize_title( $id );
}
return array(
'id' => $id,
'label' => $label,
'beacon_layer_id' => absint( $raw['beacon_layer_id'] ?? 0 ),
'position_type' => sanitize_key( $raw['position_type'] ?? '' ),
'level' => sanitize_key( $raw['level'] ?? '' ),
'output_dir' => $output_dir,
'preferred_fields' => CGOP_gis_normalize_preferred_fields( $raw['preferred_fields'] ?? '' ),
'position_name_template' => sanitize_text_field( $raw['position_name_template'] ?? '{label}' ),
'position_id_template' => sanitize_text_field( $raw['position_id_template'] ?? $id . '-{label_slug}' ),
);
}
/**
* Normalize preferred field names.
*
* @param string|array $fields Field names.
* @return string[]
*/
function CGOP_gis_normalize_preferred_fields( $fields ) {
if ( is_array( $fields ) ) {
$parts = $fields;
} else {
$parts = preg_split( '/[\r\n,]+/', (string) $fields );
}
$clean = array();
foreach ( $parts as $field ) {
$field = trim( sanitize_text_field( $field ) );
if ( '' !== $field ) {
$clean[] = $field;
}
}
return $clean ? array_values( array_unique( $clean ) ) : array( 'DisplayKey' );
}
/**
* Return temporary Beacon request values for the current admin user.
*
* @return array
*/
function CGOP_gis_get_request_memory() {
$memory = get_transient( 'CGOP_gis_request_memory_' . get_current_user_id() );
if ( ! is_array( $memory ) ) {
return array(
'qps' => '',
'cookie' => '',
'user_agent' => CGOP_gis_get_default_user_agent(),
'overrides' => '',
);
}
return wp_parse_args(
$memory,
array(
'qps' => '',
'cookie' => '',
'user_agent' => CGOP_gis_get_default_user_agent(),
'overrides' => '',
)
);
}
/**
* Store temporary Beacon request values for the current admin user.
*
* @param string $qps Beacon QPS value.
* @param string $cookie Beacon Cookie header.
* @param string $user_agent Browser User-Agent.
* @param string $overrides Feature field override lines.
* @return void
*/
function CGOP_gis_set_request_memory( $qps, $cookie, $user_agent, $overrides = '' ) {
set_transient(
'CGOP_gis_request_memory_' . get_current_user_id(),
array(
'qps' => sanitize_text_field( $qps ),
'cookie' => CGOP_gis_normalize_cookie_header( $cookie ),
'user_agent' => sanitize_text_field( $user_agent ),
'overrides' => sanitize_textarea_field( $overrides ),
),
2 * HOUR_IN_SECONDS
);
}
/**
* Return generated GeoJSON file choices from the last import status.
*
* @return array[]
*/
function CGOP_gis_get_generated_file_choices() {
$status = get_option( CGOP_GIS_IMPORT_STATUS_OPTION, array() );
$choices = array();
if ( ! empty( $status['layers'] ) && is_array( $status['layers'] ) ) {
foreach ( $status['layers'] as $layer ) {
if ( empty( $layer['files'] ) || ! is_array( $layer['files'] ) ) {
continue;
}
foreach ( $layer['files'] as $file ) {
if ( empty( $file['file'] ) ) {
continue;
}
$relative = ltrim( (string) $file['file'], '/' );
$choices[ $relative ] = array(
'file' => $relative,
'url' => CGOP_gis_get_generated_file_url( $relative ),
'label' => $file['label'] ?? $file['position_id'] ?? basename( $relative ),
'position_id' => $file['position_id'] ?? '',
'layer_label' => $layer['label'] ?? '',
);
}
}
}
foreach ( CGOP_gis_get_composite_maps() as $composite ) {
if ( empty( $composite['output_file'] ) || empty( $composite['id'] ) ) {
continue;
}
$relative = ltrim( (string) $composite['output_file'], '/' );
$choice_key = 'composite:' . sanitize_text_field( (string) $composite['id'] );
$choices[ $choice_key ] = array(
'file' => $relative,
'url' => CGOP_gis_get_generated_file_url( $relative ),
'label' => $composite['label'] ?? basename( $relative ),
'position_id' => '',
'layer_label' => __( 'Composites', 'county-gop-core' ),
'is_composite' => true,
'composite_id' => (string) $composite['id'],
);
}
foreach ( CGOP_gis_get_direct_geojson_imports() as $import ) {
if ( empty( $import['output_file'] ) || empty( $import['id'] ) ) {
continue;
}
$relative = ltrim( (string) $import['output_file'], '/' );
$choice_key = 'direct:' . sanitize_text_field( (string) $import['id'] );
$choices[ $choice_key ] = array(
'file' => $relative,
'url' => CGOP_gis_get_generated_file_url( $relative ),
'label' => $import['label'] ?? basename( $relative ),
'position_id' => '',
'layer_label' => __( 'Uploaded GeoJSON', 'county-gop-core' ),
'is_direct' => true,
'direct_id' => (string) $import['id'],
);
}
uasort(
$choices,
function ( $a, $b ) {
return strnatcasecmp( ( $a['layer_label'] . ' ' . $a['label'] ), ( $b['layer_label'] . ' ' . $b['label'] ) );
}
);
return apply_filters( 'CGOP_gis_generated_file_choices', $choices );
}
/**
* Return composite map definitions.
*
* @return array[]
*/
function CGOP_gis_get_composite_maps() {
$composites = get_option( CGOP_GIS_COMPOSITE_MAPS_OPTION, array() );
return is_array( $composites ) ? $composites : array();
}
/**
* Return direct uploaded GeoJSON import definitions.
*
* @return array[]
*/
function CGOP_gis_get_direct_geojson_imports() {
$imports = get_option( CGOP_GIS_DIRECT_IMPORTS_OPTION, array() );
return is_array( $imports ) ? $imports : array();
}
/**
* Generate a unique ID for a direct uploaded GeoJSON import.
*
* @return string
*/
function CGOP_gis_generate_direct_import_id() {
$existing = CGOP_gis_get_direct_geojson_imports();
for ( $i = 0; $i < 20; $i++ ) {
$id = wp_generate_uuid4();
if ( ! isset( $existing[ $id ] ) ) {
return $id;
}
}
return 'direct-' . wp_generate_uuid4();
}
/**
* Generate a unique ID for a new composite map.
*
* @return string
*/
function CGOP_gis_generate_composite_id() {
$existing = CGOP_gis_get_composite_maps();
for ( $i = 0; $i < 20; $i++ ) {
$id = wp_generate_uuid4();
if ( ! isset( $existing[ $id ] ) ) {
return $id;
}
}
return 'composite-' . wp_generate_uuid4();
}
/**
* Return an unused composite output slug.
*
* Composite dropdown values are now keyed by UUID, but the generated files
* still need unique paths so one composite cannot overwrite another.
*
* @param string $slug Desired slug.
* @param string $composite_id Composite ID being created or updated.
* @param array[] $composites Existing composite definitions.
* @return string
*/
function CGOP_gis_get_unique_composite_slug( $slug, $composite_id, $composites ) {
$base = CGOP_gis_slug( $slug );
if ( '' === $base ) {
$base = 'composite';
}
$used = array();
foreach ( $composites as $existing_id => $composite ) {
if ( (string) $existing_id === (string) $composite_id ) {
continue;
}
if ( empty( $composite['output_file'] ) ) {
continue;
}
$used[ ltrim( (string) $composite['output_file'], '/' ) ] = true;
}
$candidate = $base;
$suffix = substr( preg_replace( '/[^a-zA-Z0-9]/', '', (string) $composite_id ), 0, 8 );
if ( '' === $suffix ) {
$suffix = (string) time();
}
for ( $i = 0; $i < 100; $i++ ) {
$output_rel = 'composites/' . $candidate . '.geojson';
if ( ! isset( $used[ $output_rel ] ) ) {
return $candidate;
}
$candidate = 0 === $i ? $base . '-' . strtolower( $suffix ) : $base . '-' . strtolower( $suffix ) . '-' . ( $i + 1 );
}
return $base . '-' . strtolower( $suffix ) . '-' . time();
}
/**
* Return whether another composite still references an output file.
*
* @param string $output_file Relative output file path.
* @param array[] $composites Composite definitions.
* @param string $exclude_id Composite ID to ignore.
* @return bool
*/
function CGOP_gis_composite_output_is_shared( $output_file, $composites, $exclude_id = '' ) {
$output_file = ltrim( (string) $output_file, '/' );
if ( '' === $output_file ) {
return false;
}
foreach ( $composites as $composite_id => $composite ) {
if ( '' !== $exclude_id && (string) $composite_id === (string) $exclude_id ) {
continue;
}
if ( ltrim( (string) ( $composite['output_file'] ?? '' ), '/' ) === $output_file ) {
return true;
}
}
return false;
}
/**
* Clear Position Key map assignments that point at a deleted composite.
*
* @param string $composite_id Deleted composite ID.
* @param string $output_file Deleted composite output file.
* @param bool $clear_url_refs Whether URL references to the output file are safe to clear.
* @return int Number of map boundary rows deleted.
*/
function CGOP_gis_clear_deleted_composite_assignments( $composite_id, $output_file, $clear_url_refs ) {
global $wpdb;
$refs = array( 'composite:' . (string) $composite_id );
if ( $clear_url_refs && $output_file ) {
$refs[] = CGOP_gis_get_generated_file_url( $output_file );
}
$refs = array_values( array_unique( array_filter( array_map( 'strval', $refs ) ) ) );
if ( ! $refs ) {
return 0;
}
$map_table = CGOP_map_boundaries_table();
$position_table = CGOP_position_keys_table();
$deleted = 0;
// County scope for position_keys updates.
$pk_scope = function_exists( 'cgop_get_table_county_scope' ) ? cgop_get_table_county_scope( $position_table ) : false;
if ( null === $pk_scope ) {
return 0; // Column exists but no county context — skip to avoid cross-county writes.
}
foreach ( $refs as $ref ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$map_ids = $wpdb->get_col(
$wpdb->prepare( "SELECT map_id FROM `{$map_table}` WHERE json_file = %s", $ref )
);
foreach ( $map_ids as $map_id ) {
if ( false !== $pk_scope ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update( $position_table, array( 'live_in_map' => '' ), array( 'live_in_map' => $map_id, 'county_id' => $pk_scope ), array( '%s' ), array( '%s', '%s' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update( $position_table, array( 'voting_area_map' => '' ), array( 'voting_area_map' => $map_id, 'county_id' => $pk_scope ), array( '%s' ), array( '%s', '%s' ) );
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update( $position_table, array( 'live_in_map' => '' ), array( 'live_in_map' => $map_id ), array( '%s' ), array( '%s' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update( $position_table, array( 'voting_area_map' => '' ), array( 'voting_area_map' => $map_id ), array( '%s' ), array( '%s' ) );
}
if ( CGOP_delete_map_boundary( $map_id ) ) {
$deleted++;
}
}
}
CGOP_position_keys_clear_cache();
return $deleted;
}
/**
* Return whether a GeoJSON geometry can be rendered as a boundary.
*
* @param mixed $geometry GeoJSON geometry array.
* @return bool
*/
function CGOP_gis_is_boundary_geometry( $geometry ) {
if ( ! is_array( $geometry ) || empty( $geometry['type'] ) || empty( $geometry['coordinates'] ) ) {
return false;
}
return in_array( $geometry['type'], array( 'Polygon', 'MultiPolygon' ), true );
}
/**
* Return generated file choices that may serve as composite source files.
*
* Returns only Beacon-layer files (not composites themselves) to keep the
* source selection straightforward.
*
* @return array[]
*/
function CGOP_gis_get_composite_source_choices() {
$status = get_option( CGOP_GIS_IMPORT_STATUS_OPTION, array() );
$choices = array();
if ( empty( $status['layers'] ) || ! is_array( $status['layers'] ) ) {
return $choices;
}
foreach ( $status['layers'] as $layer ) {
if ( empty( $layer['files'] ) || ! is_array( $layer['files'] ) ) {
continue;
}
foreach ( $layer['files'] as $file ) {
if ( empty( $file['file'] ) ) {
continue;
}
$relative = ltrim( (string) $file['file'], '/' );
$choices[ $relative ] = array(
'file' => $relative,
'label' => $file['label'] ?? $file['position_id'] ?? basename( $relative ),
'position_id' => $file['position_id'] ?? '',
'layer_label' => $layer['label'] ?? '',
);
}
}
uasort(
$choices,
function ( $a, $b ) {
return strnatcasecmp( ( $a['layer_label'] . ' ' . $a['label'] ), ( $b['layer_label'] . ' ' . $b['label'] ) );
}
);
return $choices;
}
/**
* Validate a single composite source file path.
*
* @param string $relative Relative path inside the generated directory.
* @return WP_Error|null Null on pass; WP_Error on failure.
*/
function CGOP_gis_validate_composite_source_file( $relative ) {
if ( '' === $relative || str_contains( $relative, '..' ) ) {
return new WP_Error( 'composite_invalid_path', __( 'Source file path is invalid.', 'county-gop-core' ) );
}
if ( ! preg_match( '/\.(geojson|json)$/i', $relative ) ) {
return new WP_Error( 'composite_invalid_ext', __( 'Source files must have a .geojson or .json extension.', 'county-gop-core' ) );
}
if ( 'manifest.json' === basename( $relative ) ) {
return new WP_Error( 'composite_manifest', __( 'manifest.json cannot be used as a source file.', 'county-gop-core' ) );
}
$base = wp_normalize_path( trailingslashit( CGOP_gis_get_generated_dir() ) );
$file_path = wp_normalize_path( $base . $relative );
if ( ! str_starts_with( $file_path, $base ) ) {
return new WP_Error( 'composite_outside_dir', __( 'Source file is outside the generated maps directory.', 'county-gop-core' ) );
}
return null;
}
/**
* Build composite GeoJSON from a set of source files.
*
* Supports FeatureCollection, Feature, Polygon, and MultiPolygon source shapes.
* Adds composite metadata to each output feature without destroying source properties.
*
* @param string $composite_id Composite ID.
* @param string $label Human-readable composite label.
* @param string[] $source_files Relative source file paths.
* @return array|WP_Error GeoJSON FeatureCollection array on success.
*/
function CGOP_gis_build_composite_geojson( $composite_id, $label, $source_files ) {
$base = wp_normalize_path( trailingslashit( CGOP_gis_get_generated_dir() ) );
$features = array();
foreach ( $source_files as $relative ) {
$relative = ltrim( (string) $relative, '/' );
$file_path = wp_normalize_path( $base . $relative );
if ( ! str_starts_with( $file_path, $base ) ) {
return new WP_Error( 'composite_outside_dir', sprintf(
/* translators: %s: relative file path. */
__( 'Source file is outside the generated maps directory: %s', 'county-gop-core' ),
$relative
) );
}
if ( ! file_exists( $file_path ) ) {
return new WP_Error( 'composite_source_missing', sprintf(
/* translators: %s: relative file path. */
__( 'Source file not found: %s', 'county-gop-core' ),
$relative
) );
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$contents = file_get_contents( $file_path );
if ( false === $contents ) {
return new WP_Error( 'composite_read_failed', sprintf(
/* translators: %s: relative file path. */
__( 'Could not read source file: %s', 'county-gop-core' ),
$relative
) );
}
$geojson = json_decode( $contents, true );
if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $geojson ) ) {
return new WP_Error( 'composite_invalid_json', sprintf(
/* translators: %s: relative file path. */
__( 'Source file is not valid JSON: %s', 'county-gop-core' ),
$relative
) );
}
if ( ! isset( $geojson['type'] ) ) {
return new WP_Error( 'composite_no_type', sprintf(
/* translators: %s: relative file path. */
__( 'Source file has no GeoJSON type: %s', 'county-gop-core' ),
$relative
) );
}
$meta = array(
'composite_id' => $composite_id,
'composite_label' => $label,
'composite_source_file' => $relative,
);
$source_features = array();
$type = $geojson['type'];
if ( 'FeatureCollection' === $type ) {
if ( ! is_array( $geojson['features'] ?? null ) ) {
continue;
}
$source_features = $geojson['features'];
} elseif ( 'Feature' === $type ) {
$source_features = array( $geojson );
} elseif ( 'Polygon' === $type || 'MultiPolygon' === $type ) {
$source_features = array(
array( 'type' => 'Feature', 'properties' => array(), 'geometry' => $geojson ),
);
} else {
return new WP_Error( 'composite_unsupported_type', sprintf(
/* translators: 1: GeoJSON type. 2: file path. */
__( 'Unsupported GeoJSON type %1$s in source file: %2$s', 'county-gop-core' ),
$type,
$relative
) );
}
foreach ( $source_features as $feature ) {
if ( ! is_array( $feature ) ) {
continue;
}
$geometry = $feature['geometry'] ?? null;
if ( ! CGOP_gis_is_boundary_geometry( $geometry ) ) {
continue;
}
$props = is_array( $feature['properties'] ?? null ) ? $feature['properties'] : array();
$props = array_merge( $props, $meta );
$features[] = array(
'type' => 'Feature',
'properties' => $props,
'geometry' => $geometry,
);
}
}
if ( ! $features ) {
return new WP_Error( 'composite_no_features', __( 'Composite map has no valid Polygon or MultiPolygon boundary features.', 'county-gop-core' ) );
}
return array(
'type' => 'FeatureCollection',
'properties' => array(
'composite_id' => $composite_id,
'label' => $label,
'source_files' => array_values( $source_files ),
),
'features' => $features,
);
}
/**
* Build, write, and register a composite GeoJSON file.
*
* @param string $composite_id Composite ID (new or existing).
* @param string $label Human-readable label.
* @param string $slug Output filename slug (no extension).
* @param string[] $source_files Relative source file paths.
* @param array[]|null $existing_composites Current composites array, or null to read from option.
* @return true|WP_Error
*/
function CGOP_gis_save_composite( $composite_id, $label, $slug, $source_files, $existing_composites = null ) {
$output_rel = 'composites/' . $slug . '.geojson';
$generated = CGOP_gis_get_generated_dir();
$composites_dir = trailingslashit( $generated ) . 'composites';
if ( ! wp_mkdir_p( $composites_dir ) ) {
return new WP_Error( 'composite_mkdir_failed', __( 'Could not create the composites output directory.', 'county-gop-core' ) );
}
$geojson = CGOP_gis_build_composite_geojson( $composite_id, $label, $source_files );
if ( is_wp_error( $geojson ) ) {
return $geojson;
}
$file_path = trailingslashit( $generated ) . $output_rel;
$json = wp_json_encode( $geojson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
if ( ! $json || false === file_put_contents( $file_path, $json ) ) {
return new WP_Error( 'composite_write_failed', sprintf(
/* translators: %s: composite output file path. */
__( 'Could not write composite GeoJSON file: %s', 'county-gop-core' ),
$output_rel
) );
}
if ( null === $existing_composites ) {
$existing_composites = CGOP_gis_get_composite_maps();
}
$now = current_time( 'mysql' );
if ( isset( $existing_composites[ $composite_id ] ) ) {
$existing_composites[ $composite_id ]['label'] = $label;
$existing_composites[ $composite_id ]['output_file'] = $output_rel;
$existing_composites[ $composite_id ]['source_files'] = $source_files;
$existing_composites[ $composite_id ]['updated_at'] = $now;
} else {
$existing_composites[ $composite_id ] = array(
'id' => $composite_id,
'label' => $label,
'output_file' => $output_rel,
'source_files' => $source_files,
'created_at' => $now,
'updated_at' => $now,
);
}
update_option( CGOP_GIS_COMPOSITE_MAPS_OPTION, $existing_composites, false );
$status = get_option( CGOP_GIS_IMPORT_STATUS_OPTION, array() );
$manifest_result = CGOP_gis_write_manifest( is_array( $status ) ? $status : array() );
if ( is_wp_error( $manifest_result ) ) {
return $manifest_result;
}
return true;
}
/**
* Default Beacon request settings.
*
* @return array
*/
function CGOP_gis_get_default_settings() {
return array(
'beacon_endpoint' => 'https://beacon.schneidercorp.com/api/beaconCore/GetVectorLayer',
'minx' => 480000,
'miny' => 2190000,
'maxx' => 570000,
'maxy' => 2295000,
'feature_limit' => 1000,
);
}
/**
* Return a default browser User-Agent for Beacon AJAX requests.
*
* @return string
*/
function CGOP_gis_get_default_user_agent() {
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
}
/**
* Get saved settings merged with defaults.
*
* @return array
*/
function CGOP_gis_get_settings() {
$saved = get_option( CGOP_GIS_IMPORT_SETTINGS_OPTION, array() );
return wp_parse_args( is_array( $saved ) ? $saved : array(), CGOP_gis_get_default_settings() );
}
/**
* Register admin page under GOP Setup menu.
*/
function CGOP_gis_register_admin_page() {
add_submenu_page(
CGOP_SETUP_MENU_SLUG,
__( 'GIS Boundary Import', 'county-gop-core' ),
__( 'GIS Boundary Import', 'county-gop-core' ),
CGOP_GIS_IMPORT_CAP,
'cgop-gis-boundary-import',
'CGOP_gis_render_admin_page'
);
}
add_action( 'admin_menu', 'CGOP_gis_register_admin_page' );
/**
* Render admin page.
*/
function CGOP_gis_render_admin_page() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'You do not have permission to manage GIS boundary imports.', 'county-gop-core' ) );
}
$settings = CGOP_gis_get_settings();
$layers = CGOP_gis_get_layer_registry();
$status = get_option( CGOP_GIS_IMPORT_STATUS_OPTION, array() );
$manifest_url = CGOP_gis_get_generated_file_url( 'manifest.json' );
$preview_payload = CGOP_gis_get_label_preview_payload();
$preview_form = ! empty( $preview_payload['form'] ) && is_array( $preview_payload['form'] ) ? $preview_payload['form'] : array();
$request_memory = CGOP_gis_get_request_memory();
$direct_imports = CGOP_gis_get_direct_geojson_imports();
$composites = CGOP_gis_get_composite_maps();
$source_choices = CGOP_gis_get_composite_source_choices();
?>
<div class="wrap cgop-gis-admin">
<h1><?php esc_html_e( 'GIS Boundary Import / GeoJSON Generator', 'county-gop-core' ); ?></h1>
<?php CGOP_gis_render_admin_styles(); ?>
<?php CGOP_gis_render_notices(); ?>
<p class="cgop-gis-intro"><?php esc_html_e( 'Regenerate local GeoJSON files from configured Beacon GIS layers, combine generated files into composites, and prepare map boundaries for Position Keys. This is an admin-only workflow; public pages should use generated static files and should not call Beacon directly.', 'county-gop-core' ); ?></p>
<section class="cgop-gis-panel cgop-gis-panel--request">
<div class="cgop-gis-panel__header">
<div>
<h2><?php esc_html_e( 'Beacon Request Settings', 'county-gop-core' ); ?></h2>
<p><?php esc_html_e( 'Temporary request values used when regenerating Beacon layers. QPS, Cookie, User-Agent, and field overrides are remembered briefly for your admin user only.', 'county-gop-core' ); ?></p>
</div>
</div>
<form id="cgop-gis-regenerate-form" method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'CGOP_gis_regenerate' ); ?>
<input type="hidden" name="action" value="CGOP_gis_regenerate">
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="cgop-gis-qps"><?php esc_html_e( 'Beacon QPS value', 'county-gop-core' ); ?></label></th>
<td>
<input class="regular-text" id="cgop-gis-qps" name="qps" type="text" value="<?php echo esc_attr( $request_memory['qps'] ); ?>" autocomplete="off" required>
<p class="description"><?php esc_html_e( 'Paste the current QPS value from the Beacon URL or Network request. It is remembered for your admin user for about two hours so you can regenerate multiple layers.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="cgop-gis-cookie"><?php esc_html_e( 'Beacon Cookie header', 'county-gop-core' ); ?></label></th>
<td>
<textarea class="large-text code" id="cgop-gis-cookie" name="beacon_cookie" rows="4" autocomplete="off" spellcheck="false"><?php echo esc_textarea( $request_memory['cookie'] ); ?></textarea>
<p class="description"><?php esc_html_e( 'Optional but often required. From DevTools, copy the Cookie header or the value after curl -b. It is remembered for your admin user for about two hours and is not saved to layer definitions.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="cgop-gis-user-agent"><?php esc_html_e( 'Browser User-Agent', 'county-gop-core' ); ?></label></th>
<td>
<input class="large-text" id="cgop-gis-user-agent" name="beacon_user_agent" type="text" value="<?php echo esc_attr( $request_memory['user_agent'] ); ?>" autocomplete="off">
<p class="description"><?php esc_html_e( 'Optional browser User-Agent to send with the Beacon request. The default mimics a normal desktop browser.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="cgop-gis-endpoint"><?php esc_html_e( 'Beacon endpoint', 'county-gop-core' ); ?></label></th>
<td>
<input class="large-text" id="cgop-gis-endpoint" name="beacon_endpoint" type="url" value="<?php echo esc_attr( $settings['beacon_endpoint'] ); ?>" required>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'County extent', 'county-gop-core' ); ?></th>
<td>
<label><?php esc_html_e( 'Min X', 'county-gop-core' ); ?> <input name="minx" type="number" step="1" value="<?php echo esc_attr( $settings['minx'] ); ?>"></label>
<label><?php esc_html_e( 'Min Y', 'county-gop-core' ); ?> <input name="miny" type="number" step="1" value="<?php echo esc_attr( $settings['miny'] ); ?>"></label>
<label><?php esc_html_e( 'Max X', 'county-gop-core' ); ?> <input name="maxx" type="number" step="1" value="<?php echo esc_attr( $settings['maxx'] ); ?>"></label>
<label><?php esc_html_e( 'Max Y', 'county-gop-core' ); ?> <input name="maxy" type="number" step="1" value="<?php echo esc_attr( $settings['maxy'] ); ?>"></label>
</td>
</tr>
<tr>
<th scope="row"><label for="cgop-gis-feature-limit"><?php esc_html_e( 'Feature limit', 'county-gop-core' ); ?></label></th>
<td><input id="cgop-gis-feature-limit" name="feature_limit" type="number" min="1" max="5000" value="<?php echo esc_attr( $settings['feature_limit'] ); ?>"></td>
</tr>
<tr>
<th scope="row"><label for="cgop-gis-field-overrides"><?php esc_html_e( 'Feature field overrides', 'county-gop-core' ); ?></label></th>
<td>
<textarea class="large-text code" id="cgop-gis-field-overrides" name="field_overrides" rows="4" autocomplete="off" spellcheck="false"><?php echo esc_textarea( $request_memory['overrides'] ); ?></textarea>
<p class="description"><?php esc_html_e( 'Optional. One override per line, matched by Beacon Key or DisplayKey. Example: 5: Community=Auburn, Municipal District=5. These values are remembered for your admin user for about two hours and are not saved to layer definitions.', 'county-gop-core' ); ?></p>
</td>
</tr>
</table>
</form>
</section>
<section class="cgop-gis-panel">
<div class="cgop-gis-panel__header">
<div>
<h2><?php esc_html_e( 'Known Beacon Layers', 'county-gop-core' ); ?></h2>
<p><?php esc_html_e( 'Saved Beacon layer definitions. Use the request settings above, then regenerate one layer or all layers from this table.', 'county-gop-core' ); ?></p>
</div>
</div>
<?php if ( $layers ) : ?>
<table class="widefat striped cgop-gis-table">
<thead>
<tr>
<th><?php esc_html_e( 'Layer', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Beacon ID', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Output', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Last Run', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Features', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Action', 'county-gop-core' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $layers as $layer ) : ?>
<?php $layer_status = $status['layers'][ $layer['id'] ] ?? array(); ?>
<tr>
<td><strong><?php echo esc_html( $layer['label'] ); ?></strong></td>
<td><?php echo esc_html( (string) $layer['beacon_layer_id'] ); ?></td>
<td><code><?php echo esc_html( $layer['output_dir'] ); ?></code></td>
<td><?php echo ! empty( $layer_status['generated_at'] ) ? esc_html( $layer_status['generated_at'] ) : esc_html__( 'Never', 'county-gop-core' ); ?></td>
<td><?php echo isset( $layer_status['feature_count'] ) ? esc_html( number_format_i18n( (int) $layer_status['feature_count'] ) ) : '0'; ?></td>
<td>
<div class="cgop-gis-actions">
<button class="button" type="submit" form="cgop-gis-regenerate-form" name="layer_id" value="<?php echo esc_attr( $layer['id'] ); ?>">
<?php esc_html_e( 'Regenerate', 'county-gop-core' ); ?>
</button>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="CGOP_gis_delete_custom_layer">
<input type="hidden" name="layer_id" value="<?php echo esc_attr( $layer['id'] ); ?>">
<?php wp_nonce_field( 'CGOP_gis_delete_custom_layer_' . $layer['id'] ); ?>
<button type="submit" class="button button-link-delete" onclick="return confirm('<?php echo esc_attr__( 'Delete this Beacon layer definition? Generated files already created for it will remain until deleted separately.', 'county-gop-core' ); ?>');">
<?php esc_html_e( 'Delete layer', 'county-gop-core' ); ?>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="cgop-gis-actions">
<button class="button button-primary" type="submit" form="cgop-gis-regenerate-form" name="layer_id" value="all">
<?php esc_html_e( 'Regenerate all layers', 'county-gop-core' ); ?>
</button>
</p>
<?php else : ?>
<p><?php esc_html_e( 'No Beacon layers are configured yet. Add a known Beacon layer below before regenerating maps.', 'county-gop-core' ); ?></p>
<?php endif; ?>
</section>
<section class="cgop-gis-panel">
<div class="cgop-gis-panel__header">
<div>
<h2><?php esc_html_e( 'Generated Files', 'county-gop-core' ); ?></h2>
<p><?php esc_html_e( 'Local GeoJSON files produced by Beacon regeneration. These files can be assigned directly to positions or used as composite map sources.', 'county-gop-core' ); ?></p>
</div>
<a class="button" href="<?php echo esc_url( $manifest_url ); ?>" target="_blank" rel="noopener">
<?php esc_html_e( 'Open manifest.json', 'county-gop-core' ); ?>
</a>
</div>
<?php CGOP_gis_render_generated_files( $status ); ?>
</section>
<?php CGOP_gis_render_direct_geojson_imports_section( $direct_imports ); ?>
<?php CGOP_gis_render_composite_maps_section( $composites, $source_choices ); ?>
<section class="cgop-gis-panel">
<div class="cgop-gis-panel__header">
<div>
<h2><?php esc_html_e( 'Add Known Beacon Layer', 'county-gop-core' ); ?></h2>
<p><?php esc_html_e( 'Add a Beacon layer definition when you know the Beacon layer ID. The internal layer ID is generated automatically, and saved layers appear in the Known Beacon Layers table above.', 'county-gop-core' ); ?></p>
</div>
</div>
<?php CGOP_gis_render_label_preview( $preview_payload ); ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'CGOP_gis_add_custom_layer' ); ?>
<input type="hidden" name="action" value="CGOP_gis_add_custom_layer">
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="custom_layer_label"><?php esc_html_e( 'Layer label', 'county-gop-core' ); ?></label></th>
<td><input class="regular-text" id="custom_layer_label" name="label" type="text" value="<?php echo esc_attr( $preview_form['label'] ?? '' ); ?>" required></td>
</tr>
<tr>
<th scope="row"><label for="custom_layer_beacon_id"><?php esc_html_e( 'Beacon layer ID', 'county-gop-core' ); ?></label></th>
<td><input id="custom_layer_beacon_id" name="beacon_layer_id" type="number" min="1" value="<?php echo esc_attr( $preview_form['beacon_layer_id'] ?? '' ); ?>" required></td>
</tr>
<tr>
<th scope="row"><label for="custom_layer_output_dir"><?php esc_html_e( 'Output directory', 'county-gop-core' ); ?></label></th>
<td>
<input class="regular-text" id="custom_layer_output_dir" name="output_dir" type="text" value="<?php echo esc_attr( $preview_form['output_dir'] ?? '' ); ?>">
<p class="description"><?php esc_html_e( 'Optional. Leave blank to generate from the layer label. The internal layer ID is generated automatically.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="custom_layer_position_type"><?php esc_html_e( 'Position type', 'county-gop-core' ); ?></label></th>
<td><input class="regular-text" id="custom_layer_position_type" name="position_type" type="text" value="<?php echo esc_attr( $preview_form['position_type'] ?? '' ); ?>" placeholder="county_commissioner"></td>
</tr>
<tr>
<th scope="row"><label for="custom_layer_level"><?php esc_html_e( 'Level', 'county-gop-core' ); ?></label></th>
<td><input class="regular-text" id="custom_layer_level" name="level" type="text" value="<?php echo esc_attr( $preview_form['level'] ?? '' ); ?>" placeholder="county"></td>
</tr>
<tr>
<th scope="row"><label for="custom_layer_preview_qps"><?php esc_html_e( 'Preview QPS value', 'county-gop-core' ); ?></label></th>
<td>
<input class="regular-text" id="custom_layer_preview_qps" name="preview_qps" type="text" value="<?php echo esc_attr( $preview_form['preview_qps'] ?? $request_memory['qps'] ); ?>" autocomplete="off">
<p class="description"><?php esc_html_e( 'Used when fetching available label fields. It is remembered for your admin user for about two hours and is not saved with the layer.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="custom_layer_preview_cookie"><?php esc_html_e( 'Preview Cookie header', 'county-gop-core' ); ?></label></th>
<td>
<textarea class="large-text code" id="custom_layer_preview_cookie" name="preview_cookie" rows="3" autocomplete="off" spellcheck="false"><?php echo esc_textarea( $preview_form['preview_cookie'] ?? $request_memory['cookie'] ); ?></textarea>
<p class="description"><?php esc_html_e( 'Optional but often needed for Beacon 403 responses. It is remembered for your admin user for about two hours and is not saved with the layer.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="custom_layer_preview_user_agent"><?php esc_html_e( 'Preview User-Agent', 'county-gop-core' ); ?></label></th>
<td>
<input class="large-text" id="custom_layer_preview_user_agent" name="preview_user_agent" type="text" value="<?php echo esc_attr( $preview_form['preview_user_agent'] ?? $request_memory['user_agent'] ); ?>" autocomplete="off">
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Fetch label fields', 'county-gop-core' ); ?></th>
<td>
<button class="button" type="submit" name="CGOP_gis_preview_label_fields" value="1">
<?php esc_html_e( 'Fetch Available Label Fields', 'county-gop-core' ); ?>
</button>
<p class="description"><?php esc_html_e( 'Fetches up to 3 features from this Beacon layer, shows available field names and sample values, and preserves the form without saving the layer.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="custom_layer_fields"><?php esc_html_e( 'Preferred label fields', 'county-gop-core' ); ?></label></th>
<td>
<input class="large-text" id="custom_layer_fields" name="preferred_fields" type="text" value="<?php echo esc_attr( $preview_form['preferred_fields'] ?? 'DisplayKey' ); ?>">
<p class="description"><?php esc_html_e( 'Comma-separated Beacon field names to combine for feature labels. Example: Community, Municipal District.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="custom_layer_name_template"><?php esc_html_e( 'Position name template', 'county-gop-core' ); ?></label></th>
<td><input class="large-text" id="custom_layer_name_template" name="position_name_template" type="text" value="<?php echo esc_attr( $preview_form['position_name_template'] ?? '{label}' ); ?>"></td>
</tr>
<tr>
<th scope="row"><label for="custom_layer_id_template"><?php esc_html_e( 'Position ID / filename template', 'county-gop-core' ); ?></label></th>
<td>
<input class="large-text" id="custom_layer_id_template" name="position_id_template" type="text" value="<?php echo esc_attr( $preview_form['position_id_template'] ?? '{label_slug}' ); ?>">
<p class="description"><?php esc_html_e( 'Supports {label}, {label_slug}, and Beacon field placeholders such as {Community}, {Community_slug}, {Municipal District}, and {Municipal District_slug}. The generated filename is this rendered value plus .geojson.', 'county-gop-core' ); ?></p>
</td>
</tr>
</table>
<?php submit_button( __( 'Add Known Layer', 'county-gop-core' ) ); ?>
</form>
</section>
<?php do_action( 'CGOP_gis_admin_extra_sections' ); ?>
</div>
<?php
}
/**
* Render scoped admin styles for the GIS Boundary Import page.
*/
function CGOP_gis_render_admin_styles() {
?>
<style>
.cgop-gis-admin {
max-width: 1440px;
}
.cgop-gis-intro {
max-width: 980px;
margin: 12px 0 20px;
font-size: 14px;
line-height: 1.55;
}
.cgop-gis-panel {
margin: 18px 0;
padding: 18px 20px 20px;
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.cgop-gis-panel--request {
border-left: 4px solid #2271b1;
}
.cgop-gis-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid #dcdcde;
}
.cgop-gis-panel__header h2,
.cgop-gis-panel h3 {
margin: 0;
color: #1d2327;
}
.cgop-gis-panel__header p {
max-width: 860px;
margin: 6px 0 0;
color: #50575e;
line-height: 1.5;
}
.cgop-gis-panel .form-table {
margin-top: 0;
}
.cgop-gis-panel .form-table th {
width: 190px;
padding-left: 0;
}
.cgop-gis-panel .form-table td {
padding-right: 0;
}
.cgop-gis-panel input.regular-text,
.cgop-gis-panel input.large-text,
.cgop-gis-panel textarea.large-text {
max-width: 860px;
}
.cgop-gis-panel .submit {
margin-bottom: 0;
padding-bottom: 0;
}
.cgop-gis-panel code {
white-space: normal;
word-break: break-word;
}
.cgop-gis-id {
font-size: 11px;
}
.cgop-gis-required {
color: #b32d2e;
}
.cgop-gis-table th,
.cgop-gis-table td {
vertical-align: middle;
}
.cgop-gis-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.cgop-gis-actions form {
display: inline-flex;
margin: 0;
}
.cgop-gis-preview {
max-width: 1100px;
}
.cgop-gis-preview details {
margin-top: 1em;
}
.cgop-gis-preview pre {
max-height: 320px;
overflow: auto;
background: #fff;
border: 1px solid #ccd0d4;
padding: 12px;
}
.cgop-gis-source-list {
max-height: 360px;
overflow: auto;
max-width: 980px;
padding: 0 12px 12px;
border: 1px solid #dcdcde;
background: #f6f7f7;
border-radius: 4px;
}
.cgop-gis-source-group {
margin: 14px 0 6px;
padding: 8px 10px;
background: #fff;
border-left: 4px solid #8c8f94;
font-weight: 600;
}
.cgop-gis-source-item {
display: block;
margin: 0;
padding: 7px 10px 7px 28px;
background: #fff;
border-bottom: 1px solid #f0f0f1;
}
.cgop-gis-source-item input {
margin-left: -20px;
margin-right: 6px;
}
.cgop-gis-source-item small {
color: #646970;
}
@media (max-width: 782px) {
.cgop-gis-panel {
padding: 14px;
}
.cgop-gis-panel__header {
display: block;
}
.cgop-gis-panel__header .button {
margin-top: 10px;
}
}
</style>
<?php
}
/**
* Render admin notices for the GIS page.
*/
function CGOP_gis_render_notices() {
if ( isset( $_GET['CGOP_gis_success'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'GIS boundary regeneration completed.', 'county-gop-core' ) . '</p></div>';
}
if ( isset( $_GET['CGOP_gis_deleted'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Generated map file deleted.', 'county-gop-core' ) . '</p></div>';
}
if ( isset( $_GET['CGOP_gis_composite_created'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Composite map created.', 'county-gop-core' ) . '</p></div>';
}
if ( isset( $_GET['CGOP_gis_composite_rebuilt'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Composite map rebuilt.', 'county-gop-core' ) . '</p></div>';
}
if ( isset( $_GET['CGOP_gis_composite_deleted'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Composite map deleted. Source files were not deleted. Position Key assignments that pointed to this composite may stop rendering until reassigned.', 'county-gop-core' ) . '</p></div>';
}
if ( isset( $_GET['CGOP_gis_direct_imported'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'GeoJSON file imported and added to Position Key map choices.', 'county-gop-core' ) . '</p></div>';
}
if ( isset( $_GET['CGOP_gis_direct_deleted'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Uploaded GeoJSON import deleted. Position Key assignments that pointed to it were cleared.', 'county-gop-core' ) . '</p></div>';
}
if ( isset( $_GET['CGOP_gis_error'] ) ) {
echo '<div class="notice notice-error is-dismissible"><p>' . esc_html( sanitize_text_field( wp_unslash( $_GET['CGOP_gis_error'] ) ) ) . '</p></div>';
}
do_action( 'CGOP_gis_admin_notices' );
}
/**
* Render generated file links from status.
*
* @param array $status Saved status.
*/
function CGOP_gis_render_generated_files( $status ) {
if ( empty( $status['layers'] ) || ! is_array( $status['layers'] ) ) {
echo '<p>' . esc_html__( 'No generated files have been recorded yet.', 'county-gop-core' ) . '</p>';
return;
}
$rows = array();
foreach ( $status['layers'] as $layer ) {
if ( empty( $layer['files'] ) || ! is_array( $layer['files'] ) ) {
continue;
}
foreach ( $layer['files'] as $file ) {
$url = ! empty( $file['file'] ) ? CGOP_gis_get_generated_file_url( $file['file'] ) : '';
if ( ! $url ) {
continue;
}
$relative = ltrim( (string) $file['file'], '/' );
$rows[] = array(
'layer' => $layer,
'file' => $file,
'relative' => $relative,
'url' => $url,
'label' => $file['label'] ?? $file['position_id'] ?? basename( $relative ),
'position_id' => $file['position_id'] ?? '',
);
}
}
if ( ! $rows ) {
echo '<p>' . esc_html__( 'No generated files have been recorded yet.', 'county-gop-core' ) . '</p>';
return;
}
?>
<table class="widefat striped cgop-gis-table">
<thead>
<tr>
<th><?php esc_html_e( 'File', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Layer', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Position ID', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Path', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Actions', 'county-gop-core' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $rows as $row ) : ?>
<tr>
<td>
<a href="<?php echo esc_url( $row['url'] ); ?>" target="_blank" rel="noopener">
<strong><?php echo esc_html( $row['label'] ); ?></strong>
</a>
</td>
<td><?php echo esc_html( $row['layer']['label'] ?? $row['layer']['layer_id'] ?? '' ); ?></td>
<td><code><?php echo esc_html( $row['position_id'] ); ?></code></td>
<td><code><?php echo esc_html( $row['relative'] ); ?></code></td>
<td>
<div class="cgop-gis-actions">
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="CGOP_gis_delete_generated_file">
<input type="hidden" name="file" value="<?php echo esc_attr( $row['relative'] ); ?>">
<?php wp_nonce_field( 'CGOP_gis_delete_generated_file_' . $row['relative'] ); ?>
<button type="submit" class="button button-link-delete" onclick="return confirm('<?php echo esc_attr__( 'Delete this generated GeoJSON file? Position map assignments that point to it may stop rendering.', 'county-gop-core' ); ?>');">
<?php esc_html_e( 'Delete file', 'county-gop-core' ); ?>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
}
/**
* Return and clear the current user's label-field preview payload.
*
* @return array
*/
function CGOP_gis_get_label_preview_payload() {
$uid = get_current_user_id();
$payload = get_transient( 'CGOP_gis_label_preview_' . $uid );
if ( $payload ) {
delete_transient( 'CGOP_gis_label_preview_' . $uid );
}
return is_array( $payload ) ? $payload : array();
}
/**
* Render label-field preview results for the known Beacon layer form.
*
* @param array $payload Preview payload.
*/
function CGOP_gis_render_label_preview( $payload ) {
if ( empty( $payload ) || ! is_array( $payload ) ) {
return;
}
if ( ! empty( $payload['error'] ) ) {
echo '<div class="notice notice-error inline"><p>' . esc_html( $payload['error'] ) . '</p></div>';
return;
}
$fields = ! empty( $payload['fields'] ) && is_array( $payload['fields'] ) ? $payload['fields'] : array();
$records = ! empty( $payload['records'] ) && is_array( $payload['records'] ) ? $payload['records'] : array();
if ( ! $fields ) {
echo '<div class="notice notice-warning inline"><p>' . esc_html__( 'Beacon returned records, but no label fields could be parsed. Try DisplayKey as the preferred label field.', 'county-gop-core' ) . '</p></div>';
return;
}
?>
<div class="notice notice-info inline cgop-gis-preview">
<p><strong><?php esc_html_e( 'Available label fields from Beacon preview', 'county-gop-core' ); ?></strong></p>
<p><?php esc_html_e( 'Copy one or more field names into Preferred label fields. Multiple fields are combined in order, separated by commas.', 'county-gop-core' ); ?></p>
<table class="widefat striped cgop-gis-table">
<thead>
<tr>
<th><?php esc_html_e( 'Field name', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Sample values', 'county-gop-core' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $fields as $field_name => $values ) : ?>
<tr>
<td><code><?php echo esc_html( $field_name ); ?></code></td>
<td><?php echo esc_html( implode( ' | ', array_filter( array_unique( array_map( 'strval', (array) $values ) ) ) ) ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ( $records ) : ?>
<details>
<summary><?php esc_html_e( 'Show raw preview records', 'county-gop-core' ); ?></summary>
<pre><?php echo esc_html( wp_json_encode( $records, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); ?></pre>
</details>
<?php endif; ?>
</div>
<?php
}
/**
* Handle regeneration POST.
*/
function CGOP_gis_handle_regenerate() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'You do not have permission to manage GIS boundary imports.', 'county-gop-core' ) );
}
check_admin_referer( 'CGOP_gis_regenerate' );
$settings = CGOP_gis_sanitize_settings( wp_unslash( $_POST ) );
update_option( CGOP_GIS_IMPORT_SETTINGS_OPTION, $settings, false );
$qps = isset( $_POST['qps'] ) ? sanitize_text_field( wp_unslash( $_POST['qps'] ) ) : '';
if ( '' === $qps ) {
CGOP_gis_redirect_with_error( __( 'Beacon QPS value is required.', 'county-gop-core' ) );
}
$request_context = array(
'cookie' => isset( $_POST['beacon_cookie'] ) ? CGOP_gis_normalize_cookie_header( wp_unslash( $_POST['beacon_cookie'] ) ) : '',
'user_agent' => isset( $_POST['beacon_user_agent'] ) ? sanitize_text_field( wp_unslash( $_POST['beacon_user_agent'] ) ) : CGOP_gis_get_default_user_agent(),
'field_overrides' => isset( $_POST['field_overrides'] ) ? CGOP_gis_parse_field_overrides( wp_unslash( $_POST['field_overrides'] ) ) : array(),
);
$field_overrides_raw = isset( $_POST['field_overrides'] ) ? sanitize_textarea_field( wp_unslash( $_POST['field_overrides'] ) ) : '';
CGOP_gis_set_request_memory( $qps, $request_context['cookie'], $request_context['user_agent'], $field_overrides_raw );
$layer_id = isset( $_POST['layer_id'] ) ? sanitize_key( wp_unslash( $_POST['layer_id'] ) ) : '';
$layers = CGOP_gis_get_layer_registry();
if ( 'all' === $layer_id ) {
$selected_layers = array_values( $layers );
} elseif ( isset( $layers[ $layer_id ] ) ) {
$selected_layers = array( $layers[ $layer_id ] );
} else {
CGOP_gis_redirect_with_error( __( 'Choose a valid GIS layer to regenerate.', 'county-gop-core' ) );
}
$status = get_option( CGOP_GIS_IMPORT_STATUS_OPTION, array() );
if ( ! is_array( $status ) ) {
$status = array();
}
if ( empty( $status['layers'] ) || ! is_array( $status['layers'] ) ) {
$status['layers'] = array();
}
foreach ( $selected_layers as $layer ) {
$result = CGOP_gis_regenerate_layer( $layer, $qps, $settings, $request_context );
if ( is_wp_error( $result ) ) {
CGOP_gis_redirect_with_error( $result->get_error_message() );
}
$status['layers'][ $layer['id'] ] = $result;
}
$manifest_result = CGOP_gis_write_manifest( $status );
if ( is_wp_error( $manifest_result ) ) {
CGOP_gis_redirect_with_error( $manifest_result->get_error_message() );
}
update_option( CGOP_GIS_IMPORT_STATUS_OPTION, $status, false );
wp_safe_redirect( add_query_arg( 'CGOP_gis_success', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_gis_regenerate', 'CGOP_gis_handle_regenerate' );
/**
* Handle adding a known Beacon layer.
*/
function CGOP_gis_handle_add_custom_layer() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'You do not have permission to manage GIS boundary imports.', 'county-gop-core' ) );
}
check_admin_referer( 'CGOP_gis_add_custom_layer' );
if ( ! empty( $_POST['CGOP_gis_preview_label_fields'] ) ) {
CGOP_gis_handle_label_preview();
}
$layer = CGOP_gis_normalize_layer_config( wp_unslash( $_POST ) );
if ( empty( $layer['id'] ) || empty( $layer['label'] ) || empty( $layer['beacon_layer_id'] ) ) {
CGOP_gis_redirect_with_error( __( 'Known Beacon layer requires a label and Beacon layer ID.', 'county-gop-core' ) );
}
$custom = CGOP_gis_get_custom_layer_registry();
$custom[ $layer['id'] ] = $layer;
update_option( CGOP_GIS_IMPORT_CUSTOM_LAYERS_OPTION, $custom, false );
wp_safe_redirect( add_query_arg( 'CGOP_gis_success', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_gis_add_custom_layer', 'CGOP_gis_handle_add_custom_layer' );
/**
* Fetch sample Beacon fields for the manual layer form without saving it.
*/
function CGOP_gis_handle_label_preview() {
$uid = get_current_user_id();
$raw_form = wp_unslash( $_POST );
$form_state = CGOP_gis_sanitize_label_preview_form_state( $raw_form );
$payload = array( 'form' => $form_state );
$layer = CGOP_gis_normalize_layer_config( $raw_form );
if ( empty( $layer['label'] ) || empty( $layer['beacon_layer_id'] ) ) {
$payload['error'] = __( 'Enter a layer label and Beacon layer ID before fetching label fields.', 'county-gop-core' );
set_transient( 'CGOP_gis_label_preview_' . $uid, $payload, 120 );
wp_safe_redirect( CGOP_gis_get_admin_url() );
exit;
}
$qps = isset( $raw_form['preview_qps'] ) ? sanitize_text_field( $raw_form['preview_qps'] ) : '';
if ( '' === $qps ) {
$payload['error'] = __( 'Enter a Preview QPS value before fetching label fields.', 'county-gop-core' );
set_transient( 'CGOP_gis_label_preview_' . $uid, $payload, 120 );
wp_safe_redirect( CGOP_gis_get_admin_url() );
exit;
}
$settings = CGOP_gis_get_settings();
$settings['feature_limit'] = 3;
$request_context = array(
'cookie' => isset( $raw_form['preview_cookie'] ) ? CGOP_gis_normalize_cookie_header( $raw_form['preview_cookie'] ) : '',
'user_agent' => isset( $raw_form['preview_user_agent'] ) ? sanitize_text_field( $raw_form['preview_user_agent'] ) : CGOP_gis_get_default_user_agent(),
);
CGOP_gis_set_request_memory( $qps, $request_context['cookie'], $request_context['user_agent'] );
$records = CGOP_gis_fetch_beacon_layer( $layer, $qps, $settings, $request_context );
if ( is_wp_error( $records ) ) {
$payload['error'] = $records->get_error_message();
set_transient( 'CGOP_gis_label_preview_' . $uid, $payload, 120 );
wp_safe_redirect( CGOP_gis_get_admin_url() );
exit;
}
$preview_records = array_slice( (array) $records, 0, 3 );
$payload['fields'] = CGOP_gis_collect_preview_label_fields( $preview_records );
$payload['records'] = CGOP_gis_trim_preview_records( $preview_records );
set_transient( 'CGOP_gis_label_preview_' . $uid, $payload, 120 );
wp_safe_redirect( CGOP_gis_get_admin_url() );
exit;
}
/**
* Sanitize manual-layer form state for redisplay after preview.
*
* @param array $raw Raw form values.
* @return array
*/
function CGOP_gis_sanitize_label_preview_form_state( array $raw ) {
return array(
'label' => sanitize_text_field( $raw['label'] ?? '' ),
'beacon_layer_id' => absint( $raw['beacon_layer_id'] ?? 0 ),
'output_dir' => sanitize_title( $raw['output_dir'] ?? '' ),
'position_type' => sanitize_key( $raw['position_type'] ?? '' ),
'level' => sanitize_key( $raw['level'] ?? '' ),
'preview_qps' => sanitize_text_field( $raw['preview_qps'] ?? '' ),
'preview_cookie' => isset( $raw['preview_cookie'] ) ? CGOP_gis_normalize_cookie_header( $raw['preview_cookie'] ) : '',
'preview_user_agent' => sanitize_text_field( $raw['preview_user_agent'] ?? CGOP_gis_get_default_user_agent() ),
'preferred_fields' => sanitize_text_field( $raw['preferred_fields'] ?? 'DisplayKey' ),
'position_name_template' => sanitize_text_field( $raw['position_name_template'] ?? '{label}' ),
'position_id_template' => sanitize_text_field( $raw['position_id_template'] ?? '{label_slug}' ),
);
}
/**
* Collect label field names and sample values from Beacon records.
*
* @param array $records Beacon records.
* @return array
*/
function CGOP_gis_collect_preview_label_fields( array $records ) {
$fields = array();
foreach ( $records as $record ) {
if ( ! is_array( $record ) ) {
continue;
}
foreach ( array( 'Key', 'DisplayKey' ) as $direct_field ) {
if ( isset( $record[ $direct_field ] ) && '' !== trim( (string) $record[ $direct_field ] ) ) {
$fields[ $direct_field ][] = trim( (string) $record[ $direct_field ] );
}
}
$parsed = CGOP_gis_parse_html_fields( (string) ( $record['ResultHtml'] ?? '' ) )
+ CGOP_gis_parse_html_fields( (string) ( $record['TipHtml'] ?? '' ) );
foreach ( $parsed as $field_name => $value ) {
if ( '' === trim( (string) $value ) || '#MISSING#' === $value ) {
continue;
}
$fields[ $field_name ][] = trim( (string) $value );
}
}
ksort( $fields, SORT_NATURAL | SORT_FLAG_CASE );
return $fields;
}
/**
* Trim Beacon records to preview-friendly fields.
*
* @param array $records Beacon records.
* @return array
*/
function CGOP_gis_trim_preview_records( array $records ) {
$trimmed = array();
foreach ( $records as $record ) {
if ( ! is_array( $record ) ) {
continue;
}
$trimmed[] = array(
'Key' => $record['Key'] ?? '',
'DisplayKey' => $record['DisplayKey'] ?? '',
'ResultHtml' => $record['ResultHtml'] ?? '',
'TipHtml' => $record['TipHtml'] ?? '',
);
}
return $trimmed;
}
/**
* Handle deleting a known Beacon layer.
*/
function CGOP_gis_handle_delete_custom_layer() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'You do not have permission to manage GIS boundary imports.', 'county-gop-core' ) );
}
$layer_id = isset( $_POST['layer_id'] ) ? sanitize_key( wp_unslash( $_POST['layer_id'] ) ) : '';
check_admin_referer( 'CGOP_gis_delete_custom_layer_' . $layer_id );
if ( '' === $layer_id ) {
CGOP_gis_redirect_with_error( __( 'Choose a valid Beacon layer to delete.', 'county-gop-core' ) );
}
$custom = CGOP_gis_get_custom_layer_registry();
if ( $layer_id && isset( $custom[ $layer_id ] ) ) {
unset( $custom[ $layer_id ] );
update_option( CGOP_GIS_IMPORT_CUSTOM_LAYERS_OPTION, $custom, false );
}
wp_safe_redirect( add_query_arg( 'CGOP_gis_success', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_gis_delete_custom_layer', 'CGOP_gis_handle_delete_custom_layer' );
/**
* Handle deleting a generated GeoJSON file.
*/
function CGOP_gis_handle_delete_generated_file() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'You do not have permission to manage GIS boundary imports.', 'county-gop-core' ) );
}
$relative = isset( $_POST['file'] ) ? ltrim( sanitize_text_field( wp_unslash( $_POST['file'] ) ), '/' ) : '';
check_admin_referer( 'CGOP_gis_delete_generated_file_' . $relative );
if ( '' === $relative || str_contains( $relative, '..' ) || ! preg_match( '/\.(geojson|json)$/i', $relative ) ) {
CGOP_gis_redirect_with_error( __( 'Invalid generated file path.', 'county-gop-core' ) );
}
$base = wp_normalize_path( trailingslashit( CGOP_gis_get_generated_dir() ) );
$file_path = wp_normalize_path( $base . $relative );
if ( ! str_starts_with( $file_path, $base ) ) {
CGOP_gis_redirect_with_error( __( 'Generated file path is outside the allowed map directory.', 'county-gop-core' ) );
}
if ( file_exists( $file_path ) && ! wp_delete_file( $file_path ) ) {
CGOP_gis_redirect_with_error( __( 'Could not delete the generated GeoJSON file.', 'county-gop-core' ) );
}
$status = get_option( CGOP_GIS_IMPORT_STATUS_OPTION, array() );
if ( ! is_array( $status ) ) {
$status = array();
}
if ( ! empty( $status['layers'] ) && is_array( $status['layers'] ) ) {
foreach ( $status['layers'] as $layer_id => $layer_status ) {
if ( empty( $layer_status['files'] ) || ! is_array( $layer_status['files'] ) ) {
continue;
}
$files = array();
foreach ( $layer_status['files'] as $file ) {
if ( empty( $file['file'] ) || ltrim( (string) $file['file'], '/' ) !== $relative ) {
$files[] = $file;
}
}
$status['layers'][ $layer_id ]['files'] = $files;
$status['layers'][ $layer_id ]['feature_count'] = count( $files );
}
}
update_option( CGOP_GIS_IMPORT_STATUS_OPTION, $status, false );
$manifest_result = CGOP_gis_write_manifest( $status );
if ( is_wp_error( $manifest_result ) ) {
CGOP_gis_redirect_with_error( $manifest_result->get_error_message() );
}
wp_safe_redirect( add_query_arg( 'CGOP_gis_deleted', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_gis_delete_generated_file', 'CGOP_gis_handle_delete_generated_file' );
/**
* Sanitize request settings.
*
* @param array $raw Raw request values.
* @return array
*/
function CGOP_gis_sanitize_settings( $raw ) {
$defaults = CGOP_gis_get_default_settings();
return array(
'beacon_endpoint' => ! empty( $raw['beacon_endpoint'] ) ? esc_url_raw( $raw['beacon_endpoint'] ) : $defaults['beacon_endpoint'],
'minx' => isset( $raw['minx'] ) ? (float) $raw['minx'] : $defaults['minx'],
'miny' => isset( $raw['miny'] ) ? (float) $raw['miny'] : $defaults['miny'],
'maxx' => isset( $raw['maxx'] ) ? (float) $raw['maxx'] : $defaults['maxx'],
'maxy' => isset( $raw['maxy'] ) ? (float) $raw['maxy'] : $defaults['maxy'],
'feature_limit' => isset( $raw['feature_limit'] ) ? max( 1, min( 5000, absint( $raw['feature_limit'] ) ) ) : $defaults['feature_limit'],
);
}
/**
* Normalize a pasted Beacon Cookie header or Windows "Copy as cURL" cookie.
*
* Chrome-on-Windows cURL output escapes special characters with ^. Those escape
* characters are not part of the cookie value and will break Beacon/Cloudflare
* session matching if pasted directly.
*
* @param string $raw Raw pasted cookie text.
* @return string
*/
function CGOP_gis_normalize_cookie_header( $raw ) {
$cookie = trim( (string) $raw );
if ( '' === $cookie ) {
return '';
}
if ( preg_match( '/(?:^|\s)-b\s+\^?"([^"]+)\^?"/', $cookie, $match ) ) {
$cookie = $match[1];
} elseif ( preg_match( '/(?:^|\s)-b\s+\'([^\']+)\'/', $cookie, $match ) ) {
$cookie = $match[1];
}
$cookie = trim( $cookie );
$cookie = trim( $cookie, "\"' \t\n\r\0\x0B" );
$cookie = str_replace(
array( '^$', '^&', '^=', '^;', '^"', '^^' ),
array( '$', '&', '=', ';', '"', '^' ),
$cookie
);
$cookie = rtrim( $cookie, '^' );
return sanitize_textarea_field( $cookie );
}
/**
* Regenerate a single layer.
*
* @param array $layer Layer config.
* @param string $qps Beacon QPS.
* @param array $settings Request settings.
* @return array|WP_Error
*/
function CGOP_gis_regenerate_layer( $layer, $qps, $settings, $request_context = array() ) {
$records = CGOP_gis_fetch_beacon_layer( $layer, $qps, $settings, $request_context );
if ( is_wp_error( $records ) ) {
return $records;
}
if ( empty( $records ) ) {
return new WP_Error( 'CGOP_gis_no_features', sprintf(
/* translators: %s: layer label. */
__( 'Beacon returned zero features for %s.', 'county-gop-core' ),
$layer['label']
) );
}
$files = array();
$seen_ids = array();
$seen_paths = array();
$output_base = CGOP_gis_get_generated_dir();
$layer_dir = trailingslashit( $output_base ) . $layer['output_dir'];
if ( ! wp_mkdir_p( $layer_dir ) ) {
return new WP_Error( 'CGOP_gis_mkdir_failed', __( 'Could not create the generated GeoJSON output directory.', 'county-gop-core' ) );
}
foreach ( $records as $record ) {
if ( empty( $record['WktGeometry'] ) ) {
return new WP_Error( 'CGOP_gis_missing_wkt', __( 'Beacon returned a feature without WktGeometry.', 'county-gop-core' ) );
}
$record_fields = CGOP_gis_get_record_fields( $record );
$record_fields = CGOP_gis_apply_field_overrides(
$record_fields,
$record,
$request_context['field_overrides'] ?? array()
);
$label = CGOP_gis_extract_feature_label( $layer, $record, $record_fields );
$label_slug = CGOP_gis_slug( $label );
$position_id = CGOP_gis_apply_template( $layer['position_id_template'], $label, $label_slug, $record_fields );
$name = CGOP_gis_apply_template( $layer['position_name_template'], $label, $label_slug, $record_fields );
$template_error = CGOP_gis_validate_rendered_templates( $position_id, $name, $record );
if ( is_wp_error( $template_error ) ) {
return $template_error;
}
$file_name = $position_id . '.geojson';
$file_path = trailingslashit( $layer_dir ) . $file_name;
$relative = $layer['output_dir'] . '/' . $file_name;
if ( isset( $seen_ids[ $position_id ] ) ) {
return new WP_Error( 'CGOP_gis_duplicate_id', sprintf(
/* translators: %s: generated position ID. */
__( 'Duplicate generated position ID: %s.', 'county-gop-core' ),
$position_id
) );
}
if ( isset( $seen_paths[ $relative ] ) ) {
return new WP_Error( 'CGOP_gis_duplicate_path', sprintf(
/* translators: %s: generated file path. */
__( 'Duplicate generated file path: %s.', 'county-gop-core' ),
$relative
) );
}
$geometry = CGOP_gis_convert_wkt_to_geometry( $record['WktGeometry'] );
if ( is_wp_error( $geometry ) ) {
return $geometry;
}
$feature_collection = array(
'type' => 'FeatureCollection',
'features' => array(
array(
'type' => 'Feature',
'properties' => array(
'position_id' => $position_id,
'source_layer_id' => (int) $layer['beacon_layer_id'],
'source_layer_name' => $layer['label'],
'source_key' => (string) ( $record['Key'] ?? '' ),
'source_display_key' => (string) ( $record['DisplayKey'] ?? '' ),
'position_type' => $layer['position_type'],
'level' => $layer['level'],
'label' => $label,
'name' => $name,
'raw_result_html' => (string) ( $record['ResultHtml'] ?? '' ),
'raw_tip_html' => (string) ( $record['TipHtml'] ?? '' ),
),
'geometry' => $geometry,
),
),
);
$json = wp_json_encode( $feature_collection, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
if ( ! $json || false === file_put_contents( $file_path, $json ) ) {
return new WP_Error( 'CGOP_gis_write_failed', sprintf(
/* translators: %s: generated file path. */
__( 'Could not write generated GeoJSON file: %s.', 'county-gop-core' ),
$relative
) );
}
$seen_ids[ $position_id ] = true;
$seen_paths[ $relative ] = true;
$files[] = array(
'position_id' => $position_id,
'label' => $label,
'source_key' => (string) ( $record['Key'] ?? '' ),
'file' => $relative,
);
}
return array(
'layer_id' => $layer['id'],
'label' => $layer['label'],
'beacon_layer_id' => (int) $layer['beacon_layer_id'],
'generated_at' => current_time( 'mysql' ),
'feature_count' => count( $files ),
'files' => $files,
);
}
/**
* Fetch a Beacon layer.
*
* @param array $layer Layer config.
* @param string $qps Beacon QPS.
* @param array $settings Request settings.
* @return array|WP_Error
*/
function CGOP_gis_fetch_beacon_layer( $layer, $qps, $settings, $request_context = array() ) {
$endpoint = add_query_arg( 'QPS', $qps, $settings['beacon_endpoint'] );
$body = array(
'layerId' => (int) $layer['beacon_layer_id'],
'useSelection' => false,
'ext' => array(
'minx' => (float) $settings['minx'],
'miny' => (float) $settings['miny'],
'maxx' => (float) $settings['maxx'],
'maxy' => (float) $settings['maxy'],
),
'featureLimit' => (int) $settings['feature_limit'],
'spatialRelation' => 1,
'wkt' => null,
);
$headers = array(
'Accept' => 'text/plain, */*; q=0.01',
'Content-Type' => 'application/json',
'Origin' => 'https://beacon.schneidercorp.com',
'Referer' => 'https://beacon.schneidercorp.com/Application.aspx?AppID=385&LayerID=6053&PageTypeID=1&PageID=3291',
'X-Requested-With' => 'XMLHttpRequest',
'User-Agent' => ! empty( $request_context['user_agent'] ) ? $request_context['user_agent'] : CGOP_gis_get_default_user_agent(),
);
if ( ! empty( $request_context['cookie'] ) ) {
$headers['Cookie'] = $request_context['cookie'];
}
$response = wp_remote_post(
$endpoint,
array(
'timeout' => 60,
'headers' => $headers,
'body' => wp_json_encode( $body ),
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
if ( $code < 200 || $code >= 300 ) {
$body_snippet = wp_strip_all_tags( substr( wp_remote_retrieve_body( $response ), 0, 240 ) );
return new WP_Error( 'CGOP_gis_beacon_http', sprintf(
/* translators: 1: HTTP status code. 2: response body snippet. */
__( 'Beacon request failed with HTTP %1$d. Response: %2$s', 'county-gop-core' ),
(int) $code,
$body_snippet ? $body_snippet : __( 'No response body.', 'county-gop-core' )
) );
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
return new WP_Error( 'CGOP_gis_bad_json', __( 'Beacon returned a non-JSON response.', 'county-gop-core' ) );
}
if ( ! isset( $data['d'] ) || ! is_array( $data['d'] ) ) {
return new WP_Error( 'CGOP_gis_missing_d', __( 'Beacon response did not include the expected d array.', 'county-gop-core' ) );
}
return $data['d'];
}
/**
* Convert WKT to GeoJSON geometry using the bundled Python tool.
*
* @param string $wkt Raw WKT.
* @return array|WP_Error
*/
function CGOP_gis_convert_wkt_to_geometry( $wkt ) {
if ( ! function_exists( 'proc_open' ) ) {
return new WP_Error( 'CGOP_gis_proc_open_missing', __( 'The server cannot run the Python WKT converter because proc_open is unavailable.', 'county-gop-core' ) );
}
$script = CGOP_CORE_PLUGIN_DIR . 'tools/gis/convert_beacon_wkt_to_geojson.py';
if ( ! file_exists( $script ) ) {
return new WP_Error( 'CGOP_gis_converter_missing', __( 'The Python WKT converter script is missing.', 'county-gop-core' ) );
}
$python = CGOP_gis_find_python_binary();
if ( ! $python ) {
return new WP_Error( 'CGOP_gis_python_missing', __( 'Python was not found on the server. Install Python with shapely and pyproj to regenerate Beacon GeoJSON.', 'county-gop-core' ) );
}
$descriptors = array(
0 => array( 'pipe', 'r' ),
1 => array( 'pipe', 'w' ),
2 => array( 'pipe', 'w' ),
);
$command = escapeshellarg( $python ) . ' ' . escapeshellarg( $script );
$process = proc_open( $command, $descriptors, $pipes );
if ( ! is_resource( $process ) ) {
return new WP_Error( 'CGOP_gis_converter_start_failed', __( 'Could not start the Python WKT converter.', 'county-gop-core' ) );
}
fwrite( $pipes[0], wp_json_encode( array( 'wkt' => $wkt ) ) );
fclose( $pipes[0] );
$output = stream_get_contents( $pipes[1] );
$error = stream_get_contents( $pipes[2] );
fclose( $pipes[1] );
fclose( $pipes[2] );
$exit_code = proc_close( $process );
$data = json_decode( $output, true );
if ( 0 !== $exit_code || JSON_ERROR_NONE !== json_last_error() || empty( $data['ok'] ) || empty( $data['geometry'] ) ) {
$message = ! empty( $data['error'] ) ? $data['error'] : trim( $error );
return new WP_Error( 'CGOP_gis_converter_failed', sprintf(
/* translators: %s: converter error. */
__( 'WKT conversion failed: %s', 'county-gop-core' ),
$message ? $message : __( 'Unknown converter error.', 'county-gop-core' )
) );
}
return $data['geometry'];
}
/**
* Find an available Python executable.
*
* @return string
*/
function CGOP_gis_find_python_binary() {
static $found = null;
if ( null !== $found ) {
return $found;
}
$candidates = array( 'python3', 'python' );
foreach ( $candidates as $candidate ) {
$descriptors = array(
0 => array( 'pipe', 'r' ),
1 => array( 'pipe', 'w' ),
2 => array( 'pipe', 'w' ),
);
$process = proc_open( escapeshellarg( $candidate ) . ' --version', $descriptors, $pipes );
if ( ! is_resource( $process ) ) {
continue;
}
fclose( $pipes[0] );
stream_get_contents( $pipes[1] );
stream_get_contents( $pipes[2] );
fclose( $pipes[1] );
fclose( $pipes[2] );
if ( 0 === proc_close( $process ) ) {
$found = $candidate;
return $found;
}
}
$found = '';
return $found;
}
/**
* Extract best label from Beacon result fields.
*
* @param array $layer Layer config.
* @param array $record Beacon record.
* @return string
*/
function CGOP_gis_extract_feature_label( $layer, $record, $fields = null ) {
if ( ! is_array( $fields ) ) {
$fields = CGOP_gis_get_record_fields( $record );
}
$values = array();
foreach ( $layer['preferred_fields'] as $field_name ) {
$value = CGOP_gis_get_field_value( $fields, $field_name );
if ( '' !== $value && '#MISSING#' !== $value ) {
$values[] = $value;
}
}
if ( $values ) {
return implode( ', ', array_unique( $values ) );
}
return ! empty( $record['DisplayKey'] ) ? trim( (string) $record['DisplayKey'] ) : trim( (string) ( $record['Key'] ?? 'feature' ) );
}
/**
* Get parsed Beacon record fields with synthetic Key and DisplayKey values.
*
* @param array $record Beacon record.
* @return array
*/
function CGOP_gis_get_record_fields( $record ) {
$fields = CGOP_gis_parse_html_fields( (string) ( $record['ResultHtml'] ?? '' ) )
+ CGOP_gis_parse_html_fields( (string) ( $record['TipHtml'] ?? '' ) );
if ( isset( $record['Key'] ) ) {
$fields['Key'] = trim( (string) $record['Key'] );
}
if ( isset( $record['DisplayKey'] ) ) {
$fields['DisplayKey'] = trim( (string) $record['DisplayKey'] );
}
return $fields;
}
/**
* Get a Beacon field value by exact or normalized field name.
*
* @param array $fields Parsed fields.
* @param string $field_name Requested field name.
* @return string
*/
function CGOP_gis_get_field_value( array $fields, $field_name ) {
$field_name = trim( (string) $field_name );
if ( '' === $field_name ) {
return '';
}
if ( isset( $fields[ $field_name ] ) ) {
return trim( (string) $fields[ $field_name ] );
}
$wanted = CGOP_gis_template_token( $field_name );
foreach ( $fields as $key => $value ) {
if ( CGOP_gis_template_token( $key ) === $wanted ) {
return trim( (string) $value );
}
}
return '';
}
/**
* Parse temporary feature field overrides.
*
* Syntax:
* 5: Community=Auburn, Municipal District=5
*
* @param string $raw Raw textarea value.
* @return array
*/
function CGOP_gis_parse_field_overrides( $raw ) {
$overrides = array();
$lines = preg_split( '/\r\n|\r|\n/', (string) $raw );
foreach ( $lines as $line ) {
$line = trim( $line );
if ( '' === $line || 0 === strpos( $line, '#' ) ) {
continue;
}
$parts = explode( ':', $line, 2 );
if ( 2 !== count( $parts ) ) {
continue;
}
$key = trim( $parts[0] );
$pairs = preg_split( '/,(?=(?:[^"]*"[^"]*")*[^"]*$)/', $parts[1] );
if ( '' === $key || ! $pairs ) {
continue;
}
foreach ( $pairs as $pair ) {
$pair_parts = explode( '=', $pair, 2 );
if ( 2 !== count( $pair_parts ) ) {
continue;
}
$field = trim( $pair_parts[0] );
$value = trim( $pair_parts[1] );
$value = trim( $value, "\"'" );
if ( '' === $field || '' === $value ) {
continue;
}
$overrides[ $key ][ $field ] = $value;
}
}
return $overrides;
}
/**
* Apply temporary field overrides to a Beacon record.
*
* @param array $fields Parsed fields.
* @param array $record Beacon record.
* @param array $overrides Parsed overrides.
* @return array
*/
function CGOP_gis_apply_field_overrides( array $fields, array $record, array $overrides ) {
if ( ! $overrides ) {
return $fields;
}
$record_keys = array_filter(
array_map(
'strval',
array(
$record['Key'] ?? '',
$record['DisplayKey'] ?? '',
)
)
);
foreach ( $record_keys as $record_key ) {
if ( empty( $overrides[ $record_key ] ) || ! is_array( $overrides[ $record_key ] ) ) {
continue;
}
foreach ( $overrides[ $record_key ] as $field => $value ) {
$fields[ $field ] = $value;
}
}
return $fields;
}
/**
* Parse simple Beacon <b>Field:</b> Value<br> HTML into key/value pairs.
*
* @param string $html HTML.
* @return array
*/
function CGOP_gis_parse_html_fields( $html ) {
$fields = array();
if ( '' === $html ) {
return $fields;
}
if ( preg_match_all( '/<b>\s*([^:<]+):\s*<\/b>\s*(?: |\s)*([^<]*)/i', $html, $matches, PREG_SET_ORDER ) ) {
foreach ( $matches as $match ) {
$key = trim( wp_strip_all_tags( html_entity_decode( $match[1], ENT_QUOTES ) ) );
$val = trim( wp_strip_all_tags( html_entity_decode( $match[2], ENT_QUOTES ) ) );
if ( $key ) {
$fields[ $key ] = $val;
}
}
}
return $fields;
}
/**
* Apply a label template.
*
* @param string $template Template.
* @param string $label Label.
* @param string $label_slug Label slug.
* @return string
*/
function CGOP_gis_apply_template( $template, $label, $label_slug, $fields = array() ) {
$replacements = array(
'{label}' => $label,
'{label_slug}' => $label_slug,
);
foreach ( $fields as $field_name => $value ) {
$value = trim( (string) $value );
if ( '' === $value ) {
continue;
}
$token = CGOP_gis_template_token( $field_name );
if ( '' === $token ) {
continue;
}
$replacements[ '{' . $field_name . '}' ] = $value;
$replacements[ '{' . $field_name . '_slug}' ] = CGOP_gis_slug( $value );
$replacements[ '{' . $token . '}' ] = $value;
$replacements[ '{' . $token . '_slug}' ] = CGOP_gis_slug( $value );
$replacements[ '{' . strtolower( $token ) . '}' ] = $value;
$replacements[ '{' . strtolower( $token ) . '_slug}' ] = CGOP_gis_slug( $value );
}
return strtr( $template, $replacements );
}
/**
* Validate rendered template output before writing generated files.
*
* @param string $position_id Rendered position ID.
* @param string $name Rendered position name.
* @param array $record Beacon record.
* @return true|WP_Error
*/
function CGOP_gis_validate_rendered_templates( $position_id, $name, $record ) {
$combined = $position_id . ' ' . $name;
if ( ! preg_match_all( '/\{([^}]+)\}/', $combined, $matches ) ) {
return true;
}
$missing = array_values( array_unique( $matches[1] ) );
$key = ! empty( $record['DisplayKey'] ) ? (string) $record['DisplayKey'] : (string) ( $record['Key'] ?? '' );
$prefix = $key ? sprintf(
/* translators: %s: Beacon feature key. */
__( 'Beacon feature %s could not be generated.', 'county-gop-core' ),
$key
) : __( 'A Beacon feature could not be generated.', 'county-gop-core' );
return new WP_Error(
'CGOP_gis_unresolved_template',
sprintf(
/* translators: 1: feature message. 2: comma-separated placeholders. */
__( '%1$s Missing template field(s): %2$s. Check the layer preview and update Preferred label fields or templates.', 'county-gop-core' ),
$prefix,
implode( ', ', $missing )
)
);
}
/**
* Convert a Beacon field name into a template-safe token.
*
* @param string $value Field name.
* @return string
*/
function CGOP_gis_template_token( $value ) {
$value = html_entity_decode( wp_strip_all_tags( (string) $value ), ENT_QUOTES );
$value = preg_replace( '/[^A-Za-z0-9]+/', '_', $value );
$value = trim( $value, '_' );
return $value;
}
/**
* Slugify Beacon labels.
*
* @param string $value Raw value.
* @return string
*/
function CGOP_gis_slug( $value ) {
$value = html_entity_decode( wp_strip_all_tags( (string) $value ), ENT_QUOTES );
$value = strtolower( $value );
$value = preg_replace( '/[^a-z0-9]+/', '-', $value );
$value = preg_replace( '/-+/', '-', $value );
$value = trim( $value, '-' );
return $value ? $value : 'feature';
}
/**
* Write manifest.json.
*
* @param array $status Status data.
* @return true|WP_Error
*/
function CGOP_gis_write_manifest( $status ) {
$composites = CGOP_gis_get_composite_maps();
$direct = CGOP_gis_get_direct_geojson_imports();
$manifest = array(
'generated_at' => gmdate( 'c' ),
'source' => 'Beacon / SchneiderCorp',
'source_crs' => 'EPSG:2965',
'target_crs' => 'EPSG:4326',
'layers' => array_values( $status['layers'] ?? array() ),
'direct' => array_values( $direct ),
'composites' => array_values( $composites ),
);
$path = trailingslashit( CGOP_gis_get_generated_dir() ) . 'manifest.json';
if ( ! wp_mkdir_p( dirname( $path ) ) ) {
return new WP_Error( 'CGOP_gis_manifest_dir_failed', __( 'Could not create the generated GeoJSON directory.', 'county-gop-core' ) );
}
$json = wp_json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
if ( ! $json || false === file_put_contents( $path, $json ) ) {
return new WP_Error( 'CGOP_gis_manifest_write_failed', __( 'Could not write generated manifest.json.', 'county-gop-core' ) );
}
return true;
}
/**
* Return the generated-files base directory for the current county.
*
* Path: {uploads}/county-gop-maps/{county_slug}/generated
*
* @return string Absolute filesystem path (no trailing slash).
*/
function CGOP_gis_get_generated_dir() {
$uploads = wp_get_upload_dir();
$county_id = function_exists( 'cgop_get_current_county_id' ) ? cgop_get_current_county_id() : null;
$county_slug = '';
if ( $county_id ) {
$county_slug = function_exists( 'cgop_get_county_slug' ) ? cgop_get_county_slug( $county_id ) : '';
}
if ( $county_slug ) {
return trailingslashit( $uploads['basedir'] ) . 'county-gop-maps/' . $county_slug . '/generated';
}
return trailingslashit( $uploads['basedir'] ) . 'county-gop-maps/generated';
}
/**
* Return a public URL for a generated file given its path relative to the
* generated directory.
*
* Uses the same county-slug logic as CGOP_gis_get_generated_dir().
*
* @param string $relative Relative path within the generated directory.
* @return string Full URL.
*/
function CGOP_gis_get_generated_file_url( $relative ) {
$uploads = wp_get_upload_dir();
$county_id = function_exists( 'cgop_get_current_county_id' ) ? cgop_get_current_county_id() : null;
$county_slug = '';
if ( $county_id ) {
$county_slug = function_exists( 'cgop_get_county_slug' ) ? cgop_get_county_slug( $county_id ) : '';
}
if ( $county_slug ) {
return trailingslashit( $uploads['baseurl'] ) . 'county-gop-maps/' . $county_slug . '/generated/' . ltrim( $relative, '/' );
}
return trailingslashit( $uploads['baseurl'] ) . 'county-gop-maps/generated/' . ltrim( $relative, '/' );
}
/**
* Admin page URL.
*
* @return string
*/
function CGOP_gis_get_admin_url() {
return admin_url( 'admin.php?page=cgop-gis-boundary-import' );
}
/**
* Redirect with error.
*
* @param string $message Error message.
*/
function CGOP_gis_redirect_with_error( $message ) {
wp_safe_redirect( add_query_arg( 'CGOP_gis_error', rawurlencode( $message ), CGOP_gis_get_admin_url() ) );
exit;
}
/**
* Render the direct GeoJSON import section.
*
* @param array[] $imports Current direct uploaded GeoJSON imports.
*/
function CGOP_gis_render_direct_geojson_imports_section( $imports ) {
?>
<section class="cgop-gis-panel" id="cgop-direct-geojson-imports">
<div class="cgop-gis-panel__header">
<div>
<h2><?php esc_html_e( 'Upload GeoJSON Map', 'county-gop-core' ); ?></h2>
<p><?php esc_html_e( 'Upload a ready-made .geojson or .json boundary file and make it available in Position Keys map dropdowns. This is for files you already have and does not call Beacon or ArcGIS.', 'county-gop-core' ); ?></p>
</div>
</div>
<?php if ( $imports ) : ?>
<table class="widefat striped cgop-gis-table">
<thead>
<tr>
<th><?php esc_html_e( 'Label', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Output File', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Imported', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Actions', 'county-gop-core' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $imports as $import_id => $import ) : ?>
<?php
$relative = ltrim( (string) ( $import['output_file'] ?? '' ), '/' );
$url = $relative ? CGOP_gis_get_generated_file_url( $relative ) : '';
?>
<tr>
<td><strong><?php echo esc_html( $import['label'] ?? '' ); ?></strong></td>
<td>
<code><?php echo esc_html( $relative ); ?></code>
<?php if ( $url ) : ?>
<a href="<?php echo esc_url( $url ); ?>" target="_blank" rel="noopener noreferrer" style="font-size:.85em"> <?php esc_html_e( 'View', 'county-gop-core' ); ?></a>
<?php endif; ?>
</td>
<td><?php echo esc_html( $import['created_at'] ?? '' ); ?></td>
<td>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="CGOP_gis_delete_direct_geojson">
<input type="hidden" name="import_id" value="<?php echo esc_attr( $import_id ); ?>">
<?php wp_nonce_field( 'CGOP_gis_delete_direct_geojson_' . $import_id ); ?>
<button type="submit" class="button button-link-delete" onclick="return confirm('<?php echo esc_attr__( 'Delete this uploaded GeoJSON map? Position map assignments that point to it will be cleared.', 'county-gop-core' ); ?>');">
<?php esc_html_e( 'Delete map', 'county-gop-core' ); ?>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else : ?>
<p><?php esc_html_e( 'No direct GeoJSON uploads have been imported yet.', 'county-gop-core' ); ?></p>
<?php endif; ?>
<h3 style="margin-top:1.5rem"><?php esc_html_e( 'Import GeoJSON File', 'county-gop-core' ); ?></h3>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" enctype="multipart/form-data">
<input type="hidden" name="action" value="CGOP_gis_import_direct_geojson">
<?php wp_nonce_field( 'CGOP_gis_import_direct_geojson' ); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="direct_geojson_label"><?php esc_html_e( 'Map label', 'county-gop-core' ); ?></label></th>
<td>
<input class="regular-text" id="direct_geojson_label" name="label" type="text" required>
<p class="description"><?php esc_html_e( 'Shown in Position Keys map dropdowns under Uploaded GeoJSON.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="direct_geojson_slug"><?php esc_html_e( 'File slug', 'county-gop-core' ); ?></label></th>
<td>
<input class="regular-text" id="direct_geojson_slug" name="slug" type="text">
<p class="description"><?php esc_html_e( 'Optional. Leave blank to generate from the label. Output path: imported/{slug}.geojson.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="direct_geojson_file"><?php esc_html_e( 'GeoJSON file', 'county-gop-core' ); ?></label></th>
<td>
<input id="direct_geojson_file" name="geojson_file" type="file" accept=".geojson,.json,application/geo+json,application/json" required>
<p class="description"><?php esc_html_e( 'Accepted JSON types: FeatureCollection, Feature, Polygon, or MultiPolygon.', 'county-gop-core' ); ?></p>
</td>
</tr>
</table>
<?php submit_button( __( 'Import GeoJSON Map', 'county-gop-core' ), 'secondary' ); ?>
</form>
</section>
<?php
}
/**
* Validate decoded GeoJSON enough for local map rendering.
*
* @param mixed $geojson Decoded JSON.
* @return true|WP_Error
*/
function CGOP_gis_validate_direct_geojson( $geojson ) {
if ( ! is_array( $geojson ) || empty( $geojson['type'] ) ) {
return new WP_Error( 'invalid_geojson', __( 'Uploaded file is not valid GeoJSON.', 'county-gop-core' ) );
}
$type = (string) $geojson['type'];
if ( 'FeatureCollection' === $type ) {
if ( ! isset( $geojson['features'] ) || ! is_array( $geojson['features'] ) ) {
return new WP_Error( 'invalid_feature_collection', __( 'FeatureCollection must include a features array.', 'county-gop-core' ) );
}
return true;
}
if ( 'Feature' === $type ) {
if ( empty( $geojson['geometry'] ) || ! is_array( $geojson['geometry'] ) ) {
return new WP_Error( 'invalid_feature', __( 'Feature must include geometry.', 'county-gop-core' ) );
}
return true;
}
if ( in_array( $type, array( 'Polygon', 'MultiPolygon' ), true ) ) {
return true;
}
return new WP_Error( 'unsupported_geojson_type', __( 'Only FeatureCollection, Feature, Polygon, and MultiPolygon GeoJSON uploads are supported.', 'county-gop-core' ) );
}
/**
* Handle importing a direct GeoJSON file.
*/
function CGOP_gis_handle_import_direct_geojson() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'You do not have permission to manage GIS boundary imports.', 'county-gop-core' ) );
}
check_admin_referer( 'CGOP_gis_import_direct_geojson' );
$label = sanitize_text_field( wp_unslash( $_POST['label'] ?? '' ) );
$slug = sanitize_title( wp_unslash( $_POST['slug'] ?? '' ) );
if ( '' === $label ) {
CGOP_gis_redirect_with_error( __( 'Map label is required.', 'county-gop-core' ) );
}
if ( '' === $slug ) {
$slug = sanitize_title( $label );
}
if ( '' === $slug ) {
$slug = 'uploaded-map';
}
if ( empty( $_FILES['geojson_file'] ) || ! is_array( $_FILES['geojson_file'] ) ) {
CGOP_gis_redirect_with_error( __( 'GeoJSON file is required.', 'county-gop-core' ) );
}
$file = $_FILES['geojson_file'];
if ( ! empty( $file['error'] ) ) {
CGOP_gis_redirect_with_error( __( 'The GeoJSON upload failed.', 'county-gop-core' ) );
}
$name = sanitize_file_name( (string) ( $file['name'] ?? '' ) );
if ( ! preg_match( '/\.(geojson|json)$/i', $name ) ) {
CGOP_gis_redirect_with_error( __( 'Please upload a .geojson or .json file.', 'county-gop-core' ) );
}
$tmp = (string) ( $file['tmp_name'] ?? '' );
if ( ! $tmp || ! is_uploaded_file( $tmp ) ) {
CGOP_gis_redirect_with_error( __( 'Could not read the uploaded GeoJSON file.', 'county-gop-core' ) );
}
$raw = file_get_contents( $tmp );
if ( false === $raw ) {
CGOP_gis_redirect_with_error( __( 'Could not read the uploaded GeoJSON file.', 'county-gop-core' ) );
}
$geojson = json_decode( $raw, true );
$validation = CGOP_gis_validate_direct_geojson( $geojson );
if ( is_wp_error( $validation ) ) {
CGOP_gis_redirect_with_error( $validation->get_error_message() );
}
$imports = CGOP_gis_get_direct_geojson_imports();
$id = CGOP_gis_generate_direct_import_id();
$slug = CGOP_gis_get_unique_direct_import_slug( $slug, $id, $imports );
$dir = trailingslashit( CGOP_gis_get_generated_dir() ) . 'imported';
if ( ! wp_mkdir_p( $dir ) ) {
CGOP_gis_redirect_with_error( __( 'Could not create the uploaded GeoJSON output directory.', 'county-gop-core' ) );
}
$relative = 'imported/' . $slug . '.geojson';
$path = trailingslashit( $dir ) . $slug . '.geojson';
$json = wp_json_encode( $geojson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
if ( ! $json || false === file_put_contents( $path, $json ) ) {
CGOP_gis_redirect_with_error( __( 'Could not write the uploaded GeoJSON file.', 'county-gop-core' ) );
}
$imports[ $id ] = array(
'id' => $id,
'label' => $label,
'output_file' => $relative,
'file_hash' => md5( $json ),
'created_at' => current_time( 'mysql' ),
'updated_at' => current_time( 'mysql' ),
);
update_option( CGOP_GIS_DIRECT_IMPORTS_OPTION, $imports, false );
$status = get_option( CGOP_GIS_IMPORT_STATUS_OPTION, array() );
$manifest_result = CGOP_gis_write_manifest( is_array( $status ) ? $status : array() );
if ( is_wp_error( $manifest_result ) ) {
CGOP_gis_redirect_with_error( $manifest_result->get_error_message() );
}
wp_safe_redirect( add_query_arg( 'CGOP_gis_direct_imported', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_gis_import_direct_geojson', 'CGOP_gis_handle_import_direct_geojson' );
/**
* Generate a unique direct upload slug.
*
* @param string $slug Existing slug.
* @param string $import_id Import ID.
* @param array[] $imports Existing imports.
* @return string
*/
function CGOP_gis_get_unique_direct_import_slug( $slug, $import_id, $imports ) {
$base = sanitize_title( $slug ) ?: 'uploaded-map';
$candidate = $base;
$i = 2;
while ( CGOP_gis_direct_import_output_exists( 'imported/' . $candidate . '.geojson', $imports, $import_id ) ) {
$candidate = $base . '-' . $i;
$i++;
}
return $candidate;
}
/**
* Determine whether a direct import output file is already registered.
*
* @param string $output_file Output file path.
* @param array[] $imports Existing imports.
* @param string $exclude_id Import ID to ignore.
* @return bool
*/
function CGOP_gis_direct_import_output_exists( $output_file, $imports, $exclude_id = '' ) {
foreach ( $imports as $id => $import ) {
if ( '' !== $exclude_id && (string) $id === (string) $exclude_id ) {
continue;
}
if ( ltrim( (string) ( $import['output_file'] ?? '' ), '/' ) === $output_file ) {
return true;
}
}
return false;
}
/**
* Clear Position Key assignments and map boundary rows for a deleted direct import.
*
* @param string $import_id Direct import ID.
* @param string $output_file Relative output file.
* @return int Number of map boundary rows deleted.
*/
function CGOP_gis_clear_deleted_direct_import_assignments( $import_id, $output_file ) {
$refs = array(
'direct:' . (string) $import_id,
CGOP_gis_get_generated_file_url( $output_file ),
);
$refs = array_values( array_unique( array_filter( array_map( 'strval', $refs ) ) ) );
global $wpdb;
$map_table = CGOP_map_boundaries_table();
$position_table = CGOP_position_keys_table();
$deleted = 0;
// County scope for position_keys updates.
$pk_scope = function_exists( 'cgop_get_table_county_scope' ) ? cgop_get_table_county_scope( $position_table ) : false;
if ( null === $pk_scope ) {
return 0; // Column exists but no county context — skip to avoid cross-county writes.
}
foreach ( $refs as $ref ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$map_ids = $wpdb->get_col(
$wpdb->prepare( "SELECT map_id FROM `{$map_table}` WHERE json_file = %s", $ref )
);
foreach ( $map_ids as $map_id ) {
if ( false !== $pk_scope ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update( $position_table, array( 'live_in_map' => '' ), array( 'live_in_map' => $map_id, 'county_id' => $pk_scope ), array( '%s' ), array( '%s', '%s' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update( $position_table, array( 'voting_area_map' => '' ), array( 'voting_area_map' => $map_id, 'county_id' => $pk_scope ), array( '%s' ), array( '%s', '%s' ) );
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update( $position_table, array( 'live_in_map' => '' ), array( 'live_in_map' => $map_id ), array( '%s' ), array( '%s' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update( $position_table, array( 'voting_area_map' => '' ), array( 'voting_area_map' => $map_id ), array( '%s' ), array( '%s' ) );
}
if ( CGOP_delete_map_boundary( $map_id ) ) {
$deleted++;
}
}
}
CGOP_position_keys_clear_cache();
return $deleted;
}
/**
* Handle deleting a direct uploaded GeoJSON import.
*/
function CGOP_gis_handle_delete_direct_geojson() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'You do not have permission to manage GIS boundary imports.', 'county-gop-core' ) );
}
$import_id = sanitize_text_field( wp_unslash( $_POST['import_id'] ?? '' ) );
check_admin_referer( 'CGOP_gis_delete_direct_geojson_' . $import_id );
$imports = CGOP_gis_get_direct_geojson_imports();
if ( ! $import_id || ! isset( $imports[ $import_id ] ) ) {
CGOP_gis_redirect_with_error( __( 'Uploaded GeoJSON import not found.', 'county-gop-core' ) );
}
$output_file = ltrim( (string) ( $imports[ $import_id ]['output_file'] ?? '' ), '/' );
if ( $output_file ) {
$base = wp_normalize_path( trailingslashit( CGOP_gis_get_generated_dir() ) );
$path = wp_normalize_path( $base . $output_file );
if ( ! str_starts_with( $path, $base ) ) {
CGOP_gis_redirect_with_error( __( 'Uploaded GeoJSON file path is outside the allowed map directory.', 'county-gop-core' ) );
}
if ( file_exists( $path ) && ! wp_delete_file( $path ) ) {
CGOP_gis_redirect_with_error( __( 'Could not delete the uploaded GeoJSON file.', 'county-gop-core' ) );
}
}
CGOP_gis_clear_deleted_direct_import_assignments( $import_id, $output_file );
unset( $imports[ $import_id ] );
update_option( CGOP_GIS_DIRECT_IMPORTS_OPTION, $imports, false );
$status = get_option( CGOP_GIS_IMPORT_STATUS_OPTION, array() );
$manifest_result = CGOP_gis_write_manifest( is_array( $status ) ? $status : array() );
if ( is_wp_error( $manifest_result ) ) {
CGOP_gis_redirect_with_error( $manifest_result->get_error_message() );
}
wp_safe_redirect( add_query_arg( 'CGOP_gis_direct_deleted', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_gis_delete_direct_geojson', 'CGOP_gis_handle_delete_direct_geojson' );
/**
* Render the Composite Maps admin section.
*
* @param array[] $composites Current composite map definitions.
* @param array[] $source_choices Available Beacon-layer files for source selection.
*/
function CGOP_gis_render_composite_maps_section( $composites, $source_choices ) {
?>
<section class="cgop-gis-panel">
<div class="cgop-gis-panel__header">
<div>
<h2><?php esc_html_e( 'Composite Maps', 'county-gop-core' ); ?></h2>
<p><?php esc_html_e( 'Combine generated GeoJSON files into one assignable map. Composites do not call Beacon and do not dissolve boundaries; they package selected source features together for Position Key map assignments.', 'county-gop-core' ); ?></p>
</div>
</div>
<?php if ( $composites ) : ?>
<table class="widefat striped cgop-gis-table">
<thead>
<tr>
<th><?php esc_html_e( 'Label', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Composite ID', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Output File', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Sources', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Updated', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Actions', 'county-gop-core' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $composites as $composite ) : ?>
<?php
$c_url = ! empty( $composite['output_file'] ) ? CGOP_gis_get_generated_file_url( $composite['output_file'] ) : '';
$c_count = count( $composite['source_files'] ?? array() );
$c_updated = $composite['updated_at'] ?? $composite['created_at'] ?? '';
?>
<tr>
<td><strong><?php echo esc_html( $composite['label'] ); ?></strong></td>
<td><code class="cgop-gis-id"><?php echo esc_html( $composite['id'] ); ?></code></td>
<td><code><?php echo esc_html( $composite['output_file'] ?? '' ); ?></code></td>
<td><?php echo esc_html( number_format_i18n( $c_count ) ); ?></td>
<td><?php echo $c_updated ? esc_html( $c_updated ) : esc_html__( 'Unknown', 'county-gop-core' ); ?></td>
<td>
<div class="cgop-gis-actions">
<?php if ( $c_url ) : ?>
<a class="button" href="<?php echo esc_url( $c_url ); ?>" target="_blank" rel="noopener">
<?php esc_html_e( 'View JSON', 'county-gop-core' ); ?>
</a>
<?php endif; ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="CGOP_gis_rebuild_composite">
<input type="hidden" name="composite_id" value="<?php echo esc_attr( $composite['id'] ); ?>">
<?php wp_nonce_field( 'CGOP_gis_rebuild_composite_' . $composite['id'] ); ?>
<button type="submit" class="button">
<?php esc_html_e( 'Rebuild', 'county-gop-core' ); ?>
</button>
</form>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="CGOP_gis_delete_composite">
<input type="hidden" name="composite_id" value="<?php echo esc_attr( $composite['id'] ); ?>">
<?php wp_nonce_field( 'CGOP_gis_delete_composite_' . $composite['id'] ); ?>
<button type="submit" class="button button-link-delete"
onclick="return confirm(<?php echo esc_attr( wp_json_encode( __( 'Delete this composite map? Source files will not be deleted. Position Key assignments that pointed to this composite may stop rendering until reassigned.', 'county-gop-core' ) ) ); ?>)">
<?php esc_html_e( 'Delete', 'county-gop-core' ); ?>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else : ?>
<p><?php esc_html_e( 'No composite maps defined yet.', 'county-gop-core' ); ?></p>
<?php endif; ?>
<h3><?php esc_html_e( 'Create Composite Map', 'county-gop-core' ); ?></h3>
<?php if ( ! $source_choices ) : ?>
<p><?php esc_html_e( 'No generated GeoJSON files are available yet. Regenerate a Beacon layer first to create source files for composites.', 'county-gop-core' ); ?></p>
<?php else : ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'CGOP_gis_create_composite' ); ?>
<input type="hidden" name="action" value="CGOP_gis_create_composite">
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="composite_label"><?php esc_html_e( 'Composite label', 'county-gop-core' ); ?> <span class="cgop-gis-required">*</span></label></th>
<td>
<input class="regular-text" id="composite_label" name="composite_label" type="text" value="" required>
<p class="description"><?php esc_html_e( 'A human-readable name. Used in the Position Keys generated-file dropdown as "Composites - {label}".', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="composite_slug"><?php esc_html_e( 'Output slug / filename', 'county-gop-core' ); ?></label></th>
<td>
<input class="regular-text" id="composite_slug" name="composite_slug" type="text" value="">
<p class="description"><?php esc_html_e( 'Optional. Leave blank to generate from the label. Output path: composites/{slug}.geojson inside the generated maps directory.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Source generated files', 'county-gop-core' ); ?> <span class="cgop-gis-required">*</span></th>
<td>
<?php
$current_group = null;
foreach ( $source_choices as $relative => $choice ) :
$group = $choice['layer_label'];
if ( $group !== $current_group ) :
if ( null !== $current_group ) :
?>
</div>
<?php
endif;
?>
<div class="cgop-gis-source-group"><?php echo esc_html( $group ); ?></div>
<div class="cgop-gis-source-list">
<?php
$current_group = $group;
endif;
?>
<label class="cgop-gis-source-item">
<input type="checkbox" name="source_files[]" value="<?php echo esc_attr( $relative ); ?>">
<?php echo esc_html( $choice['label'] ); ?>
<?php if ( ! empty( $choice['position_id'] ) ) : ?>
<small>(<?php echo esc_html( $choice['position_id'] ); ?>)</small>
<?php endif; ?>
</label>
<?php endforeach; ?>
<?php if ( null !== $current_group ) : ?>
</div>
<?php endif; ?>
<p class="description"><?php esc_html_e( 'Select two or more files to combine. The composite FeatureCollection will include all features from each selected file.', 'county-gop-core' ); ?></p>
</td>
</tr>
</table>
<?php submit_button( __( 'Create Composite Map', 'county-gop-core' ) ); ?>
</form>
<?php endif; ?>
</section>
<?php
}
/**
* Handle creating a new composite map.
*/
function CGOP_gis_handle_create_composite() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'You do not have permission to manage GIS boundary imports.', 'county-gop-core' ) );
}
check_admin_referer( 'CGOP_gis_create_composite' );
$label = sanitize_text_field( wp_unslash( $_POST['composite_label'] ?? '' ) );
if ( '' === $label ) {
CGOP_gis_redirect_with_error( __( 'Composite label is required.', 'county-gop-core' ) );
}
$slug = isset( $_POST['composite_slug'] ) ? CGOP_gis_slug( sanitize_text_field( wp_unslash( $_POST['composite_slug'] ) ) ) : '';
if ( '' === $slug ) {
$slug = CGOP_gis_slug( $label );
}
if ( '' === $slug ) {
$slug = 'composite-' . time();
}
$raw_sources = isset( $_POST['source_files'] ) && is_array( $_POST['source_files'] ) ? $_POST['source_files'] : array();
if ( empty( $raw_sources ) ) {
CGOP_gis_redirect_with_error( __( 'At least one source file must be selected.', 'county-gop-core' ) );
}
$source_files = array();
foreach ( $raw_sources as $raw_file ) {
$relative = ltrim( sanitize_text_field( wp_unslash( $raw_file ) ), '/' );
$validation = CGOP_gis_validate_composite_source_file( $relative );
if ( is_wp_error( $validation ) ) {
CGOP_gis_redirect_with_error( $validation->get_error_message() );
}
$source_files[] = $relative;
}
$composites = CGOP_gis_get_composite_maps();
$composite_id = CGOP_gis_generate_composite_id();
$slug = CGOP_gis_get_unique_composite_slug( $slug, $composite_id, $composites );
$result = CGOP_gis_save_composite( $composite_id, $label, $slug, $source_files, $composites );
if ( is_wp_error( $result ) ) {
CGOP_gis_redirect_with_error( $result->get_error_message() );
}
wp_safe_redirect( add_query_arg( 'CGOP_gis_composite_created', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_gis_create_composite', 'CGOP_gis_handle_create_composite' );
/**
* Handle rebuilding an existing composite map from its saved source files.
*/
function CGOP_gis_handle_rebuild_composite() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'You do not have permission to manage GIS boundary imports.', 'county-gop-core' ) );
}
$composite_id = isset( $_POST['composite_id'] ) ? sanitize_text_field( wp_unslash( $_POST['composite_id'] ) ) : '';
check_admin_referer( 'CGOP_gis_rebuild_composite_' . $composite_id );
if ( '' === $composite_id ) {
CGOP_gis_redirect_with_error( __( 'Invalid composite map ID.', 'county-gop-core' ) );
}
$composites = CGOP_gis_get_composite_maps();
if ( ! isset( $composites[ $composite_id ] ) ) {
CGOP_gis_redirect_with_error( __( 'Composite map not found.', 'county-gop-core' ) );
}
$composite = $composites[ $composite_id ];
$label = $composite['label'];
$output_file = $composite['output_file'] ?? '';
$slug = preg_replace( '/\.(geojson|json)$/i', '', basename( $output_file ) );
$source_files = $composite['source_files'] ?? array();
if ( empty( $source_files ) ) {
CGOP_gis_redirect_with_error( __( 'Composite map has no saved source files to rebuild from.', 'county-gop-core' ) );
}
$result = CGOP_gis_save_composite( $composite_id, $label, $slug, $source_files, $composites );
if ( is_wp_error( $result ) ) {
CGOP_gis_redirect_with_error( $result->get_error_message() );
}
wp_safe_redirect( add_query_arg( 'CGOP_gis_composite_rebuilt', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_gis_rebuild_composite', 'CGOP_gis_handle_rebuild_composite' );
/**
* Handle deleting a composite map.
*
* Deletes the physical file and removes the definition from the option.
* Does not delete source files. Position Key assignments that pointed to the
* deleted composite may stop rendering until reassigned.
*/
function CGOP_gis_handle_delete_composite() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'You do not have permission to manage GIS boundary imports.', 'county-gop-core' ) );
}
$composite_id = isset( $_POST['composite_id'] ) ? sanitize_text_field( wp_unslash( $_POST['composite_id'] ) ) : '';
check_admin_referer( 'CGOP_gis_delete_composite_' . $composite_id );
if ( '' === $composite_id ) {
CGOP_gis_redirect_with_error( __( 'Invalid composite map ID.', 'county-gop-core' ) );
}
$composites = CGOP_gis_get_composite_maps();
if ( ! isset( $composites[ $composite_id ] ) ) {
CGOP_gis_redirect_with_error( __( 'Composite map not found.', 'county-gop-core' ) );
}
$output_file = $composites[ $composite_id ]['output_file'] ?? '';
$output_shared = $output_file && CGOP_gis_composite_output_is_shared( $output_file, $composites, $composite_id );
$clear_url_refs = ! $output_shared;
CGOP_gis_clear_deleted_composite_assignments( $composite_id, $output_file, $clear_url_refs );
if ( $output_file && ! $output_shared ) {
$base = wp_normalize_path( trailingslashit( CGOP_gis_get_generated_dir() ) );
$file_path = wp_normalize_path( $base . ltrim( $output_file, '/' ) );
if ( str_starts_with( $file_path, $base ) && file_exists( $file_path ) ) {
wp_delete_file( $file_path );
}
}
unset( $composites[ $composite_id ] );
update_option( CGOP_GIS_COMPOSITE_MAPS_OPTION, $composites, false );
$status = get_option( CGOP_GIS_IMPORT_STATUS_OPTION, array() );
$manifest_result = CGOP_gis_write_manifest( is_array( $status ) ? $status : array() );
if ( is_wp_error( $manifest_result ) ) {
CGOP_gis_redirect_with_error( $manifest_result->get_error_message() );
}
wp_safe_redirect( add_query_arg( 'CGOP_gis_composite_deleted', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_gis_delete_composite', 'CGOP_gis_handle_delete_composite' );