├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── .python-version ├── .vscode ├── extensions.json ├── settings.json └── tasks.json ├── Conic_v3.1.1.alfredworkflow ├── LICENSE ├── Makefile ├── README.md ├── pyproject.toml ├── src ├── coinc │ ├── __init__.py │ ├── alfred.py │ ├── config.py │ ├── exceptions.py │ ├── query.py │ └── utils.py ├── default_settings.json ├── flags │ ├── AED.png │ ├── AFN.png │ ├── ALL.png │ ├── AMD.png │ ├── ANG.png │ ├── AOA.png │ ├── ARS.png │ ├── AUD.png │ ├── AWG.png │ ├── AZN.png │ ├── BAM.png │ ├── BBD.png │ ├── BDT.png │ ├── BGN.png │ ├── BHD.png │ ├── BIF.png │ ├── BMD.png │ ├── BND.png │ ├── BOB.png │ ├── BRL.png │ ├── BSD.png │ ├── BTC.png │ ├── BTN.png │ ├── BTS.png │ ├── BWP.png │ ├── BYN.png │ ├── BZD.png │ ├── CAD.png │ ├── CDF.png │ ├── CHF.png │ ├── CLF.png │ ├── CLP.png │ ├── CNH.png │ ├── CNY.png │ ├── COP.png │ ├── CRC.png │ ├── CUC.png │ ├── CUP.png │ ├── CVE.png │ ├── CZK.png │ ├── DASH.png │ ├── DJF.png │ ├── DKK.png │ ├── DOGE.png │ ├── DOP.png │ ├── DZD.png │ ├── EAC.png │ ├── EGP.png │ ├── EMC.png │ ├── ERN.png │ ├── ETB.png │ ├── ETH.png │ ├── EUR.png │ ├── FCT.png │ ├── FJD.png │ ├── FKP.png │ ├── FTC.png │ ├── GBP.png │ ├── GEL.png │ ├── GGP.png │ ├── GHS.png │ ├── GIP.png │ ├── GMD.png │ ├── GNF.png │ ├── GTQ.png │ ├── GYD.png │ ├── HKD.png │ ├── HNL.png │ ├── HRK.png │ ├── HTG.png │ ├── HUF.png │ ├── IDR.png │ ├── ILS.png │ ├── IMP.png │ ├── INR.png │ ├── IQD.png │ ├── IRR.png │ ├── ISK.png │ ├── JEP.png │ ├── JMD.png │ ├── JOD.png │ ├── JPY.png │ ├── KES.png │ ├── KGS.png │ ├── KHR.png │ ├── KMF.png │ ├── KPW.png │ ├── KRW.png │ ├── KWD.png │ ├── KYD.png │ ├── KZT.png │ ├── LAK.png │ ├── LBP.png │ ├── LKR.png │ ├── LRD.png │ ├── LSL.png │ ├── LTC.png │ ├── LYD.png │ ├── MAD.png │ ├── MDL.png │ ├── MGA.png │ ├── MKD.png │ ├── MMK.png │ ├── MNT.png │ ├── MOP.png │ ├── MRO.png │ ├── MRU.png │ ├── MUR.png │ ├── MVR.png │ ├── MWK.png │ ├── MXN.png │ ├── MYR.png │ ├── MZN.png │ ├── NAD.png │ ├── NGN.png │ ├── NIO.png │ ├── NMC.png │ ├── NOK.png │ ├── NPR.png │ ├── NXT.png │ ├── NZD.png │ ├── OMR.png │ ├── PAB.png │ ├── PEN.png │ ├── PGK.png │ ├── PHP.png │ ├── PKR.png │ ├── PLN.png │ ├── PPC.png │ ├── PYG.png │ ├── QAR.png │ ├── RON.png │ ├── RSD.png │ ├── RUB.png │ ├── RWF.png │ ├── SAR.png │ ├── SBD.png │ ├── SCR.png │ ├── SDG.png │ ├── SEK.png │ ├── SGD.png │ ├── SLL.png │ ├── SOS.png │ ├── SRD.png │ ├── SSP.png │ ├── STD.png │ ├── STN.png │ ├── STR.png │ ├── SVC.png │ ├── SYP.png │ ├── SZL.png │ ├── THB.png │ ├── TJS.png │ ├── TMT.png │ ├── TND.png │ ├── TOP.png │ ├── TRY.png │ ├── TTD.png │ ├── TWD.png │ ├── TZS.png │ ├── UAH.png │ ├── UGX.png │ ├── USD.png │ ├── UYU.png │ ├── UZS.png │ ├── VEF.png │ ├── VEF_BLKMKT.png │ ├── VEF_DICOM.png │ ├── VEF_DIPRO.png │ ├── VES.png │ ├── VND.png │ ├── VTC.png │ ├── VUV.png │ ├── WST.png │ ├── XAF.png │ ├── XCD.png │ ├── XDR.png │ ├── XMR.png │ ├── XOF.png │ ├── XPM.png │ ├── XRP.png │ ├── YER.png │ ├── ZAR.png │ ├── ZMW.png │ └── ZWL.png ├── hints │ ├── cancel.png │ ├── gear.png │ ├── info.png │ └── save.png ├── icon.png ├── images │ └── demo.png ├── index.py ├── info.plist ├── main.py ├── symbols.json └── workflow │ ├── LICENCE.txt │ ├── Notify.tgz │ ├── __init__.py │ ├── background.py │ ├── notify.py │ ├── update.py │ ├── util.py │ ├── version │ ├── workflow.py │ └── workflow3.py └── tests ├── __init__.py ├── conftest.py ├── test_alias.py ├── test_convert.py ├── test_load.py └── test_rates.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: tomy0000000 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ["https://www.paypal.me/tomy0000000", "https://www.buymeacoffee.com/tomy0000000"] 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 Test 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: 7 | - ".github/workflows/test.yml" 8 | - ".python-version" 9 | - "pyproject.toml" 10 | - "src/**" 11 | - "tests/**" 12 | pull_request: 13 | branches: ["main"] 14 | 15 | jobs: 16 | test: 17 | name: 🧪 Test 18 | runs-on: macos-latest 19 | steps: 20 | - name: 🛒 Checkout 21 | uses: actions/checkout@v3 22 | 23 | - name: 🐍 Set up Python 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version-file: ".python-version" 27 | 28 | - name: 📦 Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip flit 31 | make install 32 | 33 | - name: 🧪 Test with pytest 34 | run: | 35 | make test 36 | 37 | - name: ⬆️ Upload coverage report 38 | uses: codecov/codecov-action@v3 39 | with: 40 | files: ./coverage.xml 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | # .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Nova 107 | .nova 108 | 109 | ### Project Specific 110 | .alfredversionchecked 111 | rates.json 112 | currencies.json 113 | prefs.plist 114 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9.6 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bungcip.better-toml", 4 | "ryanluker.vscode-coverage-gutters" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": ["tests"], 3 | "python.testing.unittestEnabled": false, 4 | "python.testing.pytestEnabled": true 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Test", 8 | "type": "shell", 9 | "command": "make test", 10 | "problemMatcher": [] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /Conic_v3.1.1.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/Conic_v3.1.1.alfredworkflow -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tomy Hsieh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | define USAGE 2 | 💰💱 Alfred Workflow for currencies conversion 3 | 4 | Commands: 5 | install Install dependencies for local development 6 | test Run coverage unit tests 7 | 8 | endef 9 | 10 | export USAGE 11 | 12 | help: 13 | @echo "$$USAGE" 14 | 15 | install: 16 | flit install --deps develop --symlink 17 | 18 | test: 19 | pytest --cov=coinc --cov-report=html --cov-report=xml 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | # Coinc 6 | 7 | [![GitHub Test Status](https://img.shields.io/github/actions/workflow/status/tomy0000000/Coinc/test.yml?branch=main&logo=github&color=ffa02e)](https://github.com/tomy0000000/Coinc/actions/workflows/test.yml) 8 | [![Coverage](https://img.shields.io/codecov/c/github/tomy0000000/Coinc?logo=codecov&logoColor=white&color=ffa02e&token=ESgHfrrk6z)](https://codecov.io/gh/tomy0000000/Coinc) 9 | 10 | [![Alfred Gallery](https://img.shields.io/badge/Availalbe%20on-Alfred%20Gallery-ffa02e?logo=alfred)](https://alfred.app/workflows/tomy0000000/coinc) 11 | [![GitHub Dwonload Count](https://img.shields.io/github/downloads/tomy0000000/Coinc/total?color=ffa02e)](https://github.com/tomy0000000/Coinc/releases) 12 | [![GitHub Release (latest SemVer)](https://img.shields.io/github/v/release/tomy0000000/Coinc?color=ffa02e)](https://github.com/tomy0000000/Coinc/releases) 13 | [![GitHub LICENSE](https://img.shields.io/github/license/tomy0000000/Coinc?color=ffa02e)](https://github.com/tomy0000000/Coinc/blob/main/LICENSE) 14 | 15 | Coinc is an Alfred workflow that does currency conversion by using live currency rates from Open Exchange Rates API. 16 | 17 | ![Demo Screenshot](src/images/demo.png) 18 | 19 | Dwonload and install from [Alfred Gallery](https://alfred.app/workflows/tomy0000000/coinc). 20 | 21 | For a list of support currencies, Installation, Setup, and Usage, see [Wiki](../../wiki) 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "coinc" 3 | readme = "README.md" 4 | classifiers = [ 5 | "License :: OSI Approved :: MIT License", 6 | ] 7 | requires-python = "~=3.9" 8 | authors = [ 9 | {name = "Tomy Hsieh", email = "pypi@tomy.me"}, 10 | ] 11 | dynamic = ["version", "description"] 12 | 13 | [project.license] 14 | file = "LICENSE" 15 | 16 | [project.optional-dependencies] 17 | dev = [ 18 | "black ~=22.12.0", 19 | "isort ~=5.10.0", 20 | "flake8 ~=6.0.0", 21 | ] 22 | test = [ 23 | "pytest >=2.7.3", 24 | "pytest-cov >=3.0.0", 25 | "pytest-env >=0.8.1", 26 | "pytest-mock >=3.0.0", 27 | ] 28 | 29 | [build-system] 30 | requires = ["flit_core >=3.4,<4"] 31 | build-backend = "flit_core.buildapi" 32 | 33 | [tool.isort] 34 | profile = "black" 35 | 36 | [tool.pytest.ini_options] 37 | addopts = [ 38 | "--import-mode=importlib", 39 | ] 40 | env = [ 41 | "alfred_preferences={HOME}/Library/Application Support/Alfred/Alfred.alfredpreferences", 42 | "alfred_preferences_localhash=kMYlGfRchUhKOL8kDlvTta744rpmbXGDyesmUwwg", # This is a random value 43 | "alfred_theme=theme.bundled.default", 44 | "alfred_theme_background=rgba(255,255,255,0.86)", 45 | "alfred_theme_selection_background=rgba(72,0,105,0.89)", 46 | "alfred_theme_subtext=0", 47 | "alfred_version=5.0.6", 48 | "alfred_version_build=2110", 49 | "alfred_workflow_bundleid=tech.tomy.coinc", 50 | "alfred_workflow_cache={HOME}/Library/Caches/com.runningwithcrayons.Alfred/Workflow Data/tech.tomy.coinc", 51 | "alfred_workflow_data={HOME}/Library/Application Support/Alfred/Workflow Data/tech.tomy.coinc", 52 | "alfred_workflow_name=Coinc", 53 | "alfred_workflow_uid=user.workflow.65ADFBE5-EE00-46BC-9ACB-A0E0FF2EE87A", # This is a random value 54 | "alfred_workflow_version=3.1.1", 55 | "alfred_debug=1", 56 | "APP_ID=8f8c7e0d9f9b4b2e8b8a7d6c5f5e4d3c", # This is a random value 57 | "BASE=USD", 58 | "EXPIRE=300", 59 | "LOCALE=en_US", 60 | "ORIENTATION=DEFAULT", 61 | "PRECISION=3", 62 | ] 63 | pythonpath = [ 64 | "src" 65 | ] 66 | -------------------------------------------------------------------------------- /src/coinc/__init__.py: -------------------------------------------------------------------------------- 1 | """💰💱 Alfred Workflow for currencies conversion""" 2 | import os 3 | from datetime import datetime 4 | 5 | from workflow import Workflow3 6 | 7 | from .alfred import persisted_data 8 | from .exceptions import CoincError, ConfigError 9 | from .query import Query 10 | from .utils import ( 11 | add_alias, 12 | currencies_filter, 13 | generate_list_items, 14 | init_workflow, 15 | load_currencies, 16 | refresh_currencies, 17 | refresh_rates, 18 | remove_alias, 19 | ) 20 | 21 | __version__ = "3.1.1" 22 | __all__ = [ 23 | "load", 24 | "convert", 25 | "add", 26 | "remove", 27 | "arrange", 28 | "save_arrange", 29 | "refresh", 30 | "alias", 31 | "save_alias", 32 | "unalias", 33 | "help_me", 34 | ] 35 | 36 | 37 | def load(workflow: Workflow3) -> None: 38 | """Load all/favorites currencies""" 39 | currencies = load_currencies() 40 | if len(workflow.args) > 3: 41 | workflow.add_item( 42 | title="One Currency at a time, please", icon="hints/cancel.png" 43 | ) 44 | workflow.send_feedback() 45 | return 46 | load_type = str(workflow.args[1]) 47 | workflow.logger.info(load_type) 48 | args = workflow.args[2:] 49 | query = "" if not args else str(args[0]).upper() 50 | if os.getenv("redirect"): 51 | workflow.add_item( 52 | title="Done", 53 | subtitle="Dismiss", 54 | icon="hints/save.png", 55 | valid=True, 56 | arg="quit", 57 | ) 58 | if load_type == "all": 59 | items = generate_list_items( 60 | query, list(currencies.keys()), workflow.settings["favorites"], True 61 | ) 62 | elif load_type == "favorites": 63 | items = generate_list_items(query, workflow.settings["favorites"]) 64 | for item in items: 65 | item = workflow.add_item(**item) 66 | item.setvar("redirect", True) 67 | if not items: 68 | workflow.add_item( 69 | title="No Currency Found...", 70 | subtitle="Perhaps trying something else?", 71 | icon="hints/info.png", 72 | ) 73 | if load_type == "all": 74 | workflow.add_item( 75 | title="Kind notice", 76 | subtitle="Your existing favorites won't show up here", 77 | icon="hints/info.png", 78 | ) 79 | workflow.send_feedback() 80 | 81 | 82 | def convert(workflow: Workflow3) -> None: 83 | """Run conversion patterns""" 84 | try: 85 | init_workflow(workflow) 86 | query = Query(workflow.args[1:], workflow.config) 87 | query.run_pattern(workflow) 88 | except CoincError as error: 89 | workflow.logger.info(f"Coinc: {type(error).__name__}") 90 | workflow.logger.info(error) 91 | workflow.add_item( 92 | title=error.args[0], subtitle=error.args[1], icon="hints/cancel.png" 93 | ) 94 | workflow.send_feedback() 95 | 96 | 97 | def add(workflow: Workflow3) -> None: 98 | """Add currency to favorite list""" 99 | currency = workflow.args[1] 100 | workflow.settings["favorites"].append(currency) 101 | workflow.settings.save() 102 | currencies = load_currencies() 103 | print(f"{currencies[currency]} ({currency})") 104 | 105 | 106 | def remove(workflow: Workflow3) -> None: 107 | """Remove currency from favorite list""" 108 | currency = workflow.args[1] 109 | workflow.settings["favorites"].remove(currency) 110 | workflow.settings.save() 111 | currencies = load_currencies() 112 | print(f"{currencies[currency]} ({currency})") 113 | 114 | 115 | def arrange(workflow: Workflow3) -> None: 116 | """Rearrange favorite currencies order""" 117 | currencies = load_currencies() 118 | favorites = workflow.settings["favorites"] 119 | args = workflow.args[1:] 120 | if len(args) == len(favorites): 121 | workflow.add_item( 122 | title="Save", 123 | subtitle="Save current arrangement", 124 | icon="hints/save.png", 125 | valid=True, 126 | arg=f"save {' '.join(args)}", 127 | ) 128 | workflow.add_item( 129 | title="Cancel", 130 | subtitle="Cancel the operation without saving", 131 | icon="hints/cancel.png", 132 | valid=True, 133 | arg=f"cancel {' '.join(args)}", 134 | ) 135 | for abbreviation in favorites: 136 | if abbreviation not in args: 137 | query = f"{' '.join(args)} {abbreviation}" if args else abbreviation 138 | workflow.add_item( 139 | title=currencies[abbreviation], 140 | subtitle=abbreviation, 141 | icon=f"flags/{abbreviation}.png", 142 | valid=True, 143 | arg=query, 144 | autocomplete=query, 145 | ) 146 | if args: 147 | workflow.add_item(title="---------- Begin New Arrangement ----------") 148 | for arg in args: 149 | if arg in favorites: 150 | workflow.add_item( 151 | title=currencies[arg], subtitle=arg, icon=f"flags/{arg}.png" 152 | ) 153 | else: 154 | workflow.add_item( 155 | title=f"Currency {arg} isn't in favortie list", 156 | icon="hints/cancel.png", 157 | ) 158 | workflow.send_feedback() 159 | return None 160 | if args: 161 | workflow.add_item(title="---------- End New Arrangement ----------") 162 | workflow.add_item( 163 | title="To insert, press Return or Tab on selected item", icon="hints/info.png" 164 | ) 165 | workflow.add_item( 166 | title="To remove last item, press Option-Delete", icon="hints/info.png" 167 | ) 168 | workflow.send_feedback() 169 | 170 | 171 | def save_arrange(workflow: Workflow3) -> None: 172 | """Save new favorite arrangement""" 173 | args = workflow.args[2:] 174 | workflow.logger.info(args) 175 | workflow.settings["favorites"] = [str(arg) for arg in args] 176 | print(" ".join(args)) 177 | 178 | 179 | def refresh(workflow: Workflow3) -> None: 180 | """Manually trigger rates refresh""" 181 | try: 182 | init_workflow(workflow) 183 | except ConfigError as error: 184 | workflow.logger.info(error) 185 | workflow.add_item( 186 | title=error.args[0], subtitle=error.args[1], icon="hints/cancel.png" 187 | ) 188 | try: 189 | refresh_rates(workflow.config) 190 | refresh_currencies() 191 | except CoincError as error: 192 | workflow.logger.info(error) 193 | print(f"❌ Error occured during refresh,Coinc: {type(error).__name__}") 194 | print(f"✅ Currency list and rates have refreshed,{datetime.now()}") 195 | 196 | 197 | def alias(workflow: Workflow3) -> None: 198 | """Check if alias exists""" 199 | aliases = persisted_data("alias") 200 | currencies = load_currencies() 201 | workflow.logger.info(f"{workflow.args=}") 202 | 203 | alias = workflow.args[1].upper() if len(workflow.args) > 1 else "" 204 | query = workflow.args[2].upper() if len(workflow.args) > 2 else "" 205 | 206 | if len(workflow.args) > 3: 207 | workflow.add_item( 208 | title="Too many arguments", 209 | subtitle="Usage: cur-alias ", 210 | icon="hints/cancel.png", 211 | ) 212 | elif not alias: 213 | # No alias is provided, hint user to type one 214 | workflow.add_item( 215 | title="Type the alias", 216 | subtitle="valid alias should not contain digits", 217 | icon="hints/gear.png", 218 | ) 219 | elif alias in aliases: 220 | # Alias already exists 221 | currency = aliases[alias] 222 | workflow.add_item( 223 | title=f"{alias} is already aliased", 224 | subtitle=f"to {currencies[currency]} ({currency})", 225 | icon=f"flags/{currency}.png", 226 | ) 227 | elif any(char.isdigit() for char in alias): 228 | # Alias contains number 229 | workflow.add_item( 230 | title="Can't create alias with number in it", 231 | icon="hints/cancel.png", 232 | ) 233 | elif query in currencies: 234 | # Arguments are valid, confirm to save 235 | workflow.add_item( 236 | title=f"Alias {alias} to {currencies[query]} ({query})", 237 | subtitle="Confirm to save", 238 | icon=f"flags/{query}.png", 239 | valid=True, 240 | arg=f"create,{alias},{query}", 241 | ) 242 | else: 243 | # Alias is valid, currency is empty or invalid code, suggest currencies 244 | items = generate_list_items(query, list(currencies.keys())) 245 | if items: 246 | workflow.add_item( 247 | title=f"Alias '{alias}' to ...", 248 | subtitle="Type the currency (name or code) after alias with space to search", 249 | icon="hints/info.png", 250 | ) 251 | for item in items: 252 | code = item["arg"] 253 | item["arg"] = f"redirect,{alias} {code}" 254 | item = workflow.add_item(**item) 255 | else: 256 | workflow.add_item( 257 | title=f"No currency found for '{query}'", 258 | icon="hints/cancel.png", 259 | ) 260 | workflow.send_feedback() 261 | 262 | 263 | def unalias(workflow: Workflow3) -> None: 264 | """Remove alias""" 265 | aliases = persisted_data("alias") 266 | currencies = load_currencies() 267 | if len(workflow.args) < 3: 268 | query = workflow.args[1].upper() if len(workflow.args) == 2 else "" 269 | for alias in aliases: 270 | code = aliases[alias] 271 | currency = currencies[code] 272 | if currencies_filter(query, code, currency) or query in alias: 273 | workflow.add_item( 274 | title=f"'{alias}' is aliased to {currency} ({code})", 275 | subtitle="Press enter to confirm unalias", 276 | icon=f"flags/{code}.png", 277 | valid=True, 278 | arg=f"remove,{alias},{code}", 279 | ) 280 | if not workflow._items: 281 | workflow.add_item( 282 | title=f"Alias '{query}' not found", 283 | icon="hints/cancel.png", 284 | ) 285 | else: 286 | workflow.add_item( 287 | title="Too many arguments", 288 | subtitle="Usage: cur-unalias ", 289 | icon="hints/cancel.png", 290 | ) 291 | workflow.send_feedback() 292 | 293 | 294 | def save_alias(workflow: Workflow3) -> None: 295 | """Save alias""" 296 | action, alias, currency = workflow.args[1].split(",") 297 | alias = alias.upper() 298 | if action == "create": 299 | add_alias(alias, currency) 300 | print(f"✅ Currency alias created,{alias} → {currency}") 301 | elif action == "remove": 302 | remove_alias(alias) 303 | print(f"✅ Currency alias removed,{alias} → {currency}") 304 | else: 305 | print(f"❌ Invalid action,{action}") 306 | 307 | 308 | def help_me(workflow: Workflow3) -> None: 309 | """Function for showing example usage""" 310 | workflow.add_item( 311 | title="cur", 312 | subtitle="Convert between all favorite currencies and base currency with 1 unit", 313 | valid=True, 314 | arg="cur", 315 | ) 316 | workflow.add_item( 317 | title="cur 200", 318 | subtitle="Convert between all favorite currencies and base currency with unit", 319 | valid=True, 320 | arg="cur 200", 321 | ) 322 | workflow.add_item( 323 | title="cur GBP", 324 | subtitle="Convert between and base currency with 1 unit", 325 | valid=True, 326 | arg="cur GBP", 327 | ) 328 | workflow.add_item( 329 | title="cur 5 GBP", 330 | subtitle="Convert between and base currency with unit", 331 | valid=True, 332 | arg="cur 5 GBP", 333 | ) 334 | workflow.add_item( 335 | title="cur GBP TWD", 336 | subtitle="Convert between and with 1 unit", 337 | valid=True, 338 | arg="cur GBP TWD", 339 | ) 340 | workflow.add_item( 341 | title="cur 5 GBP TWD", 342 | subtitle="Convert between and with unit", 343 | valid=True, 344 | arg="cur 5 GBP TWD", 345 | ) 346 | workflow.add_item( 347 | title="cur-add TWD", 348 | subtitle="Add TWD to favorite list", 349 | icon="hints/gear.png", 350 | valid=True, 351 | arg="cur-add TWD", 352 | ) 353 | workflow.add_item( 354 | title="cur-rm GBP", 355 | subtitle="Remove GBP from favorite list", 356 | icon="hints/gear.png", 357 | valid=True, 358 | arg="cur-rm GBP", 359 | ) 360 | workflow.add_item( 361 | title="cur-arr", 362 | subtitle="Arrange orders of the favorite list", 363 | icon="hints/gear.png", 364 | valid=True, 365 | arg="cur-arr", 366 | ) 367 | workflow.add_item( 368 | title="cur-ref", 369 | subtitle="Refresh currency list & rates", 370 | icon="hints/gear.png", 371 | valid=True, 372 | arg="cur-ref", 373 | ) 374 | workflow.add_item( 375 | title="cur-index", 376 | subtitle="Add alias keyword triggers", 377 | icon="hints/gear.png", 378 | valid=True, 379 | arg="cur-index", 380 | ) 381 | workflow.add_item( 382 | title="cur-alias 新台幣 TWD", 383 | subtitle="Add '新台幣' as alias to match it to TWD", 384 | icon="hints/gear.png", 385 | valid=True, 386 | arg="cur-alias 新台幣 TWD", 387 | ) 388 | workflow.add_item( 389 | title="Documentation", 390 | subtitle="See documentation for more comprehensive usage", 391 | icon="hints/info.png", 392 | valid=True, 393 | arg="cur workflow:help", 394 | ) 395 | workflow.send_feedback() 396 | -------------------------------------------------------------------------------- /src/coinc/alfred.py: -------------------------------------------------------------------------------- 1 | """Utilities functions for Alfred""" 2 | import json 3 | import os 4 | from pathlib import Path 5 | from typing import Union 6 | 7 | PERSISTED_DATA_DIR = os.getenv("alfred_workflow_data") 8 | if not PERSISTED_DATA_DIR: 9 | raise RuntimeError("Alfred data directory not found") 10 | PERSISTED_DATA_FILE = Path(PERSISTED_DATA_DIR) / "settings.json" 11 | 12 | 13 | def persisted_data(key: str, content: Union[dict, None] = None) -> dict: 14 | """Read or write data to a file in the workflow's data directory""" 15 | 16 | # Read content 17 | if not PERSISTED_DATA_FILE.exists(): 18 | settings = dict() 19 | else: 20 | with open(PERSISTED_DATA_FILE, "r") as file: 21 | settings = json.load(file) 22 | 23 | # Write content 24 | if content is not None: 25 | settings[key] = content 26 | with open(PERSISTED_DATA_FILE, "w") as file: 27 | json.dump(settings, file, ensure_ascii=False) 28 | 29 | return settings.get(key, dict()) 30 | -------------------------------------------------------------------------------- /src/coinc/config.py: -------------------------------------------------------------------------------- 1 | """Helper class for loading config set in Alfred Variable Sheet""" 2 | import locale 3 | import os 4 | 5 | from .exceptions import ConfigError 6 | 7 | 8 | class Config: 9 | """Helper class for loading config set in Alfred Environment Variables Sheet 10 | 11 | Raises: 12 | ConfigError -- Raised when there are invalid value 13 | filled in Configuration Sheet 14 | """ 15 | 16 | def __init__(self) -> None: 17 | from .utils import load_currencies 18 | 19 | # App_ID 20 | app_id = os.getenv("APP_ID") 21 | if not app_id: 22 | raise ConfigError( 23 | "Please setup APP_ID to refresh new rates", 24 | ( 25 | "Paste your App ID into workflow environment " 26 | "variables sheet in Alfred Preferences" 27 | ), 28 | ) 29 | self.app_id = app_id 30 | # Base 31 | currencies = load_currencies() 32 | base_raw = os.getenv("BASE", "USD") 33 | if base_raw.upper() in currencies: 34 | self.base = base_raw.upper() 35 | else: 36 | raise ConfigError( 37 | f"Invalid base currency: {base_raw}", 38 | ( 39 | "Fix this in workflow environment " 40 | "variables sheet in Alfred Preferences" 41 | ), 42 | ) 43 | # Expire 44 | expire_raw = os.getenv("EXPIRE", 300) 45 | try: 46 | self.expire = int(expire_raw) 47 | except ValueError: 48 | raise ConfigError( 49 | f"Invalid expire value: {expire_raw}", 50 | ( 51 | "Fix this in workflow environment " 52 | "variables sheet in Alfred Preferences" 53 | ), 54 | ) 55 | # Orientation 56 | orientation_raw = os.getenv("ORIENTATION", "DEFAULT").upper() 57 | VALID = ["DEFAULT", "FROM_FAV", "TO_FAV"] 58 | if orientation_raw.replace(" ", "_").upper() in VALID: 59 | self.orientation = orientation_raw.replace(" ", "_").upper() 60 | else: 61 | raise ConfigError( 62 | f"Invalid orientation value: {orientation_raw}", 63 | ( 64 | "Fix this in workflow environment " 65 | "variables sheet in Alfred Preferences" 66 | ), 67 | ) 68 | # Precision 69 | precision_raw = os.getenv("PRECISION", 2) 70 | try: 71 | self.precision = int(precision_raw) 72 | except ValueError: 73 | raise ConfigError( 74 | f"Invalid precision value: {precision_raw}", 75 | ( 76 | "Fix this in workflow environment " 77 | "variables sheet in Alfred Preferences" 78 | ), 79 | ) 80 | # Locale 81 | locale_raw = os.getenv("LOCALE", "") 82 | try: 83 | locale.setlocale(locale.LC_ALL, locale_raw) 84 | except locale.Error: 85 | raise ConfigError( 86 | f"Invalid locale value: {locale_raw}", 87 | ( 88 | "Fix this in workflow environment " 89 | "variables sheet in Alfred Preferences" 90 | ), 91 | ) 92 | self.locale = locale_raw 93 | -------------------------------------------------------------------------------- /src/coinc/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions used in this module""" 2 | 3 | 4 | class CoincError(Exception): 5 | """Base Class used to declare other errors for Coinc 6 | 7 | Extends: 8 | Exception 9 | """ 10 | 11 | pass 12 | 13 | 14 | class ConfigError(CoincError): 15 | """Raised when there are invalid value filled in Configuration Sheet 16 | 17 | Extends: 18 | CoincError 19 | """ 20 | 21 | pass 22 | 23 | 24 | class QueryError(CoincError): 25 | """Raised when invalid query were given 26 | 27 | Extends: 28 | CoincError 29 | """ 30 | 31 | pass 32 | 33 | 34 | class AppIDError(CoincError): 35 | """Raised when App ID can not be used 36 | 37 | Extends: 38 | CoincError 39 | """ 40 | 41 | pass 42 | 43 | 44 | class ApiError(CoincError): 45 | """Raised when API is unreachable or return bad response 46 | 47 | Extends: 48 | CoincError 49 | """ 50 | 51 | pass 52 | -------------------------------------------------------------------------------- /src/coinc/query.py: -------------------------------------------------------------------------------- 1 | """Query parser and conversion mappings""" 2 | from workflow import Workflow3 3 | 4 | from .config import Config 5 | from .exceptions import QueryError 6 | from .utils import ( 7 | currencies_filter, 8 | generate_result_item, 9 | is_it_alias, 10 | is_it_currency, 11 | is_it_float, 12 | is_it_something_mixed, 13 | load_currencies, 14 | load_rates, 15 | ) 16 | 17 | 18 | class Query: 19 | """Query parser and conversion mappings 20 | 21 | Arguments: 22 | args {list} -- list of arguments to filled 23 | 24 | Raises: 25 | QueryError -- Raised when invalid query were given 26 | """ 27 | 28 | def __init__(self, args: list, config: Config) -> None: 29 | self.value: float | None = None 30 | self.currency_one: str | None = None 31 | self.currency_two: str | None = None 32 | self.bit_pattern: int = 0 33 | self.binding: bool = False 34 | invalids = [] 35 | for arg in args: 36 | value = is_it_float(arg) 37 | if value: 38 | self._fill_value(value) 39 | continue 40 | currency = is_it_currency(config, arg) 41 | if currency: 42 | self._fill_currency(currency) 43 | continue 44 | aliases = is_it_alias(arg) 45 | if aliases: 46 | self._fill_currency(aliases) 47 | continue 48 | mixed = is_it_something_mixed(config, arg) 49 | if mixed: 50 | self._fill_value(mixed[0]) 51 | self._fill_currency(mixed[1], inplace=True) 52 | self.binding = True 53 | continue 54 | invalids.append(arg) 55 | if len(args) == 1 and self.bit_pattern == 0: 56 | # If there's only one argument, try to guess a currency 57 | try: 58 | self.currency_two = str(args[0]) 59 | except UnicodeEncodeError: 60 | raise QueryError("Invalid Currency", args[0]) 61 | self.bit_pattern += 4 62 | elif invalids: 63 | if len(invalids) == 1: 64 | raise QueryError("Invalid Currency", invalids[0]) 65 | else: 66 | raise QueryError("Invalid Currencies", ", ".join(invalids)) 67 | 68 | def _fill_value(self, value: float) -> float: 69 | """Fill value and run checks 70 | 71 | Arguments: 72 | value {float} -- Value to be filled in 73 | 74 | Returns: 75 | float -- Value that passed in 76 | 77 | Raises: 78 | QueryError -- Raised when there is no space to fill value 79 | """ 80 | if not self.value: 81 | self.value = value 82 | self.bit_pattern += 1 83 | return value 84 | raise QueryError("Too many value", "Query can contain one numeric value only") 85 | 86 | def _fill_currency(self, currency: str, inplace: bool = False) -> str: 87 | """Fill currency into proper position 88 | 89 | Arguments: 90 | currency {str} -- Currency code to be filled in 91 | 92 | Keyword Arguments: 93 | inplace {bool} -- If True, `currency` will be filled 94 | into `currency_one`, old value will 95 | moved to `currency_two` (default: {False}) 96 | 97 | Returns: 98 | str -- Currency code that passed in 99 | 100 | Raises: 101 | QueryError -- Raised when there is no space to fill currency 102 | """ 103 | if not self.currency_one: 104 | self.currency_one = str(currency) 105 | self.bit_pattern += 2 106 | return currency 107 | if inplace and self.currency_one: 108 | currency, self.currency_one = self.currency_one, currency 109 | if not self.currency_two: 110 | self.currency_two = str(currency) 111 | self.bit_pattern += 4 112 | return currency 113 | raise QueryError( 114 | "Too many currencies", "Query can contain two currency code or symbol only" 115 | ) 116 | 117 | def run_pattern(self, workflow: Workflow3) -> None: 118 | """Run Correspond Function by Pattern 119 | 120 | | Pattern | currency_two | currency_one | value | 121 | | ------- | ------------ | ------------ | ----- | 122 | | 0 | 0 | 0 | 0 | 123 | | 1 | 0 | 0 | 1 | 124 | | 2 | 0 | 1 | 0 | 125 | | 3 | 0 | 1 | 1 | 126 | | 4 | 1 | 0 | 0 | 127 | | 5 | 1 | 0 | 1 | -> Undefined 128 | | 6 | 1 | 1 | 0 | 129 | | 7 | 1 | 1 | 1 | 130 | 131 | Arguments: 132 | workflow {workflow.Workflow3} -- workflow object 133 | """ 134 | workflow.logger.info(f"Run Pattern {self.bit_pattern}") 135 | workflow.logger.info(f"Value: {self.value}") 136 | workflow.logger.info(f"Currency One: {self.currency_one}") 137 | workflow.logger.info(f"Currency Two: {self.currency_two}") 138 | rates = load_rates(workflow.config) 139 | func = getattr(self, f"_pattern_{self.bit_pattern}") 140 | func(workflow, rates) 141 | 142 | # Show rates update time 143 | workflow.add_item( 144 | title="Last Update", subtitle=rates["last_update"], icon="hints/info.png" 145 | ) 146 | 147 | def _pattern_0(self, workflow: Workflow3, rates: dict) -> None: 148 | """Run Pattern 0 149 | 150 | Query contains: 151 | | item | Example Value | 152 | | ------------ | ------------- | 153 | | value | | 154 | | currency_one | | 155 | | currency_two | | 156 | 157 | Results: 158 | 1 = ? 159 | 1 = ? 160 | 1 = ? 161 | 1 = ? 162 | ... 163 | 164 | Arguments: 165 | workflow {workflow.Workflow3} -- workflow object 166 | rates {dict} -- dict containing rates 167 | """ 168 | for currency in workflow.settings["favorites"]: 169 | if workflow.config.orientation in ["DEFAULT", "FROM_FAV"]: 170 | generate_result_item( 171 | workflow, 1, currency, workflow.config.base, rates, currency 172 | ) 173 | if workflow.config.orientation in ["DEFAULT", "TO_FAV"]: 174 | generate_result_item( 175 | workflow, 1, workflow.config.base, currency, rates, currency 176 | ) 177 | 178 | def _pattern_1(self, workflow: Workflow3, rates: dict) -> None: 179 | """Run Pattern 1 180 | 181 | Query contains: 182 | | item | Example Value | 183 | | ------------ | ------------- | 184 | | value | 5 | 185 | | currency_one | | 186 | | currency_two | | 187 | 188 | Results: 189 | 5 = ? 190 | 5 = ? 191 | 5 = ? 192 | 5 = ? 193 | ... 194 | 195 | Arguments: 196 | workflow {workflow.Workflow3} -- workflow object 197 | rates {dict} -- dict containing rates 198 | """ 199 | for currency in workflow.settings["favorites"]: 200 | if workflow.config.orientation in ["DEFAULT", "FROM_FAV"]: 201 | generate_result_item( 202 | workflow, 203 | self.value, 204 | currency, 205 | workflow.config.base, 206 | rates, 207 | currency, 208 | ) 209 | if workflow.config.orientation in ["DEFAULT", "TO_FAV"]: 210 | generate_result_item( 211 | workflow, 212 | self.value, 213 | workflow.config.base, 214 | currency, 215 | rates, 216 | currency, 217 | ) 218 | 219 | def _pattern_2(self, workflow: Workflow3, rates: dict) -> None: 220 | """Run Pattern 2 221 | 222 | Query contains: 223 | | item | Example Value | 224 | | ------------ | ------------- | 225 | | value | | 226 | | currency_one | GBP | 227 | | currency_two | | 228 | 229 | Results: 230 | 1 GBP = ? 231 | 1 = ? GBP 232 | 233 | Arguments: 234 | workflow {workflow.Workflow3} -- workflow object 235 | rates {dict} -- dict containing rates 236 | """ 237 | generate_result_item( 238 | workflow, 239 | 1, 240 | self.currency_one, 241 | workflow.config.base, 242 | rates, 243 | self.currency_one, 244 | ) 245 | generate_result_item( 246 | workflow, 247 | 1, 248 | workflow.config.base, 249 | self.currency_one, 250 | rates, 251 | self.currency_one, 252 | ) 253 | 254 | def _pattern_3(self, workflow: Workflow3, rates: dict) -> None: 255 | """Run Pattern 3 256 | 257 | Query contains: 258 | | item | Example Value | 259 | | ------------ | ------------- | 260 | | value | 5 | 261 | | currency_one | GBP | 262 | | currency_two | | 263 | 264 | Results: 265 | 5 GBP = ? 266 | 5 = ? GBP 267 | 268 | Arguments: 269 | workflow {workflow.Workflow3} -- workflow object 270 | rates {dict} -- dict containing rates 271 | """ 272 | generate_result_item( 273 | workflow, 274 | self.value, 275 | self.currency_one, 276 | workflow.config.base, 277 | rates, 278 | self.currency_one, 279 | ) 280 | if not self.binding: 281 | generate_result_item( 282 | workflow, 283 | self.value, 284 | workflow.config.base, 285 | self.currency_one, 286 | rates, 287 | self.currency_one, 288 | ) 289 | 290 | def _pattern_4(self, workflow: Workflow3, rates: dict) -> None: 291 | """ 292 | Method 4 293 | @#$ (broken currency) 294 | List possible currencies and redirect to Method 2 295 | """ 296 | currencies = load_currencies() 297 | items = [] 298 | for abbreviation in rates: 299 | currency = currencies.get(abbreviation, "") 300 | if currencies_filter(self.currency_two, abbreviation, currency): 301 | items.append( 302 | dict( 303 | title=currency, 304 | subtitle=abbreviation, 305 | icon=f"flags/{abbreviation}.png", 306 | valid=True, 307 | autocomplete=abbreviation, 308 | arg=f"redirect,{abbreviation}", 309 | ) 310 | ) 311 | items = sorted(items, key=lambda item: item["subtitle"]) 312 | for item in items: 313 | workflow.add_item(**item) 314 | if not items: 315 | raise QueryError("Invalid Currency", self.currency_two) 316 | 317 | def _pattern_6(self, workflow: Workflow3, rates: dict) -> None: 318 | """Run Pattern 6 319 | 320 | Query contains: 321 | | item | Example Value | 322 | | ------------ | ------------- | 323 | | value | | 324 | | currency_one | GBP | 325 | | currency_two | CAD | 326 | 327 | Results: 328 | 1 GBP = ? CAD 329 | 1 CAD = ? GBP 330 | 331 | Arguments: 332 | workflow {workflow.Workflow3} -- workflow object 333 | rates {dict} -- dict containing rates 334 | """ 335 | generate_result_item( 336 | workflow, 1, self.currency_one, self.currency_two, rates, self.currency_two 337 | ) 338 | generate_result_item( 339 | workflow, 1, self.currency_two, self.currency_one, rates, self.currency_one 340 | ) 341 | 342 | def _pattern_7(self, workflow: Workflow3, rates: dict) -> None: 343 | """Run Pattern 7 344 | 345 | Query contains: 346 | | item | Example Value | 347 | | ------------ | ------------- | 348 | | value | 5 | 349 | | currency_one | GBP | 350 | | currency_two | CAD | 351 | 352 | Results: 353 | 5 GBP = ? CAD 354 | 5 CAD = ? GBP 355 | 356 | Arguments: 357 | workflow {workflow.Workflow3} -- workflow object 358 | rates {dict} -- dict containing rates 359 | """ 360 | generate_result_item( 361 | workflow, 362 | self.value, 363 | self.currency_one, 364 | self.currency_two, 365 | rates, 366 | self.currency_two, 367 | ) 368 | if not self.binding: 369 | generate_result_item( 370 | workflow, 371 | self.value, 372 | self.currency_two, 373 | self.currency_one, 374 | rates, 375 | self.currency_one, 376 | ) 377 | -------------------------------------------------------------------------------- /src/coinc/utils.py: -------------------------------------------------------------------------------- 1 | """Helper Functions""" 2 | import json 3 | import locale 4 | import os 5 | import plistlib 6 | import re 7 | import time 8 | import unicodedata 9 | from decimal import Decimal 10 | from typing import Union 11 | from urllib import error, request 12 | 13 | from workflow import Workflow3 14 | from workflow.workflow3 import Item3 15 | 16 | from .alfred import persisted_data 17 | from .config import Config 18 | from .exceptions import ApiError, AppIDError 19 | 20 | INFO_PLIST_PATH = "info.plist" 21 | OLD_BUNDLE_ID = "tech.tomy.coon" 22 | NEW_BUNDLE_ID = "tech.tomy.coinc" 23 | WORKFLOW_DATA_PATH = "~/Library/Application Support/Alfred/Workflow Data" 24 | RATE_ENDPOINT = ( 25 | "https://openexchangerates.org/api/latest.json?show_alternative=1&app_id={}" 26 | ) 27 | CURRENCY_ENDPOINT = ( 28 | "https://openexchangerates.org/api/currencies.json?show_alternative=1" 29 | ) 30 | 31 | 32 | def manual_update_patch(workflow: Workflow3) -> bool: 33 | """manual update metadatas for v1.3.0 name change 34 | 35 | Update include two section, change Bundle ID in info.plist to a new one, 36 | and rename the old data directory into new one 37 | 38 | Arguments: 39 | workflow {workflow.Workflow3} -- The workflow object 40 | 41 | Returns: 42 | bool -- Whether any modification got invoked 43 | """ 44 | updated = False 45 | # Fix Bundle ID 46 | if workflow.bundleid.encode("utf-8") == OLD_BUNDLE_ID: 47 | with open(INFO_PLIST_PATH, "rwb") as file: 48 | info = plistlib.load(file) 49 | info["bundleid"] = NEW_BUNDLE_ID 50 | plistlib.dump(info, file) 51 | workflow.logger.info("Bundle ID modified") 52 | updated = True 53 | 54 | # Move Data Directory 55 | old_path = os.path.expanduser(os.path.join(WORKFLOW_DATA_PATH, OLD_BUNDLE_ID)) 56 | if os.path.exists(old_path): 57 | new_path = os.path.expanduser(os.path.join(WORKFLOW_DATA_PATH, NEW_BUNDLE_ID)) 58 | os.rename(old_path, new_path) 59 | workflow.logger.info("Data Directory moved") 60 | updated = True 61 | return updated 62 | 63 | 64 | def init_workflow(workflow: Workflow3) -> Workflow3: 65 | """Run operation to get workflow ready 66 | 67 | Inject config into Workflow 68 | 69 | Arguments: 70 | workflow {workflow.Workflow3} -- The workflow object 71 | 72 | Returns: 73 | workflow -- the passed in workflow object 74 | """ 75 | from .config import Config 76 | 77 | workflow.config = Config() 78 | workflow.logger.info( 79 | f"Locale: {'(System)' if not workflow.config.locale else workflow.config.locale}" 80 | ) 81 | return workflow 82 | 83 | 84 | def _calculate( 85 | value: float, from_currency: str, to_currency: str, rates: dict, precision: int 86 | ) -> Decimal: 87 | """The Main Calculation of Conversion 88 | 89 | Arguments: 90 | value {float} -- amount of from_currency to be convert 91 | from_currency {str} -- currency code of value 92 | to_currency {str} -- currency code to be convert 93 | rates {dict} -- rates used to perform conversion 94 | precision {int} -- precision point to be round 95 | 96 | Returns: 97 | Decimal -- The result of the conversion 98 | """ 99 | return round( 100 | Decimal(value) * (Decimal(rates[to_currency]) / Decimal(rates[from_currency])), 101 | precision, 102 | ) 103 | 104 | 105 | def _format(value: Union[Decimal, float], precision) -> str: 106 | """Format the result of conversion 107 | 108 | Arguments: 109 | value {Decimal} -- The result of conversion 110 | precision {int} -- precision point to be round 111 | 112 | Returns: 113 | str -- The formatted result 114 | """ 115 | return locale.format_string(f"%.{precision}f", value, grouping=True, monetary=True) 116 | 117 | 118 | def is_it_float(query: str) -> Union[float, None]: 119 | """Check if query is a valid number 120 | 121 | Arguments: 122 | query {str} -- Input query string 123 | 124 | Returns: 125 | float -- Parsed float 126 | None -- if query failed to be parsed 127 | """ 128 | try: 129 | return float(query.replace(",", "")) 130 | except ValueError: 131 | return None 132 | 133 | 134 | def is_it_currency(config: Config, query: str) -> Union[str, None]: 135 | """Check if query is a valid currency 136 | 137 | Arguments: 138 | query {str} -- Input query string 139 | 140 | Returns: 141 | str -- Normalized currency code 142 | None -- if query failed to be parsed 143 | """ 144 | rates = load_rates(config) 145 | query = query.upper() 146 | if query in rates: 147 | return query 148 | return None 149 | 150 | 151 | def is_it_alias(query: str) -> Union[str, None]: 152 | """Check if query is a valid currency symbol 153 | 154 | Arguments: 155 | query {str} -- Input query string 156 | 157 | Returns: 158 | str -- Normalized currency code 159 | None -- if query failed to be parsed 160 | """ 161 | aliases = persisted_data("alias") 162 | # Full-width to half-width transition 163 | query = unicodedata.normalize("NFKC", query).upper() 164 | if query in aliases: 165 | return aliases[query] 166 | return None 167 | 168 | 169 | def is_it_something_mixed(config: Config, query: str) -> Union[tuple[float, str], None]: 170 | """Check if query is Mixed with value and currency 171 | 172 | [description] 173 | 174 | Arguments: 175 | query {str} -- Input query string 176 | 177 | Returns: 178 | tuple -- tuple with type (, ) contain parsed float and 179 | normalized currency code, respectively 180 | None -- if query failed to be parsed 181 | """ 182 | 183 | # Type 1: {number}{currency} 184 | match_result = re.match(r"^([0-9,]+(\.\d+)?)([A-Z_]+)$", query.upper()) 185 | if match_result: 186 | value = is_it_float(match_result.groups()[0]) 187 | currency = is_it_currency(config, match_result.groups()[2]) 188 | if value and currency: 189 | return (value, currency) 190 | 191 | # Type 2: {currency}{number} 192 | match_result = re.match(r"^([A-Z_]+)([0-9,]+(\.\d+)?)$", query.upper()) 193 | if match_result: 194 | value = is_it_float(match_result.groups()[1]) 195 | currency = is_it_currency(config, match_result.groups()[0]) 196 | if value and currency: 197 | return (value, currency) 198 | 199 | # Type 3: {alias}{number} 200 | match_result = re.match( 201 | r"^(.+?)([0-9,]+(\.\d+)?)$", query 202 | ) # Use '+?' for non-progressive match 203 | if match_result: 204 | value = is_it_float(match_result.groups()[1]) 205 | currency_alias = is_it_alias(match_result.groups()[0]) 206 | if value and currency_alias: 207 | return (value, currency_alias) 208 | 209 | # Type 4: {number}{alias} 210 | match_result = re.match( 211 | r"^([0-9,]+(\.\d+)?)(.*?)$", query 212 | ) # Use '+?' for non-progressive match 213 | if match_result: 214 | value = is_it_float(match_result.groups()[0]) 215 | currency_alias = is_it_alias(match_result.groups()[2]) 216 | if value and currency_alias: 217 | return (value, currency_alias) 218 | 219 | # If nothing matched 220 | return None 221 | 222 | 223 | def load_currencies(path: Union[str, os.PathLike] = "currencies.json") -> dict: 224 | """Load currency list, create one if not exists 225 | 226 | Keyword Arguments: 227 | path {str} -- path or filename of Currencies JSON 228 | (default: {"currencies.json"}) 229 | 230 | Returns: 231 | dict -- loaded dictionary of currency list 232 | """ 233 | if not os.path.exists(path): 234 | return refresh_currencies(path) 235 | last_update = int(time.time() - os.path.getmtime(path)) 236 | # Update currencies list if too old (30 days) 237 | if 2592000 < last_update: 238 | return refresh_currencies(path) 239 | with open(path) as file: 240 | currencies = json.load(file) 241 | return currencies 242 | 243 | 244 | def refresh_currencies(path: Union[str, os.PathLike] = "currencies.json") -> dict: 245 | """Fetch and save the newest currency list 246 | 247 | Keyword Arguments: 248 | path {str} -- path or filename of Currencies JSON to be saved 249 | (default: {"currencies.json"}) 250 | 251 | Returns: 252 | dict -- fetched dictionary of currency list 253 | 254 | Raises: 255 | ApiError -- Raised when API is unreachable or return bad response 256 | """ 257 | try: 258 | response = request.urlopen(CURRENCY_ENDPOINT) 259 | except error.HTTPError as err: 260 | response = json.load(err) 261 | raise ApiError("Unexpected Error", response["description"]) 262 | currencies = json.load(response) 263 | with open(path, "w+") as file: 264 | json.dump(currencies, file) 265 | return currencies 266 | 267 | 268 | def load_rates(config: Config, path: Union[str, os.PathLike] = "rates.json") -> dict: 269 | """Load rates, update if not exist or too-old 270 | 271 | Arguments: 272 | config {currency.config} -- Config object 273 | 274 | Keyword Arguments: 275 | path {str} -- path or filename of Rates JSON (default: {"rates.json"}) 276 | 277 | Returns: 278 | dict -- loaded dictionary of rates 279 | """ 280 | if not os.path.exists(path): 281 | return refresh_rates(config, path) 282 | with open(path) as file: 283 | rates = json.load(file) 284 | last_update = int(time.time() - os.path.getmtime(path)) 285 | if config.expire < last_update: 286 | return refresh_rates(config, path) 287 | # Inject rates file modification datetime 288 | rates["rates"]["last_update"] = f"{last_update} seconds ago" 289 | return rates["rates"] 290 | 291 | 292 | def refresh_rates(config: Config, path: Union[str, os.PathLike] = "rates.json") -> dict: 293 | """Fetch and save the newest rates 294 | 295 | Arguments: 296 | config {currency.config} -- Config object 297 | 298 | Keyword Arguments: 299 | path {str} -- path or filename of Rates JSON to be saved 300 | (default: {"rates.json"}) 301 | 302 | Returns: 303 | dict -- fetched dictionary of rates 304 | 305 | Raises: 306 | AppIDError -- Raised when App ID can not be used 307 | ApiError -- Raised when API is unreachable or return bad response 308 | """ 309 | 310 | try: 311 | response = request.urlopen(RATE_ENDPOINT.format(config.app_id)) 312 | except error.HTTPError as err: 313 | response = json.load(err) 314 | if err.code == 401: 315 | raise AppIDError( 316 | f"Invalid App ID: {config.app_id}", response["description"] 317 | ) 318 | elif err.code == 429: 319 | raise AppIDError("Access Restricted", response["description"]) 320 | else: 321 | raise ApiError("Unexpected Error", response["description"]) 322 | rates = json.load(response) 323 | with open(path, "w+") as file: 324 | json.dump(rates, file) 325 | rates["rates"]["last_update"] = "Now" 326 | return rates["rates"] 327 | 328 | 329 | def add_alias(alias: str, currency: str) -> None: 330 | """Save new alias 331 | 332 | Arguments: 333 | alias {str} -- new alias 334 | currency {str} -- currency code 335 | """ 336 | aliases = persisted_data("alias") 337 | aliases[alias] = currency 338 | persisted_data("alias", aliases) 339 | 340 | 341 | def remove_alias(alias: str) -> None: 342 | """Remove alias 343 | 344 | Arguments: 345 | alias {str} -- alias to be removed 346 | """ 347 | aliases = persisted_data("alias") 348 | aliases.pop(alias) 349 | persisted_data("alias", aliases) 350 | 351 | 352 | def load_symbols(path: Union[str, os.PathLike] = "symbols.json") -> dict: 353 | """Load symbols, return empty dict if file not found 354 | 355 | Keyword Arguments: 356 | path {str} -- path or filename of Symbols JSON 357 | (default: {"symbols.json"}) 358 | 359 | Returns: 360 | dict -- loaded dictionary of symbols 361 | """ 362 | if not os.path.exists(path): 363 | return {} 364 | with open(path) as file: 365 | symbols = json.load(file) 366 | return symbols 367 | 368 | 369 | def generate_result_item( 370 | workflow: Workflow3, 371 | value: float, 372 | from_currency: str, 373 | to_currency: str, 374 | rates: dict, 375 | icon: str, 376 | ) -> Item3: 377 | """Calculate conversion result and append item to workflow 378 | 379 | Arguments: 380 | workflow {workflow.Workflow3} -- The workflow object 381 | value {float} -- amount of from_currency to be convert 382 | from_currency {str} -- currency code of value 383 | to_currency {str} -- currency code to be convert 384 | rates {dict} -- rates used to perform conversion 385 | icon {str} -- currency code of icon to be display for workflow item 386 | 387 | Returns: 388 | workflow.workflow3.Item3 -- Item3 object generated 389 | """ 390 | symbols = load_symbols() 391 | result = _calculate( 392 | value, from_currency, to_currency, rates, workflow.config.precision 393 | ) 394 | value_formatted = _format(value, workflow.config.precision) 395 | result_formatted = _format(result, workflow.config.precision) 396 | result_symboled = f"{symbols[to_currency]}{result_formatted}" 397 | item = workflow.add_item( 398 | title=f"{value_formatted} {from_currency} = {result_formatted} {to_currency}", 399 | subtitle=f"Copy '{result_formatted}' to clipboard", 400 | icon=f"flags/{icon}.png", 401 | valid=True, 402 | arg=f"{result_formatted}", 403 | copytext=f"{result_formatted}", 404 | ) 405 | item.add_modifier( 406 | key="alt", 407 | subtitle=f"Copy '{result_symboled}' to clipboard", 408 | icon=f"flags/{icon}.png", 409 | valid=True, 410 | arg=result_symboled, 411 | ) 412 | return item 413 | 414 | 415 | def generate_list_items( 416 | query: str, 417 | currency_codes: list, 418 | filter: Union[list, None] = None, 419 | sort: bool = False, 420 | ) -> list: 421 | """Generate items from currency codes that can be add to workflow 422 | 423 | Arguments: 424 | query {str} -- Input query string 425 | currency_codes {list} -- list of currency code that are used to 426 | generate items 427 | 428 | Keyword Arguments: 429 | filter {list} -- list of favorites currency code 430 | (default: {None}) 431 | sort {bool} -- should items be sort (default: {False}) 432 | 433 | Returns: 434 | list -- list of items generate from currency_codes that can be add to 435 | workflow 436 | """ 437 | currencies = load_currencies() 438 | items = [] 439 | for code in currency_codes: 440 | if currencies_filter(query, code, currencies[code], filter): 441 | items.append( 442 | { 443 | "title": currencies[code], 444 | "subtitle": code, 445 | "icon": f"flags/{code}.png", 446 | "valid": True, 447 | "arg": code, 448 | } 449 | ) 450 | if sort: 451 | items = sorted(items, key=lambda item: item["subtitle"]) 452 | return items 453 | 454 | 455 | def currencies_filter( 456 | query: str, code: str, currency_name: str, filter: Union[list, None] = None 457 | ) -> bool: 458 | """Determine whether query matched with the code or currency name 459 | 460 | For query to match, it must not be a item in filter 461 | (if filter is provided), and be one of the following: 462 | * Empty query 463 | * Matching code from start (case insensitive) 464 | * Matching one of the word in currency_name from start (case insensitive) 465 | 466 | Arguments: 467 | query {str} -- Input query string 468 | code {str} -- Currency code 469 | currency_name {str} -- Full name of currency 470 | 471 | Keyword Arguments: 472 | filter {list} -- list of filter currency code (default: {None}) 473 | 474 | Returns: 475 | bool -- True if matched else False 476 | """ 477 | filter = filter or [] 478 | if code in filter: 479 | return False 480 | if not query: 481 | return True 482 | if code.startswith(query.upper()): 483 | return True 484 | for key_word in currency_name.split(): 485 | if key_word.lower().startswith(query.lower()): 486 | return True 487 | return False 488 | -------------------------------------------------------------------------------- /src/default_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "favorites": ["EUR", "CNY", "JPY", "GBP"], 3 | "alias": { 4 | "$": "USD", 5 | "AU$": "AUD", 6 | "C$": "NIO", 7 | "CA$": "CAD", 8 | "CN¥": "CNY", 9 | "RMB": "CNY", 10 | "HK$": "HKD", 11 | "L$": "LD", 12 | "MX$": "MXN", 13 | "NT$": "TWD", 14 | "NZ$": "NZD", 15 | "R$": "BRL", 16 | "S$": "SGD", 17 | "zł": "PLN", 18 | "£": "GBP", 19 | "¥": "JPY", 20 | "Ð": "DOGE", 21 | "Ł": "LTC", 22 | "ƒ": "AWG", 23 | "ɱ": "XMR", 24 | "Ψ": "XPM", 25 | "Դ": "AMD", 26 | "฿": "THB", 27 | "ლ": "GEL", 28 | "៛": "KHR", 29 | "Ᵽ": "PPC", 30 | "₡": "CRC", 31 | "₣": "CHF", 32 | "₤": "TRY", 33 | "₦": "NGN", 34 | "₨": "IDR", 35 | "₩": "KRW", 36 | "₪": "ILS", 37 | "₫": "VND", 38 | "€": "EUR", 39 | "₭": "LAK", 40 | "₮": "MNT", 41 | "₱": "PHP", 42 | "₲": "PYG", 43 | "₴": "UAH", 44 | "₵": "GHS", 45 | "₹": "INR", 46 | "₿": "BTC", 47 | "ℕ": "NMC", 48 | "〒": "KZT" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/flags/AED.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/AED.png -------------------------------------------------------------------------------- /src/flags/AFN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/AFN.png -------------------------------------------------------------------------------- /src/flags/ALL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/ALL.png -------------------------------------------------------------------------------- /src/flags/AMD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/AMD.png -------------------------------------------------------------------------------- /src/flags/ANG.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/ANG.png -------------------------------------------------------------------------------- /src/flags/AOA.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/AOA.png -------------------------------------------------------------------------------- /src/flags/ARS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/ARS.png -------------------------------------------------------------------------------- /src/flags/AUD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/AUD.png -------------------------------------------------------------------------------- /src/flags/AWG.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/AWG.png -------------------------------------------------------------------------------- /src/flags/AZN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/AZN.png -------------------------------------------------------------------------------- /src/flags/BAM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BAM.png -------------------------------------------------------------------------------- /src/flags/BBD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BBD.png -------------------------------------------------------------------------------- /src/flags/BDT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BDT.png -------------------------------------------------------------------------------- /src/flags/BGN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BGN.png -------------------------------------------------------------------------------- /src/flags/BHD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BHD.png -------------------------------------------------------------------------------- /src/flags/BIF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BIF.png -------------------------------------------------------------------------------- /src/flags/BMD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BMD.png -------------------------------------------------------------------------------- /src/flags/BND.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BND.png -------------------------------------------------------------------------------- /src/flags/BOB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BOB.png -------------------------------------------------------------------------------- /src/flags/BRL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BRL.png -------------------------------------------------------------------------------- /src/flags/BSD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BSD.png -------------------------------------------------------------------------------- /src/flags/BTC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BTC.png -------------------------------------------------------------------------------- /src/flags/BTN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BTN.png -------------------------------------------------------------------------------- /src/flags/BTS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BTS.png -------------------------------------------------------------------------------- /src/flags/BWP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BWP.png -------------------------------------------------------------------------------- /src/flags/BYN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BYN.png -------------------------------------------------------------------------------- /src/flags/BZD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/BZD.png -------------------------------------------------------------------------------- /src/flags/CAD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/CAD.png -------------------------------------------------------------------------------- /src/flags/CDF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/CDF.png -------------------------------------------------------------------------------- /src/flags/CHF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/CHF.png -------------------------------------------------------------------------------- /src/flags/CLF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/CLF.png -------------------------------------------------------------------------------- /src/flags/CLP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/CLP.png -------------------------------------------------------------------------------- /src/flags/CNH.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/CNH.png -------------------------------------------------------------------------------- /src/flags/CNY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/CNY.png -------------------------------------------------------------------------------- /src/flags/COP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/COP.png -------------------------------------------------------------------------------- /src/flags/CRC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/CRC.png -------------------------------------------------------------------------------- /src/flags/CUC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/CUC.png -------------------------------------------------------------------------------- /src/flags/CUP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/CUP.png -------------------------------------------------------------------------------- /src/flags/CVE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/CVE.png -------------------------------------------------------------------------------- /src/flags/CZK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/CZK.png -------------------------------------------------------------------------------- /src/flags/DASH.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/DASH.png -------------------------------------------------------------------------------- /src/flags/DJF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/DJF.png -------------------------------------------------------------------------------- /src/flags/DKK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/DKK.png -------------------------------------------------------------------------------- /src/flags/DOGE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/DOGE.png -------------------------------------------------------------------------------- /src/flags/DOP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/DOP.png -------------------------------------------------------------------------------- /src/flags/DZD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/DZD.png -------------------------------------------------------------------------------- /src/flags/EAC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/EAC.png -------------------------------------------------------------------------------- /src/flags/EGP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/EGP.png -------------------------------------------------------------------------------- /src/flags/EMC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/EMC.png -------------------------------------------------------------------------------- /src/flags/ERN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/ERN.png -------------------------------------------------------------------------------- /src/flags/ETB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/ETB.png -------------------------------------------------------------------------------- /src/flags/ETH.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/ETH.png -------------------------------------------------------------------------------- /src/flags/EUR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/EUR.png -------------------------------------------------------------------------------- /src/flags/FCT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/FCT.png -------------------------------------------------------------------------------- /src/flags/FJD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/FJD.png -------------------------------------------------------------------------------- /src/flags/FKP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/FKP.png -------------------------------------------------------------------------------- /src/flags/FTC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/FTC.png -------------------------------------------------------------------------------- /src/flags/GBP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/GBP.png -------------------------------------------------------------------------------- /src/flags/GEL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/GEL.png -------------------------------------------------------------------------------- /src/flags/GGP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/GGP.png -------------------------------------------------------------------------------- /src/flags/GHS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/GHS.png -------------------------------------------------------------------------------- /src/flags/GIP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/GIP.png -------------------------------------------------------------------------------- /src/flags/GMD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/GMD.png -------------------------------------------------------------------------------- /src/flags/GNF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/GNF.png -------------------------------------------------------------------------------- /src/flags/GTQ.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/GTQ.png -------------------------------------------------------------------------------- /src/flags/GYD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/GYD.png -------------------------------------------------------------------------------- /src/flags/HKD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/HKD.png -------------------------------------------------------------------------------- /src/flags/HNL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/HNL.png -------------------------------------------------------------------------------- /src/flags/HRK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/HRK.png -------------------------------------------------------------------------------- /src/flags/HTG.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/HTG.png -------------------------------------------------------------------------------- /src/flags/HUF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/HUF.png -------------------------------------------------------------------------------- /src/flags/IDR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/IDR.png -------------------------------------------------------------------------------- /src/flags/ILS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/ILS.png -------------------------------------------------------------------------------- /src/flags/IMP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/IMP.png -------------------------------------------------------------------------------- /src/flags/INR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/INR.png -------------------------------------------------------------------------------- /src/flags/IQD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/IQD.png -------------------------------------------------------------------------------- /src/flags/IRR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/IRR.png -------------------------------------------------------------------------------- /src/flags/ISK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/ISK.png -------------------------------------------------------------------------------- /src/flags/JEP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/JEP.png -------------------------------------------------------------------------------- /src/flags/JMD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/JMD.png -------------------------------------------------------------------------------- /src/flags/JOD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/JOD.png -------------------------------------------------------------------------------- /src/flags/JPY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/JPY.png -------------------------------------------------------------------------------- /src/flags/KES.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/KES.png -------------------------------------------------------------------------------- /src/flags/KGS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/KGS.png -------------------------------------------------------------------------------- /src/flags/KHR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/KHR.png -------------------------------------------------------------------------------- /src/flags/KMF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/KMF.png -------------------------------------------------------------------------------- /src/flags/KPW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/KPW.png -------------------------------------------------------------------------------- /src/flags/KRW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/KRW.png -------------------------------------------------------------------------------- /src/flags/KWD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/KWD.png -------------------------------------------------------------------------------- /src/flags/KYD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/KYD.png -------------------------------------------------------------------------------- /src/flags/KZT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/KZT.png -------------------------------------------------------------------------------- /src/flags/LAK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/LAK.png -------------------------------------------------------------------------------- /src/flags/LBP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/LBP.png -------------------------------------------------------------------------------- /src/flags/LKR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/LKR.png -------------------------------------------------------------------------------- /src/flags/LRD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/LRD.png -------------------------------------------------------------------------------- /src/flags/LSL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/LSL.png -------------------------------------------------------------------------------- /src/flags/LTC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/LTC.png -------------------------------------------------------------------------------- /src/flags/LYD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/LYD.png -------------------------------------------------------------------------------- /src/flags/MAD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MAD.png -------------------------------------------------------------------------------- /src/flags/MDL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MDL.png -------------------------------------------------------------------------------- /src/flags/MGA.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MGA.png -------------------------------------------------------------------------------- /src/flags/MKD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MKD.png -------------------------------------------------------------------------------- /src/flags/MMK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MMK.png -------------------------------------------------------------------------------- /src/flags/MNT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MNT.png -------------------------------------------------------------------------------- /src/flags/MOP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MOP.png -------------------------------------------------------------------------------- /src/flags/MRO.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MRO.png -------------------------------------------------------------------------------- /src/flags/MRU.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MRU.png -------------------------------------------------------------------------------- /src/flags/MUR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MUR.png -------------------------------------------------------------------------------- /src/flags/MVR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MVR.png -------------------------------------------------------------------------------- /src/flags/MWK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MWK.png -------------------------------------------------------------------------------- /src/flags/MXN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MXN.png -------------------------------------------------------------------------------- /src/flags/MYR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MYR.png -------------------------------------------------------------------------------- /src/flags/MZN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/MZN.png -------------------------------------------------------------------------------- /src/flags/NAD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/NAD.png -------------------------------------------------------------------------------- /src/flags/NGN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/NGN.png -------------------------------------------------------------------------------- /src/flags/NIO.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/NIO.png -------------------------------------------------------------------------------- /src/flags/NMC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/NMC.png -------------------------------------------------------------------------------- /src/flags/NOK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/NOK.png -------------------------------------------------------------------------------- /src/flags/NPR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/NPR.png -------------------------------------------------------------------------------- /src/flags/NXT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/NXT.png -------------------------------------------------------------------------------- /src/flags/NZD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/NZD.png -------------------------------------------------------------------------------- /src/flags/OMR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/OMR.png -------------------------------------------------------------------------------- /src/flags/PAB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/PAB.png -------------------------------------------------------------------------------- /src/flags/PEN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/PEN.png -------------------------------------------------------------------------------- /src/flags/PGK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/PGK.png -------------------------------------------------------------------------------- /src/flags/PHP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/PHP.png -------------------------------------------------------------------------------- /src/flags/PKR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/PKR.png -------------------------------------------------------------------------------- /src/flags/PLN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/PLN.png -------------------------------------------------------------------------------- /src/flags/PPC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/PPC.png -------------------------------------------------------------------------------- /src/flags/PYG.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/PYG.png -------------------------------------------------------------------------------- /src/flags/QAR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/QAR.png -------------------------------------------------------------------------------- /src/flags/RON.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/RON.png -------------------------------------------------------------------------------- /src/flags/RSD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/RSD.png -------------------------------------------------------------------------------- /src/flags/RUB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/RUB.png -------------------------------------------------------------------------------- /src/flags/RWF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/RWF.png -------------------------------------------------------------------------------- /src/flags/SAR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SAR.png -------------------------------------------------------------------------------- /src/flags/SBD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SBD.png -------------------------------------------------------------------------------- /src/flags/SCR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SCR.png -------------------------------------------------------------------------------- /src/flags/SDG.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SDG.png -------------------------------------------------------------------------------- /src/flags/SEK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SEK.png -------------------------------------------------------------------------------- /src/flags/SGD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SGD.png -------------------------------------------------------------------------------- /src/flags/SLL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SLL.png -------------------------------------------------------------------------------- /src/flags/SOS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SOS.png -------------------------------------------------------------------------------- /src/flags/SRD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SRD.png -------------------------------------------------------------------------------- /src/flags/SSP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SSP.png -------------------------------------------------------------------------------- /src/flags/STD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/STD.png -------------------------------------------------------------------------------- /src/flags/STN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/STN.png -------------------------------------------------------------------------------- /src/flags/STR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/STR.png -------------------------------------------------------------------------------- /src/flags/SVC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SVC.png -------------------------------------------------------------------------------- /src/flags/SYP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SYP.png -------------------------------------------------------------------------------- /src/flags/SZL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/SZL.png -------------------------------------------------------------------------------- /src/flags/THB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/THB.png -------------------------------------------------------------------------------- /src/flags/TJS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/TJS.png -------------------------------------------------------------------------------- /src/flags/TMT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/TMT.png -------------------------------------------------------------------------------- /src/flags/TND.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/TND.png -------------------------------------------------------------------------------- /src/flags/TOP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/TOP.png -------------------------------------------------------------------------------- /src/flags/TRY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/TRY.png -------------------------------------------------------------------------------- /src/flags/TTD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/TTD.png -------------------------------------------------------------------------------- /src/flags/TWD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/TWD.png -------------------------------------------------------------------------------- /src/flags/TZS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/TZS.png -------------------------------------------------------------------------------- /src/flags/UAH.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/UAH.png -------------------------------------------------------------------------------- /src/flags/UGX.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/UGX.png -------------------------------------------------------------------------------- /src/flags/USD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/USD.png -------------------------------------------------------------------------------- /src/flags/UYU.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/UYU.png -------------------------------------------------------------------------------- /src/flags/UZS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/UZS.png -------------------------------------------------------------------------------- /src/flags/VEF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/VEF.png -------------------------------------------------------------------------------- /src/flags/VEF_BLKMKT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/VEF_BLKMKT.png -------------------------------------------------------------------------------- /src/flags/VEF_DICOM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/VEF_DICOM.png -------------------------------------------------------------------------------- /src/flags/VEF_DIPRO.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/VEF_DIPRO.png -------------------------------------------------------------------------------- /src/flags/VES.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/VES.png -------------------------------------------------------------------------------- /src/flags/VND.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/VND.png -------------------------------------------------------------------------------- /src/flags/VTC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/VTC.png -------------------------------------------------------------------------------- /src/flags/VUV.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/VUV.png -------------------------------------------------------------------------------- /src/flags/WST.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/WST.png -------------------------------------------------------------------------------- /src/flags/XAF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/XAF.png -------------------------------------------------------------------------------- /src/flags/XCD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/XCD.png -------------------------------------------------------------------------------- /src/flags/XDR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/XDR.png -------------------------------------------------------------------------------- /src/flags/XMR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/XMR.png -------------------------------------------------------------------------------- /src/flags/XOF.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/XOF.png -------------------------------------------------------------------------------- /src/flags/XPM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/XPM.png -------------------------------------------------------------------------------- /src/flags/XRP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/XRP.png -------------------------------------------------------------------------------- /src/flags/YER.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/YER.png -------------------------------------------------------------------------------- /src/flags/ZAR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/ZAR.png -------------------------------------------------------------------------------- /src/flags/ZMW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/ZMW.png -------------------------------------------------------------------------------- /src/flags/ZWL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/flags/ZWL.png -------------------------------------------------------------------------------- /src/hints/cancel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/hints/cancel.png -------------------------------------------------------------------------------- /src/hints/gear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/hints/gear.png -------------------------------------------------------------------------------- /src/hints/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/hints/info.png -------------------------------------------------------------------------------- /src/hints/save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/hints/save.png -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/icon.png -------------------------------------------------------------------------------- /src/images/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/images/demo.png -------------------------------------------------------------------------------- /src/index.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import pathlib 4 | import plistlib 5 | import re 6 | import shutil 7 | import sys 8 | from uuid import uuid4 9 | 10 | from coinc.alfred import persisted_data 11 | 12 | UUID_MATCHER = re.compile( 13 | r"^[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12}$" 14 | ) 15 | NORMAL_KEYWORDS = ["coinc", "cur-ref", "cur-index"] + list(map(str, range(10))) 16 | BEGIN_YPOS = 1540 17 | GAP = 130 18 | 19 | 20 | def load() -> tuple[dict, dict, dict]: 21 | with open("info.plist", "rb") as f: 22 | content = plistlib.load(f) 23 | aliases = persisted_data("alias") 24 | with open("currencies.json", "rb") as f: 25 | currencies = json.load(f) 26 | return content, aliases, currencies 27 | 28 | 29 | def save(content): 30 | with open("info.plist", "wb") as f: 31 | plistlib.dump(content, f) 32 | 33 | 34 | def clear_icons(): 35 | for file in pathlib.Path(".").glob("*.png"): 36 | if UUID_MATCHER.match(file.stem): 37 | os.remove(file) 38 | 39 | 40 | def copy_icon(currency: str, uid: str): 41 | try: 42 | shutil.copyfile(f"flags/{currency}.png", f"{uid}.png") 43 | except FileNotFoundError: 44 | pass 45 | 46 | 47 | def add_alias_entry( 48 | blocks, ui_blocks, connections, currencies, alias, currency, ypos, junction_uid 49 | ): 50 | argvar_uid = str(uuid4()) 51 | keyword_uid = str(uuid4()) 52 | 53 | # Arg and Var Block 54 | blocks.append( 55 | { 56 | "config": { 57 | "argument": f"{alias}{{query}}", 58 | "passthroughargument": False, 59 | "variables": {}, 60 | }, 61 | "type": "alfred.workflow.utility.argument", 62 | "uid": argvar_uid, 63 | "version": 1, 64 | } 65 | ) 66 | ui_blocks[argvar_uid] = {"xpos": 265.0, "ypos": ypos + 30} 67 | connections[argvar_uid] = [ 68 | { 69 | "destinationuid": junction_uid, 70 | "modifiers": 0, 71 | "modifiersubtext": "", 72 | "vitoclose": False, 73 | } 74 | ] 75 | 76 | # Keyword Block 77 | blocks.append( 78 | { 79 | "config": { 80 | "argumenttype": 0, 81 | "keyword": alias, 82 | "subtext": "Convert {query} to your favorite currencies with Coinc", 83 | "text": currencies[currency], 84 | "withspace": False, 85 | }, 86 | "type": "alfred.workflow.input.keyword", 87 | "uid": keyword_uid, 88 | "version": 1, 89 | } 90 | ) 91 | ui_blocks[keyword_uid] = {"xpos": 30.0, "ypos": ypos} 92 | connections[keyword_uid] = [ 93 | { 94 | "destinationuid": argvar_uid, 95 | "modifiers": 0, 96 | "modifiersubtext": "", 97 | "vitoclose": True, 98 | } 99 | ] 100 | copy_icon(currency, keyword_uid) 101 | 102 | 103 | def main(): 104 | content, aliases, currencies = load() 105 | blocks = content["objects"] 106 | ui_blocks = content["uidata"] 107 | connections = content["connections"] 108 | 109 | remove_block_indexes = [] 110 | remove_blocks = [] 111 | junction_uid = None 112 | for i, block in enumerate(blocks): 113 | if ( 114 | block["type"] == "alfred.workflow.input.keyword" 115 | and block["config"]["keyword"] not in NORMAL_KEYWORDS 116 | ) or ( 117 | block["type"] == "alfred.workflow.utility.argument" 118 | and not re.match(r"^\d\{query\}$", block["config"]["argument"]) 119 | ): 120 | remove_block_indexes.append(i) 121 | remove_blocks.append(block["uid"]) 122 | if block["type"] == "alfred.workflow.utility.junction": 123 | junction_uid = block["uid"] 124 | 125 | if not junction_uid: 126 | raise Exception("Junction block not found") 127 | 128 | # Remove blocks and connections 129 | for remove_index in remove_block_indexes[::-1]: 130 | blocks.pop(remove_index) 131 | for block_uid in remove_blocks: 132 | ui_blocks.pop(block_uid) 133 | connections.pop(block_uid) 134 | 135 | # Remove icons 136 | clear_icons() 137 | 138 | # Add blocks and connections 139 | ypos = BEGIN_YPOS 140 | for alias, currency in aliases.items(): 141 | add_alias_entry( 142 | blocks, 143 | ui_blocks, 144 | connections, 145 | currencies, 146 | alias, 147 | currency, 148 | ypos, 149 | junction_uid, 150 | ) 151 | ypos += GAP 152 | 153 | # Write back to info.plist 154 | save(content) 155 | return 0 156 | 157 | 158 | if __name__ == "__main__": 159 | sys.exit(main()) 160 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Script for Default keyword""" 3 | import json 4 | import sys 5 | from pathlib import Path 6 | 7 | import coinc 8 | from workflow import Workflow3 9 | from workflow.util import reload_workflow 10 | 11 | with open(Path(__file__).parent / "default_settings.json", "r") as file: 12 | DEFAULT_SETTINGS = json.load(file) 13 | 14 | 15 | def main(workflow): 16 | """The main workflow entry function""" 17 | method = str(workflow.args.pop(0)) 18 | if coinc.utils.manual_update_patch(workflow): 19 | reload_workflow() 20 | workflow.logger.info("Workflow Reloaded") 21 | if method in coinc.__all__: 22 | workflow.run(getattr(coinc, method)) 23 | else: 24 | workflow.run(coinc.help_me) 25 | 26 | 27 | if __name__ == "__main__": 28 | WF = Workflow3( 29 | default_settings=DEFAULT_SETTINGS, 30 | help_url="https://github.com/tomy0000000/Coinc/wiki/User-Guide", 31 | ) 32 | sys.exit(WF.run(main)) 33 | -------------------------------------------------------------------------------- /src/symbols.json: -------------------------------------------------------------------------------- 1 | { 2 | "AED": "د.إ", 3 | "AFN": "Af", 4 | "ALL": "L", 5 | "AMD": "Դ", 6 | "ANG": "ƒ", 7 | "AOA": "Kz", 8 | "ARS": "$", 9 | "AUD": "$", 10 | "AWG": "ƒ", 11 | "AZN": "ман", 12 | "BAM": "КМ", 13 | "BBD": "$", 14 | "BDT": "৳", 15 | "BGN": "лв", 16 | "BHD": "ب.د", 17 | "BIF": "₣", 18 | "BMD": "$", 19 | "BND": "$", 20 | "BOB": "Bs.", 21 | "BRL": "R$", 22 | "BSD": "$", 23 | "BTC": "₿", 24 | "BTN": "Nu.", 25 | "BTS": "BTS", 26 | "BWP": "P", 27 | "BYN": "Br", 28 | "BZD": "$", 29 | "CAD": "$", 30 | "CDF": "₣", 31 | "CHF": "₣", 32 | "CLF": "UF", 33 | "CLP": "$", 34 | "CNH": "¥", 35 | "CNY": "¥", 36 | "COP": "$", 37 | "CRC": "₡", 38 | "CUC": "$", 39 | "CUP": "$", 40 | "CVE": "$", 41 | "CZK": "Kč", 42 | "DASH": "DASH", 43 | "DJF": "₣", 44 | "DKK": "kr", 45 | "DOGE": "Ð", 46 | "DOP": "$", 47 | "DZD": "د.ج", 48 | "EGP": "£", 49 | "ERN": "Nfk", 50 | "EUR": "€", 51 | "ETH": "Ξ", 52 | "FJD": "$", 53 | "FKP": "£", 54 | "GBP": "£", 55 | "GEL": "ლ", 56 | "GGP": "£", 57 | "GHS": "₵", 58 | "GIP": "£", 59 | "GMD": "D", 60 | "GNF": "₣", 61 | "GTQ": "Q", 62 | "GYD": "$", 63 | "HKD": "$", 64 | "HNL": "L", 65 | "HRK": "Kn", 66 | "HTG": "G", 67 | "HUF": "Ft", 68 | "IDR": "₨", 69 | "ILS": "₪", 70 | "IMP": "£", 71 | "INR": "₹", 72 | "IQD": "ع.د", 73 | "IRR": "﷼", 74 | "ISK": "kr", 75 | "JEP": "£", 76 | "JMD": "$", 77 | "JOD": "د.ا", 78 | "JPY": "¥", 79 | "KES": "Sh", 80 | "KHR": "៛", 81 | "KMF": "CF", 82 | "KPW": "₩", 83 | "KRW": "₩", 84 | "KWD": "د.ك", 85 | "KYD": "$", 86 | "KZT": "〒", 87 | "LAK": "₭", 88 | "LBP": "ل.ل", 89 | "LD": "L$", 90 | "LKR": "₨", 91 | "LRD": "$", 92 | "LSL": "L", 93 | "LTC": "Ł", 94 | "LYD": "ل.د", 95 | "MAD": "د.م.", 96 | "MDL": "L", 97 | "MGA": "Ar", 98 | "MKD": "ден", 99 | "MMK": "K", 100 | "MNT": "₮", 101 | "MOP": "P", 102 | "MRO": "UM", 103 | "MRU": "UM", 104 | "MUR": "₨", 105 | "MVR": "ރ.", 106 | "MWK": "MK", 107 | "MXN": "$", 108 | "MYR": "RM", 109 | "MZN": "MTn", 110 | "NAD": "$", 111 | "NGN": "₦", 112 | "NIO": "C$", 113 | "NMC": "ℕ", 114 | "NOK": "kr", 115 | "NPR": "₨", 116 | "NXT": "NXT", 117 | "NZD": "$", 118 | "OMR": "ر.ع.", 119 | "PAB": "B/.", 120 | "PEN": "S/.", 121 | "PGK": "K", 122 | "PHP": "₱", 123 | "PKR": "₨", 124 | "PLN": "zł", 125 | "PPC": "Ᵽ", 126 | "PYG": "₲", 127 | "QAR": "ر.ق", 128 | "RON": "L", 129 | "RSD": "din", 130 | "RUB": "р.", 131 | "RWF": "₣", 132 | "SAR": "ر.س", 133 | "SBD": "$", 134 | "SCR": "₨", 135 | "SDG": "£", 136 | "SEK": "kr", 137 | "SGD": "S$", 138 | "SHP": "£", 139 | "SLL": "Le", 140 | "SOS": "Sh", 141 | "SRD": "$", 142 | "SSP": "£", 143 | "STD": "Db", 144 | "STN": "Db", 145 | "STR": "*", 146 | "SVC": "₡", 147 | "SYP": "£", 148 | "SZL": "L", 149 | "THB": "฿", 150 | "TJS": "ЅМ", 151 | "TMT": "m", 152 | "TND": "د.ت", 153 | "TOP": "T$", 154 | "TRY": "₤", 155 | "TTD": "$", 156 | "TWD": "$", 157 | "TZS": "Sh", 158 | "UAH": "₴", 159 | "UGX": "Sh", 160 | "USD": "$", 161 | "UYU": "$", 162 | "UZS": "лв", 163 | "VEF": "Bs.F.", 164 | "VEF_BLKMKT": "Bs.F.", 165 | "VEF_DICOM": "Bs.F.", 166 | "VEF_DIPRO": "Bs.F.", 167 | "VES": "Bs.S.", 168 | "VND": "₫", 169 | "VTC": "VTC", 170 | "VUV": "Vt", 171 | "WST": "T", 172 | "XAF": "₣", 173 | "XAG": "XAG", 174 | "XAU": "XAU", 175 | "XCD": "$", 176 | "XDR": "SDR", 177 | "XMR": "ɱ", 178 | "XOF": "CFA", 179 | "XPD": "XPD", 180 | "XPF": "₣", 181 | "XPM": "Ψ", 182 | "XPT": "XPT", 183 | "YER": "﷼", 184 | "ZAR": "R", 185 | "ZMW": "ZK", 186 | "ZWL": "$" 187 | } -------------------------------------------------------------------------------- /src/workflow/LICENCE.txt: -------------------------------------------------------------------------------- 1 | All Python source code is under the MIT Licence. 2 | 3 | The documentation, in particular the tutorials, are under the 4 | Creative Commons Attribution-NonCommercial (CC BY-NC) licence. 5 | 6 | --------------------------------------------------------------------- 7 | 8 | The MIT License (MIT) 9 | 10 | Copyright (c) 2014 Dean Jackson 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in 20 | all copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 28 | THE SOFTWARE. 29 | 30 | --------------------------------------------------------------------- 31 | 32 | Creative Commons Attribution-NonCommercial (CC BY-NC) licence 33 | 34 | https://creativecommons.org/licenses/by-nc/4.0/legalcode 35 | 36 | (This one's quite long.) 37 | -------------------------------------------------------------------------------- /src/workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/src/workflow/Notify.tgz -------------------------------------------------------------------------------- /src/workflow/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-02-15 9 | # 10 | 11 | """A helper library for `Alfred `_ workflows.""" 12 | 13 | import os 14 | 15 | # Filter matching rules 16 | # Icons 17 | # Exceptions 18 | # Workflow objects 19 | from .workflow import ( 20 | ICON_ACCOUNT, 21 | ICON_BURN, 22 | ICON_CLOCK, 23 | ICON_COLOR, 24 | ICON_COLOUR, 25 | ICON_EJECT, 26 | ICON_ERROR, 27 | ICON_FAVORITE, 28 | ICON_FAVOURITE, 29 | ICON_GROUP, 30 | ICON_HELP, 31 | ICON_HOME, 32 | ICON_INFO, 33 | ICON_NETWORK, 34 | ICON_NOTE, 35 | ICON_SETTINGS, 36 | ICON_SWIRL, 37 | ICON_SWITCH, 38 | ICON_SYNC, 39 | ICON_TRASH, 40 | ICON_USER, 41 | ICON_WARNING, 42 | ICON_WEB, 43 | MATCH_ALL, 44 | MATCH_ALLCHARS, 45 | MATCH_ATOM, 46 | MATCH_CAPITALS, 47 | MATCH_INITIALS, 48 | MATCH_INITIALS_CONTAIN, 49 | MATCH_INITIALS_STARTSWITH, 50 | MATCH_STARTSWITH, 51 | MATCH_SUBSTRING, 52 | KeychainError, 53 | PasswordNotFound, 54 | Workflow, 55 | manager, 56 | ) 57 | from .workflow3 import Variables, Workflow3 58 | 59 | __title__ = "Alfred-Workflow" 60 | __version__ = open(os.path.join(os.path.dirname(__file__), "version")).read() 61 | __author__ = "Dean Jackson" 62 | __licence__ = "MIT" 63 | __copyright__ = "Copyright 2014-2019 Dean Jackson" 64 | 65 | __all__ = [ 66 | "Variables", 67 | "Workflow", 68 | "Workflow3", 69 | "manager", 70 | "PasswordNotFound", 71 | "KeychainError", 72 | "ICON_ACCOUNT", 73 | "ICON_BURN", 74 | "ICON_CLOCK", 75 | "ICON_COLOR", 76 | "ICON_COLOUR", 77 | "ICON_EJECT", 78 | "ICON_ERROR", 79 | "ICON_FAVORITE", 80 | "ICON_FAVOURITE", 81 | "ICON_GROUP", 82 | "ICON_HELP", 83 | "ICON_HOME", 84 | "ICON_INFO", 85 | "ICON_NETWORK", 86 | "ICON_NOTE", 87 | "ICON_SETTINGS", 88 | "ICON_SWIRL", 89 | "ICON_SWITCH", 90 | "ICON_SYNC", 91 | "ICON_TRASH", 92 | "ICON_USER", 93 | "ICON_WARNING", 94 | "ICON_WEB", 95 | "MATCH_ALL", 96 | "MATCH_ALLCHARS", 97 | "MATCH_ATOM", 98 | "MATCH_CAPITALS", 99 | "MATCH_INITIALS", 100 | "MATCH_INITIALS_CONTAIN", 101 | "MATCH_INITIALS_STARTSWITH", 102 | "MATCH_STARTSWITH", 103 | "MATCH_SUBSTRING", 104 | ] 105 | -------------------------------------------------------------------------------- /src/workflow/background.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 deanishe@deanishe.net 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-04-06 8 | # 9 | 10 | """This module provides an API to run commands in background processes. 11 | 12 | Combine with the :ref:`caching API ` to work from cached data 13 | while you fetch fresh data in the background. 14 | 15 | See :ref:`the User Manual ` for more information 16 | and examples. 17 | """ 18 | 19 | 20 | import os 21 | import pickle 22 | import signal 23 | import subprocess 24 | import sys 25 | 26 | from workflow import Workflow 27 | 28 | __all__ = ["is_running", "run_in_background"] 29 | 30 | _wf = None 31 | 32 | 33 | def wf(): 34 | global _wf 35 | if _wf is None: 36 | _wf = Workflow() 37 | return _wf 38 | 39 | 40 | def _log(): 41 | return wf().logger 42 | 43 | 44 | def _arg_cache(name): 45 | """Return path to pickle cache file for arguments. 46 | 47 | :param name: name of task 48 | :type name: ``unicode`` 49 | :returns: Path to cache file 50 | :rtype: ``unicode`` filepath 51 | 52 | """ 53 | return wf().cachefile(name + ".argcache") 54 | 55 | 56 | def _pid_file(name): 57 | """Return path to PID file for ``name``. 58 | 59 | :param name: name of task 60 | :type name: ``unicode`` 61 | :returns: Path to PID file for task 62 | :rtype: ``unicode`` filepath 63 | 64 | """ 65 | return wf().cachefile(name + ".pid") 66 | 67 | 68 | def _process_exists(pid): 69 | """Check if a process with PID ``pid`` exists. 70 | 71 | :param pid: PID to check 72 | :type pid: ``int`` 73 | :returns: ``True`` if process exists, else ``False`` 74 | :rtype: ``Boolean`` 75 | 76 | """ 77 | try: 78 | os.kill(pid, 0) 79 | except OSError: # not running 80 | return False 81 | return True 82 | 83 | 84 | def _job_pid(name): 85 | """Get PID of job or `None` if job does not exist. 86 | 87 | Args: 88 | name (str): Name of job. 89 | 90 | Returns: 91 | int: PID of job process (or `None` if job doesn't exist). 92 | """ 93 | pidfile = _pid_file(name) 94 | if not os.path.exists(pidfile): 95 | return 96 | 97 | with open(pidfile, "rb") as fp: 98 | read = fp.read() 99 | # print(str(read)) 100 | pid = int.from_bytes(read, sys.byteorder) 101 | # print(pid) 102 | 103 | if _process_exists(pid): 104 | return pid 105 | 106 | os.unlink(pidfile) 107 | 108 | 109 | def is_running(name): 110 | """Test whether task ``name`` is currently running. 111 | 112 | :param name: name of task 113 | :type name: unicode 114 | :returns: ``True`` if task with name ``name`` is running, else ``False`` 115 | :rtype: bool 116 | 117 | """ 118 | if _job_pid(name) is not None: 119 | return True 120 | 121 | return False 122 | 123 | 124 | def _background( 125 | pidfile, stdin="/dev/null", stdout="/dev/null", stderr="/dev/null" 126 | ): # pragma: no cover 127 | """Fork the current process into a background daemon. 128 | 129 | :param pidfile: file to write PID of daemon process to. 130 | :type pidfile: filepath 131 | :param stdin: where to read input 132 | :type stdin: filepath 133 | :param stdout: where to write stdout output 134 | :type stdout: filepath 135 | :param stderr: where to write stderr output 136 | :type stderr: filepath 137 | 138 | """ 139 | 140 | def _fork_and_exit_parent(errmsg, wait=False, write=False): 141 | try: 142 | pid = os.fork() 143 | if pid > 0: 144 | if write: # write PID of child process to `pidfile` 145 | tmp = pidfile + ".tmp" 146 | with open(tmp, "wb") as fp: 147 | fp.write(pid.to_bytes(4, sys.byteorder)) 148 | os.rename(tmp, pidfile) 149 | if wait: # wait for child process to exit 150 | os.waitpid(pid, 0) 151 | os._exit(0) 152 | except OSError as err: 153 | _log().critical("%s: (%d) %s", errmsg, err.errno, err.strerror) 154 | raise err 155 | 156 | # Do first fork and wait for second fork to finish. 157 | _fork_and_exit_parent("fork #1 failed", wait=True) 158 | 159 | # Decouple from parent environment. 160 | os.chdir(wf().workflowdir) 161 | os.setsid() 162 | 163 | # Do second fork and write PID to pidfile. 164 | _fork_and_exit_parent("fork #2 failed", write=True) 165 | 166 | # Now I am a daemon! 167 | # Redirect standard file descriptors. 168 | si = open(stdin, "r", 1) 169 | so = open(stdout, "a+", 1) 170 | se = open(stderr, "a+", 1) 171 | if hasattr(sys.stdin, "fileno"): 172 | os.dup2(si.fileno(), sys.stdin.fileno()) 173 | if hasattr(sys.stdout, "fileno"): 174 | os.dup2(so.fileno(), sys.stdout.fileno()) 175 | if hasattr(sys.stderr, "fileno"): 176 | os.dup2(se.fileno(), sys.stderr.fileno()) 177 | 178 | 179 | def kill(name, sig=signal.SIGTERM): 180 | """Send a signal to job ``name`` via :func:`os.kill`. 181 | 182 | .. versionadded:: 1.29 183 | 184 | Args: 185 | name (str): Name of the job 186 | sig (int, optional): Signal to send (default: SIGTERM) 187 | 188 | Returns: 189 | bool: `False` if job isn't running, `True` if signal was sent. 190 | """ 191 | pid = _job_pid(name) 192 | if pid is None: 193 | return False 194 | 195 | os.kill(pid, sig) 196 | return True 197 | 198 | 199 | def run_in_background(name, args, **kwargs): 200 | r"""Cache arguments then call this script again via :func:`subprocess.call`. 201 | 202 | :param name: name of job 203 | :type name: unicode 204 | :param args: arguments passed as first argument to :func:`subprocess.call` 205 | :param \**kwargs: keyword arguments to :func:`subprocess.call` 206 | :returns: exit code of sub-process 207 | :rtype: int 208 | 209 | When you call this function, it caches its arguments and then calls 210 | ``background.py`` in a subprocess. The Python subprocess will load the 211 | cached arguments, fork into the background, and then run the command you 212 | specified. 213 | 214 | This function will return as soon as the ``background.py`` subprocess has 215 | forked, returning the exit code of *that* process (i.e. not of the command 216 | you're trying to run). 217 | 218 | If that process fails, an error will be written to the log file. 219 | 220 | If a process is already running under the same name, this function will 221 | return immediately and will not run the specified command. 222 | 223 | """ 224 | if is_running(name): 225 | _log().info("[%s] job already running", name) 226 | return 227 | 228 | argcache = _arg_cache(name) 229 | 230 | # Cache arguments 231 | with open(argcache, "wb") as fp: 232 | pickle.dump({"args": args, "kwargs": kwargs}, fp) 233 | _log().debug("[%s] command cached: %s", name, argcache) 234 | 235 | # Call this script 236 | cmd = [sys.executable, "-m", "workflow.background", name] 237 | _log().debug("[%s] passing job to background runner: %r", name, cmd) 238 | retcode = subprocess.call(cmd) 239 | 240 | if retcode: # pragma: no cover 241 | _log().error("[%s] background runner failed with %d", name, retcode) 242 | else: 243 | _log().debug("[%s] background job started", name) 244 | 245 | return retcode 246 | 247 | 248 | def main(wf): # pragma: no cover 249 | """Run command in a background process. 250 | 251 | Load cached arguments, fork into background, then call 252 | :meth:`subprocess.call` with cached arguments. 253 | 254 | """ 255 | log = wf.logger 256 | name = wf.args[0] 257 | argcache = _arg_cache(name) 258 | if not os.path.exists(argcache): 259 | msg = "[{0}] command cache not found: {1}".format(name, argcache) 260 | log.critical(msg) 261 | raise IOError(msg) 262 | 263 | # Fork to background and run command 264 | pidfile = _pid_file(name) 265 | _background(pidfile) 266 | 267 | # Load cached arguments 268 | with open(argcache, "rb") as fp: 269 | data = pickle.load(fp) 270 | 271 | # Cached arguments 272 | args = data["args"] 273 | kwargs = data["kwargs"] 274 | 275 | # Delete argument cache file 276 | os.unlink(argcache) 277 | 278 | try: 279 | # Run the command 280 | log.debug("[%s] running command: %r", name, args) 281 | 282 | retcode = subprocess.call(args, **kwargs) 283 | 284 | if retcode: 285 | log.error("[%s] command failed with status %d", name, retcode) 286 | finally: 287 | os.unlink(pidfile) 288 | 289 | log.debug("[%s] job complete", name) 290 | 291 | 292 | if __name__ == "__main__": # pragma: no cover 293 | wf().run(main) 294 | -------------------------------------------------------------------------------- /src/workflow/notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2015 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2015-11-26 9 | # 10 | 11 | # TODO: Exclude this module from test and code coverage in py2.6 12 | 13 | """ 14 | Post notifications via the macOS Notification Center. 15 | 16 | This feature is only available on Mountain Lion (10.8) and later. 17 | It will silently fail on older systems. 18 | 19 | The main API is a single function, :func:`~workflow.notify.notify`. 20 | 21 | It works by copying a simple application to your workflow's data 22 | directory. It replaces the application's icon with your workflow's 23 | icon and then calls the application to post notifications. 24 | """ 25 | 26 | 27 | import os 28 | import plistlib 29 | import shutil 30 | import subprocess 31 | import sys 32 | import tarfile 33 | import tempfile 34 | import uuid 35 | from typing import List 36 | 37 | from . import workflow 38 | 39 | _wf = None 40 | _log = None 41 | 42 | 43 | #: Available system sounds from System Preferences > Sound > Sound Effects 44 | SOUNDS = ( 45 | "Basso", 46 | "Blow", 47 | "Bottle", 48 | "Frog", 49 | "Funk", 50 | "Glass", 51 | "Hero", 52 | "Morse", 53 | "Ping", 54 | "Pop", 55 | "Purr", 56 | "Sosumi", 57 | "Submarine", 58 | "Tink", 59 | ) 60 | 61 | 62 | def wf(): 63 | """Return Workflow object for this module. 64 | 65 | Returns: 66 | workflow.Workflow: Workflow object for current workflow. 67 | """ 68 | global _wf 69 | if _wf is None: 70 | _wf = workflow.Workflow() 71 | return _wf 72 | 73 | 74 | def log(): 75 | """Return logger for this module. 76 | 77 | Returns: 78 | logging.Logger: Logger for this module. 79 | """ 80 | global _log 81 | if _log is None: 82 | _log = wf().logger 83 | return _log 84 | 85 | 86 | def notifier_program(): 87 | """Return path to notifier applet executable. 88 | 89 | Returns: 90 | unicode: Path to Notify.app ``applet`` executable. 91 | """ 92 | return wf().datafile("Notify.app/Contents/MacOS/applet") 93 | 94 | 95 | def notifier_icon_path(): 96 | """Return path to icon file in installed Notify.app. 97 | 98 | Returns: 99 | unicode: Path to ``applet.icns`` within the app bundle. 100 | """ 101 | return wf().datafile("Notify.app/Contents/Resources/applet.icns") 102 | 103 | 104 | def install_notifier(): 105 | """Extract ``Notify.app`` from the workflow to data directory. 106 | 107 | Changes the bundle ID of the installed app and gives it the 108 | workflow's icon. 109 | """ 110 | archive = os.path.join(os.path.dirname(__file__), "Notify.tgz") 111 | destdir = wf().datadir 112 | app_path = os.path.join(destdir, "Notify.app") 113 | n = notifier_program() 114 | log().debug("installing Notify.app to %r ...", destdir) 115 | # z = zipfile.ZipFile(archive, 'r') 116 | # z.extractall(destdir) 117 | tgz = tarfile.open(archive, "r:gz") 118 | tgz.extractall(destdir) 119 | if not os.path.exists(n): # pragma: nocover 120 | raise RuntimeError("Notify.app could not be installed in " + destdir) 121 | 122 | # Replace applet icon 123 | icon = notifier_icon_path() 124 | workflow_icon = wf().workflowfile("icon.png") 125 | if os.path.exists(icon): 126 | os.unlink(icon) 127 | 128 | png_to_icns(workflow_icon, icon) 129 | 130 | # Set file icon 131 | # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, 132 | # none of this code will "work" on pre-10.8 systems. Let it run 133 | # until I figure out a better way of excluding this module 134 | # from coverage in py2.6. 135 | if sys.version_info >= (2, 7): # pragma: no cover 136 | from AppKit import NSImage, NSWorkspace 137 | 138 | ws = NSWorkspace.sharedWorkspace() 139 | img = NSImage.alloc().init() 140 | img.initWithContentsOfFile_(icon) 141 | ws.setIcon_forFile_options_(img, app_path, 0) 142 | 143 | # Change bundle ID of installed app 144 | ip_path = os.path.join(app_path, "Contents/Info.plist") 145 | bundle_id = "{0}.{1}".format(wf().bundleid, uuid.uuid4().hex) 146 | data = plistlib.readPlist(ip_path) 147 | log().debug("changing bundle ID to %r", bundle_id) 148 | data["CFBundleIdentifier"] = bundle_id 149 | plistlib.writePlist(data, ip_path) 150 | 151 | 152 | def validate_sound(sound): 153 | """Coerce ``sound`` to valid sound name. 154 | 155 | Returns ``None`` for invalid sounds. Sound names can be found 156 | in ``System Preferences > Sound > Sound Effects``. 157 | 158 | Args: 159 | sound (str): Name of system sound. 160 | 161 | Returns: 162 | str: Proper name of sound or ``None``. 163 | """ 164 | if not sound: 165 | return None 166 | 167 | # Case-insensitive comparison of `sound` 168 | if sound.lower() in [s.lower() for s in SOUNDS]: 169 | # Title-case is correct for all system sounds as of macOS 10.11 170 | return sound.title() 171 | return None 172 | 173 | 174 | def notify(title="", text="", sound=None): 175 | """Post notification via Notify.app helper. 176 | 177 | Args: 178 | title (str, optional): Notification title. 179 | text (str, optional): Notification body text. 180 | sound (str, optional): Name of sound to play. 181 | 182 | Raises: 183 | ValueError: Raised if both ``title`` and ``text`` are empty. 184 | 185 | Returns: 186 | bool: ``True`` if notification was posted, else ``False``. 187 | """ 188 | if title == text == "": 189 | raise ValueError("Empty notification") 190 | 191 | sound = validate_sound(sound) or "" 192 | 193 | n = notifier_program() 194 | 195 | if not os.path.exists(n): 196 | install_notifier() 197 | 198 | env = os.environ.copy() 199 | enc = "utf-8" 200 | env["NOTIFY_TITLE"] = title.encode(enc) 201 | env["NOTIFY_MESSAGE"] = text.encode(enc) 202 | env["NOTIFY_SOUND"] = sound.encode(enc) 203 | cmd = [n] 204 | retcode = subprocess.call(cmd, env=env) 205 | if retcode == 0: 206 | return True 207 | 208 | log().error("Notify.app exited with status {0}.".format(retcode)) 209 | return False 210 | 211 | 212 | def usr_bin_env(*args: str) -> List[str]: 213 | return ["/usr/bin/env", f'PATH={os.environ["PATH"]}'] + list(args) 214 | 215 | 216 | def convert_image(inpath, outpath, size): 217 | """Convert an image file using ``sips``. 218 | 219 | Args: 220 | inpath (str): Path of source file. 221 | outpath (str): Path to destination file. 222 | size (int): Width and height of destination image in pixels. 223 | 224 | Raises: 225 | RuntimeError: Raised if ``sips`` exits with non-zero status. 226 | """ 227 | cmd = ["sips", "-z", str(size), str(size), inpath, "--out", outpath] 228 | # log().debug(cmd) 229 | with open(os.devnull, "w") as pipe: 230 | retcode = subprocess.call( 231 | cmd, shell=True, stdout=pipe, stderr=subprocess.STDOUT 232 | ) 233 | 234 | if retcode != 0: 235 | raise RuntimeError("sips exited with %d" % retcode) 236 | 237 | 238 | def png_to_icns(png_path, icns_path): 239 | """Convert PNG file to ICNS using ``iconutil``. 240 | 241 | Create an iconset from the source PNG file. Generate PNG files 242 | in each size required by macOS, then call ``iconutil`` to turn 243 | them into a single ICNS file. 244 | 245 | Args: 246 | png_path (str): Path to source PNG file. 247 | icns_path (str): Path to destination ICNS file. 248 | 249 | Raises: 250 | RuntimeError: Raised if ``iconutil`` or ``sips`` fail. 251 | """ 252 | tempdir = tempfile.mkdtemp(prefix="aw-", dir=wf().datadir) 253 | 254 | try: 255 | iconset = os.path.join(tempdir, "Icon.iconset") 256 | 257 | if os.path.exists(iconset): # pragma: nocover 258 | raise RuntimeError("iconset already exists: " + iconset) 259 | 260 | os.makedirs(iconset) 261 | 262 | # Copy source icon to icon set and generate all the other 263 | # sizes needed 264 | configs = [] 265 | for i in (16, 32, 128, 256, 512): 266 | configs.append(("icon_{0}x{0}.png".format(i), i)) 267 | configs.append((("icon_{0}x{0}@2x.png".format(i), i * 2))) 268 | 269 | shutil.copy(png_path, os.path.join(iconset, "icon_256x256.png")) 270 | shutil.copy(png_path, os.path.join(iconset, "icon_128x128@2x.png")) 271 | 272 | for name, size in configs: 273 | outpath = os.path.join(iconset, name) 274 | if os.path.exists(outpath): 275 | continue 276 | convert_image(png_path, outpath, size) 277 | 278 | cmd = ["iconutil", "-c", "icns", "-o", icns_path, iconset] 279 | 280 | retcode = subprocess.call(cmd) 281 | if retcode != 0: 282 | raise RuntimeError("iconset exited with %d" % retcode) 283 | 284 | if not os.path.exists(icns_path): # pragma: nocover 285 | raise ValueError("generated ICNS file not found: " + repr(icns_path)) 286 | finally: 287 | try: 288 | shutil.rmtree(tempdir) 289 | except OSError: # pragma: no cover 290 | pass 291 | 292 | 293 | if __name__ == "__main__": # pragma: nocover 294 | # Simple command-line script to test module with 295 | # This won't work on 2.6, as `argparse` isn't available 296 | # by default. 297 | import argparse 298 | from unicodedata import normalize 299 | 300 | def ustr(s): 301 | """Coerce `s` to normalised Unicode.""" 302 | return normalize("NFD", s.decode("utf-8")) 303 | 304 | p = argparse.ArgumentParser() 305 | p.add_argument("-p", "--png", help="PNG image to convert to ICNS.") 306 | p.add_argument( 307 | "-l", "--list-sounds", help="Show available sounds.", action="store_true" 308 | ) 309 | p.add_argument("-t", "--title", help="Notification title.", type=ustr, default="") 310 | p.add_argument( 311 | "-s", "--sound", type=ustr, help="Optional notification sound.", default="" 312 | ) 313 | p.add_argument( 314 | "text", type=ustr, help="Notification body text.", default="", nargs="?" 315 | ) 316 | o = p.parse_args() 317 | 318 | # List available sounds 319 | if o.list_sounds: 320 | for sound in SOUNDS: 321 | print(sound) 322 | sys.exit(0) 323 | 324 | # Convert PNG to ICNS 325 | if o.png: 326 | icns = os.path.join( 327 | os.path.dirname(o.png), 328 | os.path.splitext(os.path.basename(o.png))[0] + ".icns", 329 | ) 330 | 331 | print("converting {0!r} to {1!r} ...".format(o.png, icns), file=sys.stderr) 332 | 333 | if os.path.exists(icns): 334 | raise ValueError("destination file already exists: " + icns) 335 | 336 | png_to_icns(o.png, icns) 337 | sys.exit(0) 338 | 339 | # Post notification 340 | if o.title == o.text == "": 341 | print("ERROR: empty notification.", file=sys.stderr) 342 | sys.exit(1) 343 | else: 344 | notify(o.title, o.text, o.sound) 345 | -------------------------------------------------------------------------------- /src/workflow/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Fabio Niephaus , 5 | # Dean Jackson 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2014-08-16 10 | # 11 | 12 | """Self-updating from GitHub. 13 | 14 | .. versionadded:: 1.9 15 | 16 | .. note:: 17 | 18 | This module is not intended to be used directly. Automatic updates 19 | are controlled by the ``update_settings`` :class:`dict` passed to 20 | :class:`~workflow.workflow.Workflow` objects. 21 | 22 | """ 23 | 24 | 25 | import json 26 | import os 27 | import re 28 | import subprocess 29 | import tempfile 30 | from collections import defaultdict 31 | from functools import total_ordering 32 | from itertools import zip_longest 33 | from urllib import request 34 | 35 | from workflow.util import atomic_writer 36 | 37 | from . import workflow 38 | 39 | # __all__ = [] 40 | 41 | 42 | RELEASES_BASE = "https://api.github.com/repos/{}/releases" 43 | match_workflow = re.compile(r"\.alfred(\d+)?workflow$").search 44 | 45 | _wf = None 46 | 47 | 48 | def wf(): 49 | """Lazy `Workflow` object.""" 50 | global _wf 51 | if _wf is None: 52 | _wf = workflow.Workflow() 53 | return _wf 54 | 55 | 56 | @total_ordering 57 | class Download(object): 58 | """A workflow file that is available for download. 59 | 60 | .. versionadded: 1.37 61 | 62 | Attributes: 63 | url (str): URL of workflow file. 64 | filename (str): Filename of workflow file. 65 | version (Version): Semantic version of workflow. 66 | prerelease (bool): Whether version is a pre-release. 67 | alfred_version (Version): Minimum compatible version 68 | of Alfred. 69 | 70 | """ 71 | 72 | @classmethod 73 | def from_dict(cls, d): 74 | """Create a `Download` from a `dict`.""" 75 | return cls( 76 | url=d["url"], 77 | filename=d["filename"], 78 | version=Version(d["version"]), 79 | prerelease=d["prerelease"], 80 | ) 81 | 82 | @classmethod 83 | def from_releases(cls, js): 84 | """Extract downloads from GitHub releases. 85 | 86 | Searches releases with semantic tags for assets with 87 | file extension .alfredworkflow or .alfredXworkflow where 88 | X is a number. 89 | 90 | Files are returned sorted by latest version first. Any 91 | releases containing multiple files with the same (workflow) 92 | extension are rejected as ambiguous. 93 | 94 | Args: 95 | js (str): JSON response from GitHub's releases endpoint. 96 | 97 | Returns: 98 | list: Sequence of `Download`. 99 | """ 100 | releases = json.loads(js) 101 | downloads = [] 102 | for release in releases: 103 | tag = release["tag_name"] 104 | dupes = defaultdict(int) 105 | try: 106 | version = Version(tag) 107 | except ValueError as err: 108 | wf().logger.debug('ignored release: bad version "%s": %s', tag, err) 109 | continue 110 | 111 | dls = [] 112 | for asset in release.get("assets", []): 113 | url = asset.get("browser_download_url") 114 | filename = os.path.basename(url) 115 | m = match_workflow(filename) 116 | if not m: 117 | wf().logger.debug("unwanted file: %s", filename) 118 | continue 119 | 120 | ext = m.group(0) 121 | dupes[ext] = dupes[ext] + 1 122 | dls.append(Download(url, filename, version, release["prerelease"])) 123 | 124 | valid = True 125 | for ext, n in list(dupes.items()): 126 | if n > 1: 127 | wf().logger.debug( 128 | 'ignored release "%s": multiple assets ' 'with extension "%s"', 129 | tag, 130 | ext, 131 | ) 132 | valid = False 133 | break 134 | 135 | if valid: 136 | downloads.extend(dls) 137 | 138 | downloads.sort(reverse=True) 139 | return downloads 140 | 141 | def __init__(self, url, filename, version, prerelease=False): 142 | """Create a new Download. 143 | 144 | Args: 145 | url (str): URL of workflow file. 146 | filename (str): Filename of workflow file. 147 | version (Version): Version of workflow. 148 | prerelease (bool, optional): Whether version is 149 | pre-release. Defaults to False. 150 | 151 | """ 152 | if isinstance(version, str): 153 | version = Version(version) 154 | 155 | self.url = url 156 | self.filename = filename 157 | self.version = version 158 | self.prerelease = prerelease 159 | 160 | @property 161 | def alfred_version(self): 162 | """Minimum Alfred version based on filename extension.""" 163 | m = match_workflow(self.filename) 164 | if not m or not m.group(1): 165 | return Version("0") 166 | return Version(m.group(1)) 167 | 168 | @property 169 | def dict(self): 170 | """Convert `Download` to `dict`.""" 171 | return dict( 172 | url=self.url, 173 | filename=self.filename, 174 | version=str(self.version), 175 | prerelease=self.prerelease, 176 | ) 177 | 178 | def __str__(self): 179 | """Format `Download` for printing.""" 180 | return ( 181 | "Download(" 182 | "url={dl.url!r}, " 183 | "filename={dl.filename!r}, " 184 | "version={dl.version!r}, " 185 | "prerelease={dl.prerelease!r}" 186 | ")" 187 | ).format(dl=self) 188 | 189 | def __repr__(self): 190 | """Code-like representation of `Download`.""" 191 | return str(self) 192 | 193 | def __eq__(self, other): 194 | """Compare Downloads based on version numbers.""" 195 | if ( 196 | self.url != other.url 197 | or self.filename != other.filename 198 | or self.version != other.version 199 | or self.prerelease != other.prerelease 200 | ): 201 | return False 202 | return True 203 | 204 | def __ne__(self, other): 205 | """Compare Downloads based on version numbers.""" 206 | return not self.__eq__(other) 207 | 208 | def __lt__(self, other): 209 | """Compare Downloads based on version numbers.""" 210 | if self.version != other.version: 211 | return self.version < other.version 212 | return self.alfred_version < other.alfred_version 213 | 214 | 215 | class Version(object): 216 | """Mostly semantic versioning. 217 | 218 | The main difference to proper :ref:`semantic versioning ` 219 | is that this implementation doesn't require a minor or patch version. 220 | 221 | Version strings may also be prefixed with "v", e.g.: 222 | 223 | >>> v = Version('v1.1.1') 224 | >>> v.tuple 225 | (1, 1, 1, '') 226 | 227 | >>> v = Version('2.0') 228 | >>> v.tuple 229 | (2, 0, 0, '') 230 | 231 | >>> Version('3.1-beta').tuple 232 | (3, 1, 0, 'beta') 233 | 234 | >>> Version('1.0.1') > Version('0.0.1') 235 | True 236 | """ 237 | 238 | #: Match version and pre-release/build information in version strings 239 | match_version = re.compile(r"([0-9][0-9\.]*)(.+)?").match 240 | 241 | def __init__(self, vstr): 242 | """Create new `Version` object. 243 | 244 | Args: 245 | vstr (basestring): Semantic version string. 246 | """ 247 | if not vstr: 248 | raise ValueError("invalid version number: {!r}".format(vstr)) 249 | 250 | self.vstr = vstr 251 | self.major = 0 252 | self.minor = 0 253 | self.patch = 0 254 | self.suffix = "" 255 | self.build = "" 256 | self._parse(vstr) 257 | 258 | def _parse(self, vstr): 259 | vstr = str(vstr) 260 | if vstr.startswith("v"): 261 | m = self.match_version(vstr[1:]) 262 | else: 263 | m = self.match_version(vstr) 264 | if not m: 265 | raise ValueError("invalid version number: " + vstr) 266 | 267 | version, suffix = m.groups() 268 | parts = self._parse_dotted_string(version) 269 | self.major = parts.pop(0) 270 | if len(parts): 271 | self.minor = parts.pop(0) 272 | if len(parts): 273 | self.patch = parts.pop(0) 274 | if not len(parts) == 0: 275 | raise ValueError("version number too long: " + vstr) 276 | 277 | if suffix: 278 | # Build info 279 | idx = suffix.find("+") 280 | if idx > -1: 281 | self.build = suffix[idx + 1 :] 282 | suffix = suffix[:idx] 283 | if suffix: 284 | if not suffix.startswith("-"): 285 | raise ValueError("suffix must start with - : " + suffix) 286 | self.suffix = suffix[1:] 287 | 288 | def _parse_dotted_string(self, s): 289 | """Parse string ``s`` into list of ints and strings.""" 290 | parsed = [] 291 | parts = s.split(".") 292 | for p in parts: 293 | if p.isdigit(): 294 | p = int(p) 295 | parsed.append(p) 296 | return parsed 297 | 298 | @property 299 | def tuple(self): 300 | """Version number as a tuple of major, minor, patch, pre-release.""" 301 | return (self.major, self.minor, self.patch, self.suffix) 302 | 303 | def __lt__(self, other): 304 | """Implement comparison.""" 305 | if not isinstance(other, Version): 306 | raise ValueError("not a Version instance: {0!r}".format(other)) 307 | t = self.tuple[:3] 308 | o = other.tuple[:3] 309 | if t < o: 310 | return True 311 | if t == o: # We need to compare suffixes 312 | if self.suffix and not other.suffix: 313 | return True 314 | if other.suffix and not self.suffix: 315 | return False 316 | 317 | self_suffix = self._parse_dotted_string(self.suffix) 318 | other_suffix = self._parse_dotted_string(other.suffix) 319 | 320 | for s, o in zip_longest(self_suffix, other_suffix): 321 | if s is None: # shorter value wins 322 | return True 323 | elif o is None: # longer value loses 324 | return False 325 | elif type(s) != type(o): # type coersion 326 | s, o = str(s), str(o) 327 | if s == o: # next if the same compare 328 | continue 329 | return s < o # finally compare 330 | # t > o 331 | return False 332 | 333 | def __eq__(self, other): 334 | """Implement comparison.""" 335 | if not isinstance(other, Version): 336 | raise ValueError("not a Version instance: {0!r}".format(other)) 337 | return self.tuple == other.tuple 338 | 339 | def __ne__(self, other): 340 | """Implement comparison.""" 341 | return not self.__eq__(other) 342 | 343 | def __gt__(self, other): 344 | """Implement comparison.""" 345 | if not isinstance(other, Version): 346 | raise ValueError("not a Version instance: {0!r}".format(other)) 347 | return other.__lt__(self) 348 | 349 | def __le__(self, other): 350 | """Implement comparison.""" 351 | if not isinstance(other, Version): 352 | raise ValueError("not a Version instance: {0!r}".format(other)) 353 | return not other.__lt__(self) 354 | 355 | def __ge__(self, other): 356 | """Implement comparison.""" 357 | return not self.__lt__(other) 358 | 359 | def __str__(self): 360 | """Return semantic version string.""" 361 | vstr = "{0}.{1}.{2}".format(self.major, self.minor, self.patch) 362 | if self.suffix: 363 | vstr = "{0}-{1}".format(vstr, self.suffix) 364 | if self.build: 365 | vstr = "{0}+{1}".format(vstr, self.build) 366 | return vstr 367 | 368 | def __repr__(self): 369 | """Return 'code' representation of `Version`.""" 370 | return "Version('{0}')".format(str(self)) 371 | 372 | 373 | def retrieve_download(dl): 374 | """Saves a download to a temporary file and returns path. 375 | 376 | .. versionadded: 1.37 377 | 378 | Args: 379 | url (unicode): URL to .alfredworkflow file in GitHub repo 380 | 381 | Returns: 382 | unicode: path to downloaded file 383 | 384 | """ 385 | if not match_workflow(dl.filename): 386 | raise ValueError("attachment not a workflow: " + dl.filename) 387 | 388 | path = os.path.join(tempfile.gettempdir(), dl.filename) 389 | wf().logger.debug("downloading update from " "%r to %r ...", dl.url, path) 390 | 391 | r = request.urlopen(dl.url) 392 | 393 | with atomic_writer(path, "wb") as file_obj: 394 | file_obj.write(r.read()) 395 | 396 | return path 397 | 398 | 399 | def build_api_url(repo): 400 | """Generate releases URL from GitHub repo. 401 | 402 | Args: 403 | repo (unicode): Repo name in form ``username/repo`` 404 | 405 | Returns: 406 | unicode: URL to the API endpoint for the repo's releases 407 | 408 | """ 409 | if len(repo.split("/")) != 2: 410 | raise ValueError("invalid GitHub repo: {!r}".format(repo)) 411 | 412 | return RELEASES_BASE.format(repo) 413 | 414 | 415 | def get_downloads(repo): 416 | """Load available ``Download``s for GitHub repo. 417 | 418 | .. versionadded: 1.37 419 | 420 | Args: 421 | repo (unicode): GitHub repo to load releases for. 422 | 423 | Returns: 424 | list: Sequence of `Download` contained in GitHub releases. 425 | """ 426 | url = build_api_url(repo) 427 | 428 | def _fetch(): 429 | wf().logger.info("retrieving releases for %r ...", repo) 430 | r = request.urlopen(url) 431 | return r.read() 432 | 433 | key = "github-releases-" + repo.replace("/", "-") 434 | js = wf().cached_data(key, _fetch, max_age=60) 435 | 436 | return Download.from_releases(js) 437 | 438 | 439 | def latest_download(dls, alfred_version=None, prereleases=False): 440 | """Return newest `Download`.""" 441 | alfred_version = alfred_version or os.getenv("alfred_version") 442 | version = None 443 | if alfred_version: 444 | version = Version(alfred_version) 445 | 446 | dls.sort(reverse=True) 447 | for dl in dls: 448 | if dl.prerelease and not prereleases: 449 | wf().logger.debug("ignored prerelease: %s", dl.version) 450 | continue 451 | if version and dl.alfred_version > version: 452 | wf().logger.debug( 453 | "ignored incompatible (%s > %s): %s", 454 | dl.alfred_version, 455 | version, 456 | dl.filename, 457 | ) 458 | continue 459 | 460 | wf().logger.debug("latest version: %s (%s)", dl.version, dl.filename) 461 | return dl 462 | 463 | return None 464 | 465 | 466 | def check_update(repo, current_version, prereleases=False, alfred_version=None): 467 | """Check whether a newer release is available on GitHub. 468 | 469 | Args: 470 | repo (unicode): ``username/repo`` for workflow's GitHub repo 471 | current_version (unicode): the currently installed version of the 472 | workflow. :ref:`Semantic versioning ` is required. 473 | prereleases (bool): Whether to include pre-releases. 474 | alfred_version (unicode): version of currently-running Alfred. 475 | if empty, defaults to ``$alfred_version`` environment variable. 476 | 477 | Returns: 478 | bool: ``True`` if an update is available, else ``False`` 479 | 480 | If an update is available, its version number and download URL will 481 | be cached. 482 | 483 | """ 484 | key = "__workflow_latest_version" 485 | # data stored when no update is available 486 | no_update = {"available": False, "download": None, "version": None} 487 | current = Version(current_version) 488 | 489 | dls = get_downloads(repo) 490 | if not len(dls): 491 | wf().logger.warning("no valid downloads for %s", repo) 492 | wf().cache_data(key, no_update) 493 | return False 494 | 495 | wf().logger.info("%d download(s) for %s", len(dls), repo) 496 | 497 | dl = latest_download(dls, alfred_version, prereleases) 498 | 499 | if not dl: 500 | wf().logger.warning("no compatible downloads for %s", repo) 501 | wf().cache_data(key, no_update) 502 | return False 503 | 504 | wf().logger.debug("latest=%r, installed=%r", dl.version, current) 505 | 506 | if dl.version > current: 507 | wf().cache_data( 508 | key, {"version": str(dl.version), "download": dl.dict, "available": True} 509 | ) 510 | return True 511 | 512 | wf().cache_data(key, no_update) 513 | return False 514 | 515 | 516 | def install_update(): 517 | """If a newer release is available, download and install it. 518 | 519 | :returns: ``True`` if an update is installed, else ``False`` 520 | 521 | """ 522 | key = "__workflow_latest_version" 523 | # data stored when no update is available 524 | no_update = {"available": False, "download": None, "version": None} 525 | status = wf().cached_data(key, max_age=0) 526 | 527 | if not status or not status.get("available"): 528 | wf().logger.info("no update available") 529 | return False 530 | 531 | dl = status.get("download") 532 | if not dl: 533 | wf().logger.info("no download information") 534 | return False 535 | 536 | path = retrieve_download(Download.from_dict(dl)) 537 | 538 | wf().logger.info("installing updated workflow ...") 539 | subprocess.call(["open", path]) # nosec 540 | 541 | wf().cache_data(key, no_update) 542 | return True 543 | 544 | 545 | if __name__ == "__main__": # pragma: nocover 546 | import sys 547 | 548 | prereleases = False 549 | 550 | def show_help(status=0): 551 | """Print help message.""" 552 | print("usage: update.py (check|install) " "[--prereleases] ") 553 | sys.exit(status) 554 | 555 | argv = sys.argv[:] 556 | if "-h" in argv or "--help" in argv: 557 | show_help() 558 | 559 | if "--prereleases" in argv: 560 | argv.remove("--prereleases") 561 | prereleases = True 562 | 563 | if len(argv) != 4: 564 | show_help(1) 565 | 566 | action = argv[1] 567 | repo = argv[2] 568 | version = argv[3] 569 | 570 | try: 571 | 572 | if action == "check": 573 | check_update(repo, version, prereleases) 574 | elif action == "install": 575 | install_update() 576 | else: 577 | show_help(1) 578 | 579 | except Exception as err: # ensure traceback is in log file 580 | wf().logger.exception(err) 581 | raise err 582 | -------------------------------------------------------------------------------- /src/workflow/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2017 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2017-12-17 9 | # 10 | 11 | """A selection of helper functions useful for building workflows.""" 12 | 13 | 14 | import atexit 15 | import errno 16 | import fcntl 17 | import functools 18 | import json 19 | import os 20 | import signal 21 | import subprocess 22 | import sys 23 | import time 24 | from collections import namedtuple 25 | from contextlib import contextmanager 26 | from threading import Event 27 | 28 | # JXA scripts to call Alfred's API via the Scripting Bridge 29 | # {app} is automatically replaced with "Alfred 3" or 30 | # "com.runningwithcrayons.Alfred" depending on version. 31 | # 32 | # Open Alfred in search (regular) mode 33 | JXA_SEARCH = "Application({app}).search({arg});" 34 | # Open Alfred's File Actions on an argument 35 | JXA_ACTION = "Application({app}).action({arg});" 36 | # Open Alfred's navigation mode at path 37 | JXA_BROWSE = "Application({app}).browse({arg});" 38 | # Set the specified theme 39 | JXA_SET_THEME = "Application({app}).setTheme({arg});" 40 | # Call an External Trigger 41 | JXA_TRIGGER = "Application({app}).runTrigger({arg}, {opts});" 42 | # Save a variable to the workflow configuration sheet/info.plist 43 | JXA_SET_CONFIG = "Application({app}).setConfiguration({arg}, {opts});" 44 | # Delete a variable from the workflow configuration sheet/info.plist 45 | JXA_UNSET_CONFIG = "Application({app}).removeConfiguration({arg}, {opts});" 46 | # Tell Alfred to reload a workflow from disk 47 | JXA_RELOAD_WORKFLOW = "Application({app}).reloadWorkflow({arg});" 48 | 49 | 50 | class AcquisitionError(Exception): 51 | """Raised if a lock cannot be acquired.""" 52 | 53 | 54 | AppInfo = namedtuple("AppInfo", ["name", "path", "bundleid"]) 55 | """Information about an installed application. 56 | 57 | Returned by :func:`appinfo`. All attributes are Unicode. 58 | 59 | .. py:attribute:: name 60 | 61 | Name of the application, e.g. ``u'Safari'``. 62 | 63 | .. py:attribute:: path 64 | 65 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. 66 | 67 | .. py:attribute:: bundleid 68 | 69 | Application's bundle ID, e.g. ``u'com.apple.Safari'``. 70 | 71 | """ 72 | 73 | 74 | def jxa_app_name(): 75 | """Return name of application to call currently running Alfred. 76 | 77 | .. versionadded: 1.37 78 | 79 | Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending 80 | on which version of Alfred is running. 81 | 82 | This name is suitable for use with ``Application(name)`` in JXA. 83 | 84 | Returns: 85 | unicode: Application name or ID. 86 | 87 | """ 88 | if os.getenv("alfred_version", "").startswith("3"): 89 | # Alfred 3 90 | return "Alfred 3" 91 | # Alfred 4+ 92 | return "com.runningwithcrayons.Alfred" 93 | 94 | 95 | def unicodify(s, encoding="utf-8", norm=None): 96 | """Ensure string is Unicode. 97 | 98 | .. versionadded:: 1.31 99 | 100 | Decode encoded strings using ``encoding`` and normalise Unicode 101 | to form ``norm`` if specified. 102 | 103 | Args: 104 | s (str): String to decode. May also be Unicode. 105 | encoding (str, optional): Encoding to use on bytestrings. 106 | norm (None, optional): Normalisation form to apply to Unicode string. 107 | 108 | Returns: 109 | unicode: Decoded, optionally normalised, Unicode string. 110 | 111 | """ 112 | if not isinstance(s, str): 113 | s = str(s, encoding) 114 | 115 | if norm: 116 | from unicodedata import normalize 117 | 118 | s = normalize(norm, s) 119 | 120 | return s 121 | 122 | 123 | def utf8ify(s): 124 | """Ensure string is a bytestring. 125 | 126 | .. versionadded:: 1.31 127 | 128 | Returns `str` objects unchanced, encodes `unicode` objects to 129 | UTF-8, and calls :func:`str` on anything else. 130 | 131 | Args: 132 | s (object): A Python object 133 | 134 | Returns: 135 | str: UTF-8 string or string representation of s. 136 | 137 | """ 138 | if isinstance(s, str): 139 | return s 140 | 141 | if isinstance(s, str): 142 | return s.encode("utf-8") 143 | 144 | return str(s) 145 | 146 | 147 | def applescriptify(s): 148 | """Escape string for insertion into an AppleScript string. 149 | 150 | .. versionadded:: 1.31 151 | 152 | Replaces ``"`` with `"& quote &"`. Use this function if you want 153 | to insert a string into an AppleScript script: 154 | 155 | >>> applescriptify('g "python" test') 156 | 'g " & quote & "python" & quote & "test' 157 | 158 | Args: 159 | s (unicode): Unicode string to escape. 160 | 161 | Returns: 162 | unicode: Escaped string. 163 | 164 | """ 165 | return s.replace('"', '" & quote & "') 166 | 167 | 168 | def run_command(cmd, **kwargs): 169 | """Run a command and return the output. 170 | 171 | .. versionadded:: 1.31 172 | 173 | A thin wrapper around :func:`subprocess.check_output` that ensures 174 | all arguments are encoded to UTF-8 first. 175 | 176 | Args: 177 | cmd (list): Command arguments to pass to :func:`~subprocess.check_output`. 178 | **kwargs: Keyword arguments to pass to :func:`~subprocess.check_output`. 179 | 180 | Returns: 181 | str: Output returned by :func:`~subprocess.check_output`. 182 | 183 | """ 184 | cmd = [str(s) for s in cmd] 185 | return subprocess.check_output(cmd, **kwargs).decode() 186 | 187 | 188 | def run_applescript(script, *args, **kwargs): 189 | """Execute an AppleScript script and return its output. 190 | 191 | .. versionadded:: 1.31 192 | 193 | Run AppleScript either by filepath or code. If ``script`` is a valid 194 | filepath, that script will be run, otherwise ``script`` is treated 195 | as code. 196 | 197 | Args: 198 | script (str, optional): Filepath of script or code to run. 199 | *args: Optional command-line arguments to pass to the script. 200 | **kwargs: Pass ``lang`` to run a language other than AppleScript. 201 | Any other keyword arguments are passed to :func:`run_command`. 202 | 203 | Returns: 204 | str: Output of run command. 205 | 206 | """ 207 | lang = "AppleScript" 208 | if "lang" in kwargs: 209 | lang = kwargs["lang"] 210 | del kwargs["lang"] 211 | 212 | cmd = ["/usr/bin/osascript", "-l", lang] 213 | 214 | if os.path.exists(script): 215 | cmd += [script] 216 | else: 217 | cmd += ["-e", script] 218 | 219 | cmd.extend(args) 220 | 221 | return run_command(cmd, **kwargs) 222 | 223 | 224 | def run_jxa(script, *args): 225 | """Execute a JXA script and return its output. 226 | 227 | .. versionadded:: 1.31 228 | 229 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``. 230 | 231 | Args: 232 | script (str): Filepath of script or code to run. 233 | *args: Optional command-line arguments to pass to script. 234 | 235 | Returns: 236 | str: Output of script. 237 | 238 | """ 239 | return run_applescript(script, *args, lang="JavaScript") 240 | 241 | 242 | def run_trigger(name, bundleid=None, arg=None): 243 | """Call an Alfred External Trigger. 244 | 245 | .. versionadded:: 1.31 246 | 247 | If ``bundleid`` is not specified, the bundle ID of the calling 248 | workflow is used. 249 | 250 | Args: 251 | name (str): Name of External Trigger to call. 252 | bundleid (str, optional): Bundle ID of workflow trigger belongs to. 253 | arg (str, optional): Argument to pass to trigger. 254 | 255 | """ 256 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid") 257 | appname = jxa_app_name() 258 | opts = {"inWorkflow": bundleid} 259 | if arg: 260 | opts["withArgument"] = arg 261 | 262 | script = JXA_TRIGGER.format( 263 | app=json.dumps(appname), 264 | arg=json.dumps(name), 265 | opts=json.dumps(opts, sort_keys=True), 266 | ) 267 | 268 | run_applescript(script, lang="JavaScript") 269 | 270 | 271 | def set_theme(theme_name): 272 | """Change Alfred's theme. 273 | 274 | .. versionadded:: 1.39.0 275 | 276 | Args: 277 | theme_name (unicode): Name of theme Alfred should use. 278 | 279 | """ 280 | appname = jxa_app_name() 281 | script = JXA_SET_THEME.format(app=json.dumps(appname), arg=json.dumps(theme_name)) 282 | run_applescript(script, lang="JavaScript") 283 | 284 | 285 | def set_config(name, value, bundleid=None, exportable=False): 286 | """Set a workflow variable in ``info.plist``. 287 | 288 | .. versionadded:: 1.33 289 | 290 | If ``bundleid`` is not specified, the bundle ID of the calling 291 | workflow is used. 292 | 293 | Args: 294 | name (str): Name of variable to set. 295 | value (str): Value to set variable to. 296 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 297 | exportable (bool, optional): Whether variable should be marked 298 | as exportable (Don't Export checkbox). 299 | 300 | """ 301 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid") 302 | appname = jxa_app_name() 303 | opts = {"toValue": value, "inWorkflow": bundleid, "exportable": exportable} 304 | 305 | script = JXA_SET_CONFIG.format( 306 | app=json.dumps(appname), 307 | arg=json.dumps(name), 308 | opts=json.dumps(opts, sort_keys=True), 309 | ) 310 | 311 | run_applescript(script, lang="JavaScript") 312 | 313 | 314 | def unset_config(name, bundleid=None): 315 | """Delete a workflow variable from ``info.plist``. 316 | 317 | .. versionadded:: 1.33 318 | 319 | If ``bundleid`` is not specified, the bundle ID of the calling 320 | workflow is used. 321 | 322 | Args: 323 | name (str): Name of variable to delete. 324 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 325 | 326 | """ 327 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid") 328 | appname = jxa_app_name() 329 | opts = {"inWorkflow": bundleid} 330 | 331 | script = JXA_UNSET_CONFIG.format( 332 | app=json.dumps(appname), 333 | arg=json.dumps(name), 334 | opts=json.dumps(opts, sort_keys=True), 335 | ) 336 | 337 | run_applescript(script, lang="JavaScript") 338 | 339 | 340 | def search_in_alfred(query=None): 341 | """Open Alfred with given search query. 342 | 343 | .. versionadded:: 1.39.0 344 | 345 | Omit ``query`` to simply open Alfred's main window. 346 | 347 | Args: 348 | query (unicode, optional): Search query. 349 | 350 | """ 351 | query = query or "" 352 | appname = jxa_app_name() 353 | script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query)) 354 | run_applescript(script, lang="JavaScript") 355 | 356 | 357 | def browse_in_alfred(path): 358 | """Open Alfred's filesystem navigation mode at ``path``. 359 | 360 | .. versionadded:: 1.39.0 361 | 362 | Args: 363 | path (unicode): File or directory path. 364 | 365 | """ 366 | appname = jxa_app_name() 367 | script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path)) 368 | run_applescript(script, lang="JavaScript") 369 | 370 | 371 | def action_in_alfred(paths): 372 | """Action the give filepaths in Alfred. 373 | 374 | .. versionadded:: 1.39.0 375 | 376 | Args: 377 | paths (list): Unicode paths to files/directories to action. 378 | 379 | """ 380 | appname = jxa_app_name() 381 | script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths)) 382 | run_applescript(script, lang="JavaScript") 383 | 384 | 385 | def reload_workflow(bundleid=None): 386 | """Tell Alfred to reload a workflow from disk. 387 | 388 | .. versionadded:: 1.39.0 389 | 390 | If ``bundleid`` is not specified, the bundle ID of the calling 391 | workflow is used. 392 | 393 | Args: 394 | bundleid (unicode, optional): Bundle ID of workflow to reload. 395 | 396 | """ 397 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid") 398 | appname = jxa_app_name() 399 | script = JXA_RELOAD_WORKFLOW.format( 400 | app=json.dumps(appname), arg=json.dumps(bundleid) 401 | ) 402 | 403 | run_applescript(script, lang="JavaScript") 404 | 405 | 406 | def appinfo(name): 407 | """Get information about an installed application. 408 | 409 | .. versionadded:: 1.31 410 | 411 | Args: 412 | name (str): Name of application to look up. 413 | 414 | Returns: 415 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found. 416 | 417 | """ 418 | cmd = [ 419 | "mdfind", 420 | "-onlyin", 421 | "/Applications", 422 | "-onlyin", 423 | "/System/Applications", 424 | "-onlyin", 425 | os.path.expanduser("~/Applications"), 426 | "(kMDItemContentTypeTree == com.apple.application &&" 427 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'.format(name), 428 | ] 429 | 430 | output = run_command(cmd).strip() 431 | if not output: 432 | return None 433 | 434 | path = output.split("\n")[0] 435 | 436 | cmd = ["mdls", "-raw", "-name", "kMDItemCFBundleIdentifier", path] 437 | bid = run_command(cmd).strip() 438 | if not bid: # pragma: no cover 439 | return None 440 | 441 | return AppInfo(name, path, bid) 442 | 443 | 444 | @contextmanager 445 | def atomic_writer(fpath, mode): 446 | """Atomic file writer. 447 | 448 | .. versionadded:: 1.12 449 | 450 | Context manager that ensures the file is only written if the write 451 | succeeds. The data is first written to a temporary file. 452 | 453 | :param fpath: path of file to write to. 454 | :type fpath: ``unicode`` 455 | :param mode: sames as for :func:`open` 456 | :type mode: string 457 | 458 | """ 459 | suffix = ".{}.tmp".format(os.getpid()) 460 | temppath = fpath + suffix 461 | with open(temppath, mode) as fp: 462 | try: 463 | yield fp 464 | os.rename(temppath, fpath) 465 | finally: 466 | try: 467 | os.remove(temppath) 468 | except OSError: 469 | pass 470 | 471 | 472 | class LockFile(object): 473 | """Context manager to protect filepaths with lockfiles. 474 | 475 | .. versionadded:: 1.13 476 | 477 | Creates a lockfile alongside ``protected_path``. Other ``LockFile`` 478 | instances will refuse to lock the same path. 479 | 480 | >>> path = '/path/to/file' 481 | >>> with LockFile(path): 482 | >>> with open(path, 'w') as fp: 483 | >>> fp.write(data) 484 | 485 | Args: 486 | protected_path (unicode): File to protect with a lockfile 487 | timeout (float, optional): Raises an :class:`AcquisitionError` 488 | if lock cannot be acquired within this number of seconds. 489 | If ``timeout`` is 0 (the default), wait forever. 490 | delay (float, optional): How often to check (in seconds) if 491 | lock has been released. 492 | 493 | Attributes: 494 | delay (float): How often to check (in seconds) whether the lock 495 | can be acquired. 496 | lockfile (unicode): Path of the lockfile. 497 | timeout (float): How long to wait to acquire the lock. 498 | 499 | """ 500 | 501 | def __init__(self, protected_path, timeout=0.0, delay=0.05): 502 | """Create new :class:`LockFile` object.""" 503 | self.lockfile = protected_path + ".lock" 504 | self._lockfile = None 505 | self.timeout = timeout 506 | self.delay = delay 507 | self._lock = Event() 508 | atexit.register(self.release) 509 | 510 | @property 511 | def locked(self): 512 | """``True`` if file is locked by this instance.""" 513 | return self._lock.is_set() 514 | 515 | def acquire(self, blocking=True): 516 | """Acquire the lock if possible. 517 | 518 | If the lock is in use and ``blocking`` is ``False``, return 519 | ``False``. 520 | 521 | Otherwise, check every :attr:`delay` seconds until it acquires 522 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. 523 | 524 | """ 525 | if self.locked and not blocking: 526 | return False 527 | 528 | start = time.time() 529 | while True: 530 | # Raise error if we've been waiting too long to acquire the lock 531 | if self.timeout and (time.time() - start) >= self.timeout: 532 | raise AcquisitionError("lock acquisition timed out") 533 | 534 | # If already locked, wait then try again 535 | if self.locked: 536 | time.sleep(self.delay) 537 | continue 538 | 539 | # Create in append mode so we don't lose any contents 540 | if self._lockfile is None: 541 | self._lockfile = open(self.lockfile, "a") 542 | 543 | # Try to acquire the lock 544 | try: 545 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 546 | self._lock.set() 547 | break 548 | except IOError as err: # pragma: no cover 549 | if err.errno not in (errno.EACCES, errno.EAGAIN): 550 | raise 551 | 552 | # Don't try again 553 | if not blocking: # pragma: no cover 554 | return False 555 | 556 | # Wait, then try again 557 | time.sleep(self.delay) 558 | 559 | return True 560 | 561 | def release(self): 562 | """Release the lock by deleting `self.lockfile`.""" 563 | if not self._lock.is_set(): 564 | return False 565 | 566 | try: 567 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN) 568 | except IOError: # pragma: no cover 569 | pass 570 | finally: 571 | self._lock.clear() 572 | self._lockfile = None 573 | try: 574 | os.unlink(self.lockfile) 575 | except OSError: # pragma: no cover 576 | pass 577 | 578 | return True # noqa: B012 579 | 580 | def __enter__(self): 581 | """Acquire lock.""" 582 | self.acquire() 583 | return self 584 | 585 | def __exit__(self, typ, value, traceback): 586 | """Release lock.""" 587 | self.release() 588 | 589 | def __del__(self): 590 | """Clear up `self.lockfile`.""" 591 | self.release() # pragma: no cover 592 | 593 | 594 | class uninterruptible(object): 595 | """Decorator that postpones SIGTERM until wrapped function returns. 596 | 597 | .. versionadded:: 1.12 598 | 599 | .. important:: This decorator is NOT thread-safe. 600 | 601 | As of version 2.7, Alfred allows Script Filters to be killed. If 602 | your workflow is killed in the middle of critical code (e.g. 603 | writing data to disk), this may corrupt your workflow's data. 604 | 605 | Use this decorator to wrap critical functions that *must* complete. 606 | If the script is killed while a wrapped function is executing, 607 | the SIGTERM will be caught and handled after your function has 608 | finished executing. 609 | 610 | Alfred-Workflow uses this internally to ensure its settings, data 611 | and cache writes complete. 612 | 613 | """ 614 | 615 | def __init__(self, func, class_name=""): 616 | """Decorate `func`.""" 617 | self.func = func 618 | functools.update_wrapper(self, func) 619 | self._caught_signal = None 620 | 621 | def signal_handler(self, signum, frame): 622 | """Called when process receives SIGTERM.""" 623 | self._caught_signal = (signum, frame) 624 | 625 | def __call__(self, *args, **kwargs): 626 | """Trap ``SIGTERM`` and call wrapped function.""" 627 | self._caught_signal = None 628 | # Register handler for SIGTERM, then call `self.func` 629 | self.old_signal_handler = signal.getsignal(signal.SIGTERM) 630 | signal.signal(signal.SIGTERM, self.signal_handler) 631 | 632 | self.func(*args, **kwargs) 633 | 634 | # Restore old signal handler 635 | signal.signal(signal.SIGTERM, self.old_signal_handler) 636 | 637 | # Handle any signal caught during execution 638 | if self._caught_signal is not None: 639 | signum, frame = self._caught_signal 640 | if callable(self.old_signal_handler): 641 | self.old_signal_handler(signum, frame) 642 | elif self.old_signal_handler == signal.SIG_DFL: 643 | sys.exit(0) 644 | 645 | def __get__(self, obj=None, klass=None): 646 | """Decorator API.""" 647 | return self.__class__(self.func.__get__(obj, klass), klass.__name__) 648 | -------------------------------------------------------------------------------- /src/workflow/version: -------------------------------------------------------------------------------- 1 | 1.40.0 -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomy0000000/Coinc/d3188b45e615f978291474228d6eacc5ac9a11eb/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | from pytest_mock import MockFixture 6 | 7 | from workflow import Workflow3 8 | 9 | with open(Path(__file__).parent.parent / "src" / "default_settings.json", "r") as file: 10 | DEFAULT_SETTINGS = json.load(file) 11 | 12 | 13 | class Helpers: 14 | WORKFLOW_INIT_KWARGS = dict( 15 | default_settings=DEFAULT_SETTINGS, 16 | help_url="https://github.com/tomy0000000/Coinc/wiki/User-Guide", 17 | ) 18 | 19 | @staticmethod 20 | def decode_json(data: str) -> dict: 21 | try: 22 | return json.loads(data) 23 | except json.JSONDecodeError: 24 | raise ValueError("result is not JSON-decodable") 25 | 26 | 27 | @pytest.fixture 28 | def helpers(): 29 | return Helpers 30 | 31 | 32 | @pytest.fixture(autouse=True) 33 | def test_dir(monkeypatch): 34 | monkeypatch.chdir("src") 35 | 36 | 37 | @pytest.fixture() 38 | def settings(mocker: MockFixture): 39 | mocker.patch( 40 | "workflow.workflow.Settings", 41 | return_value=DEFAULT_SETTINGS, 42 | ) 43 | mocker.patch( 44 | "coinc.persisted_data", 45 | return_value=DEFAULT_SETTINGS["alias"], 46 | ) 47 | mocker.patch( 48 | "coinc.utils.persisted_data", 49 | return_value=DEFAULT_SETTINGS["alias"], 50 | ) 51 | 52 | 53 | @pytest.fixture 54 | def rates(mocker: MockFixture): 55 | with open("../tests/test_rates.json") as file: 56 | mocked_rates = json.load(file) 57 | mocker.patch("coinc.query.load_rates", return_value=mocked_rates) 58 | mocker.patch("coinc.utils.load_rates", return_value=mocked_rates) 59 | 60 | 61 | @pytest.fixture 62 | def workflow(helpers, settings, rates) -> Workflow3: 63 | return Workflow3(**helpers.WORKFLOW_INIT_KWARGS) 64 | -------------------------------------------------------------------------------- /tests/test_alias.py: -------------------------------------------------------------------------------- 1 | import coinc 2 | 3 | 4 | class TestAlias: 5 | def test_alias_empty_args(self, workflow, helpers, monkeypatch, capsys): 6 | monkeypatch.setattr("sys.argv", ["main.py", "alias"]) 7 | coinc.alias(workflow) 8 | out = helpers.decode_json(capsys.readouterr().out) 9 | 10 | assert "Type the alias" in out["items"][0]["title"] 11 | 12 | def test_alias_too_many_args(self, workflow, helpers, monkeypatch, capsys): 13 | monkeypatch.setattr( 14 | "sys.argv", ["main.py", "alias", "some-alias", "currency", "something"] 15 | ) 16 | coinc.alias(workflow) 17 | out = helpers.decode_json(capsys.readouterr().out) 18 | 19 | assert "Too many arguments" in out["items"][0]["title"] 20 | 21 | def test_alias_invalid_alias(self, workflow, helpers, monkeypatch, capsys): 22 | monkeypatch.setattr("sys.argv", ["main.py", "alias", "1"]) 23 | coinc.alias(workflow) 24 | out = helpers.decode_json(capsys.readouterr().out) 25 | 26 | assert "Can't create alias with number in it" in out["items"][0]["title"] 27 | 28 | def test_alias_existed_alias(self, workflow, helpers, monkeypatch, capsys): 29 | monkeypatch.setattr("sys.argv", ["main.py", "alias", "$"]) 30 | coinc.alias(workflow) 31 | out = helpers.decode_json(capsys.readouterr().out) 32 | 33 | assert "is already aliased" in out["items"][0]["title"] 34 | 35 | def test_alias_no_currency(self, workflow, helpers, monkeypatch, capsys): 36 | monkeypatch.setattr("sys.argv", ["main.py", "alias", "some-alias"]) 37 | coinc.alias(workflow) 38 | out = helpers.decode_json(capsys.readouterr().out) 39 | 40 | RESULTS = ["search", "AED", "AFN", "ALL"] 41 | for result, item in zip(RESULTS, out["items"]): 42 | assert result in item["subtitle"] 43 | 44 | def test_alias_currency_prompt(self, workflow, helpers, monkeypatch, capsys): 45 | monkeypatch.setattr("sys.argv", ["main.py", "alias", "some-alias", "united"]) 46 | coinc.alias(workflow) 47 | out = helpers.decode_json(capsys.readouterr().out) 48 | 49 | RESULTS = ["search", "AED", "USD"] 50 | for result, item in zip(RESULTS, out["items"]): 51 | assert result in item["subtitle"] 52 | 53 | def test_alias_currency_not_found(self, workflow, helpers, monkeypatch, capsys): 54 | monkeypatch.setattr("sys.argv", ["main.py", "alias", "some-alias", "ZZZ"]) 55 | coinc.alias(workflow) 56 | out = helpers.decode_json(capsys.readouterr().out) 57 | 58 | assert "No currency found" in out["items"][0]["title"] 59 | 60 | def test_alias_valid(self, workflow, helpers, monkeypatch, capsys): 61 | monkeypatch.setattr("sys.argv", ["main.py", "alias", "some-alias", "USD"]) 62 | coinc.alias(workflow) 63 | out = helpers.decode_json(capsys.readouterr().out) 64 | 65 | assert "SOME-ALIAS" in out["items"][0]["title"] 66 | assert "USD" in out["items"][0]["title"] 67 | assert "Confirm to save" in out["items"][0]["subtitle"] 68 | 69 | 70 | class TestUnalias: 71 | def test_unalias_empty_args(self, workflow, helpers, monkeypatch, capsys): 72 | monkeypatch.setattr("sys.argv", ["main.py", "unalias"]) 73 | coinc.unalias(workflow) 74 | out = helpers.decode_json(capsys.readouterr().out) 75 | 76 | RESULTS = ["'$'", "'AU$'", "'C$'", "'CA$'"] 77 | for result, item in zip(RESULTS, out["items"]): 78 | assert result in item["title"] 79 | 80 | def test_unalias_too_many_args(self, workflow, helpers, monkeypatch, capsys): 81 | monkeypatch.setattr( 82 | "sys.argv", ["main.py", "unalias", "some-alias", "something"] 83 | ) 84 | coinc.unalias(workflow) 85 | out = helpers.decode_json(capsys.readouterr().out) 86 | 87 | assert "Too many arguments" in out["items"][0]["title"] 88 | 89 | def test_unalias_invalid_alias(self, workflow, helpers, monkeypatch, capsys): 90 | monkeypatch.setattr("sys.argv", ["main.py", "unalias", "some-alias"]) 91 | coinc.unalias(workflow) 92 | out = helpers.decode_json(capsys.readouterr().out) 93 | 94 | assert "not found" in out["items"][0]["title"] 95 | 96 | def test_unalias_valid(self, workflow, helpers, monkeypatch, capsys): 97 | monkeypatch.setattr("sys.argv", ["main.py", "unalias", "$"]) 98 | coinc.unalias(workflow) 99 | out = helpers.decode_json(capsys.readouterr().out) 100 | 101 | assert "$" in out["items"][0]["title"] 102 | assert "USD" in out["items"][0]["title"] 103 | assert "confirm unalias" in out["items"][0]["subtitle"] 104 | 105 | 106 | class TestSaveAlias: 107 | def test_save_alias_create(self, workflow, helpers, monkeypatch, capsys): 108 | # TODO 109 | pass 110 | 111 | def test_save_alias_remove(self, workflow, helpers, monkeypatch, capsys): 112 | # TODO 113 | pass 114 | 115 | def test_save_alias_invalid(self, workflow, helpers, monkeypatch, capsys): 116 | # TODO 117 | pass 118 | -------------------------------------------------------------------------------- /tests/test_convert.py: -------------------------------------------------------------------------------- 1 | import coinc 2 | from workflow import Workflow3 3 | 4 | 5 | class TestConvert: 6 | def test_cur(self, workflow, helpers, monkeypatch, capsys): 7 | monkeypatch.setattr("sys.argv", ["main.py", "convert"]) 8 | coinc.convert(workflow) 9 | out = helpers.decode_json(capsys.readouterr().out) 10 | 11 | RESULTS = [ 12 | "1.054 USD", 13 | "0.949 EUR", 14 | "0.142 USD", 15 | "7.025 CNY", 16 | "0.007 USD", 17 | "134.315 JPY", 18 | "1.230 USD", 19 | "0.813 GBP", 20 | "Last Update", 21 | ] 22 | for result, item in zip(RESULTS, out["items"]): 23 | assert result in item["title"] 24 | 25 | def test_cur_200(self, workflow, helpers, monkeypatch, capsys): 26 | monkeypatch.setattr("sys.argv", ["main.py", "convert", "200"]) 27 | coinc.convert(workflow) 28 | out = helpers.decode_json(capsys.readouterr().out) 29 | 30 | RESULTS = [ 31 | "210.750 USD", 32 | "189.798 EUR", 33 | "28.471 USD", 34 | "1,404.940 CNY", 35 | "1.489 USD", 36 | "26,862.999 JPY", 37 | "246.000 USD", 38 | "162.602 GBP", 39 | "Last Update", 40 | ] 41 | for result, item in zip(RESULTS, out["items"]): 42 | assert result in item["title"] 43 | 44 | def test_cur_GBP(self, workflow, helpers, monkeypatch, capsys): 45 | monkeypatch.setattr("sys.argv", ["main.py", "convert", "GBP"]) 46 | coinc.convert(workflow) 47 | out = helpers.decode_json(capsys.readouterr().out) 48 | 49 | RESULTS = ["1.230 USD", "0.813 GBP", "Last Update"] 50 | for result, item in zip(RESULTS, out["items"]): 51 | assert result in item["title"] 52 | 53 | def test_cur_5_GBP(self, workflow, helpers, monkeypatch, capsys): 54 | monkeypatch.setattr("sys.argv", ["main.py", "convert", "5", "GBP"]) 55 | coinc.convert(workflow) 56 | out = helpers.decode_json(capsys.readouterr().out) 57 | 58 | RESULTS = ["6.150 USD", "4.065 GBP", "Last Update"] 59 | for result, item in zip(RESULTS, out["items"]): 60 | assert result in item["title"] 61 | 62 | def test_cur_GBP_TWD(self, workflow, helpers, monkeypatch, capsys): 63 | monkeypatch.setattr("sys.argv", ["main.py", "convert", "GBP", "TWD"]) 64 | coinc.convert(workflow) 65 | out = helpers.decode_json(capsys.readouterr().out) 66 | 67 | RESULTS = ["37.580 TWD", "0.027 GBP", "Last Update"] 68 | for result, item in zip(RESULTS, out["items"]): 69 | assert result in item["title"] 70 | 71 | def test_cur_5_GBP_TWD(self, workflow, helpers, monkeypatch, capsys): 72 | monkeypatch.setattr("sys.argv", ["main.py", "convert", "5", "GBP", "TWD"]) 73 | coinc.convert(workflow) 74 | out = helpers.decode_json(capsys.readouterr().out) 75 | 76 | RESULTS = ["187.900 TWD", "0.133 GBP", "Last Update"] 77 | for result, item in zip(RESULTS, out["items"]): 78 | assert result in item["title"] 79 | 80 | def test_config_app_id_empty(self, helpers, monkeypatch, capsys): 81 | monkeypatch.delenv("APP_ID") 82 | workflow = Workflow3(**helpers.WORKFLOW_INIT_KWARGS) 83 | monkeypatch.setattr("sys.argv", ["main.py", "convert"]) 84 | coinc.convert(workflow) 85 | out = helpers.decode_json(capsys.readouterr().out) 86 | 87 | assert "APP_ID" in out["items"][0]["title"] 88 | 89 | def test_config_base_invalid(self, helpers, rates, monkeypatch, capsys): 90 | monkeypatch.setenv("BASE", "CURRENCY") 91 | workflow = Workflow3(**helpers.WORKFLOW_INIT_KWARGS) 92 | monkeypatch.setattr("sys.argv", ["main.py", "convert"]) 93 | coinc.convert(workflow) 94 | out = helpers.decode_json(capsys.readouterr().out) 95 | 96 | assert "Invalid" in out["items"][0]["title"] 97 | assert "CURRENCY" in out["items"][0]["title"] 98 | 99 | def test_config_base(self, helpers, rates, monkeypatch, mocker, capsys): 100 | monkeypatch.setenv("BASE", "TWD") 101 | mocker.patch( 102 | "workflow.workflow.Settings", 103 | return_value={"favorites": ["USD"]}, 104 | ) 105 | workflow = Workflow3(**helpers.WORKFLOW_INIT_KWARGS) 106 | monkeypatch.setattr("sys.argv", ["main.py", "convert"]) 107 | coinc.convert(workflow) 108 | out = helpers.decode_json(capsys.readouterr().out) 109 | 110 | RESULTS = ["30.553 TWD", "0.033 USD", "Last Update"] 111 | for result, item in zip(RESULTS, out["items"]): 112 | assert result in item["title"] 113 | 114 | def test_config_locale_invalid(self, helpers, rates, monkeypatch, capsys): 115 | monkeypatch.setenv("LOCALE", "language_country") 116 | workflow = Workflow3(**helpers.WORKFLOW_INIT_KWARGS) 117 | monkeypatch.setattr("sys.argv", ["main.py", "convert"]) 118 | coinc.convert(workflow) 119 | out = helpers.decode_json(capsys.readouterr().out) 120 | 121 | assert "Invalid" in out["items"][0]["title"] 122 | assert "language_country" in out["items"][0]["title"] 123 | 124 | def test_config_locale(self, helpers, rates, monkeypatch, mocker, capsys): 125 | monkeypatch.setenv("LOCALE", "fr_fr") 126 | mocker.patch( 127 | "workflow.workflow.Settings", 128 | return_value={"favorites": ["EUR"]}, 129 | ) 130 | workflow = Workflow3(**helpers.WORKFLOW_INIT_KWARGS) 131 | monkeypatch.setattr("sys.argv", ["main.py", "convert"]) 132 | coinc.convert(workflow) 133 | out = helpers.decode_json(capsys.readouterr().out) 134 | 135 | RESULTS = ["1,054 USD", "0,949 EUR", "Last Update"] 136 | for result, item in zip(RESULTS, out["items"]): 137 | assert result in item["title"] 138 | 139 | def test_config_orientation_invalid(self, helpers, rates, monkeypatch, capsys): 140 | monkeypatch.setenv("ORIENTATION", "orientation") 141 | workflow = Workflow3(**helpers.WORKFLOW_INIT_KWARGS) 142 | monkeypatch.setattr("sys.argv", ["main.py", "convert"]) 143 | coinc.convert(workflow) 144 | out = helpers.decode_json(capsys.readouterr().out) 145 | 146 | assert "Invalid" in out["items"][0]["title"] 147 | assert "orientation" in out["items"][0]["title"] 148 | 149 | def test_config_orientation_from( 150 | self, helpers, settings, rates, monkeypatch, mocker, capsys 151 | ): 152 | monkeypatch.setenv("ORIENTATION", "FROM_FAV") 153 | workflow = Workflow3(**helpers.WORKFLOW_INIT_KWARGS) 154 | monkeypatch.setattr("sys.argv", ["main.py", "convert"]) 155 | coinc.convert(workflow) 156 | out = helpers.decode_json(capsys.readouterr().out) 157 | 158 | RESULTS = ["1.054 USD", "0.142 USD", "0.007 USD", "1.230 USD", "Last Update"] 159 | for result, item in zip(RESULTS, out["items"]): 160 | assert result in item["title"] 161 | 162 | def test_config_orientation_to( 163 | self, helpers, settings, rates, monkeypatch, mocker, capsys 164 | ): 165 | monkeypatch.setenv("ORIENTATION", "TO_FAV") 166 | workflow = Workflow3(**helpers.WORKFLOW_INIT_KWARGS) 167 | monkeypatch.setattr("sys.argv", ["main.py", "convert"]) 168 | coinc.convert(workflow) 169 | out = helpers.decode_json(capsys.readouterr().out) 170 | 171 | RESULTS = ["0.949 EUR", "7.025 CNY", "134.315 JPY", "0.813 GBP", "Last Update"] 172 | for result, item in zip(RESULTS, out["items"]): 173 | assert result in item["title"] 174 | 175 | def test_config_precision_invalid(self, helpers, rates, monkeypatch, capsys): 176 | monkeypatch.setenv("PRECISION", "precision") 177 | workflow = Workflow3(**helpers.WORKFLOW_INIT_KWARGS) 178 | monkeypatch.setattr("sys.argv", ["main.py", "convert"]) 179 | coinc.convert(workflow) 180 | out = helpers.decode_json(capsys.readouterr().out) 181 | 182 | assert "Invalid" in out["items"][0]["title"] 183 | assert "precision" in out["items"][0]["title"] 184 | 185 | def test_config_precision(self, helpers, rates, monkeypatch, mocker, capsys): 186 | monkeypatch.setenv("PRECISION", "10") 187 | mocker.patch( 188 | "workflow.workflow.Settings", 189 | return_value={"favorites": ["EUR"]}, 190 | ) 191 | workflow = Workflow3(**helpers.WORKFLOW_INIT_KWARGS) 192 | monkeypatch.setattr("sys.argv", ["main.py", "convert"]) 193 | coinc.convert(workflow) 194 | out = helpers.decode_json(capsys.readouterr().out) 195 | 196 | RESULTS = ["1.0537496628 USD", "0.9489920000 EUR", "Last Update"] 197 | for result, item in zip(RESULTS, out["items"]): 198 | assert result in item["title"] 199 | -------------------------------------------------------------------------------- /tests/test_load.py: -------------------------------------------------------------------------------- 1 | from pprint import pprint 2 | 3 | import coinc 4 | 5 | 6 | class TestLoad: 7 | def test_load_all(self, workflow, helpers, monkeypatch, capsys): 8 | monkeypatch.setattr("sys.argv", ["main.py", "load", "all"]) 9 | coinc.load(workflow) 10 | out = helpers.decode_json(capsys.readouterr().out) 11 | 12 | EXPECTED_CURRENCIES = ["TWD", "KRW", "HKD", "SGD", "CAD", "AUD", "NZD", "CHF"] 13 | UNEXPECTED_CURRENCIES = ["EUR", "CNY", "JPY", "GBP"] 14 | 15 | appear = {currency: False for currency in EXPECTED_CURRENCIES} 16 | for item in out["items"]: 17 | currency = item["subtitle"] 18 | assert currency not in UNEXPECTED_CURRENCIES # No unexpected currencies 19 | if currency in EXPECTED_CURRENCIES: 20 | appear[currency] = True 21 | 22 | # All expected currencies should appear 23 | assert all(appear.values()) 24 | 25 | def test_load_all_sgd(self, workflow, helpers, monkeypatch, capsys): 26 | monkeypatch.setattr("sys.argv", ["main.py", "load", "all", "singapore"]) 27 | coinc.load(workflow) 28 | out = helpers.decode_json(capsys.readouterr().out) 29 | 30 | EXPECTED_CURRENCIES = ["SGD"] 31 | UNEXPECTED_CURRENCIES = [ 32 | "EUR", 33 | "CNY", 34 | "JPY", 35 | "GBP", 36 | "TWD", 37 | "KRW", 38 | "HKD", 39 | "CAD", 40 | "AUD", 41 | "NZD", 42 | "CHF", 43 | ] 44 | 45 | appear = {currency: False for currency in EXPECTED_CURRENCIES} 46 | for item in out["items"]: 47 | currency = item["subtitle"] 48 | assert currency not in UNEXPECTED_CURRENCIES # No unexpected currencies 49 | if currency in EXPECTED_CURRENCIES: 50 | appear[currency] = True 51 | 52 | def test_load_all_none(self, workflow, helpers, monkeypatch, capsys): 53 | monkeypatch.setattr("sys.argv", ["main.py", "load", "all", "!@#$%^&*"]) 54 | coinc.load(workflow) 55 | out = helpers.decode_json(capsys.readouterr().out) 56 | 57 | UNEXPECTED_CURRENCIES = [ 58 | "EUR", 59 | "CNY", 60 | "JPY", 61 | "GBP", 62 | "TWD", 63 | "KRW", 64 | "HKD", 65 | "SGD", 66 | "CAD", 67 | "AUD", 68 | "NZD", 69 | "CHF", 70 | ] 71 | 72 | for item in out["items"]: 73 | currency = item["subtitle"] 74 | assert currency not in UNEXPECTED_CURRENCIES # No unexpected currencies 75 | 76 | def test_load_favorites(self, workflow, helpers, monkeypatch, capsys): 77 | monkeypatch.setattr("sys.argv", ["main.py", "load", "favorites"]) 78 | coinc.load(workflow) 79 | out = helpers.decode_json(capsys.readouterr().out) 80 | 81 | EXPECTED_CURRENCIES = ["EUR", "CNY", "JPY", "GBP"] 82 | 83 | appear = {currency: False for currency in EXPECTED_CURRENCIES} 84 | for item in out["items"]: 85 | currency = item["subtitle"] 86 | if currency in EXPECTED_CURRENCIES: 87 | appear[currency] = True 88 | 89 | # All expected currencies should appear 90 | assert all(appear.values()) 91 | -------------------------------------------------------------------------------- /tests/test_rates.json: -------------------------------------------------------------------------------- 1 | { 2 | "CNY": 7.0247, 3 | "EUR": 0.948992, 4 | "GBP": 0.813008, 5 | "JPY": 134.31499706, 6 | "TWD": 30.5529, 7 | "USD": 1, 8 | "last_update": "Now" 9 | } 10 | --------------------------------------------------------------------------------