Last active
May 31, 2025 16:59
-
-
Save brandonjp/d44decb9ed29bc4fb977404ec923339b to your computer and use it in GitHub Desktop.
Add GPS Coordinates Column to WordPress Media Library [SnipSnip.pro]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* Title: Add GPS Coordinates Column to WordPress Media Library (Best Version) | |
* Description: Extracts and displays GPS coordinates from image EXIF data in the media library table view. Robust extraction (multiple fallbacks), developer-configurable UI, admin notice, and copy-to-clipboard functionality. | |
* Version: 1.5.0 | |
* Author: brandonjp.com | |
* Last Updated: 2025-05-31 | |
* Blog URL: http://snipsnip.pro/880 | |
* Requirements: WordPress 5.0+ | |
* License: GPL v2 or later | |
* | |
* Changelog: | |
* 1.5.0 - Fixed click-to-copy functionality: proper script dependencies, event delegation, timing fixes | |
* 1.4.0 - Use proven working copy-to-clipboard JS, fix map icon hover, config-driven feedback, improved reliability | |
* 1.3.1 - Fix click to copy bugs and add text labels to config options | |
* 1.3.0 - Best version: robust extraction, configurable UI, admin notice, modern JS feedback | |
* 1.2.0 - Simplified to reliable methods only: PHP EXIF and WordPress core | |
* 1.1.0 - Added multiple fallback methods | |
* 1.0.0 - Initial release | |
*/ | |
// ===================== | |
// CONFIGURATION SECTION | |
// ===================== | |
$gps_column_config = array( | |
// UI style: 'compact' (two lines, icons) or 'verbose' (labels, WP buttons, two lines) | |
'ui_style' => 'verbose', | |
// Show admin notice about available extraction methods | |
'show_admin_notice' => true, | |
// Extraction methods order (array of method keys) | |
'extraction_order' => array('wordpress', 'exif', 'getimagesize', 'exiftool'), | |
// Show dash or text if no GPS: 'dash' or 'text' | |
'no_gps_display' => 'dash', | |
// Enable error logging for extraction failures | |
'log_errors' => false, | |
// UI text and emoji config | |
'text_map_icon' => '🗺️', | |
'text_map_icon_hover' => '📍', | |
'text_map_label' => 'Map', | |
'text_copy_icon' => '📋', | |
'text_copy_icon_success' => '✓', | |
'text_copy_label' => 'Copy', | |
'text_copied_label' => '✓ Copied!', | |
'text_no_gps' => 'No GPS', | |
'text_no_gps_verbose' => 'No GPS data', | |
'text_dash' => '—', | |
); | |
if (!class_exists('WP_Media_Library_GPS_Coordinates_Column')): | |
class WP_Media_Library_GPS_Coordinates_Column { | |
const VERSION = '1.5.0'; | |
const COLUMN_ID = 'gps_coordinates'; | |
private $config; | |
public function __construct($config = array()) { | |
$this->config = $config; | |
add_action('init', array($this, 'init')); | |
} | |
public function init() { | |
add_filter('manage_media_columns', array($this, 'add_gps_column')); | |
add_action('manage_media_custom_column', array($this, 'display_gps_column'), 10, 2); | |
add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_assets')); | |
if (!empty($this->config['show_admin_notice'])) { | |
add_action('admin_notices', array($this, 'show_extraction_methods_info')); | |
} | |
} | |
public function add_gps_column($columns) { | |
$new_columns = array(); | |
foreach ($columns as $key => $value) { | |
if ($key === 'date') { | |
$new_columns[self::COLUMN_ID] = ($this->config['ui_style'] === 'verbose') ? 'GPS Coordinates' : 'GPS'; | |
} | |
$new_columns[$key] = $value; | |
} | |
return $new_columns; | |
} | |
public function display_gps_column($column_name, $attachment_id) { | |
if ($column_name !== self::COLUMN_ID) return; | |
$coordinates = $this->get_gps_coordinates($attachment_id); | |
$ui = $this->config['ui_style']; | |
$no_gps = $this->config['no_gps_display']; | |
$cfg = $this->config; | |
if ($coordinates) { | |
$lat = $coordinates['latitude']; | |
$lng = $coordinates['longitude']; | |
$maps_url = "https://maps.google.com/?q={$lat},{$lng}"; | |
if ($ui === 'verbose') { | |
echo '<div class="gps-coordinates-wrapper">'; | |
echo '<div class="gps-coords" data-lat="' . esc_attr($lat) . '" data-lng="' . esc_attr($lng) . '">'; | |
echo '<strong>Lat:</strong> ' . esc_html($lat) . '<br>'; | |
echo '<strong>Lng:</strong> ' . esc_html($lng) . '</div>'; | |
echo '<div class="gps-actions">'; | |
echo '<a href="' . esc_url($maps_url) . '" target="_blank" class="button button-small gps-map-link" title="Open in Google Maps" data-map-icon="' . esc_attr($cfg['text_map_icon']) . '" data-map-icon-hover="' . esc_attr($cfg['text_map_icon_hover']) . '">' . esc_html($cfg['text_map_icon']) . ' ' . esc_html($cfg['text_map_label']) . '</a> '; | |
echo '<button type="button" class="button button-small copy-gps" data-coords="' . esc_attr($lat . ',' . $lng) . '" title="Copy coordinates" data-copy-icon="' . esc_attr($cfg['text_copy_icon']) . '" data-copy-icon-success="' . esc_attr($cfg['text_copy_icon_success']) . '">' . esc_html($cfg['text_copy_icon']) . ' ' . esc_html($cfg['text_copy_label']) . '</button>'; | |
echo '</div></div>'; | |
} else { // compact | |
echo '<div class="gps-wrapper">'; | |
echo '<div class="gps-coords">' . esc_html($lat) . '<br>' . esc_html($lng) . '</div>'; | |
echo '<div class="gps-actions">'; | |
echo '<a href="' . esc_url($maps_url) . '" target="_blank" class="gps-map-link" title="View on map" data-map-icon="' . esc_attr($cfg['text_map_icon']) . '" data-map-icon-hover="' . esc_attr($cfg['text_map_icon_hover']) . '">' . esc_html($cfg['text_map_icon']) . '</a> '; | |
echo '<button type="button" class="copy-gps" data-coords="' . esc_attr($lat . ',' . $lng) . '" title="Copy coordinates" data-copy-icon="' . esc_attr($cfg['text_copy_icon']) . '" data-copy-icon-success="' . esc_attr($cfg['text_copy_icon_success']) . '">' . esc_html($cfg['text_copy_icon']) . '</button>'; | |
echo '</div></div>'; | |
} | |
} else { | |
if ($no_gps === 'text') { | |
echo ($ui === 'verbose') ? '<span class="description">' . esc_html($cfg['text_no_gps_verbose']) . '</span>' : '<span class="no-gps">' . esc_html($cfg['text_no_gps']) . '</span>'; | |
} else { | |
echo '<span class="no-gps">' . esc_html($cfg['text_dash']) . '</span>'; | |
} | |
} | |
} | |
/** | |
* Extract GPS coordinates using multiple fallback methods | |
* @param int $attachment_id | |
* @return array|false | |
*/ | |
private function get_gps_coordinates($attachment_id) { | |
$file_path = get_attached_file($attachment_id); | |
if (!$file_path || !file_exists($file_path)) return false; | |
$mime_type = get_post_mime_type($attachment_id); | |
if (!str_starts_with($mime_type, 'image/')) return false; | |
$order = isset($this->config['extraction_order']) ? $this->config['extraction_order'] : array('wordpress', 'exif', 'getimagesize', 'exiftool'); | |
foreach ($order as $method) { | |
try { | |
$coordinates = false; | |
switch ($method) { | |
case 'wordpress': | |
$coordinates = $this->extract_gps_with_wordpress($file_path); | |
break; | |
case 'exif': | |
$coordinates = $this->extract_gps_with_exif($file_path); | |
break; | |
case 'getimagesize': | |
$coordinates = $this->extract_gps_with_getimagesize($file_path); | |
break; | |
case 'exiftool': | |
$coordinates = $this->extract_gps_with_exiftool($file_path); | |
break; | |
} | |
if ($coordinates !== false) return $coordinates; | |
} catch (Exception $e) { | |
if (!empty($this->config['log_errors'])) { | |
error_log('GPS Coordinates: ' . get_class($this) . " $method extraction failed - " . $e->getMessage()); | |
} | |
continue; | |
} | |
} | |
return false; | |
} | |
/** Extraction methods **/ | |
private function extract_gps_with_wordpress($file_path) { | |
$metadata = wp_read_image_metadata($file_path); | |
if (!$metadata || empty($metadata['latitude']) || empty($metadata['longitude'])) return false; | |
return array( | |
'latitude' => round((float) $metadata['latitude'], 6), | |
'longitude' => round((float) $metadata['longitude'], 6) | |
); | |
} | |
private function extract_gps_with_exif($file_path) { | |
if (!extension_loaded('exif')) return false; | |
$exif = @exif_read_data($file_path); | |
if (!$exif || !isset($exif['GPS'])) return false; | |
return $this->parse_gps_from_exif($exif['GPS']); | |
} | |
private function extract_gps_with_getimagesize($file_path) { | |
if (!function_exists('getimagesize')) return false; | |
$imageinfo = array(); | |
@getimagesize($file_path, $imageinfo); | |
if (empty($imageinfo['APP1'])) return false; | |
$exif = @exif_read_data('data://image/jpeg;base64,' . base64_encode($imageinfo['APP1'])); | |
if (!$exif || !isset($exif['GPS'])) return false; | |
return $this->parse_gps_from_exif($exif['GPS']); | |
} | |
private function extract_gps_with_exiftool($file_path) { | |
if (!$this->is_exiftool_available()) return false; | |
$escaped_path = escapeshellarg($file_path); | |
$command = "exiftool -GPS:GPSLatitude -GPS:GPSLongitude -GPS:GPSLatitudeRef -GPS:GPSLongitudeRef -n -T {$escaped_path} 2>/dev/null"; | |
$output = @shell_exec($command); | |
if (!$output) return false; | |
$values = explode("\t", trim($output)); | |
if (count($values) < 4 || $values[0] === '-' || $values[1] === '-') return false; | |
$latitude = (float) $values[0]; | |
$longitude = (float) $values[1]; | |
$lat_ref = trim($values[2]); | |
$lng_ref = trim($values[3]); | |
if (strtoupper($lat_ref) === 'S') $latitude = -$latitude; | |
if (strtoupper($lng_ref) === 'W') $longitude = -$longitude; | |
return array( | |
'latitude' => round($latitude, 6), | |
'longitude' => round($longitude, 6) | |
); | |
} | |
private function is_exiftool_available() { | |
$disabled_functions = explode(',', ini_get('disable_functions')); | |
$disabled_functions = array_map('trim', $disabled_functions); | |
if (in_array('shell_exec', $disabled_functions) || in_array('exec', $disabled_functions)) return false; | |
$output = @shell_exec('exiftool -ver 2>/dev/null'); | |
return !empty($output) && is_numeric(trim($output)); | |
} | |
private function parse_gps_from_exif($gps) { | |
if (!isset($gps['GPSLatitude']) || !isset($gps['GPSLongitude']) || | |
!isset($gps['GPSLatitudeRef']) || !isset($gps['GPSLongitudeRef'])) return false; | |
$latitude = $this->convert_gps_to_decimal($gps['GPSLatitude'], $gps['GPSLatitudeRef']); | |
$longitude = $this->convert_gps_to_decimal($gps['GPSLongitude'], $gps['GPSLongitudeRef']); | |
if ($latitude === false || $longitude === false) return false; | |
return array( | |
'latitude' => round($latitude, 6), | |
'longitude' => round($longitude, 6) | |
); | |
} | |
private function convert_gps_to_decimal($coordinate, $hemisphere) { | |
if (!is_array($coordinate) || count($coordinate) < 3) return false; | |
$degrees = $this->fraction_to_decimal($coordinate[0]); | |
$minutes = $this->fraction_to_decimal($coordinate[1]); | |
$seconds = $this->fraction_to_decimal($coordinate[2]); | |
if ($degrees === false || $minutes === false || $seconds === false) return false; | |
$decimal = $degrees + ($minutes / 60) + ($seconds / 3600); | |
if (in_array(strtoupper($hemisphere), array('S', 'W'))) $decimal = -$decimal; | |
return $decimal; | |
} | |
private function fraction_to_decimal($fraction) { | |
if (is_numeric($fraction)) return (float) $fraction; | |
if (is_string($fraction) && strpos($fraction, '/') !== false) { | |
$parts = explode('/', $fraction); | |
if (count($parts) === 2 && is_numeric($parts[0]) && is_numeric($parts[1]) && $parts[1] != 0) { | |
return (float) $parts[0] / (float) $parts[1]; | |
} | |
} | |
return false; | |
} | |
/** | |
* Show available extraction methods info (dismissible) | |
*/ | |
public function show_extraction_methods_info() { | |
if (get_current_screen()->id !== 'upload') return; | |
$user_id = get_current_user_id(); | |
$dismissed = get_user_meta($user_id, 'gps_extraction_methods_dismissed', true); | |
if ($dismissed) return; | |
$methods = $this->get_available_extraction_methods(); | |
$method_names = array(); | |
foreach ($methods as $method) { | |
$method_names[] = $method['name']; | |
} | |
echo '<div class="notice notice-info is-dismissible" data-dismiss-key="gps_extraction_methods">'; | |
echo '<p><strong>GPS Coordinates Column:</strong> Available extraction methods: ' . esc_html(implode(', ', $method_names)) . '</p>'; | |
echo '</div>'; | |
$this->add_dismiss_handler(); | |
} | |
private function add_dismiss_handler() { | |
$js = <<<'JAVASCRIPT' | |
jQuery(document).on('click', '.notice[data-dismiss-key] .notice-dismiss', function() { | |
var dismissKey = jQuery(this).parent().data('dismiss-key'); | |
if (dismissKey) { | |
jQuery.post(ajaxurl, { | |
action: 'dismiss_gps_notice', | |
key: dismissKey, | |
nonce: wpApiSettings.nonce | |
}); | |
} | |
}); | |
JAVASCRIPT; | |
wp_add_inline_script('jquery', $js); | |
add_action('wp_ajax_dismiss_gps_notice', array($this, 'handle_dismiss_notice')); | |
} | |
public function handle_dismiss_notice() { | |
check_ajax_referer(wp_rest_get_server()->get_nonce(), 'nonce'); | |
$key = sanitize_text_field($_POST['key']); | |
$user_id = get_current_user_id(); | |
if ($key === 'gps_extraction_methods') { | |
update_user_meta($user_id, 'gps_extraction_methods_dismissed', true); | |
} | |
wp_die(); | |
} | |
private function get_available_extraction_methods() { | |
$methods = array(); | |
if (extension_loaded('exif')) { | |
$methods[] = array('name' => 'PHP EXIF', 'status' => 'available'); | |
} | |
$methods[] = array('name' => 'WordPress Core', 'status' => 'available'); | |
if (function_exists('getimagesize')) { | |
$methods[] = array('name' => 'getimagesize()', 'status' => 'available'); | |
} | |
if ($this->is_exiftool_available()) { | |
$methods[] = array('name' => 'ExifTool', 'status' => 'available'); | |
} | |
return $methods; | |
} | |
/** | |
* Enqueue admin styles and scripts | |
*/ | |
public function enqueue_admin_assets() { | |
$screen = get_current_screen(); | |
if ($screen && $screen->id === 'upload') { | |
$this->add_admin_styles(); | |
$this->add_admin_scripts(); | |
} | |
} | |
private function add_admin_styles() { | |
$ui = $this->config['ui_style']; | |
if ($ui === 'verbose') { | |
$css = <<<'STYLES' | |
.gps-coordinates-wrapper { | |
font-size: 12px; | |
line-height: 1.4; | |
} | |
.gps-coords { | |
margin-bottom: 5px; | |
color: #666; | |
} | |
.gps-actions { | |
margin-top: 5px; | |
} | |
.gps-actions .button { | |
font-size: 11px; | |
padding: 2px 6px; | |
line-height: 1.2; | |
height: auto; | |
margin-right: 3px; | |
} | |
.copy-gps.copied { | |
background-color: #00a32a; | |
border-color: #00a32a; | |
color: white; | |
} | |
.column-gps_coordinates { | |
width: 150px; | |
} | |
@media screen and (max-width: 782px) { | |
.column-gps_coordinates { | |
display: none; | |
} | |
} | |
STYLES; | |
} else { | |
$css = <<<'CSS' | |
.gps-wrapper { | |
font-size: 11px; | |
line-height: 1.3; | |
} | |
.gps-coords { | |
color: #666; | |
margin-bottom: 2px; | |
word-break: break-all; | |
} | |
.gps-actions a, | |
.gps-actions button { | |
background: none; | |
border: none; | |
cursor: pointer; | |
font-size: 14px; | |
text-decoration: none; | |
padding: 0; | |
margin-right: 4px; | |
} | |
.gps-actions button:hover { | |
opacity: 0.7; | |
} | |
.copy-gps.copied { | |
opacity: 0.5; | |
} | |
.column-gps_coordinates { | |
width: 110px; | |
} | |
.no-gps { | |
color: #ccc; | |
} | |
@media screen and (max-width: 782px) { | |
.column-gps_coordinates { | |
display: none; | |
} | |
} | |
CSS; | |
} | |
wp_add_inline_style('wp-admin', $css); | |
} | |
private function add_admin_scripts() { | |
$cfg = $this->config; | |
$js = <<<JAVASCRIPT | |
window.addEventListener("load", function() { | |
(function createGPSCoordinatesScope() { | |
"use strict"; | |
var config = { | |
copiedLabel: '{$cfg['text_copied_label']}', | |
mapIcon: '{$cfg['text_map_icon']}', | |
mapIconHover: '{$cfg['text_map_icon_hover']}' | |
}; | |
// Copy to clipboard functionality | |
function handleCopyClick(event) { | |
event.preventDefault(); | |
event.stopPropagation(); | |
var button = event.target.closest('.copy-gps'); | |
if (!button) return; | |
var coords = button.getAttribute('data-coords'); | |
if (!coords) { | |
showFeedback(button, '❌ Failed'); | |
return; | |
} | |
copyToClipboard(coords).then(function() { | |
showFeedback(button, config.copiedLabel); | |
}).catch(function() { | |
showFeedback(button, '❌ Failed'); | |
}); | |
} | |
function copyToClipboard(text) { | |
return new Promise(function(resolve, reject) { | |
// Try modern clipboard API first | |
if (navigator.clipboard && window.isSecureContext) { | |
navigator.clipboard.writeText(text).then(resolve).catch(function() { | |
fallbackCopy(text, resolve, reject); | |
}); | |
} else { | |
fallbackCopy(text, resolve, reject); | |
} | |
}); | |
} | |
function fallbackCopy(text, resolve, reject) { | |
try { | |
var textArea = document.createElement('textarea'); | |
textArea.value = text; | |
textArea.style.position = 'fixed'; | |
textArea.style.left = '-999999px'; | |
textArea.style.top = '-999999px'; | |
textArea.style.opacity = '0'; | |
document.body.appendChild(textArea); | |
textArea.focus(); | |
textArea.select(); | |
var successful = document.execCommand('copy'); | |
document.body.removeChild(textArea); | |
if (successful) { | |
resolve(); | |
} else { | |
reject(new Error('execCommand failed')); | |
} | |
} catch (err) { | |
if (document.body.contains(textArea)) { | |
document.body.removeChild(textArea); | |
} | |
reject(err); | |
} | |
} | |
function showFeedback(button, message) { | |
var originalText = button.textContent; | |
var originalBg = button.style.backgroundColor; | |
var originalColor = button.style.color; | |
button.textContent = message; | |
if (message.includes('✓')) { | |
button.style.backgroundColor = '#46b450'; | |
button.style.color = 'white'; | |
button.classList.add('copied'); | |
} else { | |
button.style.backgroundColor = '#dc3232'; | |
button.style.color = 'white'; | |
} | |
setTimeout(function() { | |
button.textContent = originalText; | |
button.style.backgroundColor = originalBg; | |
button.style.color = originalColor; | |
button.classList.remove('copied'); | |
}, 1500); | |
} | |
// Map icon hover functionality | |
function initMapHover() { | |
var mapLinks = document.querySelectorAll('.gps-map-link'); | |
mapLinks.forEach(function(link) { | |
var icon = link.getAttribute('data-map-icon') || config.mapIcon; | |
var iconHover = link.getAttribute('data-map-icon-hover') || config.mapIconHover; | |
var textContent = link.textContent; | |
var label = textContent.replace(icon, '').trim(); | |
link.addEventListener('mouseenter', function() { | |
link.innerHTML = iconHover + (label ? ' ' + label : ''); | |
}); | |
link.addEventListener('mouseleave', function() { | |
link.innerHTML = icon + (label ? ' ' + label : ''); | |
}); | |
}); | |
} | |
// Initialize all functionality | |
function initGPSColumnFeatures() { | |
// Use event delegation for copy buttons | |
document.body.addEventListener('click', function(event) { | |
if (event.target.closest('.copy-gps')) { | |
handleCopyClick(event); | |
} | |
}); | |
// Initialize map hover | |
initMapHover(); | |
} | |
// Setup mutation observer for dynamic content | |
function setupDynamicContentHandler() { | |
var observer = new MutationObserver(function(mutations) { | |
var shouldReinit = false; | |
mutations.forEach(function(mutation) { | |
mutation.addedNodes.forEach(function(node) { | |
if (node.nodeType === 1 && ( | |
node.classList.contains('gps-coordinates-wrapper') || | |
node.classList.contains('gps-wrapper') || | |
node.querySelector('.gps-map-link') | |
)) { | |
shouldReinit = true; | |
} | |
}); | |
}); | |
if (shouldReinit) { | |
setTimeout(function() { | |
initMapHover(); | |
}, 100); | |
} | |
}); | |
observer.observe(document.body, { | |
childList: true, | |
subtree: true | |
}); | |
} | |
// Initialize everything | |
initGPSColumnFeatures(); | |
setupDynamicContentHandler(); | |
})(); | |
}); | |
JAVASCRIPT; | |
wp_add_inline_script('jquery', $js); | |
} | |
} | |
endif; | |
// Initialize the plugin with config | |
if (class_exists('WP_Media_Library_GPS_Coordinates_Column')): | |
new WP_Media_Library_GPS_Coordinates_Column($gps_column_config); | |
endif; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment