Problem with API call within extension

I have created this extension to detect currency values in GBP, USD, EUR, and JPY and then convert them to the other 3 currencies automatically.

The extension runs fine, but it uses the fallback_rates not the rates from the API. What am I doing wrong here?

"use strict";

/**
 * "Convert" extension for PopClip - Currency Conversion with live exchange rates
 * Uses the ExchangeRate API to get the latest rates
 * #popclip
 * name: Currency Converter
 * icon: circle
 * entitlements: [network]
 */

Object.defineProperty(exports, "__esModule", { value: true });

// Configuration - Replace with your actual API key
const API_KEY = "MY API KEY"; 
const API_URL = `https://v6.exchangerate-api.com/v6/${API_KEY}/latest/USD`;

// Fallback rates in case API is unavailable
const FALLBACK_RATES = {
  USD: 1,
  EUR: 0.9518,
  GBP: 0.7898,
  JPY: 149.1802
};

// Global variables for rates
let currentRates = FALLBACK_RATES;
let lastFetchTime = 0;
const CACHE_DURATION = 3600000; // 1 hour in milliseconds

/**
 * Formats the output number with thousand separators and no decimal points.
 * @param {number} num - The number to format.
 * @returns {string} - The formatted number as a string.
 */
function formatOutput(num) {
  return Intl.NumberFormat(undefined, {
    useGrouping: true,
    minimumFractionDigits: 0,
    maximumFractionDigits: 0
  }).format(num);
}

/**
 * Determines the multiplier based on the suffix.
 * @param {string} suffix - The suffix indicating the scale (e.g., K, million, 千).
 * @returns {number} - The multiplier corresponding to the suffix.
 */
function getMultiplier(suffix) {
  if (!suffix) return 1;

  switch (suffix.toLowerCase()) {
    case 'k':
    case 'thousand':
    case '千':
      return 1000;
    case 'm':
    case 'million':
      return 1000000;
    case '万':
      return 10000;
    case 'b':
    case 'billion':
      return 1000000000;
    case '億':
      return 100000000;
    default:
      return 1;
  }
}

/**
 * Constructs a regular expression to match currency expressions.
 * @param {string} partial - The currency symbol or code.
 * @returns {RegExp} - The constructed regular expression.
 */
function makeRegex(partial) {
  return new RegExp(
    `^\\s*(?:` +
    `(${partial})\\s*(\\d{1,3}(?:,\\d{3})*(?:\\.\\d+)?|\\d+(?:\\.\\d+)?)` +
    `(?:\\s*([KkMmBb]|thousand|million|billion|千|万|億))?` +
    `|` +
    `(\\d{1,3}(?:,\\d{3})*(?:\\.\\d+)?|\\d+(?:\\.\\d+)?)` +
    `(?:\\s*([KkMmBb]|thousand|million|billion|千|万|億))?\\s*(${partial})` +
    `)\\s*$`,
    'ui'
  );
}

/**
 * Calculate all cross-rates based on the current rates
 * @returns {Object} - Object with all cross rates
 */
function calculateCrossRates() {
  const rates = currentRates;
  return {
    // USD conversions
    usdToEur: rates.EUR,
    usdToGbp: rates.GBP,
    usdToJpy: rates.JPY,

    // EUR conversions
    eurToUsd: 1 / rates.EUR,
    eurToGbp: rates.GBP / rates.EUR,
    eurToJpy: rates.JPY / rates.EUR,

    // GBP conversions
    gbpToUsd: 1 / rates.GBP,
    gbpToEur: rates.EUR / rates.GBP,
    gbpToJpy: rates.JPY / rates.GBP,

    // JPY conversions
    jpyToUsd: 1 / rates.JPY,
    jpyToEur: rates.EUR / rates.JPY,
    jpyToGbp: rates.GBP / rates.JPY
  };
}

/**
 * Get conversion definitions using the current rates
 * @returns {Array} - Array of conversion definitions
 */
function getConversions() {
  const rates = calculateCrossRates();
  return [
    // USD Conversions
    { regex: makeRegex('\\$|US\\$|USD'), outputUnit: 'EUR', factor: rates.usdToEur },
    { regex: makeRegex('\\$|US\\$|USD'), outputUnit: 'GBP', factor: rates.usdToGbp },
    { regex: makeRegex('\\$|US\\$|USD'), outputUnit: 'JPY', factor: rates.usdToJpy },

    // EUR Conversions
    { regex: makeRegex('€|EUR'), outputUnit: 'USD', factor: rates.eurToUsd },
    { regex: makeRegex('€|EUR'), outputUnit: 'GBP', factor: rates.eurToGbp },
    { regex: makeRegex('€|EUR'), outputUnit: 'JPY', factor: rates.eurToJpy },

    // GBP Conversions
    { regex: makeRegex('£|GBP'), outputUnit: 'USD', factor: rates.gbpToUsd },
    { regex: makeRegex('£|GBP'), outputUnit: 'EUR', factor: rates.gbpToEur },
    { regex: makeRegex('£|GBP'), outputUnit: 'JPY', factor: rates.gbpToJpy },

    // JPY Conversions
    { regex: makeRegex('¥|JPY|円'), outputUnit: 'USD', factor: rates.jpyToUsd },
    { regex: makeRegex('¥|JPY|円'), outputUnit: 'EUR', factor: rates.jpyToEur },
    { regex: makeRegex('¥|JPY|円'), outputUnit: 'GBP', factor: rates.jpyToGbp }
  ];
}

