Skip to content

Commit

Permalink
Merge branch 'release/v2.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
AvatarHurden committed Jan 9, 2019
2 parents 3e42970 + 536c53a commit bb36232
Show file tree
Hide file tree
Showing 7 changed files with 963 additions and 102 deletions.
73 changes: 59 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,74 @@ move it to the `InstalledPackage` folder located at:

## Usage

A ```Convert Currency``` item is inserted into the catalog.
A `Convert Currency` item is inserted into the catalog.
Select this item to enter conversion mode.

Enter the amount to convert, the source currency code and the destination currency code.
If either the source or destination currency are omitted, the defaults are used.
If the amount is omitted, the current exchange rate is shown.
For the most basic usage, simply enter the amount to convert, the source currency and the destination currency, such as `5 USD in EUR`.
You can perform mathematical operations for the source amount, such as `10*(2+1) usd in EUR`, and you can even perform some math on the resulting amount `5 usd in EUR / 2`.

*Currency* allows the source and destination currencies to be separated by any of the following:
- in
- to
- :
Furthermore, you can add (or subtract) multiple currencies together, such as `5 USD + 2 GBP in EUR`.
You can also convert into multiple destination currencies, such as `5 USD in EUR, GBP`, and each conversion will be displayed as a separate result.

To convert between multiple currencies at the same time, separate each one by a comma.
This can be done in either the source or destination field, and all combinations will be displayed in the results.
If you omit the name of a currency, such as in `5 USD` or `5 in USD`, the plugin will use the default currencies specified in the configuration file.
You can also change what words and symbols are used between multiple destination currencies and between the source and destination.

This means that all of the following are allowed:
### Aliases

