Skip to content

Commit bb36232

Browse files
committed
Merge branch 'release/v2.0'
2 parents 3e42970 + 536c53a commit bb36232

File tree

7 files changed

+963
-102
lines changed

7 files changed

+963
-102
lines changed

README.md

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,29 +21,74 @@ move it to the `InstalledPackage` folder located at:
2121

2222
## Usage
2323

24-
A ```Convert Currency``` item is inserted into the catalog.
24+
A `Convert Currency` item is inserted into the catalog.
2525
Select this item to enter conversion mode.
2626

27-
Enter the amount to convert, the source currency code and the destination currency code.
28-
If either the source or destination currency are omitted, the defaults are used.
29-
If the amount is omitted, the current exchange rate is shown.
27+
For the most basic usage, simply enter the amount to convert, the source currency and the destination currency, such as `5 USD in EUR`.
28+
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`.
3029

31-
*Currency* allows the source and destination currencies to be separated by any of the following:
32-
- in
33-
- to
34-
- :
30+
Furthermore, you can add (or subtract) multiple currencies together, such as `5 USD + 2 GBP in EUR`.
31+
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.
3532

36-
To convert between multiple currencies at the same time, separate each one by a comma.
37-
This can be done in either the source or destination field, and all combinations will be displayed in the results.
33+
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.
34+
You can also change what words and symbols are used between multiple destination currencies and between the source and destination.
3835

39-
This means that all of the following are allowed:
36+
### Aliases
4037

41-
- 5 usd in inr,JPY
42-
- EUR to JPY
43-
- 10 brl,usd:EUR,gbp
38+
By default, the plugin operates only on [ISO currency codes](https://pt.wikipedia.org/wiki/ISO_4217) (and a few others).
39+
However, there is support for *aliases*, which are alternative names for currencies.
40+
In the configuration file, the user can specify as many aliases as they desire for any currency (for instance, `dollar` and `dollars` for USD).
41+
Aliases, just like regular currency codes, are case-insensitive (i.e. `EuR`, `EUR` and `eur` are all treated the same).
42+
43+
44+
### Math
45+
46+
The available mathematical operations are addition (`+`), subtraction (`-`), multiplication (`*`), division (`/`) and exponentiation (`**` or `^`).
47+
You can also use parentheses and the negative operator (`-(3 + 4) * 4`, for example).
48+
49+
### Grammar
50+
51+
For those familiar with BNF grammars and regex, below is grammar accepted by the parser (`prog` is the top-level expression):
52+
53+
```
54+
prog := sources (to_key destinations)? extra?
55+
56+
to_key := 'to' | 'in' | ':'
57+
58+
destinations := cur_code sep destinations | cur_code
59+
60+
sep := ',' | '&' | 'and'
61+
cur_code := ([^0-9\s+-/*^()]+)
62+
# excluding any words that are used as 'sep' or 'to_key'
63+
64+
extra := ('+' | '-' | '*' | '/' | '**' | '^' ) expr
65+
66+
sources := source ('+' | '-')? sources | source
67+
source := '(' source ')'
68+
| cur_code expr
69+
| expr (cur_code?)
70+
71+
expr := add_expr
72+
add_expr := mult_expr | add_expr ('+' | '-') mult_expr
73+
mult_expr := exp_expr | mult_expr ('*' | '/') exp_expr
74+
exp_expr := unary_expr | exp_expr ('^' | '**') unary_expr
75+
unary_expr := operand | ('-' | '+') unary_expr
76+
operand := number | '(' expr ')'
77+
78+
number := (0|[1-9][0-9]*)([.,][0-9]+)?([eE][+-]?[0-9]+)?
79+
```
4480

4581
## Change Log
4682

83+
### v2.0
84+
85+
* Improved parser. More flexible, and now you can specify your own separators in the config file
86+
* Math! Add, subtract, multiply, or divide numbers to obtain the source amount for a currency (also supports parentheses and exponents)
87+
* Multiple source currencies. Add or subtract amounts in different currencies to obtain a final result
88+
* An icon
89+
* Support for aliases. The user can create aliases ('nicknames') for any valid currency in the config file
90+
91+
4792
### v1.4
4893

4994
* Added a layer between clients and OpenExchangeRates to mitigate API usage

src/currency.ico

10.2 KB
Binary file not shown.

src/currency.ini

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,53 @@
2727
# Default: daily
2828
#update_freq = daily
2929

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

3635
# The code of the default output currencies to assume if none is specified at
3736
# search time
38-
# For multiple currencies, separate each one with a comma.
39-
# * Default: EUR, GBP
40-
#output_cur = EUR, GBP
37+
# Separate each currency code by any whitespace
38+
# * Default: EUR GBP
39+
#output_cur = EUR
40+
# GBP
41+
42+
# The valid separators between sources and destination.
43+
# Separate values with whitespace
44+
# * Default: to in :
45+
#separators = to
46+
# in
47+
# :
48+
49+
# The valid separators between multiple destination currencies
50+
# Separate values with whitespace
51+
# * Default: and & ,
52+
#destination_separators = and
53+
# &
54+
# ,
55+
56+
[aliases]
57+
# This is a list of aliases
58+
#
59+
# Aliases are alternative names for currencies, allowing you to enter simpler
60+
# names instead of having to memorize currency codes.
61+
# Aliases are case-insensitive, can be any length and can be composed of any
62+
# character except numbers.
63+
# Separate aliases with any whitespace
64+
#
65+
# For instance, you can write the local name (both singular and plural) for your
66+
# most used currencies.
67+
# You can also use '$' to represent your local currency (because of the INI
68+
# format, literal '$' characters must be written as '$$', as in the example
69+
# below)
70+
#
71+
# EUR = euro euros
72+
# usd =
73+
# dollar
74+
# dollars
75+
# $$
76+
# bucks
4177

4278
[var]
4379
# As in every Keypirinha's configuration file, you may optionally include a

src/currency.py

Lines changed: 93 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,11 @@
22

33
import keypirinha as kp
44
import keypirinha_util as kpu
5-
import keypirinha_net as kpnet
65

7-
from .exchange import ExchangeRates, UpdateFreq
6+
from .parser import make_parser, ParserProperties
7+
from .parsy import ParseError
8+
from .exchange import ExchangeRates, UpdateFreq, CurrencyError
89

9-
import re
10-
import json
11-
import traceback
12-
import urllib.error
13-
import urllib.parse
14-
from html.parser import HTMLParser
1510

1611
class Currency(kp.Plugin):
1712
"""
@@ -48,20 +43,19 @@ class Currency(kp.Plugin):
4843
ITEMCAT_RESULT = kp.ItemCategory.USER_BASE + 3
4944

5045
DEFAULT_SECTION = 'defaults'
46+
ALIAS_SECTION = 'aliases'
5147

5248
DEFAULT_ITEM_ENABLED = True
5349
DEFAULT_UPDATE_FREQ = 'daily'
5450
DEFAULT_ALWAYS_EVALUATE = True
5551
DEFAULT_ITEM_LABEL = 'Convert Currency'
56-
DEFAULT_CUR_IN = 'USD'
57-
DEFAULT_CUR_OUT = 'EUR, GBP'
52+
DEFAULT_SEPARATORS = 'to, in, :'
53+
DEFAULT_DESTINATION_SEPARATORS = 'and; &, ,'
5854

5955
default_item_enabled = DEFAULT_ITEM_ENABLED
6056
update_freq = UpdateFreq(DEFAULT_UPDATE_FREQ)
6157
always_evaluate = DEFAULT_ALWAYS_EVALUATE
6258
default_item_label = DEFAULT_ITEM_LABEL
63-
default_cur_in = DEFAULT_CUR_IN
64-
default_cur_out = DEFAULT_CUR_OUT
6559

6660
ACTION_COPY_RESULT = 'copy_result'
6761
ACTION_COPY_AMOUNT = 'copy_amount'
@@ -102,18 +96,26 @@ def on_suggest(self, user_input, items_chain):
10296
if items_chain and items_chain[-1].category() == self.ITEMCAT_RESULT:
10397
self.set_suggestions(items_chain, kp.Match.ANY, kp.Sort.NONE)
10498
return
99+
# This is at top level
105100
if not items_chain or items_chain[-1].category() != self.ITEMCAT_CONVERT:
106101
if not self.always_evaluate:
107102
return
108-
query = self._parse_and_merge_input(user_input, True)
109-
if 'from_cur' not in query and 'to_cur' not in query:
103+
try:
104+
query = self._parse_and_merge_input(user_input, True)
105+
# This tests whether the user entered enough information to
106+
# indicate a currency conversion request.
107+
if not self._is_direct_request(query):
108+
return
109+
# if the conversion would have failed, return now
110+
self.broker.convert(self._parse_and_merge_input(user_input))
111+
except Exception as e:
110112
return
111113

112114
if self.should_terminate(0.25):
113115
return
114116
try:
115117
query = self._parse_and_merge_input(user_input)
116-
if not query['from_cur'] or not query['to_cur'] or not user_input:
118+
if query['destinations'] is None or query['sources'] is None:
117119
return
118120

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

129131
for result in results:
130132
suggestions.append(self._create_result_item(
131133
label=result['title'],
132-
short_desc= result['source'] + ' to ' + result['destination'],
134+
short_desc=result['description'],
133135
target=result['title']
134136
))
135137
except Exception as exc:
@@ -173,43 +175,37 @@ def on_events(self, flags):
173175
self._read_config()
174176
self.on_catalog()
175177

178+
def _is_direct_request(self, query):
179+
entered_dest = ('destinations' in query and
180+
query['destinations'] is not None)
181+
entered_source = (query['sources'] is not None and
182+
len(query['sources']) > 0 and
183+
query['sources'][0]['currency'] is not None)
184+
185+
return entered_dest or entered_source
186+
176187
def _parse_and_merge_input(self, user_input=None, empty=False):
177188
if empty:
178189
query = {}
179190
else:
180191
query = {
181-
'from_cur': self.default_cur_in,
182-
'to_cur': self.default_cur_out,
183-
'amount': 1
192+
'sources': [{'currency': self.broker.default_cur_in, 'amount': 1.0}],
193+
'destinations': [{'currency': cur} for cur in self.broker.default_curs_out],
194+
'extra': None
184195
}
185196

186-
# parse user input
187-
# * supported formats:
188-
# <amount> [[from_cur][( to | in |:)to_cur]]
189-
if user_input:
190-
user_input = user_input.lstrip()
191-
query['terms'] = user_input.rstrip()
192-
193-
symbolRegex = r'[a-zA-Z]{3}(,\s*[a-zA-Z]{3})*'
194-
195-
m = re.match(
196-
(r"^(?P<amount>\d*([,.]\d+)?)?\s*" +
197-
r"(?P<from_cur>" + symbolRegex + ")?\s*" +
198-
r"(( to | in |:)\s*(?P<to_cur>" + symbolRegex +"))?$"),
199-
user_input)
200-
201-
if m:
202-
if m.group('from_cur'):
203-
from_cur = self.broker.validate_codes(m.group('from_cur'))
204-
if from_cur:
205-
query['from_cur'] = from_cur
206-
if m.group('to_cur'):
207-
to_cur = self.broker.validate_codes(m.group('to_cur'))
208-
if to_cur:
209-
query['to_cur'] = to_cur
210-
if m.group('amount'):
211-
query['amount'] = float(m.group('amount').rstrip().replace(',', '.'))
212-
return query
197+
if not user_input:
198+
return query
199+
200+
user_input = user_input.lstrip()
201+
202+
try:
203+
parsed = self.parser.parse(user_input)
204+
if not parsed['destinations'] and 'destinations' in query:
205+
parsed['destinations'] = query['destinations']
206+
return parsed
207+
except ParseError as e:
208+
return query
213209

214210
def _update_update_item(self):
215211
self.merge_catalog([self.create_item(
@@ -228,7 +224,7 @@ def joinCur(lst):
228224
else:
229225
return ', '.join(lst[:-1]) + ' and ' + lst[-1]
230226

231-
desc = 'Convert from {} to {}'.format(joinCur(self.default_cur_in), joinCur(self.default_cur_out))
227+
desc = 'Convert from {} to {}'.format(self.broker.default_cur_in, joinCur(self.broker.default_curs_out))
232228

233229
return self.create_item(
234230
category=self.ITEMCAT_CONVERT,
@@ -276,7 +272,7 @@ def _warn_cur_code(name, fallback):
276272
'update_freq',
277273
section=self.DEFAULT_SECTION,
278274
fallback=self.DEFAULT_UPDATE_FREQ,
279-
enum = [freq.value for freq in UpdateFreq]
275+
enum=[freq.value for freq in UpdateFreq]
280276
)
281277
self.update_freq = UpdateFreq(update_freq_string)
282278

@@ -287,24 +283,60 @@ def _warn_cur_code(name, fallback):
287283
input_code = settings.get_stripped(
288284
"input_cur",
289285
section=self.DEFAULT_SECTION,
290-
fallback=self.DEFAULT_CUR_IN)
291-
validated_input_code = self.broker.validate_codes(input_code)
286+
fallback=self.broker.in_cur_fallback)
287+
validated_input_code = self.broker.set_default_cur_in(input_code)
292288

293289
if not validated_input_code:
294-
_warn_cur_code("input_cur", self.DEFAULT_CUR_IN)
295-
self.default_cur_in = self.broker.format_codes(self.DEFAULT_CUR_IN)
296-
else:
297-
self.default_cur_in = validated_input_code
290+
_warn_cur_code("input_cur", self.broker.default_cur_in)
298291

299292
# default output currency
300293
output_code = settings.get_stripped(
301294
"output_cur",
302295
section=self.DEFAULT_SECTION,
303-
fallback=self.DEFAULT_CUR_OUT)
304-
validated_output_code = self.broker.validate_codes(output_code)
296+
fallback=self.broker.out_cur_fallback)
297+
validated_output_code = self.broker.set_default_curs_out(output_code)
305298

306299
if not validated_output_code:
307-
_warn_cur_code("output_cur", self.DEFAULT_CUR_OUT)
308-
self.default_cur_out = self.broker.format_codes(self.DEFAULT_CUR_OUT)
309-
else:
310-
self.default_cur_out = validated_output_code
300+
_warn_cur_code("output_cur", self.broker.default_curs_out)
301+
302+
# separators
303+
separators_string = settings.get_stripped(
304+
"separators",
305+
section=self.DEFAULT_SECTION,
306+
fallback=self.DEFAULT_SEPARATORS)
307+
separators = separators_string.split()
308+
309+
# destination_separators
310+
dest_seps_string = settings.get_stripped(
311+
"destination_separators",
312+
section=self.DEFAULT_SECTION,
313+
fallback=self.DEFAULT_DESTINATION_SEPARATORS)
314+
dest_separators = dest_seps_string.split()
315+
316+
# aliases
317+
self.broker.clear_aliases()
318+
319+
keys = settings.keys(self.ALIAS_SECTION)
320+
for key in keys:
321+
try:
322+
validatedKey = self.broker.validate_code(key)
323+
aliases = settings.get_stripped(
324+
key,
325+
section=self.ALIAS_SECTION,
326+
fallback=''
327+
).split()
328+
for alias in aliases:
329+
validated = self.broker.validate_alias(alias)
330+
if validated:
331+
self.broker.add_alias(validated, validatedKey)
332+
else:
333+
fmt = 'Alias {} is invalid. It will be ignored'
334+
self.warn(fmt.format(alias))
335+
except Exception:
336+
fmt = 'Key {} is not a valid currency. It will be ignored'
337+
self.warn(fmt.format(key))
338+
339+
properties = ParserProperties()
340+
properties.to_keywords = separators
341+
properties.sep_keywords = dest_separators
342+
self.parser = make_parser(properties)

0 commit comments

Comments
 (0)