├── src ├── xpander_data │ ├── __init__.py │ ├── examples │ │ ├── __init__.py │ │ ├── Fill form.json │ │ ├── Hyperlink from Clipboard.json │ │ ├── Emoji.json │ │ └── Thanks for order.json │ └── settings.ini ├── xpander_py │ ├── __init__.py │ ├── util.py │ ├── server.py │ ├── phrase.py │ ├── fs.py │ ├── context.py │ ├── output.py │ └── service.py ├── static │ ├── styles │ │ ├── fonts │ │ ├── about.sass │ │ ├── fillin.sass │ │ └── manager.sass │ ├── icons │ │ ├── xpander.ico │ │ ├── xpander.16x16.png │ │ ├── xpander.32x32.png │ │ ├── xpander.48x48.png │ │ ├── xpander.256x256.png │ │ ├── xpander-active-dark.ico │ │ ├── xpander-active-light.ico │ │ ├── xpander-inactive-dark.ico │ │ ├── xpander-inactive-light.ico │ │ ├── xpander-active-dark.16x16.png │ │ ├── xpander-active-dark.256x256.png │ │ ├── xpander-active-dark.32x32.png │ │ ├── xpander-active-dark.48x48.png │ │ ├── xpander-active-light.16x16.png │ │ ├── xpander-active-light.32x32.png │ │ ├── xpander-active-light.48x48.png │ │ ├── xpander-inactive-dark.16x16.png │ │ ├── xpander-inactive-dark.32x32.png │ │ ├── xpander-inactive-dark.48x48.png │ │ ├── xpander-active-light.256x256.png │ │ ├── xpander-inactive-dark.256x256.png │ │ ├── xpander-inactive-light.16x16.png │ │ ├── xpander-inactive-light.32x32.png │ │ ├── xpander-inactive-light.48x48.png │ │ ├── xpander-inactive-light.256x256.png │ │ ├── xpander.svg │ │ ├── xpander-active-dark.svg │ │ ├── xpander-active-light.svg │ │ ├── xpander-inactive-dark.svg │ │ └── xpander-inactive-light.svg │ ├── fillin.html │ ├── about.html │ └── manager.html ├── xpander_ts │ ├── about.ts │ ├── keymap.json │ ├── fillin.ts │ └── manager.ts ├── xpander.py └── xpander.ts ├── screenshots ├── about.png ├── fillin.gif ├── manager.png ├── clipboard.gif └── settings.png ├── .gitignore ├── .vscode └── settings.json ├── TODO.md ├── pyproject.toml ├── LICENCE ├── setup.py ├── cloc.md ├── package.json ├── requirements.txt ├── tsconfig.json ├── README.md └── poetry.lock /src/xpander_data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/xpander_py/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/xpander_data/examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /screenshots/about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/screenshots/about.png -------------------------------------------------------------------------------- /screenshots/fillin.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/screenshots/fillin.gif -------------------------------------------------------------------------------- /screenshots/manager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/screenshots/manager.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.egg-info 3 | .venv 4 | node_modules 5 | **/*.css 6 | **/*.js 7 | dist 8 | *.pyz 9 | -------------------------------------------------------------------------------- /screenshots/clipboard.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/screenshots/clipboard.gif -------------------------------------------------------------------------------- /screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/screenshots/settings.png -------------------------------------------------------------------------------- /src/static/styles/fonts: -------------------------------------------------------------------------------- 1 | /home/ozymandias/Projects/xpander/node_modules/material-design-icons-iconfont/dist/fonts -------------------------------------------------------------------------------- /src/static/icons/xpander.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander.ico -------------------------------------------------------------------------------- /src/static/styles/about.sass: -------------------------------------------------------------------------------- 1 | @import '~materialize-css/sass/materialize.scss' 2 | 3 | .row 4 | margin: 0 auto 5 | -------------------------------------------------------------------------------- /src/static/icons/xpander.16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander.16x16.png -------------------------------------------------------------------------------- /src/static/icons/xpander.32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander.32x32.png -------------------------------------------------------------------------------- /src/static/icons/xpander.48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander.48x48.png -------------------------------------------------------------------------------- /src/static/icons/xpander.256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander.256x256.png -------------------------------------------------------------------------------- /src/static/icons/xpander-active-dark.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-active-dark.ico -------------------------------------------------------------------------------- /src/static/icons/xpander-active-light.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-active-light.ico -------------------------------------------------------------------------------- /src/static/icons/xpander-inactive-dark.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-inactive-dark.ico -------------------------------------------------------------------------------- /src/static/icons/xpander-inactive-light.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-inactive-light.ico -------------------------------------------------------------------------------- /src/static/icons/xpander-active-dark.16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-active-dark.16x16.png -------------------------------------------------------------------------------- /src/static/icons/xpander-active-dark.256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-active-dark.256x256.png -------------------------------------------------------------------------------- /src/static/icons/xpander-active-dark.32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-active-dark.32x32.png -------------------------------------------------------------------------------- /src/static/icons/xpander-active-dark.48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-active-dark.48x48.png -------------------------------------------------------------------------------- /src/static/icons/xpander-active-light.16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-active-light.16x16.png -------------------------------------------------------------------------------- /src/static/icons/xpander-active-light.32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-active-light.32x32.png -------------------------------------------------------------------------------- /src/static/icons/xpander-active-light.48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-active-light.48x48.png -------------------------------------------------------------------------------- /src/static/icons/xpander-inactive-dark.16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-inactive-dark.16x16.png -------------------------------------------------------------------------------- /src/static/icons/xpander-inactive-dark.32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-inactive-dark.32x32.png -------------------------------------------------------------------------------- /src/static/icons/xpander-inactive-dark.48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-inactive-dark.48x48.png -------------------------------------------------------------------------------- /src/static/icons/xpander-active-light.256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-active-light.256x256.png -------------------------------------------------------------------------------- /src/static/icons/xpander-inactive-dark.256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-inactive-dark.256x256.png -------------------------------------------------------------------------------- /src/static/icons/xpander-inactive-light.16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-inactive-light.16x16.png -------------------------------------------------------------------------------- /src/static/icons/xpander-inactive-light.32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-inactive-light.32x32.png -------------------------------------------------------------------------------- /src/static/icons/xpander-inactive-light.48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-inactive-light.48x48.png -------------------------------------------------------------------------------- /src/static/icons/xpander-inactive-light.256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OzymandiasTheGreat/xpander/HEAD/src/static/icons/xpander-inactive-light.256x256.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": ".venv/bin/python", 3 | "files.exclude": { 4 | "**/*.css": true, 5 | "**/*.js": true, 6 | "node_modules/": true 7 | } 8 | } -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - Replace simditor with a better supported richtext editor 4 | - Better error reporting 5 | - Delete files to trash 6 | - Implement data sources for dynamic phrases 7 | -------------------------------------------------------------------------------- /src/xpander_data/settings.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | phrase_dir=~/.phrases 3 | light_theme=False 4 | use_tab=False 5 | keep_trig=True 6 | 7 | [HOTKEY] 8 | pause="[\"KEY_SPACE\",[\"KEY_SHIFT\",\"KEY_CTRL\"]]" 9 | manager="[\"KEY_M\",[\"KEY_SHIFT\",\"KEY_CTRL\"]]" 10 | -------------------------------------------------------------------------------- /src/xpander_data/examples/Fill form.json: -------------------------------------------------------------------------------- 1 | { 2 | "hotstring": "ffill", 3 | "triggers": [], 4 | "type": "plaintext", 5 | "body": "First name{{ key(\"TAB\") }}Last name{{ key(\"TAB\") }}Address{{ key(\"TAB\") }}Phone no.", 6 | "method": "paste", 7 | "wm_class": [], 8 | "wm_title": "", 9 | "hotkey": null 10 | } 11 | -------------------------------------------------------------------------------- /src/xpander_data/examples/Hyperlink from Clipboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "hotstring": "llink", 3 | "triggers": [ 4 | " ", 5 | "\n", 6 | "\t" 7 | ], 8 | "type": "plaintext", 9 | "body": "$|", 10 | "method": "paste", 11 | "wm_class": [], 12 | "wm_title": "", 13 | "hotkey": null 14 | } 15 | -------------------------------------------------------------------------------- /src/xpander_py/util.py: -------------------------------------------------------------------------------- 1 | from macpy import Window 2 | from .server import Server 3 | 4 | 5 | def listWindows(): 6 | windowList = [] 7 | for window in Window.list_windows(): 8 | windowList.append({'class': window.wm_class, 'title': window.title}) 9 | Server.send({'type': 'manager', 'action': 'listWindows', 'list': windowList}) 10 | -------------------------------------------------------------------------------- /src/static/styles/fillin.sass: -------------------------------------------------------------------------------- 1 | @import '~materialize-css/sass/materialize.scss' 2 | 3 | html 4 | height: 100% 5 | margin: 0 6 | 7 | body 8 | display: flex 9 | height: 100vh 10 | flex-flow: column nowrap 11 | 12 | main 13 | flex: 1 0 14 | height: 100% 15 | overflow: auto 16 | 17 | footer 18 | flex: 0 0 19 | 20 | .row .col 21 | float: none 22 | -------------------------------------------------------------------------------- /src/xpander_ts/about.ts: -------------------------------------------------------------------------------- 1 | import { shell } from "electron"; 2 | import $ from "jquery"; 3 | import M from "materialize-css"; 4 | 5 | 6 | $(document).ready(() => { 7 | $("#repo").on("click", function(event) { 8 | shell.openExternal("https://github.com/OzymandiasTheGreat/xpander"); 9 | }); 10 | $("#mail").on("click", function(event) { 11 | shell.openExternal("mailto:tomas.rav@gmail.com"); 12 | }); 13 | M.Modal.init(document.querySelectorAll(".modal")); 14 | }); 15 | -------------------------------------------------------------------------------- /src/xpander_data/examples/Emoji.json: -------------------------------------------------------------------------------- 1 | { 2 | "hotstring": ":)", 3 | "triggers": [ 4 | "\t", 5 | " ", 6 | "\n", 7 | ".", 8 | ",", 9 | "!", 10 | "?", 11 | ";", 12 | ":", 13 | "'", 14 | "\"", 15 | "(", 16 | ")", 17 | "[", 18 | "]", 19 | "{", 20 | "}", 21 | "<", 22 | ">", 23 | "-", 24 | "_", 25 | "+", 26 | "=", 27 | "/", 28 | "\\", 29 | "*", 30 | "`", 31 | "~" 32 | ], 33 | "type": "plaintext", 34 | "body": "☺", 35 | "method": "paste", 36 | "wm_class": [], 37 | "wm_title": "", 38 | "hotkey": null 39 | } 40 | -------------------------------------------------------------------------------- /src/xpander_data/examples/Thanks for order.json: -------------------------------------------------------------------------------- 1 | { 2 | "hotstring": "tthanks", 3 | "triggers": [ 4 | " ", 5 | "\n", 6 | "\t" 7 | ], 8 | "type": "richtext", 9 | "body": "Dear {{ fillentry(name=\"name\", default=\"Oz\") }},

We have received your order of {{ fillentry(name=\"amount\", default=5) }} widgets,
they should ship on {{ time(1, week, format=\"%Y-%m-%d\") }}.

{{ filloptional(\"You have not included payment information with your order.\") }}

Thank you for ordering from Acme Corporation!", 10 | "method": "paste", 11 | "wm_class": [], 12 | "wm_title": "", 13 | "hotkey": null 14 | } 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "xpander" 3 | version = "3.0.0rc0" 4 | description = "Text expander for Windows and Linux." 5 | authors = ["Ozymandias "] 6 | license = "MIT" 7 | repository = "https://github.com/OzymandiasTheGreat/xpander" 8 | packages = [{ include = "src/xpander_py" }, { include = "src/xpander_data" }] 9 | include = ["src/xpander_py/data/**/*"] 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.6" 13 | appdirs = "^1.4" 14 | macpy = "^0.1.2" 15 | klembord = "^0.2.1" 16 | Jinja2 = "^2.11.1" 17 | MarkupSafe = "^1.1.1" 18 | importlib_resources = {version = "^1.0.2", markers = "python_version < '3.7'"} 19 | 20 | [tool.poetry.dev-dependencies] 21 | shiv = "^0.1.0" 22 | wheel = "^0.34.2" 23 | 24 | [build-system] 25 | requires = ["poetry>=0.12"] 26 | build-backend = "poetry.masonry.api" 27 | -------------------------------------------------------------------------------- /src/xpander_py/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import json 5 | 6 | 7 | class Server(object): 8 | listeners = {} 9 | 10 | @classmethod 11 | def start(cls): 12 | while True: 13 | msg = sys.stdin.readline() 14 | msg = json.loads(msg) if msg else {'type': 'none'} 15 | cls.callback(msg) 16 | 17 | @classmethod 18 | def callback(cls, msg): 19 | if msg['type'] in cls.listeners: 20 | for listener in cls.listeners[msg['type']]: 21 | listener(msg) 22 | else: 23 | cls.sendError({'type': 'unknownMessage', 'message': msg['type']}) 24 | 25 | @classmethod 26 | def listen(cls, msgType, callback): 27 | if msgType in cls.listeners: 28 | cls.listeners[msgType].append(callback) 29 | else: 30 | cls.listeners[msgType] = [callback] 31 | 32 | @classmethod 33 | def send(cls, msg): 34 | print(json.dumps(msg)) 35 | 36 | @classmethod 37 | def sendError(cls, msg): 38 | print(json.dumps(msg), file=sys.stderr) 39 | -------------------------------------------------------------------------------- /src/static/fillin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Fillin 14 | 15 | 16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ozymandias (Tomas Ravinskas) 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 | -------------------------------------------------------------------------------- /src/xpander_ts/keymap.json: -------------------------------------------------------------------------------- 1 | { 2 | "BracketLeft": "LEFTBRACE", 3 | "BracketRight": "RIGHTBRACE", 4 | "Digit0": "0", 5 | "Digit1": "1", 6 | "Digit2": "2", 7 | "Digit3": "3", 8 | "Digit4": "4", 9 | "Digit5": "5", 10 | "Digit6": "6", 11 | "Digit7": "7", 12 | "Digit8": "8", 13 | "Digit9": "9", 14 | "Escape": "ESC", 15 | "ControlLeft": "CTRL", 16 | "ControlRight": "CTRL", 17 | "AltLeft": "ALT", 18 | "AltRight": "ALT", 19 | "ShiftLeft": "SHIFT", 20 | "ShiftRight": "SHIFT", 21 | "MetaLeft": "META", 22 | "MetaRight": "META", 23 | "OSLeft": "META", 24 | "OSRight": "META", 25 | "Quote": "APOSTROPHE", 26 | "Backquote": "GRAVE", 27 | "Period": "DOT", 28 | "NumpadMultiply": "KPASTERISK", 29 | "NumpadSubtract": "KPMINUS", 30 | "NumpadAdd": "KPPLUS", 31 | "NumpadDecimal": "KPDOT", 32 | "NumpadDivide": "KPSLASH", 33 | "ArrowLeft": "LEFT", 34 | "ArrowRight": "RIGHT", 35 | "ArrowUp": "UP", 36 | "ArrowDown": "DOWN", 37 | "Numpad7": "KP7", 38 | "Numpad8": "KP8", 39 | "Numpad9": "KP9", 40 | "Numpad4": "KP4", 41 | "Numpad5": "KP5", 42 | "Numpad6": "KP6", 43 | "Numpad1": "KP1", 44 | "Numpad2": "KP2", 45 | "Numpad3": "KP3", 46 | "Numpad0": "KP0" 47 | } 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from setuptools import setup 3 | 4 | 5 | scripts = ['src/xpander.py'] 6 | 7 | packages = ['xpander_py', 'xpander_data', 'xpander_data.examples'] 8 | 9 | package_dir = { 10 | 'xpander_py': 'src/xpander_py', 11 | 'xpander_data': 'src/xpander_data', 12 | } 13 | 14 | package_data = {'': ['*']} 15 | 16 | install_requires = [ 17 | 'appdirs>=1.4,<2.0', 18 | 'klembord>=0.2.1,<0.3.0', 19 | 'macpy>=0.1.2,<0.2.0', 20 | 'Jinja2>=2.11.1,<3.0.0', 21 | 'MarkupSafe>=1.1.1,<2.0.0', 22 | ] 23 | 24 | extras_require = { 25 | ':python_version < "3.7"': ['importlib_resources>=1.0.2,<2.0.0'] 26 | } 27 | 28 | setup_kwargs = { 29 | 'name': 'xpander', 30 | 'version': '3.0.0rc0', 31 | 'description': 'Text expander for Windows and Linux.', 32 | 'long_description': None, 33 | 'author': 'Ozymandias', 34 | 'author_email': 'tomas.rav@gmail.com', 35 | 'url': 'https://github.com/OzymandiasTheGreat/xpander', 36 | 'scripts': scripts, 37 | 'packages': packages, 38 | 'package_dir': package_dir, 39 | 'package_data': package_data, 40 | 'install_requires': install_requires, 41 | 'extras_require': extras_require, 42 | 'python_requires': '>=3.7,<4.0', 43 | } 44 | 45 | 46 | setup(**setup_kwargs) 47 | -------------------------------------------------------------------------------- /cloc.md: -------------------------------------------------------------------------------- 1 | ## Version 3.0.0rc0 2 | 3 | 45 text files. 4 | classified 45 files 5 | 45 unique files. 6 | 23 files ignored. 7 | 8 | github.com/AlDanial/cloc v 1.82 T=0.03 s (900.3 files/s, 83989.6 lines/s) 9 | ------------------------------------------------------------------------------- 10 | Language | files | blank | comment | code 11 | ---------------------------|--------------|------------|-----------------|----- 12 | Python | 8 | 116 | 25 | 785 13 | TypeScript | 5 | 74 | 2 | 758 14 | HTML | 3 | 9 | 0 | 255 15 | Sass | 3 | 25 | 29 | 104 16 | SVG | 5 | 0 | 0 | 57 17 | SUM: | 24 | 224 | 56 | 1959 18 | 19 | ## Version 2.0.0b0 20 | 21 | 33 text files. 22 | classified 33 files 23 | 33 unique files. 24 | 16 files ignored. 25 | 26 | github.com/AlDanial/cloc v 1.82 T=0.06 s (331.0 files/s, 111165.4 lines/s) 27 | ------------------------------------------------------------------------------- 28 | Language | files | blank | comment | code 29 | ---------------------------|--------------|------------|-----------------|----- 30 | Python | 14 | 643 | 71 | 4975 31 | SVG | 5 | 0 | 0 | 692 32 | SUM: | 19 | 643 | 71 | 5667 33 | -------------------------------------------------------------------------------- /src/xpander_py/phrase.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import Enum 3 | from traceback import format_exception 4 | from jinja2 import Template 5 | from jinja2.exceptions import TemplateError 6 | from macpy import Key 7 | 8 | 9 | class PhraseType(Enum): 10 | PLAINTEXT = 'plaintext' 11 | RICHTEXT = 'richtext' 12 | 13 | 14 | class PasteMethod(Enum): 15 | TYPE = 'type' 16 | PASTE = 'paste' 17 | ALTPASTE = 'altpaste' 18 | 19 | 20 | class Phrase(object): 21 | 22 | def __init__( 23 | self, hotstring, triggers, phrasetype, body, method, wm_class, wm_title, 24 | hotkey): 25 | super().__init__() 26 | self.name = None 27 | self.path = None 28 | self.events = () 29 | self.hotstring = hotstring 30 | self.triggers = tuple(triggers) 31 | self.type = PhraseType(phrasetype) 32 | self.body = body 33 | try: 34 | self.template = Template(body) 35 | except TemplateError as e: 36 | print(format_exception(e.__class__, e, e.__traceback__), file=sys.stderr) 37 | self.method = PasteMethod(method) 38 | self.wm_class = tuple(wm_class) 39 | self.wm_title = wm_title 40 | self.hotkey = None 41 | if hotkey is not None: 42 | self.hotkey = ( 43 | getattr(Key, hotkey[0]), 44 | tuple(getattr(Key, mod) for mod in hotkey[1]) 45 | ) 46 | 47 | def __hash__(self): 48 | return hash(self.name, self.path) 49 | 50 | def render(self, ctx): 51 | return self.template.render(ctx) 52 | 53 | 54 | def asPhrase(dct): 55 | return Phrase( 56 | dct['hotstring'], dct['triggers'], dct['type'], dct['body'], 57 | dct['method'], dct['wm_class'], dct['wm_title'], dct['hotkey'] 58 | ) 59 | 60 | 61 | def phraseToDict(phrase): 62 | return { 63 | 'hotstring': phrase.hotstring, 64 | 'triggers': phrase.triggers, 65 | 'type': phrase.type.value, 66 | 'body': phrase.body, 67 | 'method': phrase.method.value, 68 | 'wm_class': phrase.wm_class, 69 | 'wm_title': phrase.wm_title, 70 | 'hotkey': ( 71 | phrase.hotkey[0].name, tuple(mod.name for mod in phrase.hotkey[1]) 72 | ), 73 | } 74 | -------------------------------------------------------------------------------- /src/static/styles/manager.sass: -------------------------------------------------------------------------------- 1 | $material-design-icons-font-path: './fonts/' 2 | 3 | @import '~material-design-icons-iconfont/src/material-design-icons.scss' 4 | @import '~materialize-css/sass/materialize.scss' 5 | @import '~simditor/styles/simditor.scss' 6 | 7 | html, body 8 | height: 100% 9 | margin: 0 10 | 11 | #tabs 12 | border-bottom: 1px solid #bdbdbd 13 | box-sizing: border-box 14 | 15 | .tabs-content 16 | height: calc(100% - 48px) 17 | 18 | #manager 19 | margin: 0 20 | 21 | section 22 | height: calc(100% - 48px) 23 | 24 | button 25 | margin: 0 15px 26 | 27 | #sidebar 28 | border-right: 1px solid #bdbdbd 29 | height: 100%; 30 | * 31 | outline: none 32 | .jqtree_common.jqtree-tree 33 | [role="group"] 34 | padding-left: 25px 35 | .jqtree-element 36 | .renameBtn, .deleteBtn 37 | @extend .blue-grey-text, .text-lighten-3 38 | display: none 39 | i.material-icons 40 | font-size: 16px 41 | &:hover .renameBtn, &:hover .deleteBtn 42 | display: inline-block 43 | .jqtree_common.jqtree-selected 44 | background-color: #f5f5f5 45 | border-radius: 8px 46 | .jqtree_common.jqtree-toggler 47 | @extend .material-icons 48 | @extend .grey-text, .text-darken-4 49 | font-size: inherit 50 | line-height: 1 51 | vertical-align: middle 52 | .jqtree_common i.material-icons 53 | vertical-align: middle 54 | padding: 0 5px 55 | &.file-icon 56 | @extend .brown-text, .text-lighten-4 57 | &.folder-icon 58 | @extend .amber-text, .text-darken-3 59 | .jqtree_common.jqtree-title 60 | @extend .grey-text, .text-darken-4 61 | line-height: 1 62 | vertical-align: middle 63 | .fixed-action-btn 64 | right: unset 65 | left: 23px 66 | 67 | #editorToolbar 68 | height: 30px; 69 | line-height: 30px; 70 | padding: 0 16px; 71 | 72 | .simditor 73 | .simditor-toolbar 74 | height: 28px 75 | ul 76 | height: 28px 77 | li 78 | .toolbar-item 79 | line-height: 28px 80 | height: 28px 81 | width: 28px 82 | position: relative 83 | top: -1px 84 | 85 | span.separator 86 | margin: 5px 10px 87 | .simditor-body 88 | min-height: 120px 89 | max-height: 300px 90 | overflow: auto 91 | 92 | #phraseBody 93 | min-height: 150px 94 | max-height: 330px 95 | width: 100% 96 | font-size: 16px 97 | padding: 22px 15px 40px 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xpander", 3 | "version": "3.0.0rc1", 4 | "description": "Text expander for Windows and Linux.", 5 | "author": { 6 | "name": "Ozymandias (Tomas Ravinskas)", 7 | "email": "tomas.rav@gmail.com" 8 | }, 9 | "license": "MIT", 10 | "main": "src/xpander.js", 11 | "scripts": { 12 | "tsc": "tsc", 13 | "sass": "node-sass --importer=node_modules/node-sass-tilde-importer src/static/styles/ -o src/static/styles", 14 | "clean": "git clean -dxff", 15 | "build_py": "shiv -p '/usr/bin/env python3' -c xpander.py -o build/xpander.pyz . importlib_resources", 16 | "build": " yarn sass && yarn tsc", 17 | "start": "yarn build && electron --disable-gpu .", 18 | "dist": "yarn build && electron-builder -w nsis -l AppImage" 19 | }, 20 | "build": { 21 | "appId": "tk.ozymandias.xpander", 22 | "linux": { 23 | "icon": "build/icons/", 24 | "category": "Utility" 25 | }, 26 | "win": { 27 | "icon": "build/icon.ico" 28 | }, 29 | "files": [ 30 | "!.vscode", 31 | "!.venv", 32 | "!src/**/*.sass", 33 | "!src/**/*.ts", 34 | "!src/xpander.py", 35 | "!src/xpander_data/examples", 36 | "!src/xpander_data/__init__.py", 37 | "!src/xpander_py", 38 | "!**/xpander.egg-info", 39 | "!cloc.md", 40 | "!**/*requirements.txt", 41 | "!poetry.lock", 42 | "!pyproject.toml", 43 | "!setup.py", 44 | "!tsconfig.json", 45 | "!yarn.lock" 46 | ], 47 | "extraResources": [ 48 | { 49 | "from": "build/xpander.pyz", 50 | "to": "xpander.pyz" 51 | } 52 | ] 53 | }, 54 | "dependencies": { 55 | "csv-parser": "^2.3.2", 56 | "ini": "^1.3.5", 57 | "jqtree": "^1.4.12", 58 | "jquery": "^3.4.1", 59 | "material-design-icons-iconfont": "^5.0.1", 60 | "materialize-css": "^1.0.0-rc.2", 61 | "mkdirp": "^1.0.3", 62 | "python-shell": "^1.0.8", 63 | "readdirp": "^3.3.0", 64 | "rimraf": "^3.0.1", 65 | "simditor": "2.3.22", 66 | "strip-bom-stream": "^4.0.0", 67 | "xdg-app-paths": "^5.2.0" 68 | }, 69 | "devDependencies": { 70 | "@types/ini": "^1.3.30", 71 | "@types/jquery": "^3.3.31", 72 | "@types/materialize-css": "^1.0.7", 73 | "electron": "^7.1.9", 74 | "electron-builder": "^22.3.2", 75 | "node-sass": "^4.13.1", 76 | "node-sass-tilde-importer": "^1.0.2", 77 | "typescript": "^3.7.5" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/xpander.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import time 4 | from xpander_py.server import Server 5 | from xpander_py.fs import Settings, Manager 6 | from xpander_py.service import Service 7 | from xpander_py.util import listWindows 8 | from xpander_py.context import PHRASES 9 | if sys.platform.startswith('win32'): 10 | from macpy import WinWindow 11 | 12 | 13 | Settings.load() 14 | Settings.save() 15 | manager = Manager() 16 | manager.load() 17 | service = Service() 18 | 19 | 20 | def mainHandler(msg): 21 | if msg['action'] == 'exit': 22 | service.quit() 23 | elif msg['action'] == 'pause': 24 | service.togglePause(state=msg['state']) 25 | elif msg['action'] == 'focus' and sys.platform.startswith('win32'): 26 | time.sleep(0.1) 27 | WinWindow(int(msg['hwnd'])).activate() 28 | Server.send({'type': 'main', 'action': 'focus'}) 29 | 30 | 31 | def phraseHandler(msg): 32 | if msg['action'] == 'fillin': 33 | service.fillin(msg['phrase']) 34 | elif msg['action'] in {'edit', 'delete'}: 35 | if msg['path'] in manager.phrases: 36 | phrase = manager.phrases[msg['path']] 37 | del manager.phrases[msg['path']] 38 | if phrase.name in PHRASES: 39 | del PHRASES[phrase.name] 40 | service.unregisterPhrase(phrase) 41 | if msg['action'] == 'edit': 42 | phrase = manager.loadPhrase( 43 | msg['path'], 44 | Settings.getPath('phrase_dir').expanduser() 45 | ) 46 | manager.phrases[msg['path']] = phrase 47 | PHRASES[phrase.name] = phrase 48 | service.registerPhrase(phrase) 49 | elif msg['action'] == 'reload': 50 | for phrase in manager.phrases.values(): 51 | service.unregisterPhrase(phrase) 52 | if phrase.name in PHRASES: 53 | del PHRASES[phrase.name] 54 | manager.phrases = {} 55 | manager.load() 56 | for phrase in manager.phrases.values(): 57 | service.registerPhrase(phrase) 58 | 59 | 60 | def managerHandler(msg): 61 | if msg['action'] == 'listWindows': 62 | listWindows() 63 | 64 | 65 | def settingsHandler(msg): 66 | if msg['action'] == 'reload': 67 | Settings.load() 68 | service.unregisterHotkeys() 69 | service.registerHotkeys() 70 | phraseHandler({ 71 | 'type': 'phrase', 72 | 'action': 'reload', 73 | }) 74 | 75 | 76 | for filepath, phrase in manager.phrases.items(): 77 | service.registerPhrase(phrase) 78 | service.registerHotkeys() 79 | service.start() 80 | Server.listen('main', mainHandler) 81 | Server.listen('phrase', phraseHandler) 82 | Server.listen('manager', managerHandler) 83 | Server.listen('settings', settingsHandler) 84 | Server.start() 85 | -------------------------------------------------------------------------------- /src/xpander_ts/fillin.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import $ from "jquery"; 3 | import M from "materialize-css"; 4 | 5 | 6 | const fillinBody = $("#fillin-body"); 7 | const okButton = $("#ok"); 8 | const cancelButton = $("#cancel"); 9 | 10 | 11 | window.addEventListener("load", () => { 12 | setTimeout(() => { 13 | $(":input").filter(":first").focus(); 14 | }, 250); 15 | }); 16 | 17 | 18 | ipcRenderer.on("phrase", (event, msg) => { 19 | if (fillinBody) { 20 | fillinBody.html(msg.body); 21 | } 22 | M.FormSelect.init(document.querySelectorAll("select"), { dropdownOptions: { coverTrigger: false }}); 23 | M.updateTextFields(); 24 | $(":input").on("input", function() { 25 | if (this.tagName === "INPUT" && (this).type === "checkbox") { 26 | $(":input").filter(`[name="${(this).name}"]`).prop("checked", $(this).prop("checked")); 27 | } else if (this.tagName === "SELECT") { 28 | $(":input").filter(`[name="${(this).name}"]`).val($(this).val() || ''); 29 | M.FormSelect.init(document.querySelectorAll("select"), { dropdownOptions: { coverTrigger: false }}); 30 | } else { 31 | $(":input").filter(`[name="${(this).name}"]`).val($(this).val() || ''); 32 | M.updateTextFields(); 33 | } 34 | }); 35 | okButton.on("click", function(event) { 36 | $(":input").each(function() { 37 | let $this = $(this); 38 | if ($this.attr("type") === "checkbox") { 39 | if ($this.prop("checked")) { 40 | $this.parents(".xpander-fillin").replaceWith($this.val() || ""); 41 | } else { 42 | $this.parents(".xpander-fillin").replaceWith(""); 43 | } 44 | } else { 45 | $this.parents(".xpander-fillin").replaceWith($this.val() || ""); 46 | } 47 | }); 48 | ipcRenderer.send("phrase", { 49 | "type": "phrase", 50 | "action": "fillin", 51 | "phrase": { 52 | "body": fillinBody.html(), 53 | "method": msg.method, 54 | "trigger": msg.trigger, 55 | "richText": msg.richText, 56 | } 57 | }); 58 | window.close(); 59 | }); 60 | cancelButton.on("click", function(event) { 61 | window.close(); 62 | }); 63 | $(document).on("keyup", function(event) { 64 | if (event.key === "Enter") { 65 | if (this.activeElement?.tagName === "TEXTAREA" && event.ctrlKey) { 66 | okButton.click(); 67 | } else if (this.activeElement?.tagName !== "TEXTAREA") { 68 | okButton.click(); 69 | } 70 | } 71 | if (event.key === "Esc" || event.key === "Escape") { 72 | window.close(); 73 | } 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/static/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | About Xpander 13 | 14 | 15 |
16 |
17 |
18 |
19 | Xpander3 20 |

Xpander3
© 2020 Ozymandias (Tomas Ravinskas)

21 |

Released under MIT license

22 |
23 |
24 |
25 | 57 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /src/static/icons/xpander.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | X 16 | X 17 | 18 | -------------------------------------------------------------------------------- /src/static/icons/xpander-active-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/static/icons/xpander-active-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/static/icons/xpander-inactive-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/static/icons/xpander-inactive-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/xpander_py/fs.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | try: 4 | from importlib import resources 5 | except ImportError: 6 | import importlib_resources as resources 7 | from configparser import ConfigParser 8 | # from ast import literal_eval 9 | from traceback import format_tb 10 | from appdirs import user_config_dir 11 | from macpy import Key 12 | import xpander_data 13 | import xpander_data.examples 14 | from .phrase import asPhrase 15 | from .server import Server 16 | from .context import PHRASES 17 | 18 | 19 | try: 20 | PKG = json.loads(Path("package.json").read_text()) 21 | except FileNotFoundError: 22 | PKG = {'name': 'xpander'} 23 | 24 | 25 | class Settings(object): 26 | parser = ConfigParser() 27 | 28 | @classmethod 29 | def userConfig(cls): 30 | return Path(user_config_dir()) / PKG['name'] / 'settings.ini' 31 | 32 | @classmethod 33 | def getHotkey(cls, option): 34 | # key = literal_eval(cls.parser.get('HOTKEY', option)) 35 | key = json.loads(json.loads(cls.parser.get('HOTKEY', option))) 36 | if key: 37 | keyName, modNames = key 38 | mainKey = getattr(Key, keyName) 39 | mods = [] 40 | for modName in modNames: 41 | mods.append(getattr(Key, modName)) 42 | return mainKey, tuple(mods) 43 | else: 44 | return key 45 | 46 | @classmethod 47 | def setHotkey(cls, option, hotkey): 48 | if hotkey: 49 | keyName = hotkey[0].name 50 | modNames = [] 51 | for mod in hotkey[1]: 52 | modNames.append(mod.name) 53 | string = json.dumps((keyName, tuple(modNames))) 54 | cls.parser.set('HOTKEY', option, string) 55 | 56 | @classmethod 57 | def get(cls, option): 58 | return cls.parser.get('DEFAULT', option) 59 | 60 | @classmethod 61 | def set(cls, option, value): 62 | cls.parser.set('DEFAULT', option, str(value)) 63 | 64 | @classmethod 65 | def getBool(cls, option): 66 | return cls.parser.getboolean('DEFAULT', option) 67 | 68 | @classmethod 69 | def getPath(cls, option): 70 | return Path(cls.parser.get('DEFAULT', option)) 71 | 72 | @classmethod 73 | def load(cls): 74 | # with resources.path(xpander_data, 'settings.ini') as default: 75 | # return cls.parser.read((str(default), cls.userConfig())) 76 | return cls.parser.read([cls.userConfig()]) 77 | 78 | @classmethod 79 | def save(cls): 80 | with cls.userConfig().open(mode='w') as fd: 81 | cls.parser.write(fd) 82 | 83 | 84 | class Manager(object): 85 | 86 | def __init__(self): 87 | super().__init__() 88 | self.phrases = {} 89 | 90 | def load(self): 91 | root = Settings.getPath('phrase_dir').expanduser() 92 | if not root.exists(): 93 | examples = root / 'Examples' 94 | examples.mkdir(parents=True, exist_ok=True) 95 | for example in resources.contents(xpander_data.examples): 96 | if not example.startswith('__'): 97 | (examples / example).write_text( 98 | resources.read_text(xpander_data.examples, example) 99 | ) 100 | for filepath in root.glob('**/*.json'): 101 | phrase = self.loadPhrase(filepath, root) 102 | if phrase: 103 | self.phrases[str(filepath.resolve())] = phrase 104 | PHRASES[phrase.name] = phrase 105 | 106 | def loadPhrase(self, filepath, root): 107 | filepath = filepath if isinstance(filepath, Path) else Path(filepath) 108 | try: 109 | with filepath.open() as fd: 110 | phrase = json.loads(fd.read(), object_hook=asPhrase) 111 | phrase.name = filepath.stem 112 | phrase.path = filepath.expanduser().relative_to(root) 113 | return phrase 114 | except Exception as e: 115 | msg = { 116 | 'type': 'phraseLoad', 117 | 'message': 'Error loading phrase at {}'.format(filepath), 118 | 'error': repr(e), 119 | 'traceback': format_tb(e.__traceback__), 120 | } 121 | Server.sendError(msg) 122 | -------------------------------------------------------------------------------- /src/xpander_py/context.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from datetime import datetime, timedelta 3 | from subprocess import run as spawn, PIPE 4 | from shlex import split as shlexSplit 5 | from random import choice as randomChoice 6 | from string import ascii_lowercase 7 | from klembord import Selection 8 | 9 | 10 | PHRASES = {} 11 | Clipboard = Selection() 12 | if sys.platform.startswith('linux'): 13 | Primary = Selection('PRIMARY') 14 | 15 | 16 | _year = 'YEAR' 17 | _month = 'MONTH' 18 | _day = timedelta(days=1) 19 | _week = timedelta(days=7) 20 | _hour = timedelta(hours=1) 21 | _minute = timedelta(minutes=1) 22 | _second = timedelta(seconds=1) 23 | CONTEXT = { 24 | 'year': _year, 25 | 'month': _month, 26 | 'week': _week, 27 | 'day': _day, 28 | 'hour': _hour, 29 | 'minute': _minute, 30 | 'second': _second, 31 | } 32 | 33 | 34 | def genId(length=7): 35 | return ''.join(randomChoice(ascii_lowercase) for _ in range(length)) 36 | 37 | 38 | def timeFunc(period=0, unit=None, format='%Y-%m-%d'): 39 | now = datetime.now() 40 | if unit is None: 41 | return now.strftime(format) 42 | elif unit is _year: 43 | return now.replace(year=now.year + period).strftime(format) 44 | elif unit is _month: 45 | years = period // 12 46 | months = period % 12 47 | return now.replace(year=now.year + years, month=now.month + months) \ 48 | .strftime(format) 49 | else: 50 | return (now + (period * unit)).strftime(format) 51 | 52 | 53 | def run(command, dir=None, shell=False, stderr=False): 54 | proc = spawn( 55 | shlexSplit(command), 56 | cwd=dir, 57 | shell=shell, 58 | timeout=1, 59 | text=True, 60 | stdout=PIPE, 61 | stderr=PIPE, 62 | ) 63 | if stderr: 64 | return proc.stderr.strip() 65 | else: 66 | return proc.stdout.strip() 67 | 68 | 69 | def fillentry(name='', default='', width=15): 70 | htmlId = genId() 71 | template = """ 72 |
73 | 74 | 75 |
76 | """ 77 | return template.format(id=htmlId, name=name, value=default, width=width) 78 | 79 | 80 | def fillmulti(name='', default='', width=20, height=5): 81 | htmlId = genId() 82 | template = """ 83 |
84 | 85 | 86 |
87 | """ 88 | return template.format(id=htmlId, name=name, width=width, height=height, default=default) 89 | 90 | 91 | def fillchoice(*choices, name='', default=None): 92 | htmlId = genId() 93 | selectTemplate = """ 94 |
95 | 96 | 97 |
98 | """ 99 | optionTemplate = '' 100 | options = [] 101 | if default: 102 | options.append( 103 | ''.format(value=default) 104 | ) 105 | for choice in choices: 106 | options.append(optionTemplate.format(value=choice)) 107 | return selectTemplate.format(id=htmlId, name=name, options='\n'.join(options)) 108 | 109 | 110 | def filloptional(text, name=''): 111 | htmlId = genId() 112 | template = """ 113 |
114 | 118 |
119 | """ 120 | return template.format(name=name, value=text, id=htmlId) 121 | 122 | 123 | def key(key, state=None): 124 | template = "${{{{{key}{state}}}}}$" 125 | return template.format(key=key.upper(), state=':{state}'.format(state=state.upper()) if state else '') # noqa 126 | 127 | 128 | def clipboard(): 129 | text = Clipboard.get_text() 130 | return text.replace('\x00', '') if text else '' 131 | 132 | 133 | def primary(): 134 | return Primary.get_text() or '' 135 | 136 | 137 | def phrase(name): 138 | if name in PHRASES: 139 | return PHRASES[name].render(CONTEXT) 140 | return '' 141 | 142 | 143 | CONTEXT['time'] = timeFunc 144 | CONTEXT['run'] = run 145 | CONTEXT['fillentry'] = fillentry 146 | CONTEXT['fillmulti'] = fillmulti 147 | CONTEXT['fillchoice'] = fillchoice 148 | CONTEXT['filloptional'] = filloptional 149 | CONTEXT['key'] = key 150 | CONTEXT['clipboard'] = clipboard 151 | if sys.platform.startswith('linux'): 152 | CONTEXT['primary'] = primary 153 | CONTEXT['phrase'] = phrase 154 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.3 \ 2 | --hash=sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e \ 3 | --hash=sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92 4 | click==6.7 \ 5 | --hash=sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d \ 6 | --hash=sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b 7 | evdev==1.3.0; sys_platform == "linux" \ 8 | --hash=sha256:b1c649b4fed7252711011da235782b2c260b32e004058d62473471e5cd30634d 9 | importlib-resources==1.0.2; python_version < "3.7" \ 10 | --hash=sha256:6e2783b2538bd5a14678284a3962b0660c715e5a0f10243fd5e00a4b5974f50b \ 11 | --hash=sha256:d3279fd0f6f847cced9f7acc19bd3e5df54d34f93a2e7bb5f238f81545787078 12 | jinja2==2.11.1 \ 13 | --hash=sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49 \ 14 | --hash=sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250 15 | klembord==0.2.1 \ 16 | --hash=sha256:810eff42fae25a6f7a384df427268548099f7b263bac39af66117046d3c9f4d4 \ 17 | --hash=sha256:0785881aefa9c8ebcae548fbfea5be97ca554cf5f2876493e427a82ea9601de0 18 | macpy==0.1.2 \ 19 | --hash=sha256:6e39dd2c2cfb98bd98db5f408b906c1e4c2cc9bd876b1c806d9bab4d3097fad9 \ 20 | --hash=sha256:02bc984a1a93e137cdea9cf9b2cbbb20d86909b364c58398a61d15d55f3739fb 21 | markupsafe==1.1.1 \ 22 | --hash=sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161 \ 23 | --hash=sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7 \ 24 | --hash=sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183 \ 25 | --hash=sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b \ 26 | --hash=sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e \ 27 | --hash=sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f \ 28 | --hash=sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1 \ 29 | --hash=sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5 \ 30 | --hash=sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1 \ 31 | --hash=sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735 \ 32 | --hash=sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21 \ 33 | --hash=sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235 \ 34 | --hash=sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b \ 35 | --hash=sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f \ 36 | --hash=sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905 \ 37 | --hash=sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1 \ 38 | --hash=sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d \ 39 | --hash=sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff \ 40 | --hash=sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473 \ 41 | --hash=sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e \ 42 | --hash=sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66 \ 43 | --hash=sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5 \ 44 | --hash=sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d \ 45 | --hash=sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e \ 46 | --hash=sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6 \ 47 | --hash=sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2 \ 48 | --hash=sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c \ 49 | --hash=sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15 \ 50 | --hash=sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2 \ 51 | --hash=sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42 \ 52 | --hash=sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b \ 53 | --hash=sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be \ 54 | --hash=sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b 55 | python-libinput==0.1.0; sys_platform == "linux" \ 56 | --hash=sha256:e6b20e7fd889001fe74b01a1ff456e5f53c9ed01b016d6ceb65b6f573f129758 57 | python-xlib==0.26; sys_platform == "linux" \ 58 | --hash=sha256:b819c7e5f55830305919d78ea42b0cbd5e13687f5d12aa53556c33674b9876df \ 59 | --hash=sha256:244570b93cb82f5ceea3e4c861b4a0fffcea36947efa10fd63b7aa69d30047a8 60 | shiv==0.1.0 \ 61 | --hash=sha256:0aab6b68b31f605b583c37a4ce35dcbbd88822feab7f297fa3871bf2039d3f9c \ 62 | --hash=sha256:fa3f5509dd37e24b89caccf8cc7e266d0264454d8074f4c22a9320c000f8212f 63 | six==1.14.0; sys_platform == "linux" \ 64 | --hash=sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c \ 65 | --hash=sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a 66 | stopit==1.1.2; sys_platform == "linux" \ 67 | --hash=sha256:f7f39c583fd92027bd9d06127b259aee7a5b7945c1f1fa56263811e1e766996d 68 | wheel==0.34.2 \ 69 | --hash=sha256:df277cb51e61359aba502208d680f90c0493adec6f0e848af94948778aed386e \ 70 | --hash=sha256:8788e9155fe14f54164c1b9eb0a319d98ef02c160725587ad60f14ddc57b6f96 71 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | // "outDir": "./", /* Redirect output structure to the directory. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | "resolveJsonModule": true, 53 | 54 | /* Source Map Options */ 55 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 58 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 59 | 60 | /* Experimental Options */ 61 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 62 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 63 | 64 | /* Advanced Options */ 65 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/xpander_py/output.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import re 4 | from markupsafe import escape_silent 5 | from macpy import Window, Pointer 6 | from macpy import Key, KeyState, KeyboardEvent, PointerEventButton 7 | from macpy import PLATFORM, Platform 8 | from klembord import Selection 9 | from .phrase import PasteMethod 10 | if sys.platform.startswith('win32'): 11 | from ctypes import windll, c_void_p, c_uint, c_int, c_bool, POINTER, byref 12 | 13 | 14 | KEYMATCH = re.compile(r'\${{(?P[A-Z]+):?(?PUP|DOWN)?}}\$') 15 | KEYSPLIT = re.compile(r'(\${{[A-Z]+:?(?:UP|DOWN)?}}\$)') 16 | 17 | 18 | LOCKS = { 19 | 'NUMLOCK': False, 20 | 'CAPSLOCK': False, 21 | 'SCROLLLOCK': False, 22 | } 23 | MODS = { 24 | 'SHIFT': False, 25 | 'ALTGR': False, 26 | 'CTRL': False, 27 | 'ALT': False, 28 | 'META': False, 29 | } 30 | 31 | 32 | class Output(object): 33 | if sys.platform.startswith('win32'): 34 | windll.user32.GetForegroundWindow.argtypes = () 35 | windll.user32.GetForegroundWindow.restype = c_void_p 36 | windll.user32.PostMessageW.argtypes = (c_void_p, c_uint, c_int, c_uint) 37 | windll.user32.PostMessageW.restype = c_bool 38 | windll.user32.GetFocus.argtypes = () 39 | windll.user32.GetFocus.restype = c_void_p 40 | windll.kernel32.GetCurrentThreadId.argtypes = () 41 | windll.kernel32.GetCurrentThreadId.restype = c_uint 42 | windll.user32.AttachThreadInput.argtypes = (c_uint, c_uint, c_bool) 43 | windll.user32.AttachThreadInput.restype = c_bool 44 | windll.user32.GetWindowThreadProcessId.argtypes = (c_void_p, POINTER(c_uint)) 45 | windll.user32.GetWindowThreadProcessId.restype = c_uint 46 | 47 | def __init__(self, keyboard): 48 | super().__init__() 49 | self.clipboard = Selection() 50 | if sys.platform.startswith('linux'): 51 | self.primary = Selection('PRIMARY') 52 | self.keyboard = keyboard 53 | if PLATFORM is Platform.WAYLAND: 54 | self.pointer = Pointer() 55 | 56 | def paste(self, text, richText): 57 | # time.sleep(0.05) 58 | content = self.clipboard.get_with_rich_text() 59 | time.sleep(0.05) 60 | if richText: 61 | self.clipboard.set_with_rich_text(text, richText) 62 | else: 63 | # self.clipboard.set_text(text) 64 | self.clipboard.set_with_rich_text(text, str(escape_silent(text))) 65 | time.sleep(0.05) 66 | self.keyboard.keypress(Key.KEY_CTRL, state=KeyState.PRESSED) 67 | self.keyboard.keypress(Key.KEY_V) 68 | self.keyboard.keypress(Key.KEY_CTRL, state=KeyState.RELEASED) 69 | time.sleep(0.3) 70 | self.clipboard.set_with_rich_text(*(str(s) for s in content)) 71 | 72 | def altPaste(self, text, richText): 73 | if sys.platform.startswith('linux'): 74 | time.sleep(0.05) 75 | content = self.primary.get_with_rich_text() 76 | time.sleep(0.05) 77 | if richText: 78 | self.primary.set_with_rich_text(text, richText) 79 | else: 80 | self.primary.set_text(text) 81 | time.sleep(0.05) 82 | if PLATFORM is not Platform.WAYLAND: 83 | window = Window.get_active() 84 | x, y = window.size 85 | window.send_event(PointerEventButton( 86 | x // 2, 87 | y // 2, 88 | Key.BTN_MIDDLE, 89 | KeyState.PRESSED, 90 | MODS, 91 | )) 92 | window.send_event(PointerEventButton( 93 | x // 2, 94 | y // 2, 95 | Key.BTN_MIDDLE, 96 | KeyState.RELEASED, 97 | MODS, 98 | )) 99 | else: 100 | self.pointer.click(Key.BTN_MIDDLE) 101 | time.sleep(0.05) 102 | self.primary.set_with_rich_text(*content) 103 | else: 104 | content = self.clipboard.get_with_rich_text() 105 | if richText: 106 | self.clipboard.set_with_rich_text(text, richText) 107 | else: 108 | self.clipboard.set_text(text) 109 | wnd = windll.user32.GetForegroundWindow() 110 | tId = windll.user32.GetWindowThreadProcessId(wnd, byref(c_uint(0))) 111 | cId = windll.kernel32.GetCurrentThreadId() 112 | windll.user32.AttachThreadInput(cId, tId, True) 113 | hwnd = windll.user32.GetFocus() 114 | windll.user32.AttachThreadInput(cId, tId, False) 115 | # WM_PASTE 116 | windll.user32.PostMessageW(hwnd, 0x0302, 0, 0) 117 | time.sleep(0.1) 118 | self.clipboard.set_with_rich_text(*(str(s) for s in content)) 119 | 120 | def send(self, method, text, richText): 121 | def output(method, text, richText): 122 | if method is PasteMethod.TYPE: 123 | self.keyboard.type(text) 124 | elif method is PasteMethod.PASTE: 125 | self.paste(text, richText) 126 | else: 127 | self.altPaste(text, richText) 128 | 129 | if KEYSPLIT.search(text): 130 | textList = KEYSPLIT.split(text) 131 | richTextList = KEYSPLIT.split(richText) \ 132 | if richText else KEYSPLIT.split(text) 133 | for fragment in zip(textList, richTextList): 134 | match = KEYMATCH.match(fragment[0]) 135 | if match: 136 | try: 137 | key = getattr(Key, 'KEY_{}'.format(match.group('key'))) 138 | except AttributeError: 139 | break 140 | state = None 141 | if match.group('state'): 142 | state = KeyState.PRESSED \ 143 | if match.group('state') == 'DOWN' \ 144 | else KeyState.RELEASED 145 | self.keyboard.keypress(key, state) 146 | if sys.platform.startswith('linux'): 147 | time.sleep(0.01) 148 | else: 149 | output(method, fragment[0], fragment[1]) 150 | time.sleep(0.01) 151 | else: 152 | output(method, text, richText) 153 | 154 | def backspace(self, amount): 155 | for i in range(amount): 156 | self.keyboard.keypress(Key.KEY_BACKSPACE) 157 | time.sleep(0.05) 158 | 159 | def backward(self, amount): 160 | for i in range(amount): 161 | self.keyboard.keypress(Key.KEY_LEFT) 162 | time.sleep(0.05) 163 | 164 | def forward(self, amount): 165 | for i in range(amount): 166 | self.keyboard.keypress(Key.KEY_RIGHT) 167 | time.sleep(0.05) 168 | 169 | def tab(self): 170 | # if PLATFORM is Platform.X11: 171 | window = Window.get_active() 172 | window.send_event(KeyboardEvent( 173 | Key.KEY_TAB, 174 | KeyState.PRESSED, 175 | None, 176 | MODS, 177 | LOCKS, 178 | )) 179 | window.send_event(KeyboardEvent( 180 | Key.KEY_TAB, 181 | KeyState.RELEASED, 182 | None, 183 | MODS, 184 | LOCKS, 185 | )) 186 | # elif PLATFORM is Platform.WINDOWS: 187 | # self.keyboard.keypress(Key.KEY_TAB) 188 | # self.keyboard.type('\t') 189 | 190 | def quit(self): 191 | if PLATFORM is Platform.WAYLAND: 192 | self.pointer.close() 193 | -------------------------------------------------------------------------------- /src/static/manager.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Xpander 15 | 16 | 17 | 21 |
22 | 41 | 42 |
43 |
44 |
45 | 51 |
52 |
53 |
54 | 55 |
56 |
57 |
58 | 59 | 60 |
61 |
62 | 63 | 64 |
65 |
66 |
67 |
68 | 69 | 70 | Enter \t for Tab and \n for Enter 71 |
72 |
73 | 78 | 79 |
80 |
81 |
82 |
83 | 84 | 85 |
86 |
87 | 88 | 89 |
90 |
91 |
92 | 93 | 94 |
95 |
96 | 109 | 119 |
120 |
121 |
122 |
123 |
124 |
125 | folder 126 | 127 | 128 |
129 |
130 |
131 | 135 |
136 |
137 | 141 |
142 |
143 |
144 | 148 | 149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 | 157 | 158 |
159 |
160 |
161 |
162 | 163 | 164 |
165 |
166 |
167 | 168 | 169 |
170 |
171 |
172 |
173 | 174 | 175 | -------------------------------------------------------------------------------- /src/xpander.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import * as os from "os"; 4 | import { app, ipcMain, BrowserWindow, Menu, MenuItemConstructorOptions, Tray, dialog } from "electron"; 5 | import { PythonShell } from "python-shell"; 6 | import * as ini from "ini"; 7 | import mkdirp from "mkdirp"; 8 | import Xdg from "xdg-app-paths"; 9 | import * as PKG from "../package.json"; 10 | 11 | 12 | const xdg = Xdg({ name: PKG.name, suffix: "", isolated: true }); 13 | let Settings = loadSettings(); 14 | const SHELL = new PythonShell( 15 | app.isPackaged ? path.resolve(process.resourcesPath, "xpander.pyz") : "src/xpander.py", 16 | { 17 | mode: "json", 18 | pythonPath: process.platform === 'linux' ? "python3" : "python", 19 | pythonOptions: ["-u"], 20 | stderrParser: (line) => JSON.stringify(line), 21 | env: { SHIV_ROOT: xdg.cache(), PYTHONUTF8: "1", ...process.env }, 22 | }); 23 | const iconExt = process.platform === "linux" ? "png" : "ico"; 24 | const icon16 = process.platform === "linux" ? ".16x16" : ""; 25 | const icon48 = process.platform === "linux" ? ".48x48" : ""; 26 | let TRAY_MENU: Menu | null; 27 | let TRAY: Tray | null; 28 | let FILLIN_WINDOW: BrowserWindow | null; 29 | let MANAGER_WINDOW: BrowserWindow | null; 30 | let ABOUT_WINDOW: BrowserWindow | null; 31 | let PAUSE: boolean = false; 32 | 33 | 34 | function loadSettings() { 35 | const config = process.platform === 'linux' ? xdg.config() : path.join(os.homedir(), "AppData/Local/", PKG.name); 36 | let settings; 37 | try { 38 | settings = ini.parse(fs.readFileSync(path.join(config, 'settings.ini'), 'utf-8')); 39 | } catch(err) { 40 | settings = ini.parse(fs.readFileSync(path.resolve(__dirname, "./xpander_data/settings.ini"), "utf-8")); 41 | mkdirp.sync(config); 42 | fs.writeFileSync(path.join(config, "settings.ini"), ini.stringify(settings), "utf-8"); 43 | } 44 | return settings; 45 | } 46 | 47 | 48 | function createTray() { 49 | let template: MenuItemConstructorOptions[] = [ 50 | { id: "pause", label: "Pause expansion", type: "checkbox", checked: false, click: () => { 51 | SHELL.send({ "type": "main", "action": "pause", "state": null }) 52 | } }, 53 | { id: "manager", label: "Manager", click: () => { 54 | managerWindow(); 55 | } }, 56 | { id: "about", label: "About", click: () => { 57 | aboutWindow(); 58 | } }, 59 | { id: "quit", label: "Quit", click: () => { 60 | SHELL.send({ "type": "main", "action": "exit" }); 61 | SHELL.end((err, code, signal) => { console.log(err, code, signal); }); 62 | setTimeout(() => { 63 | SHELL.terminate(); 64 | app.quit(); 65 | }, 750); 66 | } }, 67 | ]; 68 | let theme = Settings.DEFAULT.light_theme === "True" ? "light" : "dark"; 69 | let icon = path.resolve(__dirname, `./static/icons/xpander-${PAUSE ? "inactive" : "active"}-${theme}${icon16}.${iconExt}`); 70 | TRAY_MENU = Menu.buildFromTemplate(template); 71 | 72 | TRAY = new Tray(icon); 73 | TRAY.setContextMenu(TRAY_MENU); 74 | } 75 | 76 | 77 | function fillinWindow() { 78 | const window = new BrowserWindow({ 79 | width: 550, 80 | height: 400, 81 | webPreferences: { 82 | nodeIntegration: true, 83 | }, 84 | resizable: false, 85 | alwaysOnTop: true, 86 | fullscreenable: false, 87 | // skipTaskbar: true, 88 | icon: path.resolve(__dirname, `./static/icons/xpander${icon48}.${iconExt}`), 89 | }); 90 | window.removeMenu(); 91 | window.once("show", () => { 92 | SHELL.send({ "type": "main", "action": "focus", "hwnd": window.getNativeWindowHandle() }); 93 | }); 94 | return window; 95 | } 96 | 97 | 98 | function managerWindow() { 99 | if (!MANAGER_WINDOW) { 100 | MANAGER_WINDOW = new BrowserWindow({ 101 | width: 1000, 102 | height: 600, 103 | webPreferences: { 104 | nodeIntegration: true, 105 | }, 106 | icon: path.resolve(__dirname, `./static/icons/xpander${icon48}.${iconExt}`), 107 | }); 108 | // ( MANAGER_WINDOW).openDevTools(); 109 | MANAGER_WINDOW.removeMenu(); 110 | MANAGER_WINDOW.loadFile(path.resolve(__dirname, "./static/manager.html")); 111 | MANAGER_WINDOW.once("closed", () => MANAGER_WINDOW = null); 112 | } 113 | return MANAGER_WINDOW; 114 | } 115 | 116 | 117 | function aboutWindow() { 118 | if (!ABOUT_WINDOW) { 119 | ABOUT_WINDOW = new BrowserWindow({ 120 | width: 500, 121 | height: 350, 122 | webPreferences: { 123 | nodeIntegration: true, 124 | }, 125 | resizable: false, 126 | fullscreenable: false, 127 | skipTaskbar: true, 128 | icon: path.resolve(__dirname, `./static/icons/xpander${icon48}.${iconExt}`), 129 | }); 130 | ABOUT_WINDOW.removeMenu(); 131 | ABOUT_WINDOW.loadFile(path.resolve(__dirname, "./static/about.html")); 132 | ABOUT_WINDOW.once("closed", () => ABOUT_WINDOW = null); 133 | } 134 | return ABOUT_WINDOW; 135 | } 136 | 137 | 138 | app.on("window-all-closed", ev => ev.preventDefault()); 139 | app.on("ready", createTray); 140 | 141 | 142 | SHELL.on("message", (msg) => { 143 | if (msg.type === "phrase") { 144 | if (msg.action === "fillin") { 145 | FILLIN_WINDOW= fillinWindow(); 146 | FILLIN_WINDOW.loadFile(path.resolve(__dirname,"./static/fillin.html")).then(() => { 147 | FILLIN_WINDOW?.webContents.send("phrase", msg); 148 | }); 149 | } 150 | } else if (msg.type === "main") { 151 | if (msg.action === "pause") { 152 | if (TRAY_MENU) { 153 | TRAY_MENU.getMenuItemById('pause').checked = msg.state; 154 | if (msg.state) { 155 | PAUSE = true; 156 | } else { 157 | PAUSE = false; 158 | } 159 | let theme = Settings.DEFAULT.light_theme === "True" ? "light" : "dark"; 160 | let icon = path.resolve(__dirname, `./static/icons/xpander-${PAUSE ? "inactive" : "active"}-${theme}${icon16}.${iconExt}`); 161 | TRAY?.setImage(icon); 162 | } 163 | } else if (msg.action === "focus") { 164 | if (FILLIN_WINDOW) { 165 | setTimeout(() => { 166 | FILLIN_WINDOW?.focus(); 167 | }, 250); 168 | } 169 | } 170 | } else if (msg.type === "manager") { 171 | if (msg.action === "show") { 172 | MANAGER_WINDOW = managerWindow(); 173 | } else if (msg.action === 'listWindows') { 174 | MANAGER_WINDOW?.webContents.send("manager", msg); 175 | } 176 | } 177 | }); 178 | SHELL.on("stderr", (err) => console.log(err)); 179 | 180 | 181 | ipcMain.on("phrase", (event, msg) => { 182 | SHELL.send(msg); 183 | }); 184 | ipcMain.on("manager", (event, msg) => { 185 | SHELL.send(msg); 186 | }); 187 | ipcMain.on("settings", (event, msg) => { 188 | SHELL.send(msg); 189 | if (msg.action === "reload") { 190 | Settings = loadSettings(); 191 | let theme = Settings.DEFAULT.light_theme === "True" ? "light" : "dark"; 192 | let icon = path.resolve(__dirname, `./static/icons/xpander-${PAUSE ? "inactive" : "active"}-${theme}${icon16}.${iconExt}`); 193 | TRAY?.setImage(icon); 194 | } 195 | }); 196 | -------------------------------------------------------------------------------- /src/xpander_py/service.py: -------------------------------------------------------------------------------- 1 | import time 2 | from threading import Thread 3 | from queue import Queue 4 | from macpy import Keyboard, HotString, Key, Platform, PLATFORM, Window 5 | from markupsafe import Markup 6 | from .server import Server 7 | from .fs import Settings 8 | from .output import Output 9 | from .phrase import PhraseType, PasteMethod 10 | from .context import CONTEXT 11 | 12 | 13 | class Service(Thread): 14 | 15 | def __init__(self): 16 | super().__init__(name='xpander service', daemon=True) 17 | self.pause = False 18 | self.phrases = {} 19 | self.queue = Queue() 20 | self.keyboard = Keyboard() 21 | self.output = Output(self.keyboard) 22 | self.tabKey = None 23 | self.pauseKey = None 24 | self.managerKey = None 25 | self.tabPos = [] 26 | 27 | self.keyboard.install_keyboard_hook(lambda event: None) 28 | self.keyboard.init_hotkeys() 29 | 30 | def registerPhrase(self, phrase): 31 | if phrase.hotstring: 32 | triggers = phrase.triggers 33 | hotstring = self.keyboard.register_hotstring( 34 | phrase.hotstring, triggers, self.callback 35 | ) 36 | self.phrases[hotstring] = phrase 37 | if phrase.hotkey: 38 | hotkey = self.keyboard.register_hotkey( 39 | phrase.hotkey[0], phrase.hotkey[1], self.callback 40 | ) 41 | self.phrases[hotkey] = phrase 42 | if phrase.hotstring and phrase.hotkey: 43 | phrase.events = (hotstring, hotkey) 44 | elif phrase.hotstring: 45 | phrase.events = (hotstring, ) 46 | elif phrase.hotkey: 47 | phrase.events = (hotkey, ) 48 | 49 | def unregisterPhrase(self, phrase): 50 | for event in phrase.events: 51 | if (event) in self.phrases: 52 | del self.phrases[event] 53 | if isinstance(event, HotString): 54 | self.keyboard.unregister_hotstring(event) 55 | else: 56 | self.keyboard.unregister_hotkey(event) 57 | phrase.events = () 58 | 59 | def registerHotkeys(self): 60 | if Settings.getBool('use_tab') and PLATFORM is not Platform.WAYLAND: 61 | self.tabKey = self.keyboard.register_hotkey( 62 | Key.KEY_TAB, (), self.callback 63 | ) 64 | pauseKey = Settings.getHotkey('pause') 65 | if pauseKey: 66 | self.pauseKey = self.keyboard.register_hotkey( 67 | *pauseKey, self.callback 68 | ) 69 | managerKey = Settings.getHotkey('manager') 70 | if managerKey: 71 | self.managerKey = self.keyboard.register_hotkey( 72 | *managerKey, self.callback 73 | ) 74 | 75 | def unregisterHotkeys(self): 76 | if self.tabKey: 77 | self.keyboard.unregister_hotkey(self.tabKey) 78 | if self.pauseKey: 79 | self.keyboard.unregister_hotkey(self.pauseKey) 80 | if self.managerKey: 81 | self.keyboard.unregister_hotkey(self.managerKey) 82 | 83 | def callback(self, event): 84 | try: 85 | phrase = self.phrases[event] 86 | self.enqueue(phrase, event) 87 | except KeyError: 88 | if event == self.tabKey and PLATFORM is not Platform.WAYLAND: 89 | if self.tabPos: 90 | self.output.forward(self.tabPos.pop()) 91 | else: 92 | self.output.tab() 93 | elif event == self.pauseKey: 94 | self.togglePause() 95 | Server.send({ 96 | 'type': 'main', 97 | 'action': 'pause', 98 | 'state': self.pause, 99 | }) 100 | elif event == self.managerKey: 101 | Server.send({ 102 | 'type': 'manager', 103 | 'action': 'show', 104 | }) 105 | else: 106 | Server.sendError({ 107 | 'type': 'event', 108 | 'message': 'Unrecognized event. Shouldn\'t happen!', 109 | 'event': str(event), 110 | }) 111 | 112 | def enqueue(self, phrase, event): 113 | self.queue.put_nowait((phrase, event)) 114 | 115 | def run(self): 116 | 117 | while True: 118 | phrase, event = self.queue.get() 119 | if phrase is None: 120 | break 121 | if ( 122 | not self.pause 123 | and self.filterWindows(phrase.wm_class, phrase.wm_title) 124 | ): 125 | if phrase.hotstring: 126 | self.output.backspace( 127 | len(phrase.hotstring) 128 | + (1 if getattr(event, 'trigger', '') else 0) 129 | ) 130 | body = phrase.render(CONTEXT) 131 | keepTrig = Settings.getBool('keep_trig') 132 | if (keepTrig and getattr(event, 'trigger', '')): 133 | trigger = getattr(event, 'trigger', '') 134 | else: 135 | trigger = '' 136 | if '$+' in body and getattr(event, 'trigger', ''): 137 | body = body.replace('$+', '') 138 | trigger = getattr(event, 'trigger', '') 139 | elif '$-' in body: 140 | body = body.replace('$-', '') 141 | trigger = '' 142 | if 'class="xpander-fillin"' in body: 143 | Server.send({ 144 | 'type': 'phrase', 145 | 'action': 'fillin', 146 | 'body': body, 147 | 'method': phrase.method.value, 148 | 'trigger': trigger, 149 | 'richText': True if phrase.type is PhraseType.RICHTEXT else False, # noqa 150 | }) 151 | else: 152 | if phrase.type is PhraseType.RICHTEXT: 153 | richText = body 154 | body = Markup(body).striptags() 155 | else: 156 | richText = None 157 | if '$|' in body: 158 | body, richText = self.getTabStops(body, richText) 159 | self.output.send( 160 | phrase.method, 161 | body + trigger, 162 | (richText + trigger) if richText else None, 163 | ) 164 | if self.tabPos: 165 | self.output.backward( 166 | len(body + trigger) - self.tabPos.pop() 167 | ) 168 | if not Settings.getBool('use_tab'): 169 | self.tabPos.clear() 170 | 171 | def fillin(self, phrase): 172 | time.sleep(0.05) 173 | if phrase['richText']: 174 | richText = phrase['body'] 175 | plainText = Markup(phrase['body']).striptags() 176 | else: 177 | richText = None 178 | plainText = phrase['body'] 179 | self.output.send( 180 | PasteMethod(phrase['method']), 181 | plainText + phrase['trigger'], 182 | (richText + phrase['trigger']) if richText else None, 183 | ) 184 | if self.tabPos: 185 | self.output.backward( 186 | len(plainText + phrase['trigger']) - self.tabPos.pop() 187 | ) 188 | 189 | def getTabStops(self, plainText, richText): 190 | self.tabPos = [] 191 | richText = richText.replace('$|', '') if richText else None 192 | for i in range(plainText.count('$|')): 193 | self.tabPos.append(plainText.index('$|')) 194 | plainText = plainText.replace('$|', '', 1) 195 | self.tabPos.reverse() 196 | return plainText, richText 197 | 198 | def filterWindows(self, wm_class, wm_title): 199 | if PLATFORM is Platform.WAYLAND: 200 | return True 201 | expand = False 202 | window = Window.get_active() 203 | if window.wm_class in wm_class or not wm_class: 204 | expand = True 205 | if wm_title: 206 | if wm_title in window.title: 207 | expand = True 208 | else: 209 | expand = False 210 | return expand 211 | 212 | def quit(self): 213 | self.keyboard.close() 214 | self.output.quit() 215 | 216 | def togglePause(self, state=None): 217 | if state is not None: 218 | self.pause = state 219 | Server.send({'type': 'main', 'action': 'pause', 'state': state}) 220 | else: 221 | self.pause = not self.pause 222 | Server.send({'type': 'main', 'action': 'pause', 'state': self.pause}) 223 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Xpander 2 | 3 | #### ARCHIVED 4 | 5 | I haven't touched this project in a very long while and I no longer use it myself, 6 | therefore I decided to archive this repository. 7 | 8 | ---------------------------------------------------------------------------------- 9 | 10 | 11 | ![Fill-in-the-blank](screenshots/fillin.gif) 12 | 13 | A [Text Expander](https://textexpander.com/) inspired text expander and text macro appication for Windows and Linux. 14 | 15 | ![Insert from clipboard](screenshots/clipboard.gif) 16 | ![Manager window](screenshots/manager.png) 17 | ![Settings tab](screenshots/settings.png) 18 | ![About window](screenshots/about.png) 19 | 20 | ## About 21 | 22 | ------------------- 23 | 24 | If you find this software useful, [consider becoming a patron](https://www.patreon.com/ozymandias) 25 | 26 | ------------------- 27 | 28 | Xpander is written python 3 and typescript. It runs on Windows and Linux, with possibility to port to more platforms. 29 | 30 | You type an abbreviation and it's automatically expanded to (replaced with) a predefined block of text called a phrase. 31 | Both plain text and richtext with formating are supported. 32 | This is very useful when filling out forms, typing emails, coding or doing creative (yet repetetive) writing. Or any kind of writing, really. 33 | 34 | Each phrase is stored as specially formatted JSON file. 35 | By default they a placed in `~/.phrases` but you can change it in 36 | settings. 37 | Since these are small text files and Xpander is cross platform you can easily sync these between computers. 38 | 39 | ## Bugs 40 | 41 | Of course there are bugs! Nothing major, just something to keep in mind while I 42 | iron them out. 43 | 44 | ### Firefox 45 | 46 | Firefox web browser has a ridiculously slow event loop, so when programatically 47 | (like, *really* quickly) sending events, some get "swallowed" or dropped. 48 | 49 | What this means in practice: 50 | 51 | - When expanding a phrase sometimes one or two of the abbreviation characters get left behind. 52 | 53 | ### Windows 54 | 55 | - Enabling caret cycling with `Tab` key in settings disables `Tab` key in most native applications. 56 | This happens because sending events directly to active window is wonky on windows. 57 | Usually you have to find active control and send the event directly to it instead. 58 | - Fill-in-the-blank window gets pushed to the background on first showing requiring to `alt-Tab` to it. 59 | This shouldn't happen when popping up a second, third, ... time. 60 | 61 | ## Features 62 | 63 | Unlike some text expanders (I'm looking at you, [Autokey](https://github.com/autokey/autokey)), Xpander fully supports multiple keyboard layouts, meaning that when you change keyboard language 64 | you won't accidentally trigger phrases. 65 | 66 | Other features include: 67 | 68 | - Rich text support with formatting. If using richtext phrase in plaintext editor, Xpander automatically removes formatting to prevent artefacts. 69 | - Templating macros. Xpander uses [Jinja2](https://jinja.palletsprojects.com/) templating engine. Combined with other features this makes for a powerful and robust way to define phrases. 70 | - Fill-in-the-blank fields in phrases. You can add information to your phrases at the time of expansion, with multiple field types, default values for when you don't need to change anything and linking of named fields (more below). 71 | - Phrase embedding. You can use an already defined phrase as part of one or more bigger phrases. 72 | - Clipboard (and mouse selection) embedding in phrases. 73 | - Caret (cursor) repositioning after expansion. You can also define multiple caret insertion points and cycle between them with the tab key. Or if it causes issues with some applications, disable this feature in settings. 74 | - Date and time formatting and math. With `time` function you can insert formatted dates, of current time, future or past. 75 | - Command running. Run an application or a shell script and use it's output in a phrase. 76 | - Key macros. With the `key` function you can send key presses and combinations to applications. 77 | - Window filters. Define a filter by window class or title and the phrase will only expand in matching windows. 78 | - And more! 79 | 80 | ## Installation 81 | 82 | Xpander only needs system installation of python 3.6+ to run. 83 | You can get it at [python.org/](https://www.python.org/). 84 | 85 | Prebuilt packages of Xpander are available on [releases page](https://github.com/OzymandiasTheGreat/xpander/releases). 86 | 87 | ## Phrase syntax, functions and tokens 88 | 89 | Phrase can be any text or in case of rich text any valid html. 90 | Defining html directly can be done, but is currently not very well supported. You should use the build-in rich text editor. 91 | 92 | No special syntaxt is necessary, how ever if you want advanced functionallity here's the docs! 93 | 94 | ### Syntax 95 | 96 | Since Xpander uses [Jinja2](https://jinja.palletsprojects.com/) so you can get *very* advanced with phrase definitions. 97 | For full description of Jinja2 syntax go [here](https://jinja.palletsprojects.com/en/2.11.x/templates/). 98 | 99 | #### Functions 100 | 101 | Jinja2 allows variable expansion and function calls from the template (phrase). 102 | For the sake of consistency, Xpander doesn't define any variables 103 | and uses functions for almost everything. 104 | Note that function arguments need to be quoted (either `'` or `"`) 105 | unless otherwise noted. This doesn't apply to numerals. 106 | 107 | Function | Arguments | Example | Explanation 108 | -------------|--------------------------------------|---------------------------------------------|---------------------- 109 | time | period, unit, format | {{ time(-1, week, "%Y-%m-%d") }} | Outputs date one week ago like 2020-01-13 110 | time | format | time(format="%B %d, %Y") | Ouputs current date like January 31, 2020 111 | clipboard | - | {{ clipboard() }} | Inserts clipboard contents 112 | primary | - | {{ primary() }} | Inserts mouse selection contents on Linux, doesn't do anything on Windows 113 | key | key | {{ key("tab") }} | Sends a key press to the focused application 114 | key | key, state | {{ key("tab", "down") }} | Presses and holds/releases a key 115 | phrase | name | {{ phrase("signature") }} | Inserts expanded phrase contents 116 | run | command | run("cowsay Hello, World!") | Runs a command and inserts it's output 117 | run | command, dir, shell, stderr | run("echo Error", shell=True, stderr=False) | 118 | fillentry | name, default, width | fillentry("name", "Oz") | Displays a fill-in-the-blank dialog with single line text field 119 | fillmulti | name, default, width, height | fillmulti() | Displays a fill-in-the-blank dialog with multi line text field 120 | fillchoice | choice1, choice2, ..., name, default | fillchoice("red", "green", name="color", default="blue") | Displays a fill-in-the-blank dialog with multiple choice widget 121 | filloptional | text, name | filloptional("This text will only output if you check the box!") | Displays a block of text with a checkbox to toggle inclusion 122 | 123 | ##### Time format tokens 124 | 125 | These are the tokens you can use in `time` function. 126 | For every posibillity check python's documentation on [`strftime`](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior). 127 | 128 | Token | Meaning | Example 129 | ------|--------------|--------- 130 | %a | Weekday | Sun, Mon 131 | %A | Weekday | Sunday 132 | %d | Day of month | 31, 28 133 | %b | Month | Jan, Feb 134 | %B | Month | January 135 | %y | Year | 99, 15 136 | %Y | Year | 2020 137 | %H | Hour (24) | 23, 12 138 | %I | Hour (12) | 12, 9 139 | %p | PM or AM | 140 | %M | Minute | 59, 05 141 | %S | Second | 45, 09 142 | %% | Percent sign | % 143 | 144 | #### Tokens 145 | 146 | Even though tokens provide addintional functionality they are not part of Jinja2 language. 147 | 148 | Token | Meaning 149 | ------|--------- 150 | $\| | Insert caret (cursor) at this point. Can be inserted multiple times 151 | $+ | Override settings to keep trigger after expansion 152 | $- | Override settings to remove trigger after expansion 153 | 154 | ## Running from source and Packaging 155 | 156 | I strongly recommend you install [poetry](https://python-poetry.org/) and [yarn](https://yarnpkg.com/) for python and node dependency management respectively. 157 | You should also use [nvm](https://github.com/nvm-sh/nvm) to install latest LTS of node. 158 | 159 | ```sh 160 | nvm install --lts --latest-npm 161 | nvm use --lts 162 | ``` 163 | 164 | First clone this repo 165 | 166 | ```sh 167 | git clone https://github.com/masaeedu/xpander.git 168 | ``` 169 | 170 | Then install dependencies 171 | 172 | ```sh 173 | poetry install 174 | ``` 175 | 176 | or 177 | 178 | ```sh 179 | pip install -r requirements.txt 180 | ``` 181 | 182 | And 183 | 184 | ```sh 185 | yarn install 186 | ``` 187 | 188 | or 189 | 190 | ```sh 191 | npm install 192 | ``` 193 | 194 | Then just run 195 | 196 | ```sh 197 | yarn start 198 | ``` 199 | 200 | or 201 | 202 | ```sh 203 | npm start 204 | ``` 205 | 206 | To package run 207 | 208 | ```sh 209 | yarn build_py 210 | yarn dist 211 | ``` 212 | 213 | or alternativly 214 | 215 | ```sh 216 | npm buold_py 217 | npm dist 218 | ``` 219 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | category = "main" 3 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 4 | name = "appdirs" 5 | optional = false 6 | python-versions = "*" 7 | version = "1.4.3" 8 | 9 | [[package]] 10 | category = "dev" 11 | description = "A simple wrapper around optparse for powerful command line utilities." 12 | name = "click" 13 | optional = false 14 | python-versions = "*" 15 | version = "6.7" 16 | 17 | [[package]] 18 | category = "main" 19 | description = "Bindings to the Linux input handling subsystem" 20 | marker = "sys_platform == \"linux\"" 21 | name = "evdev" 22 | optional = false 23 | python-versions = "*" 24 | version = "1.3.0" 25 | 26 | [[package]] 27 | category = "main" 28 | description = "Read resources from Python packages" 29 | marker = "python_version < \"3.7\"" 30 | name = "importlib-resources" 31 | optional = false 32 | python-versions = ">=2.7,!=3.0,!=3.1,!=3.2,!=3.3" 33 | version = "1.0.2" 34 | 35 | [[package]] 36 | category = "main" 37 | description = "A very fast and expressive template engine." 38 | name = "jinja2" 39 | optional = false 40 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 41 | version = "2.11.1" 42 | 43 | [package.dependencies] 44 | MarkupSafe = ">=0.23" 45 | 46 | [package.extras] 47 | i18n = ["Babel (>=0.8)"] 48 | 49 | [[package]] 50 | category = "main" 51 | description = "Full toolkit agnostic cross-platform clipboard access" 52 | name = "klembord" 53 | optional = false 54 | python-versions = ">=3.4,<4.0" 55 | version = "0.2.1" 56 | 57 | [package.dependencies] 58 | python-xlib = ">=0.26,<0.27" 59 | stopit = ">=1.1.2,<2.0.0" 60 | 61 | [[package]] 62 | category = "main" 63 | description = "Simple, cross-platform macros/GUI automation for python" 64 | name = "macpy" 65 | optional = false 66 | python-versions = ">=3.6,<4.0" 67 | version = "0.1.2" 68 | 69 | [package.dependencies] 70 | evdev = ">=1.3.0,<2.0.0" 71 | python-libinput = ">=0.1.0,<0.2.0" 72 | python-xlib = ">=0.26,<0.27" 73 | 74 | [[package]] 75 | category = "main" 76 | description = "Safely add untrusted strings to HTML/XML markup." 77 | name = "markupsafe" 78 | optional = false 79 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" 80 | version = "1.1.1" 81 | 82 | [[package]] 83 | category = "main" 84 | description = "Object-oriented wrapper for libinput using ctypes" 85 | marker = "sys_platform == \"linux\"" 86 | name = "python-libinput" 87 | optional = false 88 | python-versions = "*" 89 | version = "0.1.0" 90 | 91 | [[package]] 92 | category = "main" 93 | description = "Python X Library" 94 | marker = "sys_platform == \"linux\"" 95 | name = "python-xlib" 96 | optional = false 97 | python-versions = "*" 98 | version = "0.26" 99 | 100 | [package.dependencies] 101 | six = ">=1.10.0" 102 | 103 | [[package]] 104 | category = "dev" 105 | description = "A command line utility for building fully self contained Python zipapps." 106 | name = "shiv" 107 | optional = false 108 | python-versions = ">=3.6" 109 | version = "0.1.0" 110 | 111 | [package.dependencies] 112 | click = ">=6.7,<7.0 || >7.0" 113 | pip = ">=9.0.3" 114 | setuptools = "*" 115 | 116 | [package.dependencies.importlib-resources] 117 | python = "<3.7" 118 | version = "*" 119 | 120 | [[package]] 121 | category = "main" 122 | description = "Python 2 and 3 compatibility utilities" 123 | marker = "sys_platform == \"linux\"" 124 | name = "six" 125 | optional = false 126 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 127 | version = "1.14.0" 128 | 129 | [[package]] 130 | category = "main" 131 | description = "Timeout control decorator and context managers, raise any exception in another thread" 132 | marker = "sys_platform == \"linux\"" 133 | name = "stopit" 134 | optional = false 135 | python-versions = "*" 136 | version = "1.1.2" 137 | 138 | [[package]] 139 | category = "dev" 140 | description = "A built-package format for Python" 141 | name = "wheel" 142 | optional = false 143 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 144 | version = "0.34.2" 145 | 146 | [package.extras] 147 | test = ["pytest (>=3.0.0)", "pytest-cov"] 148 | 149 | [metadata] 150 | content-hash = "df03ccddb5685c4dae205415d99f3eac1c4ca4d081f163fe95ce15ed538e82b6" 151 | python-versions = "^3.6" 152 | 153 | [metadata.files] 154 | appdirs = [ 155 | {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, 156 | {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"}, 157 | ] 158 | click = [ 159 | {file = "click-6.7-py2.py3-none-any.whl", hash = "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d"}, 160 | {file = "click-6.7.tar.gz", hash = "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"}, 161 | ] 162 | evdev = [ 163 | {file = "evdev-1.3.0.tar.gz", hash = "sha256:b1c649b4fed7252711011da235782b2c260b32e004058d62473471e5cd30634d"}, 164 | ] 165 | importlib-resources = [ 166 | {file = "importlib_resources-1.0.2-py2.py3-none-any.whl", hash = "sha256:6e2783b2538bd5a14678284a3962b0660c715e5a0f10243fd5e00a4b5974f50b"}, 167 | {file = "importlib_resources-1.0.2.tar.gz", hash = "sha256:d3279fd0f6f847cced9f7acc19bd3e5df54d34f93a2e7bb5f238f81545787078"}, 168 | ] 169 | jinja2 = [ 170 | {file = "Jinja2-2.11.1-py2.py3-none-any.whl", hash = "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"}, 171 | {file = "Jinja2-2.11.1.tar.gz", hash = "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250"}, 172 | ] 173 | klembord = [ 174 | {file = "klembord-0.2.1-py3-none-any.whl", hash = "sha256:810eff42fae25a6f7a384df427268548099f7b263bac39af66117046d3c9f4d4"}, 175 | {file = "klembord-0.2.1.tar.gz", hash = "sha256:0785881aefa9c8ebcae548fbfea5be97ca554cf5f2876493e427a82ea9601de0"}, 176 | ] 177 | macpy = [ 178 | {file = "macpy-0.1.2-py3-none-any.whl", hash = "sha256:6e39dd2c2cfb98bd98db5f408b906c1e4c2cc9bd876b1c806d9bab4d3097fad9"}, 179 | {file = "macpy-0.1.2.tar.gz", hash = "sha256:02bc984a1a93e137cdea9cf9b2cbbb20d86909b364c58398a61d15d55f3739fb"}, 180 | ] 181 | markupsafe = [ 182 | {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, 183 | {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, 184 | {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, 185 | {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, 186 | {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, 187 | {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, 188 | {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, 189 | {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, 190 | {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, 191 | {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, 192 | {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, 193 | {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, 194 | {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, 195 | {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, 196 | {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, 197 | {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, 198 | {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, 199 | {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, 200 | {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, 201 | {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, 202 | {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, 203 | {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, 204 | {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, 205 | {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, 206 | {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, 207 | {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, 208 | {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, 209 | {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, 210 | {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, 211 | {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, 212 | {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, 213 | {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, 214 | {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, 215 | ] 216 | python-libinput = [ 217 | {file = "python-libinput-0.1.0.tar.gz", hash = "sha256:e6b20e7fd889001fe74b01a1ff456e5f53c9ed01b016d6ceb65b6f573f129758"}, 218 | ] 219 | python-xlib = [ 220 | {file = "python-xlib-0.26.tar.bz2", hash = "sha256:b819c7e5f55830305919d78ea42b0cbd5e13687f5d12aa53556c33674b9876df"}, 221 | {file = "python_xlib-0.26-py2.py3-none-any.whl", hash = "sha256:244570b93cb82f5ceea3e4c861b4a0fffcea36947efa10fd63b7aa69d30047a8"}, 222 | ] 223 | shiv = [ 224 | {file = "shiv-0.1.0-py2.py3-none-any.whl", hash = "sha256:0aab6b68b31f605b583c37a4ce35dcbbd88822feab7f297fa3871bf2039d3f9c"}, 225 | {file = "shiv-0.1.0.tar.gz", hash = "sha256:fa3f5509dd37e24b89caccf8cc7e266d0264454d8074f4c22a9320c000f8212f"}, 226 | ] 227 | six = [ 228 | {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, 229 | {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, 230 | ] 231 | stopit = [ 232 | {file = "stopit-1.1.2.tar.gz", hash = "sha256:f7f39c583fd92027bd9d06127b259aee7a5b7945c1f1fa56263811e1e766996d"}, 233 | ] 234 | wheel = [ 235 | {file = "wheel-0.34.2-py2.py3-none-any.whl", hash = "sha256:df277cb51e61359aba502208d680f90c0493adec6f0e848af94948778aed386e"}, 236 | {file = "wheel-0.34.2.tar.gz", hash = "sha256:8788e9155fe14f54164c1b9eb0a319d98ef02c160725587ad60f14ddc57b6f96"}, 237 | ] 238 | -------------------------------------------------------------------------------- /src/xpander_ts/manager.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as fs from "fs"; 3 | import * as os from "os"; 4 | import { ipcRenderer, remote } from "electron"; 5 | import * as ini from "ini"; 6 | import Xdg from "xdg-app-paths"; 7 | import * as readdirp from "readdirp"; 8 | import rimraf from "rimraf"; 9 | import $ from "jquery"; 10 | import M from "materialize-css"; 11 | import "jqtree"; 12 | import "simple-module"; 13 | import "simple-hotkeys"; 14 | import "simple-uploader"; 15 | import Simditor from "simditor"; 16 | import csv from "csv-parser"; 17 | import stripBOM from "strip-bom-stream"; 18 | import * as PKG from "../../package.json"; 19 | import * as KeyMap from "../xpander_ts/keymap.json"; 20 | 21 | 22 | Simditor.locale = "en-US"; 23 | 24 | 25 | interface IPhrase { 26 | body: string, 27 | type: "plaintext" | "richtext", 28 | hotstring: string | null, 29 | hotkey: Array | null, 30 | triggers: Array, 31 | method: "paste" | "type" | "altpaste", 32 | wm_class: Array, 33 | wm_title: string, 34 | } 35 | 36 | 37 | const newPhrase: IPhrase = { 38 | "body": "", 39 | "type": "plaintext", 40 | "hotstring": null, 41 | "hotkey": null, 42 | "triggers": [], 43 | "method": "paste", 44 | "wm_class": [], 45 | "wm_title": "", 46 | } 47 | const xdg = Xdg({ name: PKG.name, suffix: "", isolated: true }); 48 | const config = process.platform === 'linux' ? xdg.config() : path.join(os.homedir(), "AppData/Local/", PKG.name); 49 | const Settings = ini.parse(fs.readFileSync(path.join(config, 'settings.ini'), 'utf-8')); 50 | 51 | 52 | class TextEditor { 53 | simditor: Simditor | null; 54 | textarea: HTMLTextAreaElement; 55 | richtext: JQuery; 56 | 57 | constructor() { 58 | this.simditor = null; 59 | this.textarea = document.getElementById("phraseBody"); 60 | this.richtext = $(`#phraseType input[type="checkbox"]`); 61 | 62 | this.richtext.on("change", () => { 63 | if (this.richtext.prop("checked") && !this.simditor) { 64 | this.simditor = new Simditor({ 65 | textarea: $(this.textarea), 66 | toolbar: [ 67 | "title", 68 | "bold", 69 | "italic", 70 | "underline", 71 | "strikethrough", 72 | "|", 73 | "fontScale", 74 | "color", 75 | "|", 76 | "ol", 77 | "ul", 78 | "|", 79 | "blockquote", 80 | "code", 81 | "|", 82 | "table", 83 | "link", 84 | "image", 85 | "hr", 86 | "|", 87 | "indent", 88 | "outdent", 89 | "alignment", 90 | ], 91 | }); 92 | } else { 93 | if (this.simditor) { 94 | let html = this.simditor.sync(); 95 | let div = document.createElement("div"); 96 | div.innerHTML = html; 97 | let text = div.textContent || div.innerText || ""; 98 | div.remove(); 99 | this.simditor.destroy(); 100 | this.simditor = null; 101 | $(this.textarea).css({ display: "block" }); 102 | $(this.textarea).val(text); 103 | } 104 | } 105 | }); 106 | } 107 | 108 | getText(): string { 109 | if (this.simditor) { 110 | return this.simditor.getValue(); 111 | } 112 | return $(this.textarea).val(); 113 | } 114 | 115 | setText(text: string): void { 116 | if (this.simditor) { 117 | try { 118 | this.simditor.setValue(text); 119 | } catch(err) { 120 | this.setType("plaintext"); 121 | $(this.textarea).val(text); 122 | this.setType("richtext"); 123 | } 124 | 125 | } else { 126 | $(this.textarea).val(text); 127 | } 128 | } 129 | 130 | getType(): "plaintext" | "richtext" { 131 | if (this.simditor && this.richtext.prop("checked")) { 132 | return "richtext"; 133 | } 134 | return "plaintext"; 135 | } 136 | 137 | setType(type: string): void { 138 | if (type === "richtext") { 139 | this.richtext.prop("checked", true).trigger("change"); 140 | } else { 141 | this.richtext.prop("checked", false).trigger("change"); 142 | } 143 | } 144 | } 145 | 146 | 147 | function expandUser(filePath: string): string { 148 | if (filePath.startsWith("~")) { 149 | return path.join(os.homedir(), filePath.slice(1)); 150 | } 151 | return filePath; 152 | } 153 | 154 | 155 | function parseHotkey(hotkey) { 156 | let [key, mods] = hotkey; 157 | let ret = ""; 158 | mods.forEach((mod, index) => { 159 | mods[index] = mod.slice(4); 160 | }); 161 | key = key.slice(4); 162 | if (mods.includes("SHIFT")) ret += "SHIFT + "; 163 | if (mods.includes("CTRL")) ret += "CTRL + "; 164 | if (mods.includes("ALT")) ret += "ALT + "; 165 | if (mods.includes("META")) ret += "META + "; 166 | return ret + key; 167 | } 168 | 169 | 170 | function toHotkey(str): Array | null { 171 | let keys = str.split("+"); 172 | if (keys.length > 1) { 173 | let ret: Array = []; 174 | keys.forEach((key, index) => { 175 | keys[index] = `KEY_${key.trim()}`; 176 | }); 177 | ret.push(keys.pop()); 178 | ret.push(keys); 179 | return ret; 180 | } 181 | return null; 182 | } 183 | 184 | 185 | function sortFiles(arr: []) { 186 | arr.sort((a: any, b: any) => { 187 | if (a.type === b.type) { 188 | if (a.name < b.name) { 189 | return -1; 190 | } else if (a.name > b.name) { 191 | return 1; 192 | } else { 193 | return 0; 194 | } 195 | } else { 196 | if (a.type === "folder") { 197 | return -1; 198 | } else { 199 | return 1; 200 | } 201 | } 202 | }); 203 | for (let entry of arr) { 204 | if (Object.keys(entry).includes("children")) sortFiles((entry).children); 205 | } 206 | } 207 | 208 | 209 | function buildTree(rootPath: string, rootElem: JQuery): Promise { 210 | rootElem.off("tree.move"); 211 | rootElem.off("tree.dblclick"); 212 | // rootElem.off("tree.select"); 213 | rootElem.off("click", ".renameBtn"); 214 | rootElem.off("click", ".deleteBtn"); 215 | rootElem.tree("destroy"); 216 | return readdirp.promise(rootPath, { 217 | fileFilter: "*.json", 218 | depth: 5, 219 | type: "files_directories", 220 | }).then((fileList) => { 221 | let data: any = []; 222 | fileList.forEach((file) => { 223 | let parts = file.path.split(path.sep); 224 | let children = data; 225 | let name = parts.pop(); 226 | for (let part of parts) { 227 | let parent = children.find((child) => child.name === part); 228 | if (parent) children = parent.children; 229 | } 230 | if (file.dirent?.isDirectory()) { 231 | children.push({ 232 | name: path.parse(file.fullPath).name, 233 | children: [], 234 | path: file.fullPath, 235 | type: "folder", 236 | }); 237 | } else { 238 | children.push({ 239 | name: path.parse(file.fullPath).name, 240 | path: file.fullPath, 241 | type: "file", 242 | }); 243 | } 244 | }); 245 | sortFiles(data); 246 | rootElem.tree({ 247 | data: data, 248 | dragAndDrop: true, 249 | autoOpen: 0, 250 | buttonLeft: false, 251 | showEmptyFolder: true, 252 | closedIcon: "\ue315", 253 | openedIcon: "\ue313", 254 | onCanSelectNode: (node) => { 255 | if (node.type === "file") return true; 256 | return false; 257 | }, 258 | onCreateLi: function(node, $li, is_selected) { 259 | if (node.type === "folder") { 260 | $li.find(".jqtree-title").before( 261 | `folder` 262 | ); 263 | $li.find(".jqtree-toggler-right").after( 264 | ` 265 | edit 266 | 267 | 268 | delete 269 | ` 270 | ); 271 | } else { 272 | $li.find(".jqtree-title").before( 273 | `note` 274 | ); 275 | $li.find(".jqtree-title").after( 276 | ` 277 | edit 278 | 279 | 280 | delete 281 | ` 282 | ); 283 | } 284 | }, 285 | }); 286 | return Promise.resolve(rootElem); 287 | }); 288 | } 289 | 290 | 291 | function attachTreeEvents(rootElem: JQuery, textEditor: TextEditor): void { 292 | rootElem.on("tree.select", function(event: any) { 293 | if (event.node === null) { 294 | resetEditor(textEditor); 295 | $("#editor").find(":input").prop("disabled", true); 296 | } else { 297 | console.log("Node selected!"); 298 | $("#editor").find(":input").prop("disabled", false); 299 | loadPhrase(event.node.path, textEditor); 300 | } 301 | }); 302 | rootElem.on("tree.move", (event) => { 303 | const { moved_node, target_node, position } = (event).move_info; 304 | if (position === "inside") { 305 | if (target_node.type === "folder") { 306 | fs.rename( 307 | moved_node.path, 308 | path.join(target_node.path, path.basename(moved_node.path)), 309 | () => buildTree(expandUser(Settings.DEFAULT.phrase_dir), rootElem).then(() => attachTreeEvents(rootElem, textEditor)) 310 | ); 311 | } else { 312 | event.preventDefault(); 313 | } 314 | } else { 315 | let dest = target_node.parent.path; 316 | if (!dest || target_node.parent.name === "") { 317 | dest = expandUser(Settings.DEFAULT.phrase_dir); 318 | } 319 | fs.rename( 320 | moved_node.path, 321 | path.join(dest, path.basename(moved_node.path)), 322 | () => buildTree(expandUser(Settings.DEFAULT.phrase_dir), rootElem).then(() => attachTreeEvents(rootElem, textEditor)) 323 | ); 324 | } 325 | ipcRenderer.send("phrase", { "type": "phrase", "action": "reload" }); 326 | }); 327 | rootElem.on("tree.dblclick", (event) => { 328 | rootElem.tree("toggle", (event).node); 329 | }); 330 | $(".renameBtn").on("click", function(event) { 331 | let node = rootElem.tree("getNodeByHtmlElement", this); 332 | $("#renameInput").val(node.name); 333 | $("#renameDialog").modal("open"); 334 | M.updateTextFields(); 335 | $("#renameSubmit").one("click", () => { 336 | let newName = $("#renameInput").val(); 337 | let newPath: string; 338 | if (node.type === "file") { 339 | newPath = path.join(path.dirname(node.path), newName + ".json"); 340 | } else { 341 | newPath = path.join(path.dirname(node.path), newName); 342 | } 343 | fs.rename(node.path, newPath, () => { 344 | buildTree(expandUser(Settings.DEFAULT.phrase_dir), rootElem).then(() => attachTreeEvents(rootElem, textEditor)); 345 | if (node.type === "file") { 346 | ipcRenderer.send("phrase", { 347 | "type": "phrase", 348 | "action": "delete", 349 | "path": node.path, 350 | }); 351 | ipcRenderer.send("phrase", { 352 | "type": "phrase", 353 | "action": "edit", 354 | "path": newPath, 355 | }); 356 | } else { 357 | ipcRenderer.send("phrase", { "type": "phrase", "action": "reload" }); 358 | } 359 | }); 360 | }); 361 | }); 362 | $(".deleteBtn").on("click", function(event) { 363 | let node = rootElem.tree("getNodeByHtmlElement", this); 364 | if (node.type === "folder") { 365 | $("#deleteWarning").modal("open"); 366 | $("#deleteSubmit").one("click", () => { 367 | rootElem.tree("removeNode", node); 368 | rimraf(node.path, () => { 369 | buildTree(expandUser(Settings.DEFAULT.phrase_dir), rootElem).then(() => attachTreeEvents(rootElem, textEditor)); 370 | ipcRenderer.send("phrase", { "type": "phrase", "action": "reload" }); 371 | }); 372 | }); 373 | } else { 374 | rootElem.tree("removeNode", node); 375 | fs.unlink(node.path, () => { 376 | buildTree(expandUser(Settings.DEFAULT.phrase_dir), rootElem).then(() => attachTreeEvents(rootElem, textEditor)); 377 | ipcRenderer.send("phrase", { 378 | "type": "phrase", 379 | "action": "delete", 380 | "path": node.path, 381 | }); 382 | }); 383 | } 384 | resetEditor(textEditor); 385 | $("#editor").find(":input").prop("disabled", true); 386 | }); 387 | } 388 | 389 | 390 | function resetEditor(textEditor: TextEditor) { 391 | textEditor.setType("plaintext"); 392 | textEditor.setText(""); 393 | $("#hotstring").val(""); 394 | $("#hotkey").val(""); 395 | $("#triggers").val(""); 396 | $("#pasteMethod option[selected]").prop("selected", false); 397 | $("#pasteMethod").formSelect({ dropdownOptions: { coverTrigger: false }}); 398 | $("#wmClass").val(""); 399 | $("#wmTitle").val(""); 400 | M.updateTextFields(); 401 | } 402 | 403 | 404 | function loadPhrase(filePath: string, textEditor: TextEditor) { 405 | fs.readFile(filePath, 'utf-8', (err, data) => { 406 | let phrase = JSON.parse(data); 407 | textEditor.setType(phrase.type); 408 | textEditor.setText(phrase.body); 409 | $("#hotstring").val(phrase.hotstring ? phrase.hotstring : ""); 410 | $("#hotkey").val(phrase.hotkey ? parseHotkey(phrase.hotkey) : ""); 411 | $("#triggers").val(phrase.triggers ? phrase.triggers.join("").replace("\t", "\\t").replace("\n", "\\n") : ""); 412 | $(`#pasteMethod option[value="${phrase.method}"]`).prop("selected", true); 413 | $("#pasteMethod").formSelect({ dropdownOptions: { coverTrigger: false }}); 414 | $("#wmClass").val(phrase.wm_class ? phrase.wm_class.join() : ""); 415 | $("#wmTitle").val(phrase.wm_title); 416 | M.updateTextFields(); 417 | }); 418 | } 419 | 420 | 421 | function loadSettings() { 422 | $("#pathDisplay").val(expandUser(Settings.DEFAULT.phrase_dir)); 423 | $("#keepTrig").prop("checked", Settings.DEFAULT.keep_trig === "True"); 424 | $("#useTab").prop("checked", Settings.DEFAULT.use_tab); 425 | $(`#theme option[value="${Settings.DEFAULT.light_theme}"]`).prop("selected", true); 426 | $("#theme").formSelect({ dropdownOptions: { coverTrigger: false }}); 427 | $("#pauseKey").val(Settings.HOTKEY.pause ? parseHotkey(JSON.parse(Settings.HOTKEY.pause)) : ""); 428 | $("#managerKey").val(Settings.HOTKEY.manager ? parseHotkey(JSON.parse(Settings.HOTKEY.manager)) : ""); 429 | M.updateTextFields(); 430 | } 431 | 432 | 433 | function stripQuotes(str: string): string { 434 | if (str.startsWith(`"`) && str.endsWith(`"`)) { 435 | return str.slice(1, str.length - 2); 436 | } 437 | return str; 438 | } 439 | 440 | 441 | function importCSV(textEditor: TextEditor) { 442 | remote.dialog.showOpenDialog( 443 | remote.getCurrentWindow(), 444 | { 445 | filters: [{ extensions: ["csv"], name: "TextExpander CSV" }], 446 | }, 447 | ) 448 | .then((result) => { 449 | if (!result.canceled) { 450 | const rootPath = path.join(expandUser(Settings.DEFAULT.phrase_dir), "imported"); 451 | fs.mkdirSync(rootPath); 452 | for (const csvFile of result.filePaths) { 453 | if (fs.existsSync(csvFile)) { 454 | fs.createReadStream(csvFile).pipe(stripBOM()).pipe(csv(["shortcut", "body", "label"])) 455 | .on("data", (row) => { 456 | let phrase: IPhrase = JSON.parse(JSON.stringify(newPhrase)); 457 | phrase.body = stripQuotes(row.body); 458 | phrase.type = "plaintext"; 459 | phrase.hotstring = stripQuotes(row.shortcut); 460 | phrase.hotkey = toHotkey(""); 461 | phrase.triggers = []; 462 | phrase.method = "paste"; 463 | phrase.wm_class = []; 464 | phrase.wm_title = ""; 465 | const filePath = path.join(rootPath, `${stripQuotes(row.label)}.json`); 466 | fs.writeFile(filePath, JSON.stringify(phrase), () => { 467 | ipcRenderer.send("phrase", { 468 | "type": "phrase", 469 | "action": "edit", 470 | "path": filePath, 471 | }); 472 | }); 473 | }) 474 | .on("end", () => buildTree(expandUser(Settings.DEFAULT.phrase_dir), $("#files")).then(() => attachTreeEvents($("#files"), textEditor))); 475 | } 476 | } 477 | } 478 | }); 479 | } 480 | 481 | 482 | $(document).ready(() => { 483 | const textEditor = new TextEditor(); 484 | 485 | $(".tabs").tabs(); 486 | $('.modal').modal(); 487 | $(".fixed-action-btn").floatingActionButton({ 488 | direction: "right", 489 | hoverEnabled: false, 490 | }); 491 | $("select").formSelect({ dropdownOptions: { coverTrigger: false }}); 492 | $("#wmClass").autocomplete(); 493 | $("#wmTitle").autocomplete(); 494 | 495 | buildTree(expandUser(Settings.DEFAULT.phrase_dir), $("#files")).then(() => attachTreeEvents($("#files"), textEditor)); 496 | 497 | $("#newPhrase").on("click", function(event) { 498 | let node = $("#files").tree("getSelectedNode"); 499 | let dir = expandUser(Settings.DEFAULT.phrase_dir); 500 | if (node) { 501 | dir = path.dirname(node.path); 502 | } 503 | let filePath = path.join(dir, "New phrase.json"); 504 | let iteration = 1; 505 | while (fs.existsSync(filePath)) { 506 | filePath = path.join(dir, `New phrase ${iteration}.json`); 507 | iteration++; 508 | } 509 | fs.writeFile(filePath, JSON.stringify(newPhrase), () => buildTree(expandUser(Settings.DEFAULT.phrase_dir), $("#files")).then(() => attachTreeEvents($("#files"), textEditor))); 510 | }); 511 | $("#newFolder").on("click", function(event) { 512 | let node = $("#files").tree("getSelectedNode"); 513 | let dir = expandUser(Settings.DEFAULT.phrase_dir); 514 | if (node) { 515 | dir = path.dirname(node.path); 516 | } 517 | let folderPath = path.join(dir, "New folder"); 518 | let iteration = 1; 519 | while (fs.existsSync(folderPath)) { 520 | folderPath = path.join(dir, `New folder ${iteration}`); 521 | iteration++; 522 | } 523 | fs.mkdir(folderPath, () => buildTree(expandUser(Settings.DEFAULT.phrase_dir), $("#files")).then(() => attachTreeEvents($("#files"), textEditor))); 524 | }); 525 | $("#importCSV").on("click", function(event) { 526 | importCSV(textEditor); 527 | }); 528 | 529 | resetEditor(textEditor); 530 | $("#editor").find(":input").prop("disabled", true); 531 | 532 | $("#hotkey, #pauseKey, #managerKey").on("keydown", function(event) { 533 | let key = event.originalEvent?.code; 534 | if (key !== "Tab") { 535 | event.preventDefault(); 536 | 537 | let hotkey = ""; 538 | if (key === "Backspace") $(this).val(""); 539 | if (Object.keys(KeyMap).includes(key)) { 540 | key = KeyMap[key]; 541 | } 542 | hotkey += event.shiftKey ? "SHIFT + " : ""; 543 | hotkey += event.ctrlKey ? "CTRL + " : ""; 544 | hotkey += event.altKey ? "ALT + " : ""; 545 | hotkey += event.metaKey ? "META + " : ""; 546 | if (key.startsWith("Key")) key = key.slice(3); 547 | if (!["SHIFT", "CTRL", "ALT", "META"].includes(key)) hotkey += key.toUpperCase(); 548 | if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) $(this).val(hotkey); 549 | } 550 | }); 551 | $("#cancelBtn").on("click", function(event) { 552 | let node = $("#files").tree("getSelectedNode"); 553 | if (node) { 554 | loadPhrase(node.path, textEditor); 555 | } 556 | }); 557 | $("#saveBtn").on("click", function(event) { 558 | let node = $("#files").tree("getSelectedNode"); 559 | if (node) { 560 | let phrase: IPhrase = JSON.parse(JSON.stringify(newPhrase)); 561 | phrase.body = textEditor.getText(); 562 | phrase.type = textEditor.getType(); 563 | phrase.hotstring = $("#hotstring").val() || null; 564 | phrase.hotkey = toHotkey($("#hotkey").val() || ""); 565 | phrase.triggers = ($("#triggers").val())?.replace("\\t", "\t").replace("\\n", "\n").split(""); 566 | $("#pasteMethod").formSelect({ dropdownOptions: { coverTrigger: false }}); 567 | phrase.method = <"paste"|"type"|"altpaste">$("#pasteMethod").formSelect("getSelectedValues")[0]; 568 | phrase.wm_class = $("#wmClass").val() ? ($("#wmClass").val()).split(", ") : []; 569 | phrase.wm_title = ($("#wmTitle").val()) || ""; 570 | let filePath = node.path; 571 | fs.writeFile(node.path, JSON.stringify(phrase), () => { 572 | ipcRenderer.send("phrase", { 573 | "type": "phrase", 574 | "action": "edit", 575 | "path": filePath, 576 | }); 577 | }); 578 | } 579 | }); 580 | 581 | setTimeout(() => { 582 | ipcRenderer.send("manager", { "type": "manager", "action": "listWindows" }); 583 | }, 1000); 584 | ipcRenderer.on("manager", (event, msg) => { 585 | if (msg.action === "listWindows") { 586 | let classData = {}; 587 | let titleData = {}; 588 | for (let window of msg.list) { 589 | classData[window.class] = null; 590 | titleData[window.title] = null; 591 | } 592 | $("#wmClass").autocomplete("updateData", classData); 593 | $("#wmTitle").autocomplete("updateData", titleData); 594 | } 595 | }); 596 | 597 | loadSettings(); 598 | $("#phraseDir").on("click", function(event) { 599 | remote.dialog.showOpenDialog(remote.getCurrentWindow(), { 600 | defaultPath: expandUser(Settings.DEFAULT.phrase_dir), 601 | properties: ["openDirectory", "showHiddenFiles"], 602 | }).then(({ canceled, filePaths }) => { 603 | if (!canceled) { 604 | $(this).find("input").val(filePaths[0]); 605 | } 606 | }); 607 | }); 608 | $("#settingsCancel").on("click", function(event) { 609 | loadSettings(); 610 | }); 611 | $("#settingsSave").on("click", function(event) { 612 | Settings.DEFAULT.phrase_dir = $("#pathDisplay").val(); 613 | Settings.DEFAULT.keep_trig = $("#keepTrig").prop("checked") ? "True" : "False"; 614 | Settings.DEFAULT.use_tab = $("#useTab").prop("checked") ? "True" : "False"; 615 | $("#theme").formSelect({ dropdownOptions: { coverTrigger: false }}); 616 | Settings.DEFAULT.light_theme = $("#theme").formSelect("getSelectedValues")[0]; 617 | Settings.HOTKEY.pause = JSON.stringify(toHotkey($("#pauseKey").val() || "")); 618 | Settings.HOTKEY.manager = JSON.stringify(toHotkey($("#managerKey").val() || "")); 619 | fs.writeFile(path.join(config, 'settings.ini'), ini.stringify(Settings), () => { 620 | ipcRenderer.send("settings", { "type": "settings", "action": "reload" }); 621 | }); 622 | }); 623 | }); 624 | --------------------------------------------------------------------------------