- 5 usd in inr,JPY
- EUR to JPY
- 10 brl,usd:EUR,gbp
By default, the plugin operates only on [ISO currency codes](https://pt.wikipedia.org/wiki/ISO_4217) (and a few others).
However, there is support for *aliases*, which are alternative names for currencies.
In the configuration file, the user can specify as many aliases as they desire for any currency (for instance, `dollar` and `dollars` for USD).
Aliases, just like regular currency codes, are case-insensitive (i.e. `EuR`, `EUR` and `eur` are all treated the same).


### Math

The available mathematical operations are addition (`+`), subtraction (`-`), multiplication (`*`), division (`/`) and exponentiation (`**` or `^`).
You can also use parentheses and the negative operator (`-(3 + 4) * 4`, for example).

### Grammar

For those familiar with BNF grammars and regex, below is grammar accepted by the parser (`prog` is the top-level expression):

```
prog := sources (to_key destinations)? extra?
to_key := 'to' | 'in' | ':'
destinations := cur_code sep destinations | cur_code
sep := ',' | '&' | 'and'
cur_code := ([^0-9\s+-/*^()]+)
# excluding any words that are used as 'sep' or 'to_key'
extra := ('+' | '-' | '*' | '/' | '**' | '^' ) expr
sources := source ('+' | '-')? sources | source
source := '(' source ')'
| cur_code expr
| expr (cur_code?)
expr := add_expr
add_expr := mult_expr | add_expr ('+' | '-') mult_expr
mult_expr := exp_expr | mult_expr ('*' | '/') exp_expr
exp_expr := unary_expr | exp_expr ('^' | '**') unary_expr
unary_expr := operand | ('-' | '+') unary_expr
operand := number | '(' expr ')'
number := (0|[1-9][0-9]*)([.,][0-9]+)?([eE][+-]?[0-9]+)?
```

## Change Log

### v2.0

* Improved parser. More flexible, and now you can specify your own separators in the config file
* Math! Add, subtract, multiply, or divide numbers to obtain the source amount for a currency (also supports parentheses and exponents)
* Multiple source currencies. Add or subtract amounts in different currencies to obtain a final result
* An icon
* Support for aliases. The user can create aliases ('nicknames') for any valid currency in the config file


### v1.4

* Added a layer between clients and OpenExchangeRates to mitigate API usage
Expand Down
Binary file added src/currency.ico
Binary file not shown.
46 changes: 41 additions & 5 deletions src/currency.ini
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,53 @@
# Default: daily
#update_freq = daily

# The code of the default source currencies to assume if none is specified at
# The code of the default source currency to assume if none is specified at
# search time.
# For multiple currencies, separate each one with a comma.
# * Default: USD
#input_cur = USD

# The code of the default output currencies to assume if none is specified at
# search time
# For multiple currencies, separate each one with a comma.
# * Default: EUR, GBP
#output_cur = EUR, GBP
# Separate each currency code by any whitespace
# * Default: EUR GBP
#output_cur = EUR
# GBP

# The valid separators between sources and destination.
# Separate values with whitespace
# * Default: to in :
#separators = to
# in
# :

# The valid separators between multiple destination currencies
# Separate values with whitespace
# * Default: and & ,
#destination_separators = and
# &
# ,

[aliases]
# This is a list of aliases
#
# Aliases are alternative names for currencies, allowing you to enter simpler
# names instead of having to memorize currency codes.
# Aliases are case-insensitive, can be any length and can be composed of any
# character except numbers.
# Separate aliases with any whitespace
#
# For instance, you can write the local name (both singular and plural) for your
# most used currencies.
# You can also use '$' to represent your local currency (because of the INI
# format, literal '$' characters must be written as '$$', as in the example
# below)
#
# EUR = euro euros
# usd =
# dollar
# dollars
# $$
# bucks

[var]
# As in every Keypirinha's configuration file, you may optionally include a
Expand Down
154 changes: 93 additions & 61 deletions src/currency.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,11 @@

import keypirinha as kp
import keypirinha_util as kpu
import keypirinha_net as kpnet

from .exchange import ExchangeRates, UpdateFreq
from .parser import make_parser, ParserProperties
from .parsy import ParseError
from .exchange import ExchangeRates, UpdateFreq, CurrencyError

import re
import json
import traceback
import urllib.error
import urllib.parse
from html.parser import HTMLParser

class Currency(kp.Plugin):
"""
Expand Down Expand Up @@ -48,20 +43,19 @@ class Currency(kp.Plugin):
ITEMCAT_RESULT = kp.ItemCategory.USER_BASE + 3

DEFAULT_SECTION = 'defaults'
ALIAS_SECTION = 'aliases'

DEFAULT_ITEM_ENABLED = True
DEFAULT_UPDATE_FREQ = 'daily'
DEFAULT_ALWAYS_EVALUATE = True
DEFAULT_ITEM_LABEL = 'Convert Currency'
DEFAULT_CUR_IN = 'USD'
DEFAULT_CUR_OUT = 'EUR, GBP'
DEFAULT_SEPARATORS = 'to, in, :'
DEFAULT_DESTINATION_SEPARATORS = 'and; &, ,'

default_item_enabled = DEFAULT_ITEM_ENABLED
update_freq = UpdateFreq(DEFAULT_UPDATE_FREQ)
always_evaluate = DEFAULT_ALWAYS_EVALUATE
default_item_label = DEFAULT_ITEM_LABEL
default_cur_in = DEFAULT_CUR_IN
default_cur_out = DEFAULT_CUR_OUT

ACTION_COPY_RESULT = 'copy_result'
ACTION_COPY_AMOUNT = 'copy_amount'
Expand Down Expand Up @@ -102,18 +96,26 @@ def on_suggest(self, user_input, items_chain):
if items_chain and items_chain[-1].category() == self.ITEMCAT_RESULT:
self.set_suggestions(items_chain, kp.Match.ANY, kp.Sort.NONE)
return
# This is at top level
if not items_chain or items_chain[-1].category() != self.ITEMCAT_CONVERT:
if not self.always_evaluate:
return
query = self._parse_and_merge_input(user_input, True)
if 'from_cur' not in query and 'to_cur' not in query:
try:
query = self._parse_and_merge_input(user_input, True)
# This tests whether the user entered enough information to
# indicate a currency conversion request.
if not self._is_direct_request(query):
return
# if the conversion would have failed, return now
self.broker.convert(self._parse_and_merge_input(user_input))
except Exception as e:
return

if self.should_terminate(0.25):
return
try:
query = self._parse_and_merge_input(user_input)
if not query['from_cur'] or not query['to_cur'] or not user_input:
if query['destinations'] is None or query['sources'] is None:
return

if self.broker.tryUpdate():
Expand All @@ -124,12 +126,12 @@ def on_suggest(self, user_input, items_chain):
label=user_input,
short_desc="Webservice failed ({})".format(self.broker.error)))
else:
results = self.broker.convert(query['amount'], query['from_cur'], query['to_cur'])
results = self.broker.convert(query)

for result in results:
suggestions.append(self._create_result_item(
label=result['title'],
short_desc= result['source'] + ' to ' + result['destination'],
short_desc=result['description'],
target=result['title']
))
except Exception as exc:
Expand Down Expand Up @@ -173,43 +175,37 @@ def on_events(self, flags):
self._read_config()
self.on_catalog()

def _is_direct_request(self, query):
entered_dest = ('destinations' in query and
query['destinations'] is not None)
entered_source = (query['sources'] is not None and
len(query['sources']) > 0 and
query['sources'][0]['currency'] is not None)

return entered_dest or entered_source

def _parse_and_merge_input(self, user_input=None, empty=False):
if empty:
query = {}
else:
query = {
'from_cur': self.default_cur_in,
'to_cur': self.default_cur_out,
'amount': 1
'sources': [{'currency': self.broker.default_cur_in, 'amount': 1.0}],
'destinations': [{'currency': cur} for cur in self.broker.default_curs_out],
'extra': None
}

# parse user input
# * supported formats:
# <amount> [[from_cur][( to | in |:)to_cur]]
if user_input:
user_input = user_input.lstrip()
query['terms'] = user_input.rstrip()

symbolRegex = r'[a-zA-Z]{3}(,\s*[a-zA-Z]{3})*'

m = re.match(
(r"^(?P<amount>\d*([,.]\d+)?)?\s*" +
r"(?P<from_cur>" + symbolRegex + ")?\s*" +
r"(( to | in |:)\s*(?P<to_cur>" + symbolRegex +"))?$"),
user_input)

if m:
if m.group('from_cur'):
from_cur = self.broker.validate_codes(m.group('from_cur'))
if from_cur:
query['from_cur'] = from_cur
if m.group('to_cur'):
to_cur = self.broker.validate_codes(m.group('to_cur'))
if to_cur:
query['to_cur'] = to_cur
if m.group('amount'):
query['amount'] = float(m.group('amount').rstrip().replace(',', '.'))
return query
if not user_input:
return query

user_input = user_input.lstrip()

try:
parsed = self.parser.parse(user_input)
if not parsed['destinations'] and 'destinations' in query:
parsed['destinations'] = query['destinations']
return parsed
except ParseError as e:
return query

def _update_update_item(self):
self.merge_catalog([self.create_item(
Expand All @@ -228,7 +224,7 @@ def joinCur(lst):
else:
return ', '.join(lst[:-1]) + ' and ' + lst[-1]

desc = 'Convert from {} to {}'.format(joinCur(self.default_cur_in), joinCur(self.default_cur_out))
desc = 'Convert from {} to {}'.format(self.broker.default_cur_in, joinCur(self.broker.default_curs_out))

return self.create_item(
category=self.ITEMCAT_CONVERT,
Expand Down Expand Up @@ -276,7 +272,7 @@ def _warn_cur_code(name, fallback):
'update_freq',
section=self.DEFAULT_SECTION,
fallback=self.DEFAULT_UPDATE_FREQ,
enum = [freq.value for freq in UpdateFreq]
enum=[freq.value for freq in UpdateFreq]
)
self.update_freq = UpdateFreq(update_freq_string)

Expand All @@ -287,24 +283,60 @@ def _warn_cur_code(name, fallback):
input_code = settings.get_stripped(
"input_cur",
section=self.DEFAULT_SECTION,
fallback=self.DEFAULT_CUR_IN)
validated_input_code = self.broker.validate_codes(input_code)
fallback=self.broker.in_cur_fallback)
validated_input_code = self.broker.set_default_cur_in(input_code)

if not validated_input_code:
_warn_cur_code("input_cur", self.DEFAULT_CUR_IN)
self.default_cur_in = self.broker.format_codes(self.DEFAULT_CUR_IN)
else:
self.default_cur_in = validated_input_code
_warn_cur_code("input_cur", self.broker.default_cur_in)

# default output currency
output_code = settings.get_stripped(
"output_cur",
section=self.DEFAULT_SECTION,
fallback=self.DEFAULT_CUR_OUT)
validated_output_code = self.broker.validate_codes(output_code)
fallback=self.broker.out_cur_fallback)
validated_output_code = self.broker.set_default_curs_out(output_code)

if not validated_output_code:
_warn_cur_code("output_cur", self.DEFAULT_CUR_OUT)
self.default_cur_out = self.broker.format_codes(self.DEFAULT_CUR_OUT)
else:
self.default_cur_out = validated_output_code
_warn_cur_code("output_cur", self.broker.default_curs_out)

# separators
separators_string = settings.get_stripped(
"separators",
section=self.DEFAULT_SECTION,
fallback=self.DEFAULT_SEPARATORS)
separators = separators_string.split()

# destination_separators
dest_seps_string = settings.get_stripped(
"destination_separators",
section=self.DEFAULT_SECTION,
fallback=self.DEFAULT_DESTINATION_SEPARATORS)
dest_separators = dest_seps_string.split()

# aliases
self.broker.clear_aliases()

keys = settings.keys(self.ALIAS_SECTION)
for key in keys:
try:
validatedKey = self.broker.validate_code(key)
aliases = settings.get_stripped(
key,
section=self.ALIAS_SECTION,
fallback=''
).split()
for alias in aliases:
validated = self.broker.validate_alias(alias)
if validated:
self.broker.add_alias(validated, validatedKey)
else:
fmt = 'Alias {} is invalid. It will be ignored'
self.warn(fmt.format(alias))
except Exception:
fmt = 'Key {} is not a valid currency. It will be ignored'
self.warn(fmt.format(key))

properties = ParserProperties()
properties.to_keywords = separators
properties.sep_keywords = dest_separators
self.parser = make_parser(properties)
Loading

0 comments on commit bb36232

Please sign in to comment.