CountyCollective Reference
ArcGIS Boundary Import
Original ArcGIS Portal and FeatureServer import, conversion, and source-change detection logic.
<?php
/**
* County GOP Core — ArcGIS Portal / FeatureServer boundary import.
*
* Extracted from theme/cgop-theme/inc/arcgis-boundary-import.php in Pass 08.
* Extends the GIS Boundary Import admin page with ArcGIS REST support.
* Public pages must never make live ArcGIS requests — this is admin import only.
*
* Cron lifecycle: daily check unscheduled on plugin deactivation (not switch_theme)
* per Pass 08 decision P08-002.
*
* Option keys, function names, admin slugs, and action names are frozen legacy
* identifiers — do not rename without explicit owner approval.
*
* @package CountyGOPCore
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'CGOP_ARCGIS_SOURCES_OPTION', 'CGOP_arcgis_boundary_sources' );
define( 'CGOP_ARCGIS_MAPS_OPTION', 'CGOP_arcgis_generated_maps' );
define( 'CGOP_ARCGIS_SUBDIR', 'arcgis' );
// ---------------------------------------------------------------------------
// Source settings helpers
// ---------------------------------------------------------------------------
function CGOP_arcgis_get_sources() {
$saved = get_option( CGOP_ARCGIS_SOURCES_OPTION, array() );
return is_array( $saved ) ? $saved : array();
}
function CGOP_arcgis_save_sources( array $sources ) {
update_option( CGOP_ARCGIS_SOURCES_OPTION, $sources );
}
function CGOP_arcgis_get_generated_maps() {
$saved = get_option( CGOP_ARCGIS_MAPS_OPTION, array() );
return is_array( $saved ) ? $saved : array();
}
function CGOP_arcgis_save_generated_maps( array $maps ) {
update_option( CGOP_ARCGIS_MAPS_OPTION, $maps );
}
function CGOP_arcgis_generate_source_id() {
$sources = CGOP_arcgis_get_sources();
for ( $i = 0; $i < 20; $i++ ) {
$id = 'arc-' . substr( wp_generate_uuid4(), 0, 13 );
if ( ! isset( $sources[ $id ] ) ) {
return $id;
}
}
return 'arc-' . wp_generate_uuid4();
}
function CGOP_arcgis_generate_map_id() {
$maps = CGOP_arcgis_get_generated_maps();
for ( $i = 0; $i < 20; $i++ ) {
$id = 'arcmap-' . substr( wp_generate_uuid4(), 0, 13 );
if ( ! isset( $maps[ $id ] ) ) {
return $id;
}
}
return 'arcmap-' . wp_generate_uuid4();
}
/**
* Parse a Portal item URL or bare item ID and extract the item ID and portal base URL.
*
* @param string $input Full portal URL or bare 32-char item ID.
* @param string $default_base Default portal base URL.
* @return array { item_id, portal_base_url } or { item_id => '', portal_base_url => $default_base } on failure.
*/
function CGOP_arcgis_parse_portal_input( $input, $default_base = 'https://gisdata.in.gov/portal' ) {
$input = trim( (string) $input );
$result = array(
'item_id' => '',
'portal_base_url' => $default_base,
);
if ( ! $input ) {
return $result;
}
// Bare 32-hex-char item ID.
if ( preg_match( '/^[a-f0-9]{32}$/i', $input ) ) {
$result['item_id'] = strtolower( $input );
return $result;
}
// Full portal item URL: .../portal/home/item.html?id=... or .../portal/sharing/rest/content/items/...
if ( preg_match( '/[?&]id=([a-f0-9]{32})/i', $input, $m ) ) {
$result['item_id'] = strtolower( $m[1] );
} elseif ( preg_match( '/\/items\/([a-f0-9]{32})/i', $input, $m ) ) {
$result['item_id'] = strtolower( $m[1] );
}
// Extract portal base URL.
if ( preg_match( '#^(https?://[^/]+/[^/]+)/#i', $input, $m ) ) {
$result['portal_base_url'] = rtrim( $m[1], '/' );
}
return $result;
}
// ---------------------------------------------------------------------------
// File path/URL helpers
// ---------------------------------------------------------------------------
/**
* Return the ArcGIS generated-files directory (absolute path).
*
* Delegates to CGOP_gis_get_generated_dir() for the county-aware base
* path, then appends the ArcGIS subdirectory.
*
* @return string Absolute filesystem path (no trailing slash).
*/
function CGOP_arcgis_get_generated_dir() {
$base = CGOP_gis_get_generated_dir();
return trailingslashit( $base ) . CGOP_ARCGIS_SUBDIR;
}
/**
* Return a public URL for an ArcGIS generated file.
*
* @param string $relative_file Filename relative to the ArcGIS subdirectory.
* @return string Full URL.
*/
function CGOP_arcgis_get_generated_url( $relative_file ) {
return CGOP_gis_get_generated_file_url( CGOP_ARCGIS_SUBDIR . '/' . ltrim( $relative_file, '/' ) );
}
// ---------------------------------------------------------------------------
// ArcGIS REST fetch helpers
// ---------------------------------------------------------------------------
function CGOP_arcgis_wp_get( $url, $extra_args = array() ) {
$args = wp_parse_args( $extra_args, array(
'timeout' => 20,
'sslverify' => true,
) );
$response = wp_safe_remote_get( esc_url_raw( $url ), $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( $code < 200 || $code >= 300 ) {
return new WP_Error( 'http_error', 'HTTP ' . $code );
}
$body = wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
if ( null === $data ) {
return new WP_Error( 'json_parse', 'Response is not valid JSON.' );
}
if ( isset( $data['error'] ) ) {
$msg = is_array( $data['error'] )
? ( $data['error']['message'] ?? 'ArcGIS error.' )
: (string) $data['error'];
return new WP_Error( 'arcgis_error', $msg );
}
return $data;
}
/**
* Fetch ArcGIS Portal item JSON.
*
* @param string $portal_base Portal base URL.
* @param string $item_id 32-char item ID.
* @return array|WP_Error
*/
function CGOP_arcgis_fetch_item_json( $portal_base, $item_id ) {
$url = trailingslashit( $portal_base ) . 'sharing/rest/content/items/' . rawurlencode( $item_id ) . '?f=json';
return CGOP_arcgis_wp_get( $url );
}
/**
* Discover FeatureServer URL from item JSON.
*
* @param array $item Item JSON decoded.
* @return string Service URL or empty string.
*/
function CGOP_arcgis_discover_service_url( array $item ) {
// Field 'url' is the direct service URL for hosted feature layers.
if ( ! empty( $item['url'] ) ) {
return rtrim( (string) $item['url'], '/' );
}
return '';
}
/**
* Fetch FeatureServer layer list.
*
* @param string $service_url FeatureServer base URL.
* @return array|WP_Error Array with 'layers' key.
*/
function CGOP_arcgis_fetch_layers( $service_url ) {
$url = rtrim( $service_url, '/' ) . '?f=json';
$data = CGOP_arcgis_wp_get( $url );
if ( is_wp_error( $data ) ) {
return $data;
}
return isset( $data['layers'] ) ? $data : new WP_Error( 'no_layers', 'No layers found in FeatureServer metadata.' );
}
/**
* Fetch a lightweight preview of a layer (5 records, no geometry).
*
* @param string $service_url FeatureServer base URL.
* @param int $layer_id Layer ID.
* @return array|WP_Error
*/
function CGOP_arcgis_preview_layer( $service_url, $layer_id ) {
$url = rtrim( $service_url, '/' ) . '/' . (int) $layer_id
. '/query?where=1%3D1&outFields=*&returnGeometry=false&resultRecordCount=5&f=json';
return CGOP_arcgis_wp_get( $url );
}
/**
* Fetch layer features as GeoJSON. Tries native GeoJSON first, falls back to ArcGIS JSON conversion.
*
* @param string $service_url FeatureServer base URL.
* @param int $layer_id Layer ID.
* @param string $where WHERE clause (default: 1=1).
* @return array|WP_Error GeoJSON FeatureCollection array.
*/
function CGOP_arcgis_fetch_layer_geojson( $service_url, $layer_id, $where = '1=1' ) {
$base = rtrim( $service_url, '/' ) . '/' . (int) $layer_id . '/query';
$query = '?where=' . rawurlencode( $where ) . '&outFields=*&returnGeometry=true&outSR=4326&f=geojson';
$response = wp_safe_remote_get( esc_url_raw( $base . $query ), array( 'timeout' => 30 ) );
if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) {
$body = wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
if ( is_array( $data ) && isset( $data['type'] ) && 'FeatureCollection' === $data['type'] ) {
return $data;
}
}
// Fallback: fetch ArcGIS JSON and convert.
$query_json = '?where=' . rawurlencode( $where ) . '&outFields=*&returnGeometry=true&outSR=4326&f=json';
$data = CGOP_arcgis_wp_get( $base . $query_json );
if ( is_wp_error( $data ) ) {
return $data;
}
return CGOP_arcgis_convert_esri_to_geojson( $data );
}
/**
* Convert ArcGIS FeatureSet (f=json) to GeoJSON FeatureCollection.
*
* @param array $featureset ArcGIS FeatureSet.
* @return array|WP_Error GeoJSON FeatureCollection.
*/
function CGOP_arcgis_convert_esri_to_geojson( array $featureset ) {
if ( empty( $featureset['features'] ) ) {
return new WP_Error( 'no_features', 'No features returned from query.' );
}
$geo_features = array();
foreach ( $featureset['features'] as $f ) {
$attrs = is_array( $f['attributes'] ?? null ) ? $f['attributes'] : array();
$geometry = $f['geometry'] ?? null;
$geo_geom = CGOP_arcgis_esri_geometry_to_geojson( $geometry );
if ( null === $geo_geom ) {
continue;
}
$geo_features[] = array(
'type' => 'Feature',
'properties' => $attrs,
'geometry' => $geo_geom,
);
}
if ( empty( $geo_features ) ) {
return new WP_Error( 'no_valid_features', 'No convertible polygon features found.' );
}
return array(
'type' => 'FeatureCollection',
'features' => $geo_features,
);
}
/**
* Convert a single ArcGIS geometry object to GeoJSON geometry.
*
* @param array|null $esri_geom ArcGIS geometry.
* @return array|null GeoJSON geometry or null on failure.
*/
function CGOP_arcgis_esri_geometry_to_geojson( $esri_geom ) {
if ( ! is_array( $esri_geom ) ) {
return null;
}
// Polygon: rings array. First ring = outer boundary, rest = holes.
if ( isset( $esri_geom['rings'] ) && is_array( $esri_geom['rings'] ) ) {
$rings = array_map(
function ( $ring ) {
return array_map( function ( $pt ) { return array( (float) $pt[0], (float) $pt[1] ); }, $ring );
},
$esri_geom['rings']
);
if ( count( $rings ) === 1 ) {
return array( 'type' => 'Polygon', 'coordinates' => $rings );
}
return array( 'type' => 'Polygon', 'coordinates' => $rings );
}
// Polyline: paths. Convert to MultiLineString (usually not used for districts).
if ( isset( $esri_geom['paths'] ) && is_array( $esri_geom['paths'] ) ) {
$paths = array_map(
function ( $path ) {
return array_map( function ( $pt ) { return array( (float) $pt[0], (float) $pt[1] ); }, $path );
},
$esri_geom['paths']
);
return array( 'type' => 'MultiLineString', 'coordinates' => $paths );
}
// Point.
if ( isset( $esri_geom['x'] ) && isset( $esri_geom['y'] ) ) {
return array( 'type' => 'Point', 'coordinates' => array( (float) $esri_geom['x'], (float) $esri_geom['y'] ) );
}
return null;
}
// ---------------------------------------------------------------------------
// Template / slugify helpers
// ---------------------------------------------------------------------------
function CGOP_arcgis_slugify( $value ) {
return sanitize_title( (string) $value );
}
/**
* Apply {TOKEN} and {TOKEN_slug} substitutions from feature attributes.
*
* @param string $template Template string with {FIELD} tokens.
* @param array $attrs Feature attribute key => value.
* @return string Rendered string.
*/
function CGOP_arcgis_render_template( $template, array $attrs ) {
$result = (string) $template;
// Substitute {FIELD_slug} first (must come before {FIELD}).
foreach ( $attrs as $key => $value ) {
$slug_token = '{' . $key . '_slug}';
$result = str_replace( $slug_token, CGOP_arcgis_slugify( (string) $value ), $result );
}
// Substitute {FIELD}.
foreach ( $attrs as $key => $value ) {
$token = '{' . $key . '}';
$result = str_replace( $token, (string) $value, $result );
}
// Common aliases.
foreach ( $attrs as $key => $value ) {
$upper = strtoupper( $key );
if ( $upper !== $key ) {
$result = str_replace( '{' . $upper . '}', (string) $value, $result );
$result = str_replace( '{' . $upper . '_slug}', CGOP_arcgis_slugify( (string) $value ), $result );
}
}
return $result;
}
// ---------------------------------------------------------------------------
// File write helpers
// ---------------------------------------------------------------------------
/**
* Ensure the arcgis output subdirectory exists and is writable.
*
* @return string|WP_Error Directory path on success.
*/
function CGOP_arcgis_ensure_dir() {
$dir = CGOP_arcgis_get_generated_dir();
if ( ! wp_mkdir_p( $dir ) ) {
return new WP_Error( 'mkdir_failed', 'Could not create directory: ' . $dir );
}
return $dir;
}
/**
* Write a GeoJSON array to a local file under the arcgis/ subdirectory.
*
* @param string $filename Filename relative to the arcgis/ directory (must end in .geojson).
* @param array $geojson GeoJSON array.
* @return string|WP_Error Relative file path (arcgis/filename.geojson) on success.
*/
function CGOP_arcgis_write_geojson( $filename, array $geojson ) {
$filename = sanitize_file_name( ltrim( $filename, '/' ) );
if ( ! $filename ) {
return new WP_Error( 'bad_filename', 'Empty filename.' );
}
if ( ! str_ends_with( $filename, '.geojson' ) && ! str_ends_with( $filename, '.json' ) ) {
$filename .= '.geojson';
}
$dir = CGOP_arcgis_ensure_dir();
if ( is_wp_error( $dir ) ) {
return $dir;
}
$tmp = $dir . '/' . $filename . '.tmp';
$dest = $dir . '/' . $filename;
$json = wp_json_encode( $geojson, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE );
if ( false === $json ) {
return new WP_Error( 'json_encode', 'Could not encode GeoJSON.' );
}
global $wp_filesystem;
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
WP_Filesystem();
if ( ! $wp_filesystem->put_contents( $tmp, $json, FS_CHMOD_FILE ) ) {
return new WP_Error( 'write_failed', 'Could not write file: ' . $tmp );
}
if ( file_exists( $dest ) ) {
wp_delete_file( $dest );
}
if ( ! rename( $tmp, $dest ) ) {
wp_delete_file( $tmp );
return new WP_Error( 'rename_failed', 'Could not rename temp file to destination.' );
}
return CGOP_ARCGIS_SUBDIR . '/' . $filename;
}
// ---------------------------------------------------------------------------
// Import: one feature per file
// ---------------------------------------------------------------------------
/**
* Import an ArcGIS layer, creating one local GeoJSON file per feature.
*
* @param array $source Source definition array.
* @param int $layer_id Layer ID within the FeatureServer.
* @param string $where WHERE clause.
* @param string $label_template Template for the human-readable label.
* @param string $filename_template Template for the filename (without .geojson).
* @param bool $single_file When true, write all features into one file.
* @return array { imported, skipped, errors } — results summary.
*/
function CGOP_arcgis_import_layer( $source, $layer_id, $where, $label_template, $filename_template, $single_file = false ) {
$service_url = $source['service_url'] ?? '';
if ( ! $service_url ) {
return array( 'imported' => 0, 'skipped' => 0, 'errors' => array( 'No service URL configured for this source.' ) );
}
$geojson = CGOP_arcgis_fetch_layer_geojson( $service_url, $layer_id, $where );
if ( is_wp_error( $geojson ) ) {
return array( 'imported' => 0, 'skipped' => 0, 'errors' => array( $geojson->get_error_message() ) );
}
if ( empty( $geojson['features'] ) ) {
return array( 'imported' => 0, 'skipped' => 0, 'errors' => array( 'Query returned no features.' ) );
}
$imported = 0;
$skipped = 0;
$errors = array();
$now = current_time( 'mysql' );
$source_hash = md5( wp_json_encode( array( $source['portal_item_id'], (int) $layer_id, $where ) ) );
$maps = CGOP_arcgis_get_generated_maps();
if ( $single_file ) {
// All features in one file.
$single_file_attrs = array();
if ( 1 === count( $geojson['features'] ) ) {
$single_file_attrs = is_array( $geojson['features'][0]['properties'] ?? null )
? $geojson['features'][0]['properties']
: array();
}
$rendered_filename = CGOP_arcgis_render_template( $filename_template, $single_file_attrs );
if ( ! $rendered_filename ) {
$rendered_filename = 'layer-' . $source['source_id'] . '-' . $layer_id;
}
$rendered_label = CGOP_arcgis_render_template( $label_template, $single_file_attrs );
if ( ! $rendered_label ) {
$rendered_label = $rendered_filename;
}
$result = CGOP_arcgis_write_geojson( $rendered_filename . '.geojson', $geojson );
if ( is_wp_error( $result ) ) {
$errors[] = $result->get_error_message();
} else {
$map_id = CGOP_arcgis_generate_map_id();
$maps[ $map_id ] = array(
'map_id' => $map_id,
'source_id' => $source['source_id'],
'portal_item_id' => $source['portal_item_id'],
'service_url' => $service_url,
'layer_id' => (string) $layer_id,
'layer_name' => '',
'feature_id' => 'all',
'feature_label' => $rendered_label,
'output_file' => $result,
'file_hash' => md5( wp_json_encode( $geojson ) ),
'source_hash' => $source_hash,
'created_at' => $now,
'updated_at' => $now,
);
$imported++;
}
} else {
// One file per feature.
foreach ( $geojson['features'] as $feature ) {
$attrs = is_array( $feature['properties'] ?? null ) ? $feature['properties'] : array();
$objectid = (string) ( $attrs['OBJECTID'] ?? $attrs['objectid'] ?? $attrs['FID'] ?? $imported );
$rendered_filename = CGOP_arcgis_render_template( $filename_template, $attrs );
if ( ! $rendered_filename ) {
$rendered_filename = 'feature-' . $objectid;
}
$rendered_label = CGOP_arcgis_render_template( $label_template, $attrs );
if ( ! $rendered_label ) {
$rendered_label = $rendered_filename;
}
$single_geojson = array(
'type' => 'FeatureCollection',
'features' => array( $feature ),
);
$result = CGOP_arcgis_write_geojson( $rendered_filename . '.geojson', $single_geojson );
if ( is_wp_error( $result ) ) {
$errors[] = 'Feature ' . $objectid . ': ' . $result->get_error_message();
$skipped++;
continue;
}
$map_id = CGOP_arcgis_generate_map_id();
$maps[ $map_id ] = array(
'map_id' => $map_id,
'source_id' => $source['source_id'],
'portal_item_id' => $source['portal_item_id'],
'service_url' => $service_url,
'layer_id' => (string) $layer_id,
'layer_name' => '',
'feature_id' => $objectid,
'feature_label' => $rendered_label,
'output_file' => $result,
'file_hash' => md5( wp_json_encode( $single_geojson ) ),
'source_hash' => $source_hash,
'created_at' => $now,
'updated_at' => $now,
);
$imported++;
}
}
if ( $imported > 0 ) {
CGOP_arcgis_save_generated_maps( $maps );
// Update source last_imported_at.
$sources = CGOP_arcgis_get_sources();
if ( isset( $sources[ $source['source_id'] ] ) ) {
$sources[ $source['source_id'] ]['last_imported_at'] = $now;
CGOP_arcgis_save_sources( $sources );
}
}
return compact( 'imported', 'skipped', 'errors' );
}
// ---------------------------------------------------------------------------
// Auto-assign to Position Keys
// ---------------------------------------------------------------------------
/**
* Attempt to match a feature label to existing Position Keys and assign the map.
*
* @param string $map_id Map boundary map_id (from map_boundaries table).
* @param string $label Feature label to match against position labels.
* @param bool $overwrite Whether to overwrite existing voting_area_map assignments.
* @return int Number of positions updated.
*/
function CGOP_arcgis_auto_assign_to_positions( $map_id, $label, $overwrite = false ) {
global $wpdb;
$table = CGOP_position_keys_table();
$label = trim( strtolower( (string) $label ) );
$updated = 0;
// County scope — skip auto-assign when no county context to avoid cross-county writes.
$scope = function_exists( 'cgop_get_table_county_scope' ) ? cgop_get_table_county_scope( $table ) : false;
if ( null === $scope ) {
return 0;
}
if ( false !== $scope ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT id, position_key, label, role_title, district, voting_area_map FROM `{$table}` WHERE page_location = 'representative' AND county_id = %s",
$scope
) );
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results( "SELECT id, position_key, label, role_title, district, voting_area_map FROM `{$table}` WHERE page_location = 'representative'" );
}
if ( ! $rows ) {
return 0;
}
foreach ( $rows as $row ) {
if ( ! $overwrite && '' !== trim( (string) $row->voting_area_map ) ) {
continue;
}
// Build normalized match string: role_title + district.
$candidate = strtolower( trim( (string) $row->role_title . ' ' . $row->district ) );
$candidate = preg_replace( '/\s+/', ' ', $candidate );
// Simple containment match both ways.
if (
'' !== $candidate &&
'' !== $label &&
( str_contains( $label, $candidate ) || str_contains( $candidate, $label ) )
) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$table,
array( 'voting_area_map' => $map_id ),
array( 'id' => (int) $row->id ),
array( '%s' ),
array( '%d' )
);
$updated++;
}
}
CGOP_position_keys_clear_cache();
return $updated;
}
// ---------------------------------------------------------------------------
// Generated file choices integration
// ---------------------------------------------------------------------------
/**
* Inject ArcGIS-generated maps into the GIS generated-file choices array.
*
* @param array $choices Existing choices from Beacon/composite maps.
* @return array Extended choices including ArcGIS maps.
*/
function CGOP_arcgis_inject_file_choices( array $choices ) {
$maps = CGOP_arcgis_get_generated_maps();
if ( empty( $maps ) ) {
return $choices;
}
foreach ( $maps as $map_id => $map ) {
if ( empty( $map['output_file'] ) ) {
continue;
}
$key = 'arcgis:' . $map_id;
$choices[ $key ] = array(
'file' => $map['output_file'],
'url' => CGOP_arcgis_get_generated_url( basename( $map['output_file'] ) ),
'label' => $map['feature_label'] ?: basename( $map['output_file'] ),
'position_id' => '',
'layer_label' => __( 'ArcGIS', 'county-gop-core' ),
'is_arcgis' => true,
'arcgis_id' => $map_id,
);
}
return $choices;
}
add_filter( 'CGOP_gis_generated_file_choices', 'CGOP_arcgis_inject_file_choices' );
// ---------------------------------------------------------------------------
// Change checker
// ---------------------------------------------------------------------------
/**
* Build a stable hash from ArcGIS item and service metadata.
*
* @param array $item_json Decoded item JSON.
* @param array $service_json Decoded FeatureServer root JSON (may be empty).
* @return string MD5 hash.
*/
function CGOP_arcgis_build_source_hash( array $item_json, array $service_json ) {
$parts = array(
$item_json['modified'] ?? '',
$item_json['url'] ?? '',
$item_json['size'] ?? '',
$item_json['numViews'] ?? '',
);
if ( ! empty( $service_json['layers'] ) && is_array( $service_json['layers'] ) ) {
foreach ( $service_json['layers'] as $layer ) {
$parts[] = ( $layer['id'] ?? '' ) . ':' . ( $layer['name'] ?? '' );
}
}
if ( ! empty( $service_json['editingInfo'] ) ) {
$parts[] = wp_json_encode( $service_json['editingInfo'] );
}
return md5( implode( '|', $parts ) );
}
/**
* Run the change check for one source.
*
* @param array $source Source definition.
* @param string $source_id Source ID key.
*/
function CGOP_arcgis_run_check_source( $source, $source_id ) {
$item_json = CGOP_arcgis_fetch_item_json( $source['portal_base_url'] ?? 'https://gisdata.in.gov/portal', $source['portal_item_id'] ?? '' );
$service_json = array();
$now = current_time( 'mysql' );
if ( is_wp_error( $item_json ) ) {
$source['status'] = 'error';
$source['last_error'] = $item_json->get_error_message();
$source['last_checked_at'] = $now;
return $source;
}
// Discover and try to fetch service metadata (lightweight — no geometry).
$service_url = $source['service_url'] ?: CGOP_arcgis_discover_service_url( $item_json );
if ( $service_url ) {
$svc = CGOP_arcgis_wp_get( rtrim( $service_url, '/' ) . '?f=json' );
if ( ! is_wp_error( $svc ) ) {
$service_json = $svc;
}
if ( ! $source['service_url'] ) {
$source['service_url'] = $service_url;
}
}
$hash = CGOP_arcgis_build_source_hash( $item_json, $service_json );
$source['last_checked_at'] = $now;
$source['detected_hash'] = $hash;
$source['last_error'] = '';
if ( '' === $source['last_hash'] ) {
// First check — store hash as baseline.
$source['last_hash'] = $hash;
$source['status'] = 'current';
} elseif ( $hash !== $source['last_hash'] ) {
$source['status'] = 'changed';
} else {
$source['status'] = 'current';
}
return $source;
}
/**
* Run the daily change check for all enabled sources.
*/
function CGOP_arcgis_run_check_all() {
$sources = CGOP_arcgis_get_sources();
if ( empty( $sources ) ) {
return;
}
$changed = false;
foreach ( $sources as $source_id => $source ) {
if ( empty( $source['enabled'] ) ) {
continue;
}
if ( empty( $source['portal_item_id'] ) ) {
continue;
}
$sources[ $source_id ] = CGOP_arcgis_run_check_source( $source, $source_id );
$changed = true;
}
if ( $changed ) {
CGOP_arcgis_save_sources( $sources );
}
}
add_action( 'CGOP_daily_arcgis_boundary_check', 'CGOP_arcgis_run_check_all' );
// ---------------------------------------------------------------------------
// WP-Cron scheduling
// ---------------------------------------------------------------------------
function CGOP_arcgis_schedule() {
if ( ! wp_next_scheduled( 'CGOP_daily_arcgis_boundary_check' ) ) {
wp_schedule_event( time(), 'daily', 'CGOP_daily_arcgis_boundary_check' );
}
}
add_action( 'init', 'CGOP_arcgis_schedule' );
function CGOP_arcgis_unschedule() {
$ts = wp_next_scheduled( 'CGOP_daily_arcgis_boundary_check' );
if ( $ts ) {
wp_unschedule_event( $ts, 'CGOP_daily_arcgis_boundary_check' );
}
}
// Cron tied to plugin lifecycle, not theme switching (Pass 08 decision P08-002).
register_deactivation_hook( CGOP_CORE_PLUGIN_FILE, 'CGOP_arcgis_unschedule' );
// ---------------------------------------------------------------------------
// Admin notice
// ---------------------------------------------------------------------------
function CGOP_arcgis_admin_notice() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
return;
}
$sources = CGOP_arcgis_get_sources();
$changed_ids = array();
foreach ( $sources as $source_id => $source ) {
if (
'changed' === ( $source['status'] ?? '' ) &&
! empty( $source['detected_hash'] ) &&
$source['detected_hash'] !== ( $source['dismissed_hash'] ?? '' )
) {
$changed_ids[] = array( 'id' => $source_id, 'label' => $source['label'] ?? $source_id );
}
}
if ( empty( $changed_ids ) ) {
return;
}
$gis_url = function_exists( 'CGOP_gis_get_admin_url' ) ? CGOP_gis_get_admin_url() : admin_url( 'admin.php?page=cgop-gis-boundary-import' );
foreach ( $changed_ids as $item ) {
?>
<div class="notice notice-warning">
<p>
<strong><?php esc_html_e( 'ArcGIS boundary source changed', 'county-gop-core' ); ?></strong>
—
<?php echo esc_html( sprintf( __( '"%s" may have changed. Review GIS Boundary Import.', 'county-gop-core' ), $item['label'] ) ); ?>
</p>
<p>
<a href="<?php echo esc_url( $gis_url . '#cgop-arcgis-sources' ); ?>"><?php esc_html_e( 'Open GIS Boundary Import', 'county-gop-core' ); ?></a>
•
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline">
<input type="hidden" name="action" value="CGOP_arcgis_dismiss_notice">
<input type="hidden" name="source_id" value="<?php echo esc_attr( $item['id'] ); ?>">
<?php wp_nonce_field( 'CGOP_arcgis_dismiss_notice_' . $item['id'] ); ?>
<button type="submit" class="button-link"><?php esc_html_e( 'Dismiss for this change', 'county-gop-core' ); ?></button>
</form>
</p>
</div>
<?php
}
}
add_action( 'admin_notices', 'CGOP_arcgis_admin_notice' );
// ---------------------------------------------------------------------------
// Admin-post handlers
// ---------------------------------------------------------------------------
function CGOP_arcgis_handle_add_source() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
}
check_admin_referer( 'CGOP_arcgis_add_source' );
$portal_input = sanitize_text_field( wp_unslash( $_POST['portal_item_url'] ?? '' ) );
$portal_base = esc_url_raw( trim( $_POST['portal_base_url'] ?? 'https://gisdata.in.gov/portal' ) );
$label = sanitize_text_field( wp_unslash( $_POST['arcgis_label'] ?? '' ) );
$service_url = esc_url_raw( trim( $_POST['service_url_override'] ?? '' ) );
$enabled = ! empty( $_POST['arcgis_enabled'] );
$notes = sanitize_textarea_field( wp_unslash( $_POST['arcgis_notes'] ?? '' ) );
if ( ! $portal_input ) {
CGOP_gis_redirect_with_error( __( 'Portal item URL or ID is required.', 'county-gop-core' ) );
}
$parsed = CGOP_arcgis_parse_portal_input( $portal_input, $portal_base );
$item_id = $parsed['item_id'];
$portal_base = $parsed['portal_base_url'];
if ( ! $item_id ) {
CGOP_gis_redirect_with_error( __( 'Could not parse an ArcGIS item ID from the input.', 'county-gop-core' ) );
}
$source_id = CGOP_arcgis_generate_source_id();
$sources = CGOP_arcgis_get_sources();
$sources[ $source_id ] = array(
'source_id' => $source_id,
'label' => $label ?: 'ArcGIS Source ' . $item_id,
'portal_item_url' => esc_url_raw( 'https://' . wp_parse_url( $portal_base, PHP_URL_HOST ) . '/portal/home/item.html?id=' . $item_id ),
'portal_item_id' => $item_id,
'portal_base_url' => $portal_base,
'service_url' => $service_url,
'enabled' => $enabled,
'notes' => $notes,
'last_checked_at' => '',
'last_imported_at' => '',
'last_hash' => '',
'detected_hash' => '',
'status' => 'never_checked',
'last_error' => '',
'dismissed_hash' => '',
);
CGOP_arcgis_save_sources( $sources );
wp_safe_redirect( add_query_arg( 'arcgis_added', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_arcgis_add_source', 'CGOP_arcgis_handle_add_source' );
function CGOP_arcgis_handle_delete_source() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
}
$source_id = sanitize_key( wp_unslash( $_POST['source_id'] ?? '' ) );
check_admin_referer( 'CGOP_arcgis_delete_source_' . $source_id );
$sources = CGOP_arcgis_get_sources();
unset( $sources[ $source_id ] );
CGOP_arcgis_save_sources( $sources );
wp_safe_redirect( add_query_arg( 'arcgis_deleted', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_arcgis_delete_source', 'CGOP_arcgis_handle_delete_source' );
function CGOP_arcgis_handle_check_now() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
}
$source_id = sanitize_key( wp_unslash( $_POST['source_id'] ?? '' ) );
check_admin_referer( 'CGOP_arcgis_check_now_' . $source_id );
$sources = CGOP_arcgis_get_sources();
if ( ! isset( $sources[ $source_id ] ) ) {
CGOP_gis_redirect_with_error( __( 'ArcGIS source not found.', 'county-gop-core' ) );
}
$sources[ $source_id ] = CGOP_arcgis_run_check_source( $sources[ $source_id ], $source_id );
CGOP_arcgis_save_sources( $sources );
wp_safe_redirect( add_query_arg( 'arcgis_checked', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_arcgis_check_now', 'CGOP_arcgis_handle_check_now' );
function CGOP_arcgis_handle_dismiss_notice() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
}
$source_id = sanitize_key( wp_unslash( $_POST['source_id'] ?? '' ) );
check_admin_referer( 'CGOP_arcgis_dismiss_notice_' . $source_id );
$sources = CGOP_arcgis_get_sources();
if ( isset( $sources[ $source_id ] ) ) {
$sources[ $source_id ]['dismissed_hash'] = $sources[ $source_id ]['detected_hash'] ?? '';
CGOP_arcgis_save_sources( $sources );
}
$referer = wp_get_referer();
if ( $referer && strpos( $referer, admin_url() ) === 0 ) {
wp_safe_redirect( $referer );
} else {
wp_safe_redirect( CGOP_gis_get_admin_url() );
}
exit;
}
add_action( 'admin_post_CGOP_arcgis_dismiss_notice', 'CGOP_arcgis_handle_dismiss_notice' );
function CGOP_arcgis_handle_fetch_layers() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
}
$source_id = sanitize_key( wp_unslash( $_POST['source_id'] ?? '' ) );
check_admin_referer( 'CGOP_arcgis_fetch_layers_' . $source_id );
$sources = CGOP_arcgis_get_sources();
if ( ! isset( $sources[ $source_id ] ) ) {
CGOP_gis_redirect_with_error( __( 'ArcGIS source not found.', 'county-gop-core' ) );
}
$source = $sources[ $source_id ];
$item_json = CGOP_arcgis_fetch_item_json( $source['portal_base_url'], $source['portal_item_id'] );
if ( is_wp_error( $item_json ) ) {
CGOP_gis_redirect_with_error( __( 'Could not fetch item JSON: ', 'county-gop-core' ) . $item_json->get_error_message() );
}
$service_url = $source['service_url'] ?: CGOP_arcgis_discover_service_url( $item_json );
if ( ! $service_url ) {
CGOP_gis_redirect_with_error( __( 'No service URL found in item JSON. Add a Service URL override to this source.', 'county-gop-core' ) );
}
// Save discovered service URL.
if ( ! $source['service_url'] ) {
$sources[ $source_id ]['service_url'] = $service_url;
CGOP_arcgis_save_sources( $sources );
}
$layers_data = CGOP_arcgis_fetch_layers( $service_url );
if ( is_wp_error( $layers_data ) ) {
CGOP_gis_redirect_with_error( __( 'Could not fetch layers: ', 'county-gop-core' ) . $layers_data->get_error_message() );
}
$uid = get_current_user_id();
set_transient( 'CGOP_arcgis_layers_' . $source_id . '_' . $uid, array(
'service_url' => $service_url,
'layers' => $layers_data['layers'] ?? array(),
'tables' => $layers_data['tables'] ?? array(),
), 300 );
wp_safe_redirect( add_query_arg( array( 'arcgis_preview' => $source_id ), CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_arcgis_fetch_layers', 'CGOP_arcgis_handle_fetch_layers' );
function CGOP_arcgis_handle_preview_layer() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
}
$source_id = sanitize_key( wp_unslash( $_POST['source_id'] ?? '' ) );
$layer_id = absint( $_POST['layer_id'] ?? 0 );
$service_url = esc_url_raw( trim( $_POST['service_url'] ?? '' ) );
check_admin_referer( 'CGOP_arcgis_preview_layer_' . $source_id );
if ( ! $service_url ) {
CGOP_gis_redirect_with_error( __( 'No service URL.', 'county-gop-core' ) );
}
$preview = CGOP_arcgis_preview_layer( $service_url, $layer_id );
$uid = get_current_user_id();
set_transient( 'CGOP_arcgis_field_preview_' . $source_id . '_' . $layer_id . '_' . $uid, array(
'service_url' => $service_url,
'layer_id' => $layer_id,
'data' => is_wp_error( $preview ) ? array( 'error' => $preview->get_error_message() ) : $preview,
), 300 );
wp_safe_redirect( add_query_arg( array(
'arcgis_preview' => $source_id,
'arcgis_field_preview' => $layer_id,
), CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_arcgis_preview_layer', 'CGOP_arcgis_handle_preview_layer' );
function CGOP_arcgis_handle_import() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
}
$source_id = sanitize_key( wp_unslash( $_POST['source_id'] ?? '' ) );
check_admin_referer( 'CGOP_arcgis_import_' . $source_id );
$sources = CGOP_arcgis_get_sources();
if ( ! isset( $sources[ $source_id ] ) ) {
CGOP_gis_redirect_with_error( __( 'ArcGIS source not found.', 'county-gop-core' ) );
}
$source = $sources[ $source_id ];
$layer_id = absint( $_POST['import_layer_id'] ?? 0 );
$where = sanitize_text_field( wp_unslash( $_POST['import_where'] ?? '1=1' ) ) ?: '1=1';
$label_template = sanitize_text_field( wp_unslash( $_POST['import_label_template'] ?? '' ) );
$filename_template = sanitize_text_field( wp_unslash( $_POST['import_filename_template'] ?? '' ) );
$single_file = ! empty( $_POST['import_single_file'] );
$auto_assign = ! empty( $_POST['import_auto_assign'] );
$overwrite_assign = ! empty( $_POST['import_overwrite_assign'] );
// Use submitted service_url if source doesn't have one.
if ( empty( $source['service_url'] ) ) {
$submitted_svc = esc_url_raw( trim( $_POST['service_url'] ?? '' ) );
if ( $submitted_svc ) {
$source['service_url'] = $submitted_svc;
}
}
if ( ! $source['service_url'] ) {
CGOP_gis_redirect_with_error( __( 'No service URL. Fetch layers first.', 'county-gop-core' ) );
}
$result = CGOP_arcgis_import_layer( $source, $layer_id, $where, $label_template, $filename_template, $single_file );
// Store import result in transient for display.
$uid = get_current_user_id();
set_transient( 'CGOP_arcgis_import_result_' . $source_id . '_' . $uid, $result, 120 );
// Auto-assign to Position Keys if requested.
if ( $auto_assign && $result['imported'] > 0 ) {
$maps = CGOP_arcgis_get_generated_maps();
foreach ( $maps as $map_id => $map ) {
if ( ( $map['source_id'] ?? '' ) !== $source_id ) {
continue;
}
// Register a map_boundary record for this ArcGIS file so it can be assigned.
$mb_result = CGOP_arcgis_register_map_boundary( $map );
if ( ! is_wp_error( $mb_result ) ) {
CGOP_arcgis_auto_assign_to_positions( $mb_result, $map['feature_label'] ?? '', $overwrite_assign );
}
}
}
wp_safe_redirect( add_query_arg( array(
'arcgis_imported' => $result['imported'],
'arcgis_source' => $source_id,
), CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_arcgis_import', 'CGOP_arcgis_handle_import' );
/**
* Handle deleting an ArcGIS generated map record and local file.
*/
function CGOP_arcgis_handle_delete_map() {
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' ) );
}
$map_id = sanitize_key( wp_unslash( $_POST['map_id'] ?? '' ) );
check_admin_referer( 'CGOP_arcgis_delete_map_' . $map_id );
$maps = CGOP_arcgis_get_generated_maps();
if ( ! $map_id || ! isset( $maps[ $map_id ] ) ) {
CGOP_gis_redirect_with_error( __( 'ArcGIS generated map not found.', 'county-gop-core' ) );
}
$map = $maps[ $map_id ];
$output_file = ltrim( (string) ( $map['output_file'] ?? '' ), '/' );
$is_shared = CGOP_arcgis_output_is_shared( $output_file, $map_id );
if ( $output_file && ! $is_shared ) {
$filename = basename( $output_file );
$base = wp_normalize_path( trailingslashit( CGOP_arcgis_get_generated_dir() ) );
$path = wp_normalize_path( $base . $filename );
if ( ! str_starts_with( $path, $base ) ) {
CGOP_gis_redirect_with_error( __( 'ArcGIS generated 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 ArcGIS generated GeoJSON file.', 'county-gop-core' ) );
}
}
CGOP_arcgis_clear_deleted_map_assignments( $map_id, $output_file, ! $is_shared );
unset( $maps[ $map_id ] );
CGOP_arcgis_save_generated_maps( $maps );
wp_safe_redirect( add_query_arg( 'arcgis_map_deleted', '1', CGOP_gis_get_admin_url() ) );
exit;
}
add_action( 'admin_post_CGOP_arcgis_delete_map', 'CGOP_arcgis_handle_delete_map' );
/**
* Register (or find existing) map_boundary record for an ArcGIS-generated map.
*
* @param array $map ArcGIS generated map record.
* @return string|WP_Error map_id on success.
*/
function CGOP_arcgis_register_map_boundary( array $map ) {
if ( ! function_exists( 'CGOP_get_map_boundary_rows' ) || ! function_exists( 'CGOP_insert_map_boundary' ) ) {
return new WP_Error( 'no_map_boundaries', 'Map boundary functions unavailable.' );
}
$url = CGOP_arcgis_get_generated_url( basename( $map['output_file'] ?? '' ) );
if ( ! $url ) {
return new WP_Error( 'no_url', 'No URL for generated file.' );
}
// Check if a map_boundary with this URL already exists.
foreach ( CGOP_get_map_boundary_rows() as $row ) {
if ( $row->json_file === $url ) {
return $row->map_id;
}
}
// Create a new map_boundary record.
return CGOP_insert_map_boundary( array(
'position_key' => '',
'json_file' => $url,
) );
}
/**
* Determine whether another ArcGIS generated map uses the same output file.
*
* @param string $output_file Relative output file path.
* @param string $exclude_map_id Map ID being deleted.
* @return bool
*/
function CGOP_arcgis_output_is_shared( $output_file, $exclude_map_id = '' ) {
$output_file = ltrim( (string) $output_file, '/' );
if ( '' === $output_file ) {
return false;
}
foreach ( CGOP_arcgis_get_generated_maps() as $map_id => $map ) {
if ( '' !== $exclude_map_id && (string) $map_id === (string) $exclude_map_id ) {
continue;
}
if ( ltrim( (string) ( $map['output_file'] ?? '' ), '/' ) === $output_file ) {
return true;
}
}
return false;
}
/**
* Clear Position Key assignments and map boundary rows for a deleted ArcGIS map.
*
* @param string $map_id ArcGIS generated map ID.
* @param string $output_file Relative output file path.
* @param bool $clear_url_refs Whether URL references are safe to clear.
* @return int Number of map boundary rows deleted.
*/
function CGOP_arcgis_clear_deleted_map_assignments( $map_id, $output_file, $clear_url_refs ) {
$refs = array( 'arcgis:' . (string) $map_id );
if ( $clear_url_refs && $output_file ) {
$refs[] = CGOP_arcgis_get_generated_url( basename( $output_file ) );
}
$refs = array_values( array_unique( array_filter( array_map( 'strval', $refs ) ) ) );
if ( ! $refs ) {
return 0;
}
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 $boundary_id ) {
if ( false !== $pk_scope ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update( $position_table, array( 'live_in_map' => '' ), array( 'live_in_map' => $boundary_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' => $boundary_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' => $boundary_id ), array( '%s' ), array( '%s' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update( $position_table, array( 'voting_area_map' => '' ), array( 'voting_area_map' => $boundary_id ), array( '%s' ), array( '%s' ) );
}
if ( CGOP_delete_map_boundary( $boundary_id ) ) {
$deleted++;
}
}
}
CGOP_position_keys_clear_cache();
return $deleted;
}
// ---------------------------------------------------------------------------
// Admin section renderers (hooked into GIS page)
// ---------------------------------------------------------------------------
function CGOP_arcgis_render_gis_notices() {
if ( ! empty( $_GET['arcgis_added'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'ArcGIS source added.', 'county-gop-core' ) . '</p></div>';
}
if ( ! empty( $_GET['arcgis_deleted'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'ArcGIS source deleted.', 'county-gop-core' ) . '</p></div>';
}
if ( ! empty( $_GET['arcgis_checked'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'ArcGIS change check complete.', 'county-gop-core' ) . '</p></div>';
}
if ( ! empty( $_GET['arcgis_imported'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html( sprintf( __( 'ArcGIS import complete: %d file(s) created.', 'county-gop-core' ), (int) $_GET['arcgis_imported'] ) ) . '</p></div>';
}
if ( ! empty( $_GET['arcgis_map_deleted'] ) ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'ArcGIS generated map deleted. Position Key assignments that pointed to it were cleared.', 'county-gop-core' ) . '</p></div>';
}
}
add_action( 'CGOP_gis_admin_notices', 'CGOP_arcgis_render_gis_notices' );
function CGOP_arcgis_render_admin_sections() {
if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
return;
}
$sources = CGOP_arcgis_get_sources();
$arcgis_maps = CGOP_arcgis_get_generated_maps();
$uid = get_current_user_id();
$preview_source_id = sanitize_key( $_GET['arcgis_preview'] ?? '' );
$field_preview_lid = absint( $_GET['arcgis_field_preview'] ?? -1 );
$import_source_id = sanitize_key( $_GET['arcgis_source'] ?? '' );
$layers_transient = $preview_source_id ? get_transient( 'CGOP_arcgis_layers_' . $preview_source_id . '_' . $uid ) : null;
$field_preview_transient = ( $preview_source_id && $field_preview_lid >= 0 )
? get_transient( 'CGOP_arcgis_field_preview_' . $preview_source_id . '_' . $field_preview_lid . '_' . $uid )
: null;
$import_result = $import_source_id
? get_transient( 'CGOP_arcgis_import_result_' . $import_source_id . '_' . $uid )
: null;
$status_labels = array(
'never_checked' => __( 'Never checked', 'county-gop-core' ),
'current' => __( 'Current', 'county-gop-core' ),
'changed' => __( 'Changed', 'county-gop-core' ),
'error' => __( 'Error', 'county-gop-core' ),
);
?>
<section class="cgop-gis-panel" id="cgop-arcgis-sources">
<div class="cgop-gis-panel__header">
<div>
<h2><?php esc_html_e( 'ArcGIS State Boundary Sources', 'county-gop-core' ); ?></h2>
<p><?php esc_html_e( 'Import district boundaries from ArcGIS Portal / FeatureServer sources. Imported files are stored locally; public pages do not make live ArcGIS requests.', 'county-gop-core' ); ?></p>
</div>
</div>
<?php if ( $import_result ) : ?>
<div class="notice notice-info" style="margin:0 0 1rem">
<p>
<?php
printf(
esc_html__( 'Import complete: %1$d imported, %2$d skipped.', 'county-gop-core' ),
(int) ( $import_result['imported'] ?? 0 ),
(int) ( $import_result['skipped'] ?? 0 )
);
?>
</p>
<?php if ( ! empty( $import_result['errors'] ) ) : ?>
<ul style="margin:.5rem 0 0 1rem;list-style:disc">
<?php foreach ( $import_result['errors'] as $err ) : ?>
<li><?php echo esc_html( $err ); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ( $sources ) : ?>
<table class="widefat striped cgop-gis-table">
<thead>
<tr>
<th><?php esc_html_e( 'Label', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Portal Item ID', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Status', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Last Checked', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Actions', 'county-gop-core' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $sources as $source_id => $source ) : ?>
<tr>
<td>
<strong><?php echo esc_html( $source['label'] ?? '' ); ?></strong>
<?php if ( ! empty( $source['notes'] ) ) : ?>
<br><small style="color:#646970"><?php echo esc_html( $source['notes'] ); ?></small>
<?php endif; ?>
<?php if ( empty( $source['enabled'] ) ) : ?>
<br><small style="color:#646970"><?php esc_html_e( '(disabled)', 'county-gop-core' ); ?></small>
<?php endif; ?>
</td>
<td>
<code><?php echo esc_html( $source['portal_item_id'] ?? '' ); ?></code>
<?php if ( ! empty( $source['portal_item_url'] ) ) : ?>
<br><a href="<?php echo esc_url( $source['portal_item_url'] ); ?>" target="_blank" rel="noopener noreferrer" style="font-size:.85em"><?php esc_html_e( 'View portal item', 'county-gop-core' ); ?></a>
<?php endif; ?>
</td>
<td>
<?php
$s = $source['status'] ?? 'never_checked';
echo esc_html( $status_labels[ $s ] ?? $s );
if ( ! empty( $source['last_error'] ) ) :
?>
<br><code style="color:#c00;font-size:.8em"><?php echo esc_html( $source['last_error'] ); ?></code>
<?php endif; ?>
</td>
<td><?php echo $source['last_checked_at'] ? esc_html( $source['last_checked_at'] ) : '<em>' . esc_html__( 'Never', 'county-gop-core' ) . '</em>'; ?></td>
<td style="white-space:nowrap">
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline">
<input type="hidden" name="action" value="CGOP_arcgis_fetch_layers">
<input type="hidden" name="source_id" value="<?php echo esc_attr( $source_id ); ?>">
<?php wp_nonce_field( 'CGOP_arcgis_fetch_layers_' . $source_id ); ?>
<button type="submit" class="button button-small"><?php esc_html_e( 'Fetch Layers', 'county-gop-core' ); ?></button>
</form>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline">
<input type="hidden" name="action" value="CGOP_arcgis_check_now">
<input type="hidden" name="source_id" value="<?php echo esc_attr( $source_id ); ?>">
<?php wp_nonce_field( 'CGOP_arcgis_check_now_' . $source_id ); ?>
<button type="submit" class="button button-small"><?php esc_html_e( 'Check Now', 'county-gop-core' ); ?></button>
</form>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline" onsubmit="return confirm('<?php echo esc_js( __( 'Delete this ArcGIS source? Generated files will not be deleted.', 'county-gop-core' ) ); ?>')">
<input type="hidden" name="action" value="CGOP_arcgis_delete_source">
<input type="hidden" name="source_id" value="<?php echo esc_attr( $source_id ); ?>">
<?php wp_nonce_field( 'CGOP_arcgis_delete_source_' . $source_id ); ?>
<button type="submit" class="button button-small button-link-delete"><?php esc_html_e( 'Delete', 'county-gop-core' ); ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h3 style="margin-top:1.5rem"><?php esc_html_e( 'Add ArcGIS Source', 'county-gop-core' ); ?></h3>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="CGOP_arcgis_add_source">
<?php wp_nonce_field( 'CGOP_arcgis_add_source' ); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="arcgis_label"><?php esc_html_e( 'Source Label', 'county-gop-core' ); ?></label></th>
<td><input class="regular-text" id="arcgis_label" name="arcgis_label" type="text" placeholder="<?php esc_attr_e( 'Indiana State Districts', 'county-gop-core' ); ?>"></td>
</tr>
<tr>
<th scope="row"><label for="arcgis_portal_item_url"><?php esc_html_e( 'Portal Item URL or Item ID', 'county-gop-core' ); ?></label></th>
<td>
<input class="large-text" id="arcgis_portal_item_url" name="portal_item_url" type="text" required placeholder="https://gisdata.in.gov/portal/home/item.html?id=06b2e034c864408992add7439a2e3eee">
<p class="description"><?php esc_html_e( 'Paste the full Portal item page URL or just the 32-character item ID. The item ID is extracted automatically.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="arcgis_portal_base_url"><?php esc_html_e( 'Portal Base URL', 'county-gop-core' ); ?></label></th>
<td>
<input class="regular-text" id="arcgis_portal_base_url" name="portal_base_url" type="url" value="https://gisdata.in.gov/portal">
<p class="description"><?php esc_html_e( 'Default: Indiana GIS portal. Change only for other ArcGIS portals.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="arcgis_service_url"><?php esc_html_e( 'Service URL Override', 'county-gop-core' ); ?></label></th>
<td>
<input class="large-text" id="arcgis_service_url" name="service_url_override" type="url" placeholder="https://services.arcgis.com/.../FeatureServer">
<p class="description"><?php esc_html_e( 'Optional. Leave blank to auto-discover from the item JSON.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Enabled', 'county-gop-core' ); ?></th>
<td><label><input type="checkbox" name="arcgis_enabled" value="1" checked> <?php esc_html_e( 'Include in daily change checks', 'county-gop-core' ); ?></label></td>
</tr>
<tr>
<th scope="row"><label for="arcgis_notes"><?php esc_html_e( 'Notes', 'county-gop-core' ); ?></label></th>
<td><input class="regular-text" id="arcgis_notes" name="arcgis_notes" type="text"></td>
</tr>
</table>
<?php submit_button( __( 'Add ArcGIS Source', 'county-gop-core' ), 'secondary' ); ?>
</form>
</section>
<?php if ( $layers_transient && $preview_source_id ) : ?>
<?php CGOP_arcgis_render_layer_preview_section( $preview_source_id, $layers_transient, $field_preview_transient, $field_preview_lid, $sources ); ?>
<?php endif; ?>
<?php if ( $arcgis_maps ) : ?>
<section class="cgop-gis-panel" id="cgop-arcgis-maps">
<div class="cgop-gis-panel__header">
<div>
<h2><?php esc_html_e( 'ArcGIS Generated Maps', 'county-gop-core' ); ?></h2>
<p><?php esc_html_e( 'Local GeoJSON files imported from ArcGIS sources. Each file is available in Position Keys map dropdowns under the "ArcGIS" group.', 'county-gop-core' ); ?></p>
</div>
</div>
<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( 'Source', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Created', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Actions', 'county-gop-core' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $arcgis_maps as $map_id => $map ) : ?>
<?php $map_url = CGOP_arcgis_get_generated_url( basename( $map['output_file'] ?? '' ) ); ?>
<tr>
<td><?php echo esc_html( $map['feature_label'] ?? '' ); ?></td>
<td>
<code><?php echo esc_html( $map['output_file'] ?? '' ); ?></code>
<?php if ( $map_url ) : ?>
<a href="<?php echo esc_url( $map_url ); ?>" target="_blank" rel="noopener noreferrer" style="font-size:.85em"> <?php esc_html_e( 'View', 'county-gop-core' ); ?></a>
<?php endif; ?>
</td>
<td>
<?php
$src = $sources[ $map['source_id'] ?? '' ] ?? null;
echo esc_html( $src ? ( $src['label'] ?? $map['source_id'] ) : $map['source_id'] );
?>
</td>
<td><?php echo esc_html( $map['created_at'] ?? '' ); ?></td>
<td>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="CGOP_arcgis_delete_map">
<input type="hidden" name="map_id" value="<?php echo esc_attr( $map_id ); ?>">
<?php wp_nonce_field( 'CGOP_arcgis_delete_map_' . $map_id ); ?>
<button type="submit" class="button button-link-delete" onclick="return confirm('<?php echo esc_attr__( 'Delete this ArcGIS generated 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>
</section>
<?php endif; ?>
<?php
}
add_action( 'CGOP_gis_admin_extra_sections', 'CGOP_arcgis_render_admin_sections' );
/**
* Render the layer preview / import section.
*/
function CGOP_arcgis_render_layer_preview_section( $source_id, $layers_transient, $field_preview_transient, $field_preview_lid, $sources ) {
$service_url = $layers_transient['service_url'] ?? '';
$layers = $layers_transient['layers'] ?? array();
$source = $sources[ $source_id ] ?? array();
?>
<section class="cgop-gis-panel" id="cgop-arcgis-preview">
<div class="cgop-gis-panel__header">
<div>
<h2><?php esc_html_e( 'ArcGIS Layer Preview', 'county-gop-core' ); ?></h2>
<p>
<?php echo esc_html( sprintf( __( 'Source: %s', 'county-gop-core' ), $source['label'] ?? $source_id ) ); ?>
—
<code><?php echo esc_html( $service_url ); ?></code>
</p>
</div>
</div>
<?php if ( $layers ) : ?>
<table class="widefat striped cgop-gis-table">
<thead>
<tr>
<th><?php esc_html_e( 'Layer ID', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Name', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Geometry', 'county-gop-core' ); ?></th>
<th><?php esc_html_e( 'Actions', 'county-gop-core' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $layers as $layer ) : ?>
<tr>
<td><?php echo esc_html( $layer['id'] ?? '' ); ?></td>
<td><?php echo esc_html( $layer['name'] ?? '' ); ?></td>
<td><?php echo esc_html( $layer['geometryType'] ?? '' ); ?></td>
<td>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline">
<input type="hidden" name="action" value="CGOP_arcgis_preview_layer">
<input type="hidden" name="source_id" value="<?php echo esc_attr( $source_id ); ?>">
<input type="hidden" name="layer_id" value="<?php echo esc_attr( $layer['id'] ?? '' ); ?>">
<input type="hidden" name="service_url" value="<?php echo esc_attr( $service_url ); ?>">
<?php wp_nonce_field( 'CGOP_arcgis_preview_layer_' . $source_id ); ?>
<button type="submit" class="button button-small"><?php esc_html_e( 'Preview Fields', 'county-gop-core' ); ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else : ?>
<p><?php esc_html_e( 'No layers found in the FeatureServer.', 'county-gop-core' ); ?></p>
<?php endif; ?>
<?php if ( $field_preview_transient ) : ?>
<?php CGOP_arcgis_render_field_preview( $source_id, $field_preview_transient, $service_url ); ?>
<?php endif; ?>
<h3 style="margin-top:1.5rem"><?php esc_html_e( 'Import Layer', 'county-gop-core' ); ?></h3>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="CGOP_arcgis_import">
<input type="hidden" name="source_id" value="<?php echo esc_attr( $source_id ); ?>">
<input type="hidden" name="service_url" value="<?php echo esc_attr( $service_url ); ?>">
<?php wp_nonce_field( 'CGOP_arcgis_import_' . $source_id ); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="import_layer_id"><?php esc_html_e( 'Layer ID', 'county-gop-core' ); ?></label></th>
<td>
<input id="import_layer_id" name="import_layer_id" type="number" min="0" value="0" required>
</td>
</tr>
<tr>
<th scope="row"><label for="import_where"><?php esc_html_e( 'WHERE clause', 'county-gop-core' ); ?></label></th>
<td>
<input class="large-text" id="import_where" name="import_where" type="text" value="1=1">
<p class="description"><?php esc_html_e( "Use 1=1 to import all features. Quote text fields, for example: district = '3'. Numeric fields can use unquoted numbers, for example: DISTRICT_ID = 3.", 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="import_label_template"><?php esc_html_e( 'Label template', 'county-gop-core' ); ?></label></th>
<td>
<input class="large-text" id="import_label_template" name="import_label_template" type="text" value="State Senate District {DISTRICT}" placeholder="State Senate District {DISTRICT}">
<p class="description"><?php esc_html_e( 'Tokens: {FIELD_NAME}, {FIELD_NAME_slug}. Shown in Position Keys dropdowns.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="import_filename_template"><?php esc_html_e( 'Filename template', 'county-gop-core' ); ?></label></th>
<td>
<input class="large-text" id="import_filename_template" name="import_filename_template" type="text" value="state-senate-district-{DISTRICT_slug}" placeholder="state-senate-district-{DISTRICT_slug}">
<p class="description"><?php esc_html_e( '.geojson is appended automatically. Files are saved under county-gop-maps/{county_slug}/generated/arcgis/.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Mode', 'county-gop-core' ); ?></th>
<td>
<label><input type="checkbox" name="import_single_file" value="1">
<?php esc_html_e( 'Single file (all features in one GeoJSON)', 'county-gop-core' ); ?></label>
<p class="description"><?php esc_html_e( 'Uncheck (default) to create one file per feature. Preferred for per-district position map assignments. If checked, template fields are only replaced when the query returns exactly one feature.', 'county-gop-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Auto-assign to positions', 'county-gop-core' ); ?></th>
<td>
<label><input type="checkbox" name="import_auto_assign" value="1">
<?php esc_html_e( 'Try to match imported maps to Position Keys (voting_area_map only)', 'county-gop-core' ); ?></label>
<br>
<label><input type="checkbox" name="import_overwrite_assign" value="1">
<?php esc_html_e( 'Overwrite existing voting_area_map assignments', 'county-gop-core' ); ?></label>
</td>
</tr>
</table>
<?php submit_button( __( 'Import Layer / Features', 'county-gop-core' ), 'primary' ); ?>
</form>
</section>
<?php
}
/**
* Render field preview for a layer.
*/
function CGOP_arcgis_render_field_preview( $source_id, $transient, $service_url ) {
$layer_id = $transient['layer_id'] ?? 'unknown';
$data = $transient['data'] ?? array();
if ( isset( $data['error'] ) ) {
echo '<div class="notice notice-error"><p>' . esc_html( $data['error'] ) . '</p></div>';
return;
}
$features = $data['features'] ?? array();
$fields = $data['fields'] ?? array();
?>
<div style="margin-top:1rem;padding:1rem;background:#f9f9f9;border:1px solid #ddd;border-radius:3px">
<h4 style="margin:0 0 .75rem"><?php echo esc_html( sprintf( __( 'Field preview — Layer %s', 'county-gop-core' ), $layer_id ) ); ?></h4>
<?php if ( $fields ) : ?>
<p><strong><?php esc_html_e( 'Fields:', 'county-gop-core' ); ?></strong>
<?php
echo esc_html( implode( ', ', array_column( $fields, 'name' ) ) );
?>
</p>
<?php endif; ?>
<?php if ( $features ) : ?>
<p><strong><?php esc_html_e( 'Sample attributes (up to 5):', 'county-gop-core' ); ?></strong></p>
<table class="widefat" style="max-width:100%;overflow:auto">
<thead>
<tr>
<?php foreach ( array_keys( $features[0]['attributes'] ?? array() ) as $col ) : ?>
<th><?php echo esc_html( $col ); ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ( $features as $feat ) : ?>
<tr>
<?php foreach ( $feat['attributes'] ?? array() as $val ) : ?>
<td><?php echo esc_html( (string) $val ); ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else : ?>
<p><?php esc_html_e( 'No sample features returned.', 'county-gop-core' ); ?></p>
<?php endif; ?>
</div>
<?php
}