User:Rkieferbaum/CombinedEXIFdata.js

// "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;
  }
});
Category:' + finalCat + ' Category:Ambiguous location in EXIF Category:Location not applicable Category:Pages with coordinates