// "Merged EXIF Tools for Commons" by Rkieferbaum
// Version 250422
// This script combines the "Taken With" and "Location from EXIF" functionality
// To install, simply add this line to your common.js (without the //):
// importScript('User:Rkieferbaum/MergedEXIFTools.js');
//
// Features:
// - Adds "Taken with <Make> <Model>" category when applicable
// - Inserts {{location}} template from EXIF data
// - Provides "Location not applicable" and "Ambiguous" category options
// - NEW: Combined button to add both GPS EXIF and "Taken with" simultaneously when applicable
//
// Feel free to report bugs or suggestions on my talk page.
mw.loader.using(['mediawiki.api', 'jquery'], function() {
// Load EXIF.js library
mw.loader.load('https://cdnjs.cloudflare.com/ajax/libs/exif-js/2.3.0/exif.min.js');
$(function() {
// Only run on File namespace & normal "view" page
if (mw.config.get('wgNamespaceNumber') !== 6) return;
if (mw.config.get('wgAction') !== 'view') return;
if (!location.pathname.startsWith('/wiki/File:')) return;
var pageName = mw.config.get('wgPageName'),
api = new mw.Api();
// Configuration: set to 1 to always show buttons (greyed out when disabled)
var ALWAYS_SHOW_BUTTONS = 1;
// Check if file has EXIF-bearing extension
var hasExifExtension = /\.(jpe?g|tiff?|heif|webp)$/i.test(pageName);
// Fetch current page content
api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: '2'
}).done(function(data) {
var page = data.query.pages[0];
if (page.missing) {
console.error('Page not found');
return;
}
var content = page.revisions[0].content;
// Check for existing location info
var hasWithheld = /{{\s*location withheld\s*}}/i.test(content),
hasNotApp = /\[\[Category:Location not applicable\]\]/i.test(content),
hasAmbiguous = /\[\[Category:Ambiguous location in EXIF\]\]/i.test(content),
hasDirectTag = /{{(?:location|camera location|location dec)\|/i.test(content),
hasIndirectTag = /{{(?=[^}]*\bgpslat=)(?=[^}]*\bgpslong=)[^}]*}}/i.test(content),
hasGpsExif = /{{GPS(?: |-)?EXIF}}/i.test(content);
// FIX: Location templates should be independent of GPS EXIF tag
var hasLocationInfo = hasWithheld || hasNotApp || hasAmbiguous || hasDirectTag || hasIndirectTag;
// Check if already in any "Taken with" category
var $cats = $('#catlinks ul li a, #mw-normal-catlinks ul li a'),
hasTakenWith = $cats.toArray().some(function(el) {
return el.textContent.trim().toLowerCase().startsWith('taken with');
});
// If not forcing display and conditions aren't met to show panels
if (!ALWAYS_SHOW_BUTTONS && (!hasExifExtension || (hasLocationInfo && hasTakenWith))) {
console.log('Not showing buttons due to criteria.');
return;
}
// Create container for all buttons
var $container = $('<div>')
.css({
margin: '10px 0',
display: 'flex',
flexWrap: 'wrap',
gap: '10px'
});
$('#content').prepend($container);
// Get file URL and fetch EXIF data
var fileUrl = 'https://commons.wikimedia.org/wiki/Special:FilePath/' + pageName.split(':')[1];
var xhr = new XMLHttpRequest();
xhr.open('GET', fileUrl);
xhr.responseType = 'blob';
xhr.onload = function() {
if (xhr.status !== 200) {
console.error('Failed to fetch image blob');
return;
}
EXIF.getData(xhr.response, function() {
// Extract location data
var locationData = extractLocationData(this);
var validCoords = isValidCoordinates(locationData);
// Extract camera make/model data
var cameraData = extractCameraData(this);
var validCamera = (cameraData.make && cameraData.model);
// Create all buttons
// 1. Combined button (NEW)
var shouldShowCombined = (validCoords && !hasGpsExif) || (validCamera && !hasTakenWith);
var $btnCombined = createButton(
'Add GPS & Camera Info',
'#8e44ad', // Purple color
shouldShowCombined,
function() {
// Add both GPS and taken-with info as applicable
var tasks = [];
if (validCoords && !hasGpsExif) tasks.push(insertGPSExifTag);
if (validCamera && !hasTakenWith) tasks.push(function() { tryFindTakenWithCategory(cameraData, content); });
// Execute first task, which will reload page for second task if needed
if (tasks.length > 0) tasks[0](content);
}
);
$container.append($btnCombined);
// 2. GPS EXIF button
var $btnGPS = createButton(
'Add {{GPS EXIF}}',
'red',
validCoords && !hasGpsExif,
function() { insertGPSExifTag(content); }
);
$container.append($btnGPS);
// 3. Location from EXIF button - FIX: Should be enabled even when GPS EXIF exists
var $btnLoc = createButton(
'Add location from EXIF',
'#007bff',
validCoords && !hasDirectTag && !hasIndirectTag,
function() { maybeInsertLocationTemplate(locationData, content); }
);
$container.append($btnLoc);
// 4. Location not applicable button
var $btnNA = createButton(
'Location not applicable',
'#6c757d',
!hasNotApp,
function() { insertLocationNotApplicable(content); }
);
$container.append($btnNA);
// 5. Ambiguous location button
var $btnAmb = createButton(
'Ambiguous location in EXIF',
'#28a745',
!hasAmbiguous,
function() { insertAmbiguousCategory(content); }
);
$container.append($btnAmb);
// 6. Add Taken With button
var $btnTakenWith = createButton(
'Add "Taken with" category',
'#ff9800',
validCamera && !hasTakenWith,
function() { tryFindTakenWithCategory(cameraData, content); }
);
$container.append($btnTakenWith);
// If panel was hidden by criteria, disable all except the ones that should remain active
if (ALWAYS_SHOW_BUTTONS && hasTakenWith) {
$btnTakenWith.prop('disabled', true)
.css({ backgroundColor: '#ccc', cursor: 'not-allowed' })
.off('click');
}
// Fix: Only disable location buttons if actual location info is present, not just GPS EXIF
if (ALWAYS_SHOW_BUTTONS && hasLocationInfo) {
$btnLoc.prop('disabled', true)
.css({ backgroundColor: '#ccc', cursor: 'not-allowed' })
.off('click');
}
});
};
xhr.onerror = function() {
console.error('Failed to fetch image blob for EXIF');
};
xhr.send();
}).fail(function(err) {
console.error('API request failed', err);
});
});
// HELPER FUNCTIONS
// Create a styled button with common properties
function createButton(text, color, enabled, clickHandler) {
return $('<button>')
.text(text)
.css({
padding: '10px',
backgroundColor: enabled ? color : '#ccc',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: enabled ? 'pointer' : 'not-allowed'
})
.prop('disabled', !enabled)
.click(function() {
if (!$(this).prop('disabled')) clickHandler();
});
}
// Extract location data from EXIF
function extractLocationData(exifData) {
var latArr = EXIF.getTag(exifData, "GPSLatitude"),
lonArr = EXIF.getTag(exifData, "GPSLongitude"),
latRef = EXIF.getTag(exifData, "GPSLatitudeRef") || "N",
lonRef = EXIF.getTag(exifData, "GPSLongitudeRef") || "W",
latNum = null, lonNum = null;
if (latArr && lonArr && latArr.length === 3 && lonArr.length === 3) {
latNum = (latArr[0] + latArr[1]/60 + latArr[2]/3600) *
(latRef.toUpperCase()==="N"?1:-1);
lonNum = (lonArr[0] + lonArr[1]/60 + lonArr[2]/3600) *
(lonRef.toUpperCase()==="E"?1:-1);
if (latNum < -90 || latNum > 90 || lonNum < -180 || lonNum > 180) {
console.log('EXIF coords out of range:', latNum, lonNum);
latNum = lonNum = null;
} else {
latNum = latNum.toFixed(7);
lonNum = lonNum.toFixed(7);
}
}
var altitude = EXIF.getTag(exifData, "GPSAltitude"),
altRef = EXIF.getTag(exifData, "GPSAltitudeRef"),
heading = EXIF.getTag(exifData, "GPSImgDirection") ||
EXIF.getTag(exifData, "GPSTrack"),
altVal = null, headVal = null;
if (altitude) {
altVal = parseFloat(altitude);
if (altRef === 1 || altRef === "1") altVal = -altVal;
altVal = altVal.toFixed(2);
}
if (heading) headVal = parseFloat(heading).toFixed(2);
return { lat: latNum, lon: lonNum, alt: altVal, heading: headVal };
}
// Extract camera make and model from EXIF
function extractCameraData(exifData) {
var rawMakeValue = EXIF.getTag(exifData, 'Make'),
rawModelValue = EXIF.getTag(exifData, 'Model');
if (!rawMakeValue || !rawModelValue) {
return { make: null, model: null };
}
// Strip ASCII control chars then trim
var rawMakeStr = rawMakeValue.toString().replace(/[\x00-\x1F\x7F]/g, '').trim(),
rawModelStr = rawModelValue.toString().replace(/[\x00-\x1F\x7F]/g, '').trim();
return {
make: rawMakeStr,
model: rawModelStr,
rawMakeStr: rawMakeStr,
rawModelStr: rawModelStr
};
}
// Check if coordinates are valid
function isValidCoordinates(data) {
return !(data.lat === null || data.lon === null ||
isNaN(data.lat) || isNaN(data.lon) ||
Number(data.lat) === 0 || Number(data.lon) === 0 ||
data.lat < -90 || data.lat > 90 ||
data.lon < -180 || data.lon > 180);
}
// Title-case each word, preserve 1–3‑letter ALL‑CAPS tokens
function titleCase(str) {
return str.toString().trim().replace(/\b\w+\b/g, function(word) {
if (word.length <= 3 && word === word.toUpperCase()) return word;
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
}
// Insert GPS EXIF tag
function insertGPSExifTag(currentContent) {
var newContent = currentContent
.replace(/{{GPS EXIF}}/gi, '')
.replace(/{{GPS-EXIF}}/gi, '');
var template = '{{GPS EXIF}}';
var mainIndex = newContent.indexOf('{{Information');
if (mainIndex === -1) mainIndex = newContent.indexOf('{{Photograph');
if (mainIndex !== -1) {
var end = findClosingBrackets(newContent, mainIndex);
newContent = end !== -1
? insertAtPosition(newContent, end, template)
: newContent.trim() + "\n" + template;
} else {
var lic = newContent.indexOf('=={{int:license-header}}');
newContent = lic !== -1
? insertAtPosition(newContent, lic, template)
: newContent.trim() + "\n" + template;
}
var api = new mw.Api();
api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get('wgPageName'),
text: newContent,
summary: 'Adding {{GPS EXIF}} tag via script',
minor: true
}).done(function() {
console.log('GPS EXIF tag added.');
location.reload();
}).fail(function(err) {
console.error('Edit failed', err);
});
}
// Calculate haversine distance between coordinates
function haversineDistance(lat1, lon1, lat2, lon2) {
function toRad(value) {
return value * Math.PI / 180;
}
var R = 6371000; // Earth's radius in meters
var dLat = toRad(lat2 - lat1);
var dLon = toRad(lon2 - lon1);
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// Check for existing object location and warn if significantly different
function maybeInsertLocationTemplate(data, currentContent) {
var objectLocationRegex = /{{\s*object location(?:\s+dec)?\s*\|\s*([-+]?\d+(?:\.\d+)?)\s*\|\s*([-+]?\d+(?:\.\d+)?)/i;
var match = currentContent.match(objectLocationRegex);
if (match) {
var objectLat = parseFloat(match[1]),
objectLon = parseFloat(match[2]),
exifLat = parseFloat(data.lat),
exifLon = parseFloat(data.lon),
distance = haversineDistance(objectLat, objectLon, exifLat, exifLon);
console.log('Distance between object location and EXIF data:', distance, 'm');
if (distance > 300 && !confirm(
"WARNING: The EXIF location differs from the object location by more than 300 meters (" +
Math.round(distance) + " m). Proceed?"
)) {
console.log('Update cancelled by user.');
return;
}
}
insertLocationTemplate(data, currentContent);
}
// Insert location template
function insertLocationTemplate(data, currentContent) {
if (!isValidCoordinates(data)) {
console.log('Invalid coords—no insertion.');
return;
}
var nc = currentContent
.replace(/{{location\|.*?}}/gi, '')
.replace(/{{camera location\|.*?}}/gi, '')
.replace(/{{location dec\|.*?}}/gi, '')
.replace(/{{GPS(?: |-)?EXIF}}/gi, '')
.replace(/{{\s*location possible\s*}}/gi, '');
var extras = [];
if (data.alt) extras.push('alt:' + data.alt);
if (data.heading) extras.push('heading:' + data.heading);
extras.push('source:exif');
var tpl = '{{location|' + data.lat + '|' + data.lon + '|' + extras.join('_') + '}}';
var mi = nc.indexOf('{{Information');
if (mi === -1) mi = nc.indexOf('{{Photograph');
if (mi !== -1) {
var ei = findClosingBrackets(nc, mi);
nc = (ei !== -1) ? insertAtPosition(nc, ei, tpl) : nc.trim() + "\n" + tpl;
} else {
var lic = nc.indexOf('=={{int:license-header}}');
nc = (lic !== -1) ? insertAtPosition(nc, lic, tpl) : nc.trim() + "\n" + tpl;
}
var api = new mw.Api();
api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get('wgPageName'),
text: nc,
summary: 'Adding {{location}} via script',
minor: true
}).done(function() {
location.reload();
}).fail(function(err) {
console.error('Edit failed', err);
});
}
// Insert "Location not applicable" category
function insertLocationNotApplicable(currentContent) {
var nc = currentContent
.replace(/{{GPS(?: |-)?EXIF}}/gi, '')
.trim() + "\n[[Category:Location not applicable]]";
var api = new mw.Api();
api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get('wgPageName'),
text: nc,
summary: 'Marking location not applicable via script',
minor: true
}).done(function() {
location.reload();
}).fail(function(err) {
console.error(err);
});
}
// Insert "Ambiguous location in EXIF" category
function insertAmbiguousCategory(currentContent) {
var nc = currentContent
.replace(/{{GPS(?: |-)?EXIF}}/gi, '')
.trim() + "\n[[Category:Ambiguous location in EXIF]]";
var api = new mw.Api();
api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get('wgPageName'),
text: nc,
summary: 'Marking ambiguous EXIF location via script',
minor: true
}).done(function() {
location.reload();
}).fail(function(err) {
console.error(err);
});
}
// Try to find and add the appropriate "Taken with" category
function tryFindTakenWithCategory(cameraData, content) {
if (!cameraData.make || !cameraData.model) {
console.log('Missing camera make or model.');
return;
}
// Optional special model corrections (exact match keys)
var specialModels = {
'HP Photosmart R707': 'HP PhotoSmart R707'
};
// Process make and model data
var rawMakeStr = cameraData.rawMakeStr,
rawModelStr = cameraData.rawModelStr;
// Preserve hyphenated all‑caps makes, else title‑case
var makeFull = (rawMakeStr === rawMakeStr.toUpperCase() && rawMakeStr.includes('-')
? rawMakeStr
: titleCase(rawMakeStr)),
makeToken = makeFull.split(/\s+/)[0],
// strip parentheses from cleaned model
model0 = rawModelStr.replace(/\s*\(.*?\)$/, ''),
// title‑case the stripped model
modelTc = titleCase(model0),
// remove leading brand token
modelCore = modelTc.replace(new RegExp('^' + makeToken + '\\s+', 'i'), '').trim(),
// preserve original cased core
modelRawCore = model0.replace(new RegExp('^' + makeToken + '\\s+', 'i'), '').trim();
// Build candidate list with special handling
var candidates = [];
// 1) Parenthetical suffix e.g. "Moto G (4)"
var paren = rawModelStr.match(/\(([^)]+)\)$/);
if (paren) {
var num = paren[1].trim();
candidates.push(
makeToken + ' ' + modelCore + num,
makeToken + ' ' + modelCore + ' (' + num + ')',
modelCore + num,
modelCore + ' (' + num + ')'
);
}
// 2) Drop last token on multi-word models e.g. "Touch 3G T3232" → "Touch 3G"
var coreTokens = modelCore.split(/\s+/);
if (coreTokens.length > 2) {
var shortCore = coreTokens.slice(0, -1).join(' ');
candidates.push(
makeToken + ' ' + shortCore,
shortCore
);
}
// 3) Any exact special-model overrides
if (specialModels[modelCore]) {
candidates.unshift(specialModels[modelCore]);
}
// 4) Finally, original fallbacks
candidates = candidates.concat([
makeToken + ' ' + modelRawCore,
makeToken + ' ' + model0,
model0,
modelRawCore,
modelCore,
modelTc,
makeToken + ' ' + modelCore,
makeToken + ' ' + modelTc,
makeFull + ' ' + modelCore
]);
var defaultPart = makeToken + ' ' + model0,
defaultCat = 'Taken with ' + defaultPart;
// Start checking candidates
tryCandidate(0);
function tryCandidate(i) {
var api = new mw.Api();
if (i >= candidates.length) {
mw.notify('No valid "Taken with" category found for ' + defaultPart, { type: 'error' });
return;
}
var namePart = candidates[i].trim(),
origCat = 'Taken with ' + namePart;
api.get({
action: 'query',
titles: 'Category:' + origCat,
prop: 'revisions',
rvprop: 'content',
redirects: 1,
format: 'json',
formatversion: 2
}).done(function(res) {
var pg = res.query.pages[0],
exists = !pg.missing,
apiTitle = exists ? pg.title.replace(/^Category:/, '') : origCat;
var content = '';
if (exists && pg.revisions && pg.revisions.length > 0) {
content = pg.revisions[0].content || '';
}
// Match redirects: cat redirect, category redirect, categoryredirect, or redirect category
var m = content.match(/\{\{\s*(?:cat\s+redirect|category\s+redirect|categoryredirect|redirect\s+category)\s*\|(?:\s*1\s*=)?\s*([^|\}]+)/i),
finalCat = m ? m[1].trim() : apiTitle;
// Strip redundant "Category:" prefix
if (/^Category:/i.test(finalCat)) {
finalCat = finalCat.replace(/^Category:/i, '').trim();
}
// Override: if this picked "Taken with iPhone", use "(original)"
if (finalCat === 'Taken with iPhone') {
finalCat = 'Taken with iPhone (original)';
}
if (exists) {
// Add the category
var pageName = mw.config.get('wgPageName');
api.postWithToken('csrf', {
action: 'edit',
title: pageName,
appendtext: '\n[[Category:' + finalCat + ']]',
summary: 'Adding [[Category:' + finalCat + ']] using merged EXIF script',
token: mw.user.tokens.get('csrfToken')
}).done(function() {
mw.notify('Category added: ' + finalCat, { type: 'success' });
location.reload();
}).fail(function() {
mw.notify('Failed to add category.', { type: 'error' });
});
} else {
tryCandidate(i + 1);
}
});
}
}
// Insert content at specific position
function insertAtPosition(content, index, template) {
var prefix = (index > 0 && content[index - 1] !== "\n") ? "\n" : "",
suffix = (index < content.length && content[index] !== "\n") ? "\n" : "";
return content.slice(0, index) + prefix + template + suffix + content.slice(index);
}
// Find closing brackets for a template
function findClosingBrackets(content, start) {
if (start < 0) return -1;
var open = 0;
for (var i = start; i < content.length; i++) {
if (content[i] === '{' && content[i + 1] === '{') {
open++;
i++;
} else if (content[i] === '}' && content[i + 1] === '}') {
open--;
i++;
if (open === 0) return i + 1;
}
}
return -1;
}
});