chopstick
All widgets

Currency

Comes with Chopstick

Type an amount of money in search, like 100 usd in eur or €20 to yen, to see it converted at the European Central Bank's latest rate. Return copies the answer; click it to edit.

Already installed? It's in your widget library.

Type it in the notch's search

  • Convert amount

What it runs

A widget is a folder of plain files. Here is all of its code.

widget.json
{
  "id": "currency",
  "name": "Currency",
  "icon": "icon.png",
  "description": "Type an amount of money in search, like 100 usd in eur or €20 to yen, to see it converted at the European Central Bank's latest rate. Return copies the answer; click it to edit.",
  "anytime": false,
  "initial": "help",
  "commands": {
    "convert": {
      "description": "Convert an amount of money to another currency and copy the answer (100 usd in eur, $20, €5 to yen).",
      "phrase": "Convert {amount}",
      "inputs": {
        "amount": { "type": "money", "description": "An amount with a currency code, symbol or name, optionally followed by \"in\" or \"to\" and the currency to convert to" }
      },
      "preview": { "state": "converted", "run": "rate.sh" },
      "do": [ { "run": "rate.sh", "else": "failed" }, { "copy": "{{result.text}}" }, { "go": "copied" } ]
    },
    "show": {
      "description": "Show an amount of money converted to another currency.",
      "inputs": {
        "amount": { "type": "money", "description": "An amount with a currency code, symbol or name, optionally followed by \"in\" or \"to\" and the currency to convert to" }
      },
      "do": [ { "set": { "error": "" } }, { "run": "rate.sh", "then": "converted", "else": "failed" } ]
    }
  },
  "states": {
    "help": {
      "task": true,
      "open": true,
      "compact": { "leading": { "symbol": "dollarsign.arrow.circlepath" } },
      "expanded": {
        "size": "regular",
        "header": { "symbol": "dollarsign.arrow.circlepath", "title": "Currency" },
        "blocks": [
          { "input": { "placeholder": "100 usd in eur", "do": [ { "command": "show", "with": { "amount": "{{item.text}}" } } ] } },
          { "note": "{{error | Or type an amount in search (⌘Space): 100 usd in eur, €20 to yen, $5.}}" }
        ]
      }
    },
    "converted": {
      "task": true,
      "open": true,
      "compact": { "leading": { "symbol": "dollarsign.arrow.circlepath" }, "trailing": { "text": "{{result.text}}" } },
      "expanded": {
        "size": "regular",
        "header": { "symbol": "dollarsign.arrow.circlepath", "title": "Currency" },
        "blocks": [
          { "row": [
              { "stack": [ { "text": "{{input.amount.text}}", "style": "title" }, { "text": "{{input.amount.name}}", "style": "caption" } ] },
              { "text": "→", "style": "title" },
              { "stack": [ { "text": "{{result.text | …}}", "style": "title" }, { "text": "{{input.amount.to.name}}", "style": "caption" } ] }
            ], "align": "center", "spacing": "loose" },
          { "note": "{{result.rate | Getting the rate…}}" },
          { "note": "{{result.date}}" },
          { "note": "{{error}}" },
          { "input": { "placeholder": "100 usd in eur", "value": "{{input.amount.text}} in {{input.amount.to.code}}",
                       "do": [ { "command": "show", "with": { "amount": "{{item.text}}" } } ] } }
        ]
      },
      "keys": [ { "key": "cmd+c", "label": "Copy", "do": [ { "copy": "{{result.text}}" }, { "go": "copied" } ] } ]
    },
    "failed": {
      "task": true,
      "open": true,
      "compact": { "leading": { "symbol": "exclamationmark.triangle", "tint": "orange" } },
      "expanded": {
        "size": "regular",
        "header": { "symbol": "dollarsign.arrow.circlepath", "title": "Currency" },
        "blocks": [
          { "note": "{{error | Couldn't get the exchange rate.}}" },
          { "input": { "placeholder": "100 usd in eur", "value": "{{input.amount.text}} in {{input.amount.to.code}}",
                       "do": [ { "command": "show", "with": { "amount": "{{item.text}}" } } ] } }
        ]
      }
    },
    "copied": {
      "task": true,
      "dismiss_after": 2,
      "compact": { "leading": { "symbol": "doc.on.doc", "tint": "green" }, "trailing": { "text": "{{result.text}}" } }
    }
  }
}
rate.sh
#!/bin/sh
# Converts the typed amount (input.amount in $CHOPSTICK_DATA) at the European Central Bank's
# latest rates, from Frankfurter (no key). All rates come in one request, kept for an hour.
dir="${CHOPSTICK_STORAGE:-${CHOPSTICK_TMP:-/tmp}}"
rates="$dir/rates.json"
if [ -z "$(find "$rates" -mmin -60 2>/dev/null)" ]; then
  curl -fsS --max-time 8 "https://api.frankfurter.dev/v1/latest?base=EUR" > "$rates.new" && mv "$rates.new" "$rates" \
    || { rm -f "$rates.new"; [ -s "$rates" ] || { echo "Couldn't reach the exchange rate service" >&2; exit 1; }; }
fi

osascript -l JavaScript - "$CHOPSTICK_DATA" "$(cat "$rates")" <<'JS'
function run(argv) {
  const data = JSON.parse(argv[0]), ecb = JSON.parse(argv[1]);
  const amount = (data.input || {}).amount;
  if (!amount) throw new Error("No amount to convert");
  const rate = code => code === "EUR" ? 1 : ecb.rates[code];
  const from = amount.code, to = amount.to.code;
  if (!rate(from) || !rate(to)) throw new Error("No rate for " + (rate(from) ? to : from));
  const per = rate(to) / rate(from), value = amount.value * per;
  // No cents for currencies that don't use them.
  const whole = ["JPY", "KRW", "HUF", "IDR", "ISK"];
  const show = (n, code) => n.toLocaleString("en-US", { maximumFractionDigits: whole.includes(code) ? 0 : 2,
                                                         minimumFractionDigits: whole.includes(code) ? 0 : 2 }) + " " + code;
  const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
  const [y, m, d] = ecb.date.split("-").map(Number);
  return JSON.stringify({
    value: value, text: show(value, to), from: show(amount.value, from),
    rate: "1 " + from + " = " + per.toPrecision(5) + " " + to,
    date: "ECB rate of " + d + " " + months[m - 1] + " " + y
  });
}
JS