├── .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 | [](https://github.com/tomy0000000/Coinc/actions/workflows/test.yml)
8 | [](https://codecov.io/gh/tomy0000000/Coinc)
9 |
10 | [](https://alfred.app/workflows/tomy0000000/coinc)
11 | [](https://github.com/tomy0000000/Coinc/releases)
12 | [](https://github.com/tomy0000000/Coinc/releases)
13 | [](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 | 
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 |
--------------------------------------------------------------------------------