Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save brandonjp/d44decb9ed29bc4fb977404ec923339b to your computer and use it in GitHub Desktop.
Save brandonjp/d44decb9ed29bc4fb977404ec923339b to your computer and use it in GitHub Desktop.
Add GPS Coordinates Column to WordPress Media Library [SnipSnip.pro]
<?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