/**
 * Performs the currency conversion based on the input.
 * @param {string} input - The input string containing the currency and amount.
 * @returns {Array<string>} - An array of converted currency strings or an empty array if no match.
 */
function convert(input) {
  const results = [];
  const conversions = getConversions();

  for (let { regex, outputUnit, factor } of conversions) {
    const match = regex.exec(input);
    if (match === null) {
      continue;
    }

    // Determine if the symbol/code is before or after the number
    let numberPart = '';
    let suffix = '';
    if (match[1]) { // Symbol/code before the number
      numberPart = match[2];
      suffix = match[3] || '';
    } else if (match[6]) { // Symbol/code after the number
      numberPart = match[4];
      suffix = match[5] || '';
    } else {
      continue; // No valid number part found
    }

    if (!numberPart) {
      continue; // No valid number part found
    }

    // Remove commas and parse as float to handle decimals
    let amount = parseFloat(numberPart.replace(/,/g, ''));
    if (isNaN(amount)) {
      continue; // Invalid number, skip
    }

    // Apply multiplier based on suffix
    let multiplier = getMultiplier(suffix);
    let resultNumber = amount * multiplier * factor;

    // Format the converted amount with thousand separators and no decimals
    let convertedAmount = formatOutput(resultNumber);

    // Determine the appropriate symbol for the output currency
    let outputSymbol = '';
    switch (outputUnit) {
      case 'USD':
        outputSymbol = '$';
        break;
      case 'EUR':
        outputSymbol = '€';
        break;
      case 'GBP':
        outputSymbol = '£';
        break;
      case 'JPY':
        outputSymbol = '¥';
        break;
      default:
        outputSymbol = '';
    }

    // Construct the converted string
    const converted = `${outputSymbol}${convertedAmount}`;

    // Add to results array if not already included
    if (!results.includes(converted)) {
      results.push(converted);
    }
  }

  return results;
}

/**
 * Updates the exchange rates in the background using axios
 */
function updateExchangeRates() {
  const now = Date.now();
  
  // Only update if the cache is expired
  if (now - lastFetchTime < CACHE_DURATION) {
    return;
  }
  
  // Update the timestamp immediately to prevent multiple calls
  lastFetchTime = now;

  // Use axios to fetch new rates
  const axios = require("axios");
  
  axios.get(API_URL)
    .then(response => {
      const data = response.data;
      if (data.result === "success") {
        // Update the rates
        currentRates = {
          USD: 1,
          EUR: data.conversion_rates.EUR,
          GBP: data.conversion_rates.GBP,
          JPY: data.conversion_rates.JPY
        };
        console.log("Exchange rates updated:", currentRates);
      } else {
        console.error("API Error:", data["error-type"]);
      }
    })
    .catch(error => {
      console.error("Error fetching exchange rates:", error);
    });
}

// Try to update rates when the extension is loaded
updateExchangeRates();

/**
 * Defines the PopClip actions for currency conversion.
 * Each conversion is returned as an individual, selectable action.
 * @param {object} input - The input object containing the selected text.
 * @returns {Array<object>|undefined} - An array of action objects or undefined if no conversion.
 */
const actions = (input) => {
  // Try to update rates in the background, but don't wait for it
  updateExchangeRates();
  
  // Use current rates (either cached or fallback) for conversion
  const results = convert(input.text);
  
  if (results.length === 0)
    return;

  // Create an action for each conversion result
  const actionObjects = results.map((result) => {
    return {
      title: result,
      icon: null, // Allow title to show without an icon
      run: function(context) {
        context.output = result;
      }
    };
  });

  return actionObjects;
};

exports.actions = actions;

Hi, this looks like a really interesting extension. You mentioned that “the extension runs fine,” but this is dubious to me as there are some issues that prevent it from loading.

One blocker is that the actions() function needs the dynamic entitlement, which is not present in the header. As a result, this extension produces no actions.

Unfortunately, even adding that entitlement would still not work because the dynamic and network entitlements are not currently allowed at the same time. (I had security concerns when adding dynamic actions — bear in mind that dynamic extensions are run against every bit of text the user selects.)

That’s as far as I have gotten in investigating it.

Make sure to enable Debug output so you can see the errors for yourself.

(Btw, use strict; is not needed as that is applied internally by PopClip. Also the Object.defineProperty(exports, "__esModule", { value: true }); can be deleted, it does nothing of use (it’s something typescript puts in when it generates JS so you may have seen it in some of the extensions.)

Also quick question – what version of PopClip are you using?

Hi Nick, thanks for the quick feedback! It definitely runs on my machine – with version 2023.9 (1004226) popclip. Is this old? How do I update it?

I didnt know about enabling debug so will run again and report back. Many thanks again.

Yes, you are on an old version of PopClip. For updating, see: Migrate from the Mac App Store (MAS) edition to the Standalone edition — PopClip

Can you perhaps send me the zip file of the full .popclipext package you are running that is working? You’ll find it in ~/Library/Application Support/PopClip/Extensions

I’d like to examine it to understand exactly HOW it runs on your machine! You might be able to attach it here but if the forum won’t let you you can try PM me here or you can send to support@pilotmoon.com

1 Like