diff --git a/.archive/README.md b/.archive/README.md new file mode 100644 index 00000000..0ebca9d9 --- /dev/null +++ b/.archive/README.md @@ -0,0 +1,3 @@ +# The archive + +0.18 changed the API and most of the plugins here have no maintainer. Please volunteer as maintainer and help getting plugins shipped. 👍 diff --git a/atom_projects.py b/.archive/atom_projects/__init__.py similarity index 76% rename from atom_projects.py rename to .archive/atom_projects/__init__.py index da6b96fe..e8f50d1d 100644 --- a/atom_projects.py +++ b/.archive/atom_projects/__init__.py @@ -4,28 +4,25 @@ Synopsis: [filter]""" +# Copyright (c) 2022 Manuel Schneider + import os import re import time from pathlib import Path -from shutil import which import cson -from albertv0 import * +from albert import * -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Atom Projects" -__version__ = "1.0" -__trigger__ = "atom " -__author__ = "Manuel Schneider" -__dependencies__ = ["python-cson"] +__title__ = "Atom Projects" +__version__ = "0.4.0" +__triggers__ = "atom " +__authors__ = "Manuel S." +__exec_deps__ = ["atom"] +__py_deps__ = ["cson"] projects_file = str(Path.home()) + "/.atom/projects.cson" - -if which("atom") is None: - raise Exception("'atom' is not in $PATH.") - iconPath = iconLookup('atom') mtime = 0 projects = [] @@ -55,11 +52,10 @@ def handleQuery(query): items = [] for project in projects: if re.search(stripped, project['title'], re.IGNORECASE): - items.append(Item(id=__prettyname__ + project['title'], + items.append(Item(id=__title__ + project['title'], icon=iconPath, text=project['title'], subtext="Group: %s" % (project['group'] if 'group' in project else "None"), - completion=query.rawString, actions=[ ProcAction(text="Open project in Atom", commandline=["atom"] + project['paths']) diff --git a/.archive/base_converter/__init__.py b/.archive/base_converter/__init__.py new file mode 100644 index 00000000..5ed47926 --- /dev/null +++ b/.archive/base_converter/__init__.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- + +"""Convert representations of numbers. + +Synopsis: + + [padding] + + where is a literal of the form '0bXXX' (binary), '0XXX' (octal), + or '0xXXX' (hexadecimal).""" + +# Copyright (c) 2022 Manuel Schneider + +import numpy as np +from collections import defaultdict + +from albert import Item, ClipAction + +__title__ = "Base Converter" +__version__ = "0.4.1" +__triggers__ = "base " +__authors__ = ["Manuel S.", "Keating950"] +__py_deps__ = ["numpy"] + + +class keyed_defaultdict(defaultdict): + def __missing__(self, key): + return self.default_factory(key) + + +base_prefixes = keyed_defaultdict(lambda k: 8 if k[0] == "0" and len(k) > 1 else 10) +base_prefixes["0b"] = 2 +base_prefixes["0x"] = 16 + + +def buildItem(completion, dst, number, padding=0): + item = Item(id=__title__, completion=completion) + try: + src = base_prefixes[number[:2]] + dst = int(dst) + padding = int(padding) + integer = int(number, src) + item.text = np.base_repr(integer, dst) + if integer >= 0 and len(item.text) < padding: + item.text = '0'*(padding-len(item.text)) + item.text + item.subtext = "Base %s representation of %s (base %s)" % (dst, number, src) + item.addAction(ClipAction("Copy to clipboard", item.text)) + except Exception as e: + item.text = e.__class__.__name__ + item.subtext = str(e) + return item + + +def handleQuery(query): + if query.isTriggered: + fields = query.string.split() + if len(fields) == 2: + return buildItem(query.rawString, fields[0], fields[1]) + else: + item = Item(id=__title__) + item.text = __title__ + item.subtext = "Enter a query in the form of \"<dstbase> <number>\"" + return item + else: + fields = query.string.split() + if len(fields) < 2: + return + src = base_prefixes[fields[:3]] + number = fields[1] + padding = 0 if len(fields) < 3 else fields[2] + results = [] + for dst in sorted(base_prefixes.values().append(8)): + if dst == src: + continue + results.append(buildItem(query.rawString, dst, number, padding)) + return results diff --git a/binance/Binance.svg b/.archive/binance/Binance.svg similarity index 91% rename from binance/Binance.svg rename to .archive/binance/Binance.svg index f03f7c99..c79e1829 100644 --- a/binance/Binance.svg +++ b/.archive/binance/Binance.svg @@ -1 +1,5 @@ + + \ No newline at end of file diff --git a/binance/__init__.py b/.archive/binance/__init__.py similarity index 87% rename from binance/__init__.py rename to .archive/binance/__init__.py index 1fe2d451..6ae6feb9 100644 --- a/binance/__init__.py +++ b/.archive/binance/__init__.py @@ -6,7 +6,9 @@ filter [filter]""" -from albertv0 import * +# Copyright (c) 2022 Manuel Schneider + +from albert import * import time import os import urllib.request @@ -15,16 +17,14 @@ import json from threading import Thread, Event -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Binance" -__version__ = "1.2" -__trigger__ = "bnc " -__author__ = "Manuel Schneider" -__dependencies__ = [] +__title__ = "Binance" +__version__ = "0.4.3" +__triggers__ = "bnc " +__authors__ = "Manuel S." iconPath = os.path.dirname(__file__) + "/Binance.svg" exchangeInfoUrl = "https://api.binance.com/api/v1/exchangeInfo" -tradeUrl = "https://www.binance.com/tradeDetail.html?symbol=%s_%s" +tradeUrl = "https://www.binance.com/en/trade/%s_%s?layout=pro" markets = [] thread = None @@ -77,11 +77,11 @@ def finalize(): def makeItem(market): url = tradeUrl % (market.base, market.quote) return Item( - id="%s_%s%s" % (__prettyname__, market.base, market.quote), + id="%s_%s%s" % (__title__, market.base, market.quote), icon=iconPath, text="%s/%s" % (market.base, market.quote), subtext="Open the %s/%s market on binance.com" % (market.base, market.quote), - completion="%s%s%s" % (__trigger__, market.base, market.quote), + completion="%s%s%s" % (__triggers__, market.base, market.quote), actions=[ UrlAction("Show market in browser", url), ClipAction('Copy URL to clipboard', url) diff --git a/bitfinex/Bitfinex.svg b/.archive/bitfinex/Bitfinex.svg similarity index 100% rename from bitfinex/Bitfinex.svg rename to .archive/bitfinex/Bitfinex.svg diff --git a/bitfinex/__init__.py b/.archive/bitfinex/__init__.py similarity index 89% rename from bitfinex/__init__.py rename to .archive/bitfinex/__init__.py index 9053f8f2..188785cd 100644 --- a/bitfinex/__init__.py +++ b/.archive/bitfinex/__init__.py @@ -6,7 +6,7 @@ filter [filter]""" -from albertv0 import * +from albert import * import time import os import urllib.request @@ -15,12 +15,10 @@ from collections import namedtuple from threading import Thread, Event -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "BitFinex" -__version__ = "1.0" -__trigger__ = "bfx " -__author__ = "Manuel Schneider" -__dependencies__ = [] +__title__ = "BitFinex" +__version__ = "0.4.0" +__triggers__ = "bfx " +__authors__ = "Manuel S." iconPath = os.path.dirname(__file__) + "/Bitfinex.svg" symbolsEndpoint = "https://api.bitfinex.com/v1/symbols" @@ -74,11 +72,11 @@ def finalize(): def makeItem(market): url = tradeUrl % (market.base, market.quote) return Item( - id="%s_%s%s" % (__prettyname__, market.base, market.quote), + id="%s_%s%s" % (__title__, market.base, market.quote), icon=iconPath, text="%s/%s" % (market.base, market.quote), subtext="Open the %s/%s market on bitfinex.com" % (market.base, market.quote), - completion="%s%s%s" % (__trigger__, market.base, market.quote), + completion="%s%s%s" % (__triggers__, market.base, market.quote), actions=[ UrlAction("Show market in browser", url), ClipAction('Copy URL to clipboard', url) diff --git a/currency_converter.py b/.archive/currency_converter/__init__.py similarity index 85% rename from currency_converter.py rename to .archive/currency_converter/__init__.py index 907b2982..9bd8d99c 100644 --- a/currency_converter.py +++ b/.archive/currency_converter/__init__.py @@ -6,22 +6,20 @@ Synopsis: [to|as|in] """ +# Copyright (c) 2022 Manuel Schneider + import re import time from urllib.request import urlopen from xml.etree import ElementTree -from albertv0 import * +from albert import * -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Currency converter" -__version__ = "1.0" -__author__ = "Manuel Schneider" -__dependencies__ = [] +__title__ = "Currency converter" +__version__ = "0.4.0" +__authors__ = "Manuel S." -iconPath = iconLookup('accessories-calculator') -if not iconPath: - iconPath = ":python_module" +iconPath = iconLookup('accessories-calculator') or ":python_module" class EuropeanCentralBank: @@ -43,7 +41,7 @@ def convert(self, amount, src, dst): rate = float(child.attrib['rate']) self.exchange_rates[curr] = rate self.exchange_rates["EUR"] = 1.0 # For simpler algorithmic - info("%s: Updated foreign exchange rates." % __prettyname__) + info("%s: Updated foreign exchange rates." % __title__) debug(str(self.exchange_rates)) self.lastUpdate = time.time() @@ -58,6 +56,8 @@ def __init__(self): self.name = "Yahoo" def convert(self, amount, src, dst): + if amount.is_integer: + amount = int(amount) url = 'https://search.yahoo.com/search?p=%s+%s+to+%s' % (amount, src, dst) with urlopen(url) as response: html = response.read().decode() @@ -73,7 +73,7 @@ def handleQuery(query): match = regex.fullmatch(query.string.strip()) if match: prep = (float(match.group(1)), match.group(2).upper(), match.group(3).upper()) - item = Item(id=__prettyname__, icon=iconPath, completion=query.rawString) + item = Item(id=__title__, icon=iconPath) for provider in providers: result = provider.convert(*prep) if result: diff --git a/dango_emoji/__init__.py b/.archive/dango_emoji/__init__.py similarity index 87% rename from dango_emoji/__init__.py rename to .archive/dango_emoji/__init__.py index b13b075c..886853c8 100644 --- a/dango_emoji/__init__.py +++ b/.archive/dango_emoji/__init__.py @@ -9,7 +9,7 @@ Synopsis: """ -from albertv0 import * +from albert import * import json import os import urllib.error @@ -17,12 +17,10 @@ from urllib.parse import urlencode -__iid__ = "PythonInterface/v0.2" -__prettyname__ = "Dango Emoji" -__version__ = "1.1" -__trigger__ = ":" -__author__ = "David Britt" -__dependencies__ = [] +__title__ = "Dango Emoji" +__version__ = "0.4.1" +__triggers__ = ":" +__authors__ = "David Britt" iconPath = os.path.dirname(__file__) + "/dangoemoji.png" @@ -35,11 +33,9 @@ def handleQuery(query): if query.isTriggered: item = Item( - id=__prettyname__, + id=__title__, icon=icon_path, - completion=query.rawString, - text=__prettyname__, - actions=[] + text=__title__ ) if len(query.string) >= 2: @@ -58,7 +54,7 @@ def handleQuery(query): string_emojis = ''.join(all_emojis) results.append(Item( - id=__prettyname__, + id=__title__, icon=icon_path, text=string_emojis, subtext="Score > 0.025", @@ -70,7 +66,7 @@ def handleQuery(query): for emoj in json_data["results"]: results.append(Item( - id=__prettyname__, + id=__title__, icon=icon_path, text=str(emoj["text"]), subtext=str(emoj["score"]), diff --git a/dango_emoji/dangoemoji.png b/.archive/dango_emoji/dangoemoji.png similarity index 100% rename from dango_emoji/dangoemoji.png rename to .archive/dango_emoji/dangoemoji.png diff --git a/dango_kao/__init__.py b/.archive/dango_kao/__init__.py similarity index 81% rename from dango_kao/__init__.py rename to .archive/dango_kao/__init__.py index 0df90a20..8701ecc3 100755 --- a/dango_kao/__init__.py +++ b/.archive/dango_kao/__init__.py @@ -7,19 +7,17 @@ Synopsis: """ -from albertv0 import * +from albert import * import os import json import urllib.error from urllib.request import urlopen, Request from urllib.parse import urlencode -__iid__ = "PythonInterface/v0.2" -__prettyname__ = "Dango Kaomoji" -__version__ = "1.0" -__trigger__ = "kao " -__author__ = "David Britt" -__dependencies__ = [] +__title__ = "Dango Kaomoji" +__version__ = "0.4.0" +__triggers__ = "kao " +__authors__ = "David Britt" icon_path = os.path.dirname(__file__) + "/kaoicon.svg" dangoUrl = "https://customer.getdango.com/dango/api/query/kaomoji" @@ -31,11 +29,9 @@ def handleQuery(query): if query.isTriggered: item = Item( - id=__prettyname__, + id=__title__, icon=icon_path, - completion=query.rawString, - text=__prettyname__, - actions=[] + text=__title__, ) if len(query.string) >= 2: @@ -45,7 +41,7 @@ def handleQuery(query): json_data = json.loads(response.read().decode()) for emoj in json_data["items"]: results.append(Item( - id=__prettyname__, + id=__title__, icon=icon_path, text=emoj["text"], actions=[ diff --git a/dango_kao/kaoicon.svg b/.archive/dango_kao/kaoicon.svg similarity index 100% rename from dango_kao/kaoicon.svg rename to .archive/dango_kao/kaoicon.svg diff --git a/.archive/find/__init__.py b/.archive/find/__init__.py new file mode 100644 index 00000000..1b035bc6 --- /dev/null +++ b/.archive/find/__init__.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- + +""" +broken +""" +# Copyright (c) 2022 Manuel Schneider + +import albert +from albert import * +from time import sleep +import io +import os +import subprocess +from pathlib import Path + +__iid__ = "0.5" +__version__ = "1.0" +__id__ = "find" +__name__ = "Find" +__description__ = "Online search your file system" +__license__ = "BSD-3" +__url__ = "https://github.com/albertlauncher/python/tree/master/find" +__maintainers__ = "@manuelschneid3r" +__authors__ = ["@manuelschneid3r"] +__bin_dependencies__ = ["find"] +__default_trigger__ = "find " +__synopsis__ = "" + + +class Plugin(Plugin, QueryHandler): + def __init__(self): + albert.Plugin.__init__(self) + albert.QueryHandler.__init__(self) + + def id(self): + return __id__; + + def name(self): + return __name__; + + def takeThisAndModifyR(self, item): + item.id = "takeThisAndModifyR"; + + def takeThisAndModifyR_(self, item): + item.id = "takeThisAndModifyR_"; + + def takeThisAndModifyP(self, item): + item.id = "takeThisAndModifyP"; + + def description(self): + return __description__; + + def handleQuery(self, query): + info(query.string) + proc = subprocess.Popen(["find", Path.home(), "iname", query.string], stdout=subprocess.PIPE) + for line in io.TextIOWrapper(proc.stdout, encoding="utf-8"): + absolute = os.path.abspath(line) + item = Item( + id=absolute, + text=os.path.basename(absolute), + subtext=absolute, + completion="", + icon=[":python"], + actions=[ + Action( + id="clip", + text="setClipboardText (ClipAction)", + callable=lambda: setClipboardText(text=configLocation()) + ) + ] + ) + query.add(item) + + + def initialize(self): + info("Find::initialize") + +# def extensions(self): +# return [self.e] +## pass diff --git a/fortune.py b/.archive/fortune/__init__.py similarity index 67% rename from fortune.py rename to .archive/fortune/__init__.py index 6695bfb8..c1ba896f 100644 --- a/fortune.py +++ b/.archive/fortune/__init__.py @@ -6,21 +6,16 @@ Synopsis: """ -import subprocess as sp -from shutil import which - -from albertv0 import * +# Copyright (c) 2022 Manuel Schneider -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Fortune" -__version__ = "1.0" -__trigger__ = "fortune" -__author__ = "Kelvin Wong" -__dependencies__ = ["fortune"] +import subprocess as sp +from albert import * -cmd = __dependencies__[0] -if which(cmd) is None: - raise Exception("'%s' is not in $PATH." % cmd) +__title__ = "Fortune" +__version__ = "0.4.0" +__triggers__ = "fortune" +__authors__ = "Kelvin Wong" +__exec_deps__ = ["fortune"] iconPath = iconLookup("font") @@ -41,10 +36,9 @@ def generateFortune(): def getFortuneItem(query, fortune): return Item( - id=__prettyname__, + id=__title__, icon=iconPath, text=fortune, subtext="Copy this random, hopefully interesting, adage", - completion=query.rawString, actions=[ClipAction("Copy to clipboard", fortune)] ) diff --git a/gnome_dictionary.py b/.archive/gnome_dictionary/__init__.py similarity index 50% rename from gnome_dictionary.py rename to .archive/gnome_dictionary/__init__.py index a4d8092f..a171a979 100644 --- a/gnome_dictionary.py +++ b/.archive/gnome_dictionary/__init__.py @@ -6,30 +6,26 @@ Sysnopsis: """ -from shutil import which -from subprocess import run +# Copyright (c) 2022 Manuel Schneider -from albertv0 import * +from subprocess import run -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Gnome Dictionary" -__version__ = "1.0" -__trigger__ = "def " -__author__ = "Nikhil Wanpal" -__dependencies__ = ["gnome-dictionary"] +from albert import * -if which("gnome-dictionary") is None: - raise Exception("'gnome-dictionary' is not in $PATH.") +__title__ = "Gnome Dictionary" +__version__ = "0.4.0" +__triggers__ = "def " +__authors__ = "Nikhil Wanpal" +__exec_deps__ = ["gnome-dictionary"] iconPath = iconLookup('accessories-dictionary') def handleQuery(query): if query.isTriggered: - return Item(id=__prettyname__, + return Item(id=__title__, icon=iconPath, - text=__prettyname__, - subtext="Search for '%s' using %s" % (query.string, __prettyname__), - completion=query.rawString, - actions=[ProcAction("Opens %s and searches for '%s'" % (__prettyname__, query.string), + text=__title__, + subtext="Search for '%s' using %s" % (query.string, __title__), + actions=[ProcAction("Opens %s and searches for '%s'" % (__title__, query.string), ["gnome-dictionary", "--look-up=%s" % query.string])]) diff --git a/gnote.py b/.archive/gnote/__init__.py similarity index 67% rename from gnote.py rename to .archive/gnote/__init__.py index 7b76032f..18f7b59e 100644 --- a/gnote.py +++ b/.archive/gnote/__init__.py @@ -4,30 +4,26 @@ Synopsis: [filter]""" +# Copyright (c) 2022 Manuel Schneider + import re from datetime import datetime -from shutil import which from dbus import DBusException, Interface, SessionBus -from albertv0 import * - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Gnote" -__version__ = "1.1" -__trigger__ = "gn " -__author__ = "Manuel Schneider" -__dependencies__ = ["gnote", "python-dbus"] - -BUS = "org.gnome.%s" % __prettyname__ -OBJ = "/org/gnome/%s/RemoteControl" % __prettyname__ -IFACE = 'org.gnome.%s.RemoteControl' % __prettyname__ +from albert import * -cmd = __prettyname__.lower() -if which(cmd) is None: - raise Exception("'%s' is not in $PATH." % cmd) +__title__ = "Gnote" +__version__ = "0.4.1" +__triggers__ = "gn " +__authors__ = "Manuel S." +__exec_deps__ = ["gnote"] +__py_deps__ = ["dbus"] -iconPath = iconLookup(cmd) +BUS = "org.gnome.%s" % __title__ +OBJ = "/org/gnome/%s/RemoteControl" % __title__ +IFACE = 'org.gnome.%s.RemoteControl' % __title__ +iconPath = iconLookup("gnote") def handleQuery(query): @@ -35,7 +31,7 @@ def handleQuery(query): if query.isTriggered: try: if not SessionBus().name_has_owner(BUS): - warning("Seems like %s is not running" % cmd) + warning("Seems like gnote is not running") return obj = SessionBus().get_object(bus_name=BUS, object_path=OBJ) @@ -44,12 +40,11 @@ def handleQuery(query): if query.string.strip(): for note in iface.SearchNotes(query.string.lower(), False): results.append( - Item(id="%s%s" % (__prettyname__, note), + Item(id="%s%s" % (__title__, note), icon=iconPath, text=iface.GetNoteTitle(note), subtext="%s%s" % ("".join(["#%s " % re.search('.+:.+:(.+)', s).group(1) for s in iface.GetTagsForNote(note)]), datetime.fromtimestamp(iface.GetNoteChangeDate(note)).strftime("Note from %c")), - completion=query.rawString, actions=[ FuncAction("Open note", lambda note=note: iface.DisplayNote(note)), @@ -61,13 +56,12 @@ def createAndShowNote(): note = iface.CreateNote() iface.DisplayNote(note) - results.append(Item(id="%s-create" % __prettyname__, + results.append(Item(id="%s-create" % __title__, icon=iconPath, - text=__prettyname__, - subtext="%s notes" % __prettyname__, - completion=query.rawString, + text=__title__, + subtext="%s notes" % __title__, actions=[ - FuncAction("Open %s" % __prettyname__, + FuncAction("Open %s" % __title__, lambda: iface.DisplaySearch()), FuncAction("Create a new note", createAndShowNote) ])) diff --git a/google_translate.py b/.archive/google_translate/__init__.py similarity index 77% rename from google_translate.py rename to .archive/google_translate/__init__.py index 9c8bd6b0..30710ba8 100644 --- a/google_translate.py +++ b/.archive/google_translate/__init__.py @@ -6,31 +6,28 @@ Synopsis: """ +# Copyright (c) 2022 Manuel Schneider + import json import urllib.parse import urllib.request -from albertv0 import * +from albert import * -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Google Translate" -__version__ = "1.0" -__trigger__ = "tr " -__author__ = "Manuel Schneider" -__dependencies__ = [] +__title__ = "Google Translate" +__version__ = "0.4.0" +__triggers__ = "tr " +__authors__ = "Manuel S." ua = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.62 Safari/537.36" urltmpl = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=%s&tl=%s&dt=t&q=%s" -iconPath = iconLookup('config-language') -if not iconPath: - iconPath = ":python_module" - +iconPath = iconLookup('config-language') or ":python_module" def handleQuery(query): if query.isTriggered: fields = query.string.split() - item = Item(id=__prettyname__, icon=iconPath, completion=query.rawString) + item = Item(id=__title__, icon=iconPath) if len(fields) >= 3: src = fields[0] dst = fields[1] @@ -45,6 +42,6 @@ def handleQuery(query): item.addAction(ClipAction("Copy translation to clipboard", result)) return item else: - item.text = __prettyname__ + item.text = __title__ item.subtext = "Enter a query in the form of \"<srclang> <dstlang> <text>\"" return item diff --git a/.archive/googletrans/__init__.py b/.archive/googletrans/__init__.py new file mode 100644 index 00000000..3c0d240d --- /dev/null +++ b/.archive/googletrans/__init__.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- + +""" +Translator using py-googletrans +""" + +from locale import getdefaultlocale +from pathlib import Path +from time import sleep + +from albert import * +from googletrans import Translator, LANGUAGES + +md_iid = '2.0' +md_version = "1.2" +md_name = "Google Translate" +md_description = "Translate sentences using googletrans" +md_license = "BSD-3" +md_url = "https://github.com/albertlauncher/python/" +md_lib_dependencies = "googletrans==4.0.0-rc1" +md_maintainers = "@manuelschneid3r" + + +class Plugin(PluginInstance, TriggerQueryHandler): + + def __init__(self): + TriggerQueryHandler.__init__(self, + id=md_id, + name=md_name, + description=md_description, + synopsis="[[src] dest] text", + defaultTrigger='tr ') + PluginInstance.__init__(self, extensions=[self]) + self.iconUrls = [f"file:{Path(__file__).parent}/google_translate.png"] + self.translator = Translator() + self.lang = getdefaultlocale()[0][0:2] + + def handleTriggerQuery(self, query): + stripped = query.string.strip() + if stripped: + for _ in range(50): + sleep(0.01) + if not query.isValid: + return + + src = None + dest, text = self.lang, stripped + splits = text.split(maxsplit=1) + if 1 < len(splits) and splits[0] in LANGUAGES: + dest, text = splits[0], splits[1] + splits = text.split(maxsplit=1) + if 1 < len(splits) and splits[0] in LANGUAGES: + src = dest + dest, text = splits[0], splits[1] + + if src: + translation = self.translator.translate(text, src=src, dest=dest) + else: + translation = self.translator.translate(text, dest=dest) + + query.add(StandardItem( + id=md_id, + text=translation.text, + subtext=f'From {LANGUAGES[translation.src]} to {LANGUAGES[translation.dest]}', + iconUrls=self.iconUrls, + actions=[Action("copy", "Copy result to clipboard", + lambda t=translation.text: setClipboardText(t))] + )) diff --git a/.archive/googletrans/google_translate.png b/.archive/googletrans/google_translate.png new file mode 100644 index 00000000..63617cf5 Binary files /dev/null and b/.archive/googletrans/google_translate.png differ diff --git a/.archive/inhibit_sleep/__init__.py b/.archive/inhibit_sleep/__init__.py new file mode 100644 index 00000000..76829305 --- /dev/null +++ b/.archive/inhibit_sleep/__init__.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider + +""" +Provides an item 'Inhibit sleep' which can be used to temporarily disable system suspension. + +This is a prototype using `systemd-inhibit`. A sophisticated implementation would probably use the systemd D-Bus \ +interface documented [here](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html). +""" + +from albert import * +from subprocess import Popen, TimeoutExpired + +md_iid = '2.3' +md_version = '1.2' +md_name = 'Inhibit sleep' +md_description = 'Inhibit system sleep mode.' +md_license = "MIT" +md_url = 'https://github.com/albertlauncher/python/tree/main/inhibit_sleep' +md_authors = "@manuelschneid3r" +md_bin_dependencies = ['systemd-inhibit', "sleep"] + + +class Plugin(PluginInstance, GlobalQueryHandler): + + def __init__(self): + PluginInstance.__init__(self) + GlobalQueryHandler.__init__( + self, self.id, self.name, self.description, + defaultTrigger='is ' + ) + self.proc = None + + def finalize(self): + if self.proc: + self.toggle() + + def toggle(self): + if self.proc: + self.proc.terminate() + try: + self.proc.wait(timeout=1) + except TimeoutExpired: + self.proc.kill() + self.proc = None + else: + self.proc = Popen(["systemd-inhibit", + "--what=idle:sleep", "--who=Albert", "--why=User", + "sleep", "infinity"]) + info(str(self.proc)) + + def configWidget(self): + return [ + { + 'type': 'label', + 'text': __doc__.strip(), + 'widget_properties': { + 'textFormat': 'Qt::MarkdownText' + } + } + ] + + def handleGlobalQuery(self, query): + stripped = query.string.strip().lower() + if stripped in "inhibit sleep": + return [ + RankItem( + StandardItem( + id=md_name, + text=md_name, + subtext=f"{'Enable' if self.proc else 'Disable'} sleep mode", + iconUrls=[f"gen:?text=💤"], + actions=[Action("inhibit", "Toggle", self.toggle)] + ), + len(stripped)/len(md_name)) + + ] + return [] diff --git a/ip.py b/.archive/ip/__init__.py similarity index 81% rename from ip.py rename to .archive/ip/__init__.py index 125e638a..18f9910f 100644 --- a/ip.py +++ b/.archive/ip/__init__.py @@ -4,16 +4,17 @@ Synopsis: """ +# Copyright (c) 2022 Manuel Schneider + import socket from urllib import request -from albertv0 import * +from albert import * -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "IP Addresses" -__version__ = "1.0" -__trigger__ = "ip " -__author__ = "Manuel Schneider, Benedict Dudel" +__title__ = "IP Addresses" +__version__ = "0.4.0" +__triggers__ = "ip " +__authors__ = ["Manuel S.", "Benedict Dudel"] iconPath = iconLookup("preferences-system-network") @@ -33,7 +34,7 @@ def handleQuery(query): items = [] if externalIP: items.append(Item( - id = __prettyname__, + id = __title__, icon = iconPath, text = externalIP, subtext = "Your external ip address from ipecho.net", @@ -42,7 +43,7 @@ def handleQuery(query): if internalIP: items.append(Item( - id = __prettyname__, + id = __title__, icon = iconPath, text = internalIP, subtext = "Your internal ip address", diff --git a/.archive/lpass/__init__.py b/.archive/lpass/__init__.py new file mode 100644 index 00000000..0ea9d392 --- /dev/null +++ b/.archive/lpass/__init__.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- + +"""LastPass Vault Search + +Synopsis: """ + +# Copyright (c) 2022 Manuel Schneider + +from shutil import which +from albert import * +import subprocess +import re +import os + +__title__ = 'LastPass' +__version__ = '0.4.1' +__triggers__ = 'lp ' +__authors__ = 'David Piçarra' +__exec_deps__ = ['lpass'] + +if not which('lpass'): + raise Exception("`lpass` is not in $PATH.") +clipmgrs = ['xclip', 'xsel', 'pbcopy', 'putclip'] +hasclipmgr = False +for mgr in clipmgrs: + if which(mgr): + hasclipmgr = True + break +if not hasclipmgr: + raise Exception("`xclip`, `xsel`, `pbcopy`, or `putclip` is not in $PATH.") + +ICON_PATH = os.path.dirname(__file__)+"/lastpass.svg" + +def handleQuery(query): + if query.isTriggered: + stripped = query.string.strip() + + try: + lpass = subprocess.check_output(['lpass', 'status']) + except Exception as e: + return Item( + id=__title__, + icon=ICON_PATH, + text=f'Not logged in.', + subtext=f'Please enter your lastpass email address', + actions=[ + ProcAction("lpass login with given email", ["lpass", "login", stripped]), + ] + ) + + + if stripped: + try: + lpass = subprocess.Popen(['lpass', 'ls', '--long'], stdout=subprocess.PIPE) + try: + output = subprocess.check_output(['grep', '-i', stripped], stdin=lpass.stdout) + except subprocess.CalledProcessError as e: + return Item( + id=__title__, + icon=ICON_PATH, + text=__title__, + subtext=f'No results found for {stripped}' + ) + items = [] + for line in output.splitlines(): + match = re.match(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2} (.*) \[id: (\d*)\] \[username: (.*)\]', line.decode("utf-8")) + items.append(Item( + id=__title__, + icon=ICON_PATH, + text=match.group(1), + subtext=match.group(3), + actions=[ + ProcAction("Copy password to clipboard", ["lpass", "show", "-cp", match.group(2)]), + ProcAction("Copy username to clipboard", ["lpass", "show", "-cu", match.group(2)]), + ProcAction("Copy notes to clipboard", ["lpass", "show", "-c", "--notes", match.group(2)]) + ] + )) + + return items + + except subprocess.CalledProcessError as e: + return Item( + id=__title__, + icon=ICON_PATH, + text=f'Error: {str(e.output)}', + subtext=str(e), + actions=[ClipAction('Copy CalledProcessError to clipboard', str(e))] + ) + except Exception as e: + return Item( + id=__title__, + icon=ICON_PATH, + text=f'Generic Exception: {str(e)}', + subtext=str(e), + actions=[ClipAction('Copy Exception to clipboard', str(e))] + ) + + else: + return Item( + id=__title__, + icon=ICON_PATH, + text=__title__, + subtext='Search the LastPass vault' + ) diff --git a/.archive/lpass/lastpass.svg b/.archive/lpass/lastpass.svg new file mode 100644 index 00000000..8fa4c946 --- /dev/null +++ b/.archive/lpass/lastpass.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.archive/mathematica_eval/__init__.py b/.archive/mathematica_eval/__init__.py new file mode 100644 index 00000000..31de13cc --- /dev/null +++ b/.archive/mathematica_eval/__init__.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + +import subprocess +from tempfile import NamedTemporaryFile +from threading import Lock + +from albert import * + +md_iid = "2.0" +md_version = "1.1" +md_name = "Mathematica Eval" +md_description = "Evaluate Mathemtica code" +md_license = "GPL-3.0" +md_url = "https://github.com/albertlauncher/python/tree/master/mathematica_eval" +md_maintainers = "@tyilo" +md_bin_dependencies = ["wolframscript"] + + +class Plugin(PluginInstance, TriggerQueryHandler): + + def __init__(self): + TriggerQueryHandler.__init__(self, + id=md_id, + name=md_name, + description=md_description, + synopsis='', + defaultTrigger='mma ') + PluginInstance.__init__(self, extensions=[self]) + + def handleTriggerQuery(self, query: TriggerQuery) -> None: + stripped = query.string.strip() + if not stripped: + return + + with NamedTemporaryFile("w") as f: + f.write(stripped) + f.flush() + process = subprocess.Popen( + ["wolframscript", "-print", "-f", f.name], + encoding="utf-8", + stdout=subprocess.PIPE, + ) + + while True: + if not query.isValid: + process.kill() + return + + try: + output, _ = process.communicate(timeout=0.1) + break + except subprocess.TimeoutExpired: + pass + + result_str = output.strip() + + query.add( + StandardItem( + id=md_id, + text=result_str, + inputActionText=query.trigger + result_str, + iconUrls=["xdg:wolfram-mathematica"], + actions=[ + Action( + "copy", + "Copy result to clipboard", + lambda r=result_str: setClipboardText(r), + ), + ], + ) + ) diff --git a/multi_google_translate.py b/.archive/multi_google_translate/__init__.py similarity index 89% rename from multi_google_translate.py rename to .archive/multi_google_translate/__init__.py index 8038ea65..ba8eb56e 100644 --- a/multi_google_translate.py +++ b/.archive/multi_google_translate/__init__.py @@ -9,6 +9,8 @@ Synopsis: [query]""" +# Copyright (c) 2022 Manuel Schneider + import json import os import urllib.error @@ -16,15 +18,13 @@ import urllib.request from time import sleep -from albertv0 import (ClipAction, Item, ProcAction, UrlAction, configLocation, +from albert import (ClipAction, Item, ProcAction, UrlAction, configLocation, iconLookup) -__iid__ = "PythonInterface/v0.2" -__prettyname__ = "MultiTranslate" -__version__ = "1.2" -__trigger__ = "mtr " -__author__ = "David Britt" -__dependencies__ = [] +__title__ = "MultiTranslate" +__version__ = "0.4.2" +__triggers__ = "mtr " +__authors__ = "David Britt" iconPath = iconLookup('config-language') if not iconPath: @@ -34,7 +34,7 @@ urltmpl = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=%s&dt=t&q=%s" urlbrowser = "https://translate.google.com/#auto/%s/%s" configurationFileName = "language_config.json" -configuration_directory = os.path.join(configLocation(), __prettyname__) +configuration_directory = os.path.join(configLocation(), __title__) language_configuration_file = os.path.join(configuration_directory, configurationFileName) languages = [] @@ -65,10 +65,9 @@ def handleQuery(query): return item = Item( - id=__prettyname__, + id=__title__, icon=iconPath, - completion=query.rawString, - text=__prettyname__, + text=__title__, actions=[ProcAction("Open the language configuration file.", commandline=["xdg-open", language_configuration_file])] ) @@ -90,7 +89,7 @@ def handleQuery(query): else: results.append( Item( - id=__prettyname__, + id=__title__, icon=iconPath, text="%s" % (translText), subtext="%s" % lang.upper(), diff --git a/.archive/node_eval/__init__.py b/.archive/node_eval/__init__.py new file mode 100644 index 00000000..a9dc25f8 --- /dev/null +++ b/.archive/node_eval/__init__.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +"""Evaluate simple JavaScript expressions. Use it with care every keystroke triggers an evaluation.""" + +# Copyright (c) 2022 Manuel Schneider + +import os +import subprocess +from albert import * + +__title__ = 'Node Eval' +__version__ = '0.4.0' +__triggers__ = 'node ' +__authors__ = 'Hammed Oyedele' +__exec_deps__ = ['node'] + +iconPath = os.path.dirname(__file__) + '/nodejs.svg' + + +def run(exp): + return subprocess.getoutput('node --print "%s"' % exp.replace('"', '\\"')) + + +def handleQuery(query): + if query.isTriggered: + item = Item( + id=__title__, + icon=iconPath + ) + stripped = query.string.strip() + + if stripped == '': + item.text = 'Enter a JavaScript expression...' + else: + item.text = run(stripped) + item.subtext = run( + 'Object.prototype.toString.call(%s).slice(8, -1).toLowerCase()' % stripped) + item.addAction(ClipAction('Copy result to clipboard', item.text)) + + return item diff --git a/.archive/node_eval/nodejs.svg b/.archive/node_eval/nodejs.svg new file mode 100644 index 00000000..aa75111e --- /dev/null +++ b/.archive/node_eval/nodejs.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/npm/__init__.py b/.archive/npm/__init__.py similarity index 78% rename from npm/__init__.py rename to .archive/npm/__init__.py index 9a6f384a..5c481197 100644 --- a/npm/__init__.py +++ b/.archive/npm/__init__.py @@ -6,46 +6,38 @@ Synopsis: [filter]""" -from albertv0 import * -from shutil import which +# Copyright (c) 2022 Manuel Schneider + +from albert import * import os import json import subprocess +__title__ = "npm" +__version__ = "0.4.0" +__triggers__ = "npm " +__authors__ = "Benedict Dudel" +__exec_deps__ = ["npm"] -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "npm" -__version__ = "1.0" -__trigger__ = "npm " -__author__ = "Benedict Dudel" -__dependencies__ = ["npm"] - - -if which("npm") is None: - raise Exception("'npm' is not in $PATH.") - -iconPath = iconLookup("npm") -if not iconPath: - iconPath = os.path.dirname(__file__)+"/logo.svg" - +iconPath = iconLookup("npm") or os.path.dirname(__file__)+"/logo.svg" def handleQuery(query): if query.isTriggered: if not query.string.strip(): return Item( - id = __prettyname__, + id = __title__, icon = iconPath, text = "Update", subtext = "Update all globally installed packages", actions = [ - TermAction("", ["npm", "update", "--global"]) + TermAction("Update packages", ["npm", "update", "--global"]) ] ) items = getSearchResults(query.string.strip()) if not items: return Item( - id = __prettyname__, + id = __title__, icon = iconPath, text = "Search on npmjs.com", subtext = "No modules found in local database. Try to search on npmjs.com", @@ -66,7 +58,7 @@ def getSearchResults(query): for module in json.loads(proc.stdout.decode()): items.append( Item( - id = __prettyname__, + id = __title__, icon = iconPath, text = "%s (%s)" % (module["name"], module["version"]), subtext = module.get("description", ""), diff --git a/npm/logo.svg b/.archive/npm/logo.svg similarity index 94% rename from npm/logo.svg rename to .archive/npm/logo.svg index 77b50052..15e123ca 100644 --- a/npm/logo.svg +++ b/.archive/npm/logo.svg @@ -1,4 +1,8 @@ + + [tag|type] """ -from albertv0 import * +# Copyright (c) 2022 Manuel Schneider + +from albert import * import os import json import urllib.request -from shutil import which - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Packagist" -__version__ = "1.0" -__trigger__ = "packagist " -__author__ = "Benedict Dudel" -__dependencies__ = ["composer"] - -if which("composer") is None: - raise Exception("'composer' is not in $PATH.") +__title__ = "Packagist" +__version__ = "0.4.0" +__triggers__ = "packagist " +__authors__ = "Benedict Dudel" +__exec_deps__ = ["composer"] iconPath = os.path.dirname(__file__)+"/logo.png" @@ -36,7 +32,7 @@ def handleQuery(query): icon = iconPath, text = "by tag", subtext = "Searching for packages by tag", - completion = "%stag " % __trigger__, + completion = "%stag " % __triggers__, actions=[] ), Item( @@ -44,7 +40,7 @@ def handleQuery(query): icon = iconPath, text = "by type", subtext = "Searching for packages by type", - completion = "%stype " % __trigger__, + completion = "%stype " % __triggers__, actions=[] ) ] @@ -70,7 +66,7 @@ def getItems(url): icon = iconPath, text = package["name"], subtext = package["description"], - completion = "%sname %s" % (__trigger__, package["name"]), + completion = "%sname %s" % (__triggers__, package["name"]), actions = [ UrlAction( text = "Open on packagist.org", diff --git a/packagist/logo.png b/.archive/packagist/logo.png similarity index 100% rename from packagist/logo.png rename to .archive/packagist/logo.png diff --git a/.archive/php_eval/__init__.py b/.archive/php_eval/__init__.py new file mode 100644 index 00000000..513f3b38 --- /dev/null +++ b/.archive/php_eval/__init__.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +"""Evaluate simple PHP expressions. Use it with care every keystroke triggers an evaluation.""" + +# Copyright (c) 2022 Manuel Schneider + +import os +import subprocess +from albert import * + +__title__ = 'PHP Eval' +__version__ = '0.4.0' +__triggers__ = 'php ' +__authors__ = 'Hammed Oyedele' +__exec_deps__ = ['php'] + +iconPath = os.path.dirname(__file__) + '/php.svg' + +def run(exp): + return subprocess.getoutput('php -r "%s"' % exp.replace('"', '\\"')) + + +def handleQuery(query): + if query.isTriggered: + item = Item( + id=__title__, + icon=iconPath + ) + stripped = query.string.strip() + + if stripped == '': + item.text = 'Enter a PHP expression...' + else: + item.text = run('echo %s;' % stripped) + item.subtext = run('echo gettype(%s);' % stripped) + item.addAction(ClipAction('Copy result to clipboard', item.text)) + + return item diff --git a/.archive/php_eval/php.svg b/.archive/php_eval/php.svg new file mode 100644 index 00000000..de22880b --- /dev/null +++ b/.archive/php_eval/php.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/pidgin.py b/.archive/pidgin/__init__.py similarity index 90% rename from pidgin.py rename to .archive/pidgin/__init__.py index 9d7ccaca..f3d648c9 100644 --- a/pidgin.py +++ b/.archive/pidgin/__init__.py @@ -4,16 +4,18 @@ Synopsis: """ +# Copyright (c) 2022 Manuel Schneider + import dbus -from albertv0 import * +from albert import * -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Pidgin" -__version__ = "1.0" -__author__ = "Greizgh" -__trigger__ = "pidgin " -__dependencies__ = ["dbus"] +__title__ = "Pidgin" +__version__ = "0.4.0" +__authors__ = "Greizgh" +__triggers__ = "pidgin " +__exec_deps__ = ["python"] +__py_deps__ = ["dbus"] iconPath = iconLookup("pidgin") bus = dbus.SessionBus() @@ -74,7 +76,7 @@ def handleQuery(query): for match in handler.getMatch(target): items.append( Item( - id=__prettyname__, + id=__title__, icon=iconPath, text="Chat with {}".format(match[0]), subtext="Open a pidgin chat window", diff --git a/.archive/rand/__init__.py b/.archive/rand/__init__.py new file mode 100644 index 00000000..da93ba10 --- /dev/null +++ b/.archive/rand/__init__.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- + +"""Draws a random integer. + +This extension provides the rand item, which can be used with : + - 1 argument a : draws an integer between 1 and a (included) + - 2 argumens a, b: draws an integer between a and b (included) + - 3 arguments a, b, nb: draws nb integers between a and b (included) + +Synopsis: + rand [min] max [numbers] +""" + +import os + +import random + +from albertv0 import * + +__iid__ = "PythonInterface/v0.1" +__prettyname__ = "Rand" +__version__ = "0.1" +__trigger__ = "rand " +__author__ = "Cyprien Ruffino" +__dependencies__ = [] + + +usage_string = "Usage: [min] max [numbers]" + + +def createBlankItem(text): + return Item( + id=__prettyname__, + icon="rand/rand.png", + text=str(text), + subtext="", + actions=[]) + + +def handleQuery(query): + if query.isTriggered: + + tokens = query.string.split(" ") + + # No arguments + if len(tokens) == 1 and tokens[0] == "": + return createBlankItem(usage_string) + + # At least one argument + try: + tokens = [int(token) for token in tokens] + except ValueError: + return createBlankItem(usage_string) + + if len(tokens) == 1: + b = tokens[0] + rand = random.randint(1, b) + return createBlankItem(str(rand)) + + elif len(tokens) == 2: + a, b = tokens + rand = random.randint(a, b) + return createBlankItem(str(rand)) + + elif len(tokens) == 3: + a, b, nb = tokens + items = [] + for i in range(nb): + rand = random.randint(a, b) + items.append(createBlankItem(rand)) + return items + + else: + return createBlankItem(usage_string) diff --git a/.archive/rand/rand.png b/.archive/rand/rand.png new file mode 100644 index 00000000..2e58996a Binary files /dev/null and b/.archive/rand/rand.png differ diff --git a/scrot.py b/.archive/scrot/__init__.py similarity index 84% rename from scrot.py rename to .archive/scrot/__init__.py index 005930c2..80c2d44a 100644 --- a/scrot.py +++ b/.archive/scrot/__init__.py @@ -8,23 +8,20 @@ Synopsis: """ +# Copyright (c) 2022 Manuel Schneider + import os import subprocess import tempfile from shutil import which -from albertv0 import FuncAction, Item, iconLookup - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "SCReenshOT utility" -__version__ = "1.0" -__trigger__ = "scrot " -__author__ = "Benedict Dudel" -__dependencies__ = ["scrot", "xclip"] +from albert import FuncAction, Item, iconLookup -for dep in __dependencies__: - if not which(dep): - raise Exception("'%s' is not in $PATH." % dep) +__title__ = "SCReenshOT utility" +__version__ = "0.4.0" +__triggers__ = "scrot " +__authors__ = "Benedict Dudel" +__exec_deps__ = ["scrot", "xclip"] iconPath = iconLookup("camera-photo") @@ -33,7 +30,7 @@ def handleQuery(query): if query.isTriggered: return [ Item( - id = "%s-whole-screen" % __prettyname__, + id = "%s-whole-screen" % __title__, icon = iconPath, text = "Screen", subtext = "Take a screenshot of the whole screen", @@ -49,7 +46,7 @@ def handleQuery(query): ] ), Item( - id = "%s-area-of-screen" % __prettyname__, + id = "%s-area-of-screen" % __title__, icon = iconPath, text = "Area", subtext = "Draw a rectangle with your mouse to capture an area", @@ -61,7 +58,7 @@ def handleQuery(query): ] ), Item( - id = "%s-current-window" % __prettyname__, + id = "%s-current-window" % __title__, icon = iconPath, text = "Window", subtext = "Take a screenshot of the current active window", diff --git a/.archive/texdoc/__init__.py b/.archive/texdoc/__init__.py new file mode 100644 index 00000000..de3f42b6 --- /dev/null +++ b/.archive/texdoc/__init__.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +"""texdoc extension + +This is an extension to search for LaTeX documentation. + +Synopsis: """ + +# Copyright (c) 2022 Manuel Schneider + +import re +import subprocess +from pathlib import Path +from albert import * + +__title__ = 'TeXdoc' +__version__ = '0.4.0' +__triggers__ = 'td' +__authors__ = 'Florian Adamsky (@cit)' +__exec_deps__ = ['texdoc'] + +iconPath = Path(__file__).parent / 'texdoc-logo.svg' +texdoc_cmd = ['texdoc', '-I', '-q', '-s', '-M'] + +def handleQuery(query): + if not query.isTriggered: + return + + query.disableSort() + + stripped_query = query.string.strip() + + if stripped_query: + process = subprocess.run(texdoc_cmd + [stripped_query], + stdout=subprocess.PIPE) + texdoc_output = process.stdout.decode('utf-8') + + results = [] + for line in texdoc_output.split("\n"): + + match = re.search('\t(/.*/)([\w\.-]+)\t\t', line, re.IGNORECASE) + if match: + directory = match.group(1).strip() + filename = match.group(2).strip() + full_path = directory.join(['/', filename]) + + results.append(Item(id = __title__, + icon = str(iconPath), + text = filename, + subtext = directory, + completion = full_path, + actions = [ + ProcAction(text = 'This action opens the documentation.', + commandline=['xdg-open', full_path]) + ])) + + return results + else: + return Item(id = __title__, + icon = str(iconPath), + text = __title__, + subtext = 'Enter a query to search with texdoc', + completion = query.rawString) diff --git a/.archive/texdoc/texdoc-logo.svg b/.archive/texdoc/texdoc-logo.svg new file mode 100644 index 00000000..0245dcd6 --- /dev/null +++ b/.archive/texdoc/texdoc-logo.svg @@ -0,0 +1,263 @@ + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.archive/timer/__init__.py b/.archive/timer/__init__.py new file mode 100644 index 00000000..5a22e160 --- /dev/null +++ b/.archive/timer/__init__.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# # Copyright (c) 2018-2024 Manuel Schneider +# # Copyright (c) 2020 Andreas Dominik Preikschat + +""" +Takes arguments in the form of '`[[hrs:]mins:]secs [name]`'. Empty fields resolve to `0`. \ +Fields exceeding the maximum amount of the time interval are automatically refactorized. + +Examples: +- `5:` starts a 5 minutes timer +- `1:: ` starts a 1 hour timer +- `120:` starts a 2 hours timer +""" + +import threading +from datetime import timedelta +from pathlib import Path +from time import strftime, time, localtime + +from albert import * + +md_iid = '2.3' +md_version = "1.8" +md_name = "Timer" +md_description = "Set up timers" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/timer" +md_authors = ["@manuelschneid3r", "@googol42"] + + +class Timer(threading.Timer): + + def __init__(self, interval, name, callback): + super().__init__(interval=interval, + function=lambda: callback(self)) + self.name = name + self.begin = int(time()) + self.end = self.begin + interval + self.start() + + +class Plugin(PluginInstance, TriggerQueryHandler): + + def __init__(self): + TriggerQueryHandler.__init__(self, + id=md_id, + name=md_name, + description=md_description, + synopsis='[[hrs:]mins:]secs [name]', + defaultTrigger='timer ') + PluginInstance.__init__(self) + self.iconUrls = [f"file:{Path(__file__).parent}/time.svg"] + self.soundPath = Path(__file__).parent / "bing.wav" + self.timers = [] + self.notification = None + + def finalize(self): + for timer in self.timers: + timer.cancel() + self.timers.clear() + + def startTimer(self, interval, name): + self.timers.append(Timer(interval, name, self.onTimerTimeout)) + + def deleteTimer(self, timer): + self.timers.remove(timer) + timer.cancel() + + def onTimerTimeout(self, timer): + self.notification = Notification( + title=f"Timer '{timer.name if timer.name else 'Timer'}'", + body=f"Timed out at {strftime('%X', localtime(timer.end))}" + ) + self.deleteTimer(timer) + + def configWidget(self): + return [ + { + 'type': 'label', + 'text': __doc__.strip(), + 'widget_properties': { 'textFormat': 'Qt::MarkdownText' } + } + ] + + def handleTriggerQuery(self, query): + if not query.isValid: + return + + if query.string.strip(): + args = query.string.strip().split(maxsplit=1) + fields = args[0].split(":") + name = args[1] if 1 < len(args) else '' + if not all(field.isdigit() or field == '' for field in fields): + return StandardItem( + id=self.name, + text="Invalid input", + subtext="Enter a query in the form of '%s[[hours:]minutes:]seconds [name]'" % self.defaultTrigger(), + iconUrls=self.iconUrls, + ) + + seconds = 0 + fields.reverse() + for i in range(len(fields)): + seconds += int(fields[i] if fields[i] else 0)*(60**i) + + query.add(StandardItem( + id=self.name, + text=str(timedelta(seconds=seconds)), + subtext='Set a timer with name "%s"' % name if name else 'Set a timer', + iconUrls=self.iconUrls, + actions=[Action("set-timer", "Set timer", lambda sec=seconds: self.startTimer(sec, name))] + )) + return + + # List timers + items = [] + for timer in self.timers: + m, s = divmod(timer.interval, 60) + h, m = divmod(m, 60) + identifier = "%d:%02d:%02d" % (h, m, s) + + timer_name_with_quotes = '"%s"' % timer.name if timer.name else '' + items.append(StandardItem( + id=self.name, + text='Delete timer %s [%s]' % (timer_name_with_quotes, identifier), + subtext="Times out %s" % strftime("%X", localtime(timer.end)), + iconUrls=self.iconUrls, + actions=[Action("delete-timer", "Delete timer", lambda t=timer: self.deleteTimer(t))] + )) + + if items: + query.add(items) diff --git a/tomboy.py b/.archive/tomboy/__init__.py similarity index 66% rename from tomboy.py rename to .archive/tomboy/__init__.py index eaae4fa7..6e8f03ec 100644 --- a/tomboy.py +++ b/.archive/tomboy/__init__.py @@ -1,41 +1,34 @@ # -*- coding: utf-8 -*- -""""Search, open, create and delete Tomboy notes. +"""Search, open, create and delete Tomboy notes. Synopsis: """ +# Copyright (c) 2022 Manuel Schneider + import re from datetime import datetime -from shutil import which - from dbus import DBusException, Interface, SessionBus +from albert import * -from albertv0 import * - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Tomboy" -__version__ = "1.1" -__trigger__ = "tb " -__author__ = "Manuel Schneider" -__dependencies__ = ["tomboy", "python-dbus"] - -BUS = "org.gnome.%s" % __prettyname__ -OBJ = "/org/gnome/%s/RemoteControl" % __prettyname__ -IFACE = 'org.gnome.%s.RemoteControl' % __prettyname__ - -cmd = __dependencies__[0] -if which(cmd) is None: - raise Exception("'%s' is not in $PATH." % cmd) - -iconPath = iconLookup(cmd) +__title__ = "Tomboy" +__version__ = "0.4.1" +__triggers__ = "tb " +__authors__ = "Manuel S." +__exec_deps__ = ["tomboy"] +__py_deps__ = ["dbus"] +BUS = "org.gnome.%s" % __title__ +OBJ = "/org/gnome/%s/RemoteControl" % __title__ +IFACE = 'org.gnome.%s.RemoteControl' % __title__ +iconPath = iconLookup("tomboy") def handleQuery(query): results = [] if query.isTriggered: try: if not SessionBus().name_has_owner(BUS): - warning("Seems like %s is not running" % cmd) + warning("Seems like %s is not running" % __title__) return obj = SessionBus().get_object(bus_name=BUS, object_path=OBJ) @@ -44,12 +37,11 @@ def handleQuery(query): if query.string.strip(): for note in iface.SearchNotes(query.string.lower(), False): results.append( - Item(id="%s%s" % (cmd, note), + Item(id="%s%s" % (__title__, note), icon=iconPath, text=iface.GetNoteTitle(note), subtext="%s%s" % ("".join(["#%s " % re.search('.+:.+:(.+)', s).group(1) for s in iface.GetTagsForNote(note)]), datetime.fromtimestamp(iface.GetNoteChangeDate(note)).strftime("Note from %c")), - completion=query.rawString, actions=[ FuncAction("Open note", lambda note=note: iface.DisplayNote(note)), @@ -61,13 +53,12 @@ def createAndShowNote(): note = iface.CreateNote() iface.DisplayNote(note) - results.append(Item(id="%s-create" % cmd, + results.append(Item(id="%s-create" % __title__, icon=iconPath, - text=__prettyname__, - subtext="%s notes" % __prettyname__, - completion=query.rawString, + text=__title__, + subtext="%s notes" % __title__, actions=[ - FuncAction("Open %s" % __prettyname__, + FuncAction("Open %s" % __title__, lambda: iface.DisplaySearch()), FuncAction("Create a new note", createAndShowNote) ])) diff --git a/unicode_emoji/__init__.py b/.archive/unicode_emoji/__init__.py similarity index 91% rename from unicode_emoji/__init__.py rename to .archive/unicode_emoji/__init__.py index 28359776..a1191072 100644 --- a/unicode_emoji/__init__.py +++ b/.archive/unicode_emoji/__init__.py @@ -4,28 +4,24 @@ Synopsis: [filter]""" -from albertv0 import * +# Copyright (c) 2022 Manuel Schneider + +from albert import * from collections import namedtuple from threading import Thread import datetime import os -import shutil import subprocess import urllib.request +import shutil -__iid__ = "PythonInterface/v0.2" -__prettyname__ = "Unicode Emojis" -__version__ = "1.3" -__trigger__ = ":" -__author__ = "Tim Zeitz, Manuel Schneider" -__dependencies__ = ["convert"] - -for dep in __dependencies__: - if shutil.which(dep) is None: - raise Exception("'%s' is not in $PATH." % dep) +__title__ = "Unicode Emojis" +__version__ = "0.4.3" +__triggers__ = ":" +__authors__ = ["Tim Zeitz", "Manuel S."] +__exec_deps__ = ["convert"] EmojiSpec = namedtuple('EmojiSpec', ['string', 'name', 'modifiers']) - emoji_data_src_url = "https://unicode.org/Public/emoji/latest/emoji-test.txt" emoji_data_path = os.path.join(dataLocation(), "emoji.txt") icon_path_template = os.path.join(cacheLocation(), __name__, "%s.png") @@ -86,7 +82,7 @@ def initialize(): os.remove(new_path) except Exception as e: - warn(e) + warning(e) # Build the index and icon cache global thread diff --git a/unicode_emoji/emoji.txt b/.archive/unicode_emoji/emoji.txt similarity index 100% rename from unicode_emoji/emoji.txt rename to .archive/unicode_emoji/emoji.txt diff --git a/.archive/units/__init__.py b/.archive/units/__init__.py new file mode 100644 index 00000000..aef326b8 --- /dev/null +++ b/.archive/units/__init__.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +"""Convert units. + +This extension is a wrapper for the (extremely) powerful GNU units tool. Note that spaces are \ +interpreted as separators, i.e. dont use spaces between numbers and units. + +Synopsis: + [dst] + to """ + +# Copyright (c) 2022 Manuel Schneider + +import re +import subprocess as sp +from albert import * + +__title__ = "GNU Units" +__version__ = "0.4.2" +__triggers__ = "units " +__authors__ = ["Manuel S.", "iyzana"] +__exec_deps__ = ["units"] + +icon = iconLookup('calc') or ":python_module" + +regex = re.compile(r"(\S+)(?:\s+to)\s+(\S+)") +unitListOutput = re.compile(r"(\d+(e[+-]\d{2,})?;)+[\d.]+(e[+-]\d{2,})?") + + +def getUnitsResult(args): + command = ['units', '--terse', '--'] + list(args) + query = "units -t -- %s" % ' '.join(args) + try: + output = sp.check_output(command, stderr=sp.STDOUT).decode().strip() + + # usually we want terse output, but when we get a unit-list output + # it looks like this 1;124;18;11;14.025322 which is not friendly + # so we're falling back to not quite terse output + if unitListOutput.fullmatch(output): + command = ['units', '--strict', '--one-line', + '--quiet', '--'] + list(args) + query = "units -s1q -- %s" % ' '.join(args) + output = sp.check_output( + command, stderr=sp.STDOUT).decode().strip() + + return (output, query, True) + except sp.CalledProcessError as e: + return (e.stdout.decode().strip().splitlines()[0], query, False) + + +def handleQuery(query): + if query.isTriggered: + args = query.string.split() + item = Item(id='python.gnu_units', icon=icon) + if args: + result, command, success = getUnitsResult(args) + item.text = result + item.subtext = "Result of '%s'" % command + item.addAction(ClipAction("Copy to clipboard", item.text)) + else: + item.text = "Empty input" + item.subtext = "Enter a query of the form []" + return item + else: + match = regex.fullmatch(query.string.strip()) + if match: + args = match.group(1, 2) + result, command, success = getUnitsResult(args) + if not success: + return + item = Item(id='python.gnu_units', icon=icon) + item.text = result + item.subtext = "Result of '%s'" % command + item.addAction(ClipAction("Copy to clipboard", item.text)) + return item diff --git a/.archive/vpn/__init__.py b/.archive/vpn/__init__.py new file mode 100644 index 00000000..f529bc93 --- /dev/null +++ b/.archive/vpn/__init__.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020 janeklb +# Copyright (c) 2023 Bierchermuesli +# Copyright (c) 2020-2024 Manuel Schneider + +import subprocess +from collections import namedtuple + +from albert import * + +md_iid = "3.0" +md_version = "2.0" +md_name = "VPN" +md_description = "Manage NetworkManager VPN connections" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/vpn" +md_authors = ["@janeklb", "@Bierchermuesli", "@manuelschneid3r"] +md_bin_dependencies = ["nmcli"] + + +class Plugin(PluginInstance, TriggerQueryHandler): + + VPNConnection = namedtuple('VPNConnection', ['name', 'connected']) + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + + def defaultTrigger(self): + return "vpn " + + def getVPNConnections(self): + consStr = subprocess.check_output( + 'nmcli -t connection show', + shell=True, + encoding='UTF-8' + ) + for conStr in consStr.splitlines(): + con = conStr.split(':') + if con[2] in ['vpn', 'wireguard']: + yield self.VPNConnection(name=con[0], connected=con[3] != '') + + @staticmethod + def buildItem(con): + name = con.name + command = 'down' if con.connected else 'up' + text = f'Connect to {name}' if command == 'up' else f'Disconnect from {name}' + commandline = ['nmcli', 'connection', command, 'id', name] + return StandardItem( + id=f'vpn-{command}-{name}', + text=name, + subtext=text, + iconUrls=['xdg:network-wired'], + inputActionText=name, + actions=[Action("run", text=text, callable=lambda: runDetachedProcess(commandline))] + ) + + def handleTriggerQuery(self, query): + if query.isValid: + connections = self.getVPNConnections() + if query.string: + connections = [con for con in connections if query.string.lower() in con.name.lower()] + query.add([self.buildItem(con) for con in connections]) diff --git a/.archive/window_switcher/__init__.py b/.archive/window_switcher/__init__.py new file mode 100644 index 00000000..ea65e157 --- /dev/null +++ b/.archive/window_switcher/__init__.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +"""List and manage X11 windows. + +Synopsis: """ + +# Copyright (c) 2022 Manuel Schneider + +import subprocess +from collections import namedtuple +from albert import Item, ProcAction, iconLookup + +__title__ = "Window Switcher" +__version__ = "0.4.5" +__authors__ = ["Ed Perez", "Manuel S.", "dshoreman"] +__exec_deps__ = ["wmctrl"] + +Window = namedtuple("Window", ["wid", "desktop", "wm_class", "host", "wm_name"]) + +def handleQuery(query): + stripped = query.string.strip().lower() + if stripped: + results = [] + for line in subprocess.check_output(['wmctrl', '-l', '-x']).splitlines(): + win = Window(*parseWindow(line)) + + if win.desktop == "-1": + continue + + win_instance, win_class = win.wm_class.replace(' ', '-').split('.') + matches = [ + win_instance.lower(), + win_class.lower(), + win.wm_name.lower() + ] + + if any(stripped in match for match in matches): + iconPath = iconLookup(win_instance) or iconLookup(win_class.lower()) + results.append(Item(id="%s%s" % (__title__, win.wm_class), + icon=iconPath, + text="%s - Desktop %s" % (win_class.replace('-',' '), win.desktop), + subtext=win.wm_name, + actions=[ProcAction("Switch Window", + ["wmctrl", '-i', '-a', win.wid] ), + ProcAction("Move window to this desktop", + ["wmctrl", '-i', '-R', win.wid] ), + ProcAction("Close the window gracefully.", + ["wmctrl", '-c', win.wid])])) + return results + +def parseWindow(line): + win_id, desktop, rest = line.decode().split(None, 2) + win_class, rest = rest.split(' ', 1) + host, title = rest.strip().split(None, 1) + + return [win_id, desktop, win_class, host, title] diff --git a/.archive/xkcd/.gitignore b/.archive/xkcd/.gitignore new file mode 100644 index 00000000..8f793ad3 --- /dev/null +++ b/.archive/xkcd/.gitignore @@ -0,0 +1,72 @@ +# Compiled source # +###################### +*.com +*.class +*.dll +*.exe +*.o +*.so +*.pyc + +# Packages # +###################### +# it's better to unpack these files and commit the raw source +# git has its own built in compression methods +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Logs and databases # +###################### +*.log +*.sql +*.sqlite + +# OS generated files # +###################### +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +tags + +_build + +# CMake +build* +documentation +*.user* +log +*.dir* +*.a +*.make +doc/html* +doc/latex* + +# Python +.mypy_cache +# Python - Coverage +.coverage +coverage.xml +htmlcov/ + +# Vim +Session.vim +.netrwhist +*~ +tags +.projections.json +compile_commands.json + + +# backup files - created during sed operations +*.bak diff --git a/.archive/xkcd/LICENSE b/.archive/xkcd/LICENSE new file mode 100644 index 00000000..de8114f9 --- /dev/null +++ b/.archive/xkcd/LICENSE @@ -0,0 +1,18 @@ +Copyright 2019 Nikos Koukis + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/.archive/xkcd/README.md b/.archive/xkcd/README.md new file mode 100644 index 00000000..e271f7d1 --- /dev/null +++ b/.archive/xkcd/README.md @@ -0,0 +1,54 @@ +# Albert xkcd Plugin + + + + + + +## Description + +The xkcd Albert plugin lets you launch an xkcd comic in your browser from the +albert prompt. Here are its main features: + +* On toggle (default trigger: `xkcd`) it shows you the comics in newest-first + order. +* If you want to find a comic with a specific title then you can use fuzzy search to do so): + * `xkcd some words from the title` + +## Demo + +![demo_gif](https://github.com/bergercookie/xkcd-albert-plugin/blob/master/misc/demo.gif) + +## Motivation + +I love reading xkcd. I also wanted to get into developing plugins for Albert +thus this was the perfect opportunity to do so. + +## Manual installation instructions + +Requirements: + +- Albert - [Installation instructions](https://albertlauncher.github.io/docs/installing/) + - Albert Python Interface: v0.2 +- Python version >= 3.5 + + +Download and run the ``install-plugin.sh`` script or run the following to do +that automatically: + +``````sh +curl https://raw.githubusercontent.com/bergercookie/xkcd-albert-plugin/master/install-plugin.sh | bash +`````` + +## Self Promotion + +If you find this tool useful, please [star it on +Github](https://github.com/bergercookie/xkcd-albert-plugin) + +## TODO List + +See [ISSUES list](https://github.com/bergercookie/xkcd-albert-plugin/issues) for +the things that I'm currently either working on or interested in implementing in +the near future. In case there's something you are interesting in working on, +don't hesitate to either ask for clarifications or just do it and directly make +a PR. diff --git a/.archive/xkcd/__init__.py b/.archive/xkcd/__init__.py new file mode 100644 index 00000000..0089739b --- /dev/null +++ b/.archive/xkcd/__init__.py @@ -0,0 +1,126 @@ +"""Fetch xkcd comics like a boss.""" + +# Copyright (c) 2022 Manuel Schneider + +from datetime import datetime, timedelta +from pathlib import Path +import json +import os +import subprocess +import sys + +import albertv0 as v0 +from fuzzywuzzy import process +from shutil import which + +__iid__ = "PythonInterface/v0.2" +__prettyname__ = "xkcd" +__version__ = "0.1" +__trigger__ = "xkcd" +__author__ = "Nikos Koukis" +__dependencies__ = [] +__homepage__ = "https://github.com/bergercookie/xkcd-albert-plugin" + + +# TODO pyproject toml file +# TODO xkcd-dl executable? +# TODO Upload to github - change support url on error +# TODO Send to albert plugins + +if not which("xkcd-dl"): + raise RuntimeError("xkcd-dl not in $PATH - Please install it via pip3 first.") + +iconPath = v0.iconLookup("xkcd") +if not iconPath: + iconPath = os.path.join(os.path.dirname(__file__), "image.png") +SETTINGS_PATH = Path(v0.cacheLocation()) / "xkcd" +LAST_UPDATE_PATH = SETTINGS_PATH / "last_update" +XKCD_DICT = Path.home() / ".xkcd_dict.json" + + +def initialize(): + # Called when the extension is loaded (ticked in the settings) - blocking + + # create cache location + SETTINGS_PATH.mkdir(parents=False, exist_ok=True) + if not LAST_UPDATE_PATH.is_file(): + update_date_file() + update_xkcd_db() + + +def finalize(): + pass + + +def handleQuery(query): + results = [] + + # check whether I have downlaoded the latest metadata + with open(LAST_UPDATE_PATH, "r") as f: + date_str = float(f.readline().strip()) + + last_date = datetime.fromtimestamp(date_str) + if datetime.now() - last_date > timedelta(days=1): # run an update daily + update_date_file() + update_xkcd_db() + + if query.isTriggered: + try: + with open(XKCD_DICT, "r", encoding="utf-8") as f: + d = json.load(f) + + if len(query.string) in [0, 1]: # Display all items + for k, v in d.items(): + results.append(get_as_item(k, v)) + else: # fuzzy search + desc_to_item = {item[1]["description"]: item for item in d.items()} + matched = process.extract( + query.string.strip(), list(desc_to_item.keys()), limit=20 + ) + for m in [elem[0] for elem in matched]: + # bypass a unicode issue - use .get + item = desc_to_item.get(m) + if item: + results.append(get_as_item(*item)) + + except Exception as e: # user to report error + results.insert( + 0, + v0.Item( + id=__prettyname__, + icon=iconPath, + text="Something went wrong! Press [ENTER] to copy error and report it", + actions=[ + v0.ClipAction( + f"Copy error - report it to {__homepage__[8:]}", + f"{sys.exc_info()}", + ) + ], + ), + ) + + return results + + +def get_as_item(k: str, v: dict): + return v0.Item( + id=__prettyname__, + icon=iconPath, + text=v["description"], + subtext=v["date-published"], + completion="", + actions=[ + v0.UrlAction("Open in xkcd.com", f"https://www.xkcd.com/{k}"), + v0.ClipAction("Copy URL", f"https://www.xkcd.com/{k}"), + ], + ) + + +def update_date_file(): + now = (datetime.now() - datetime(1970, 1, 1)).total_seconds() + with open(LAST_UPDATE_PATH, "w") as f: + f.write(str(now)) + + +def update_xkcd_db(): + return subprocess.call(["xkcd-dl", "-u"]) diff --git a/.archive/xkcd/image.png b/.archive/xkcd/image.png new file mode 100644 index 00000000..690194bc Binary files /dev/null and b/.archive/xkcd/image.png differ diff --git a/.archive/xkcd/install-plugin.sh b/.archive/xkcd/install-plugin.sh new file mode 100755 index 00000000..2e98fac9 --- /dev/null +++ b/.archive/xkcd/install-plugin.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +## do this if there's any error with the installation and you want to report a bug +# set -x + +# supplementary funs ----------------------------------------------------------- +function announce +{ + echo + echo "**********************************************************************" + echo -e "$@" + echo "**********************************************************************" + echo +} + +function announce_err +{ + announce "[ERROR] $*" +} + +function install_pkg +{ + announce "Installing \"$*\"" + pip3 install --user --upgrade "$*" + announce "Installed $*" +} + +function is_installed +{ + if [ "$(which "$*" 2>&1 1>/dev/null)" = "1" ] + then + return 1 + else + return 0 + fi +} + +# Check prereqs ---------------------------------------------------------------- +ret=$(is_installed albert) +if [ "$ret" = "1" ] +then + announce_err "Please install albert first. Exiting" + return 1 +fi +ret=$(is_installed git) +if [ "$ret" = "1" ] +then + announce_err "Please install git first. Exiting" + return 1 +fi + +DST="$HOME/.local/share/albert/org.albert.extension.python/modules" +if [[ ! -d "$DST" ]] +then + announce_err "Local extensions directory doesn't exist. Please check your albert installation. Exiting" + return 1 +fi + +# Install ---------------------------------------------------------------------- +install_pkg git+https://github.com/tasdikrahman/xkcd-dl +install_pkg fuzzywuzzy + +# Seesm like the xkcd-dl beautifulsoup4 version is outdated +install_pkg beautifulsoup4 + +PLUGIN_DIR="$DST/xkcd" +if [ -d "$PLUGIN_DIR" ] +then + rm -rf "$PLUGIN_DIR" +fi +announce "Cloning and installing xkcd-albert-plugin -> $PLUGIN_DIR" +git clone https://github.com/bergercookie/xkcd-albert-plugin "$PLUGIN_DIR" +announce "Installed xkcd-albert-plugin -> $PLUGIN_DIR" + +announce "Plugin ready - Enable it from the Albert settings" diff --git a/.archive/xkcd/misc/demo.gif b/.archive/xkcd/misc/demo.gif new file mode 100644 index 00000000..4205356f Binary files /dev/null and b/.archive/xkcd/misc/demo.gif differ diff --git a/.archive/youtube/__init__.py b/.archive/youtube/__init__.py new file mode 100644 index 00000000..f1b58ec0 --- /dev/null +++ b/.archive/youtube/__init__.py @@ -0,0 +1,189 @@ +import json +import re +import tempfile +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from albert import Action, StandardItem, TriggerQuery, PluginInstance, TriggerQueryHandler, openUrl # pylint: disable=import-error + + +md_iid = '2.0' +md_version = '1.5' +md_name = 'YouTube' +md_description = 'Query and open YouTube videos and channels' +md_url = 'https://github.com/albertlauncher/python/' +md_maintainers = '@stevenxxiu' + +DATA_REGEX = re.compile(r'\b(var\s|window\[")ytInitialData("\])?\s*=\s*(.*)\s*;', re.MULTILINE) + +HEADERS = { + 'User-Agent': ( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36' + ) +} + + +def log_html(html: bytes) -> None: + log_time = time.strftime('%Y%m%d-%H%M%S') + log_name = 'albert.plugins.youtube_dump' + log_path = Path(f'/tmp/{log_name}-{log_time}.html') + + with log_path.open('wb') as sr: + sr.write(html) + + critical(f'The HTML output has been dumped to {log_path}') + critical('If the page looks ok in a browser, please include the dump in a new issue:') + critical(' https://www.github.com/albertlauncher/albert/issues/new') + + +def urlopen_with_headers(url: str) -> Any: + req = Request(headers=HEADERS, url=url) + return urlopen(req) + + +def text_from(val: dict[str, Any]) -> str: + text = val['simpleText'] if 'runs' not in val else ''.join(str(v['text']) for v in val['runs']) + + return text.strip() + + +def download_item_icon(item: StandardItem, temp_dir: Path) -> None: + url = item.icon[0] + video_id = url.split('/')[-2] + path = temp_dir / f'{video_id}.png' + with urlopen_with_headers(url) as response, path.open('wb') as sr: + sr.write(response.read()) + item.icon = ["file:" + str(path)] + + +def entry_to_item(type_, data) -> StandardItem | None: + icon = Plugin.iconUrls[0] + match type_: + case 'videoRenderer': + subtext = ['Video'] + action = 'Watch on Youtube' + url_path = f'watch?v={data["videoId"]}' + if 'lengthText' in data: + subtext.append(text_from(data['lengthText'])) + if 'shortViewCountText' in data: + subtext.append(text_from(data['shortViewCountText'])) + if 'publishedTimeText' in data: + subtext.append(text_from(data['publishedTimeText'])) + if data['thumbnail']['thumbnails']: + icon = data['thumbnail']['thumbnails'][0]['url'].split('?', 1)[0] + case 'channelRenderer': + subtext = ['Channel'] + action = 'Show on Youtube' + url_path = f'channel/{data["channelId"]}' + if 'videoCountText' in data: + subtext.append(text_from(data['videoCountText'])) + if 'subscriberCountText' in data: + subtext.append(text_from(data['subscriberCountText'])) + case _: + return None + + return StandardItem( + id=f'{md_name}/{url_path}', + text=text_from(data['title']), + subtext=' | '.join(subtext), + iconUrls=[icon], + actions=[Action(f'{md_name}/{url_path}', action, lambda: openUrl(f'https://www.youtube.com/{url_path}'))], + ) + + +def results_to_items(results: dict) -> list[StandardItem]: + items: list[Item] = [] + for result in results: + for type_, data in result.items(): + try: + item = entry_to_item(type_, data) + if item is None: + continue + items.append(item) + except KeyError as e: + critical(e) + critical(json.dumps(result, indent=4)) + return items + + +class Plugin(PluginInstance, TriggerQueryHandler): + temp_dir = None + iconUrls = [f"file:{Path(__file__).parent}/youtube.svg"] + + def __init__(self): + TriggerQueryHandler.__init__(self, + id=md_id, + name=md_name, + description=md_description, + synopsis='query', + defaultTrigger='yt ') + PluginInstance.__init__(self, extensions=[self]) + self.temp_dir = Path(tempfile.mkdtemp(prefix='albert_yt_')) + + def finalize(self) -> None: + for child in self.temp_dir.iterdir(): + child.unlink() + self.temp_dir.rmdir() + + def handleTriggerQuery(self, query: TriggerQuery) -> None: + query_str = query.string.strip() + if not query_str: + return + + # Avoid rate limiting + for _ in range(50): + time.sleep(0.01) + if not query.isValid: + return + + info(f'Searching YouTube for \'{query_str}\'') + url = f'https://www.youtube.com/results?{urlencode({"search_query": query_str})}' + + with urlopen_with_headers(url) as response: + response_bytes: bytes = response.read() + match = re.search(DATA_REGEX, response_bytes.decode()) + if match is None: + critical( + 'Failed to receive expected data from YouTube. This likely means API changes, but could just be a ' + 'failed request.' + ) + log_html(response_bytes) + return + + results = json.loads(match.group(3)) + primary_contents = results['contents']['twoColumnSearchResultsRenderer']['primaryContents'] + results = primary_contents['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'] + items = results_to_items(results) + + # Purge previous icons + for child in self.temp_dir.iterdir(): + child.unlink() + + # Download icons + with ThreadPoolExecutor(max_workers=10) as e: + for item in items: + e.submit(download_item_icon, item, self.temp_dir) + if not query.isValid: + return + + for item in items: + query.add(item) + + # Add a link to the *YouTube* page, in case there's more results, including results we didn't include + item = StandardItem( + id=f'{md_name}/show_more', + text='Show more in browser', + iconUrls=self.iconUrls, + actions=[ + Action( + f'{md_name}/show_more', + 'Show more in browser', + lambda: openUrl(f'https://www.youtube.com/results?search_query={query_str}'), + ) + ], + ) + query.add(item) diff --git a/.archive/youtube/youtube.svg b/.archive/youtube/youtube.svg new file mode 100644 index 00000000..8d07b9db --- /dev/null +++ b/.archive/youtube/youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.github/workflows/telegram_notify_comments.yml b/.github/workflows/telegram_notify_comments.yml new file mode 100644 index 00000000..a7658abd --- /dev/null +++ b/.github/workflows/telegram_notify_comments.yml @@ -0,0 +1,25 @@ +name: Telegram Notifications + +on: + + issue_comment: + types: [created] + +jobs: + notify: + + runs-on: ubuntu-latest + + steps: + - name: Send notifications to Telegram + run: > + curl -s + -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_NOTIFIER_BOT_TOKEN }}/sendMessage + -d chat_id=${{ secrets.TELEGRAM_ALBERT_CHAT_ID }} + -d text="${MESSAGE}" + -d parse_mode=HTML + -d disable_web_page_preview=true + >> /dev/null + env: + MESSAGE: "${{ github.event.comment.user.login }} on ${{ github.event.repository.name }}#${{ github.event.issue.number }}: ${{ github.event.issue.title }}%0A${{ github.event.comment.body }}" + diff --git a/.github/workflows/telegram_notify_issues.yml b/.github/workflows/telegram_notify_issues.yml new file mode 100644 index 00000000..dfcc0ab4 --- /dev/null +++ b/.github/workflows/telegram_notify_issues.yml @@ -0,0 +1,24 @@ +name: Telegram Notifications + +on: + issues: + types: [opened, reopened] + +jobs: + notify: + + runs-on: ubuntu-latest + + steps: + - name: Send notifications to Telegram + run: > + curl -s + -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_NOTIFIER_BOT_TOKEN }}/sendMessage + -d chat_id=${{ secrets.TELEGRAM_ALBERT_CHAT_ID }} + -d text="${MESSAGE}" + -d parse_mode=HTML + -d disable_web_page_preview=true + >> /dev/null + env: + MESSAGE: "New issue:%0A${{ github.event.repository.name }}#${{ github.event.issue.number }}: ${{ github.event.issue.title }}" + diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..bef49833 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +/.idea +/.vscode +/.venv +albert.pyi \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..e69de29b diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..4742ff5a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,16 @@ +## How to contribute to this repository + +### Do you have an issue? + +* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/albertlauncher/albert/issues). +* Create a new issue using the templates provided. +* Ping the authors of the related plugin. + +### Do you want to contribute code? + +* Add a copyright notice, otherwise the code is in public domain. +* You agree to publish your contribution under the MIT license. +* Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. +* Changes that do not add anything substantial to the stability, functionality, or testability will generally not be accepted. + +Thanks! :heart: diff --git a/README.md b/README.md index 17b6d7f2..95b590ae 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,3 @@ -### This is the official repository for python extensions. +# ⚠️ ARCHIVED ⚠️ -This repository is shipped with albert. If you want to have bleeding edge extensions or share your extension clone the repository. Check the docs on Python extensions. - -To install the extensions in user space type the following in your terminal: - -``` -git clone https://github.com/albertlauncher/python.git "~/.local/share/albert/org.albert.extension.python/modules" -``` - -If you send a PR I'll invite you to the reviewers team (if I don't forget it), I'd appreciate if you could review others contributions. +The plugins in this repository have been moved to [dedicated repositories](https://github.com/orgs/albertlauncher/repositories) using `history_to_submodules.sh`. diff --git a/api_test/__init__.py b/api_test/__init__.py deleted file mode 100644 index 9336b077..00000000 --- a/api_test/__init__.py +++ /dev/null @@ -1,114 +0,0 @@ -# -*- coding: utf-8 -*- - -"""This is a simple python template extension. - -This extension should show the API in a comprehensible way. Use the module docstring to provide a \ -description of the extension. The docstring should have three paragraphs: A brief description in \ -the first line, an optional elaborate description of the plugin, and finally the synopsis of the \ -extension. - -Synopsis: [delay|throw] """ - -from albertv0 import * -import os -from time import sleep - - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Api Test" -__version__ = "1.0" -__trigger__ = "test " -__author__ = "Manuel Schneider" -__dependencies__ = ["whatever"] - -iconPath = iconLookup("albert") - - -# Can be omitted -def initialize(): - pass - - -# Can be omitted -def finalize(): - pass - - -def handleQuery(query): - if not query.isTriggered: - return - - # Note that when storing a reference to query, e.g. in a closure, you must not use - # query.isValid. Apart from the query beeing invalid anyway it will crash the appplication. - # The Python type holds a pointer to the C++ type used for isValid(). The C++ type will be - # deleted when the query is finished. Therfore getting isValid will result in a SEGFAULT. - - if query.string.startswith("delay"): - sleep(2) - return Item(id=__prettyname__, - icon=os.path.dirname(__file__)+"/plugin.svg", - text="Delayed test item", - subtext="Query string: %s" % query.string) - - if query.string.startswith("throw"): - raise ValueError('EXPLICITLY REQUESTED TEST EXCEPTION!') - - info(query.string) - info(query.rawString) - info(query.trigger) - info(str(query.isTriggered)) - info(str(query.isValid)) - - critical(query.string) - warning(query.string) - debug(query.string) - debug(query.string) - - results = [] - - item = Item() - - item.icon = iconPath - item.text = 'Python item containing %s' % query.string - item.subtext = 'Python description' - item.completion = __trigger__ + 'Completion Harharhar' - item.urgency = ItemBase.Notification # Alert, Normal - info(item.icon) - info(item.text) - info(item.subtext) - info(item.completion) - info(str(item.urgency)) - def function(): info(query.string) - item.addAction(FuncAction("Print info", function)) - item.addAction(FuncAction("Print warning", lambda: warning(query.string))) - results.append(item) - - item = Item(id=__prettyname__, - icon=os.path.dirname(__file__)+"/plugin.svg", - text="This is the primary text", - subtext="This is the subtext, some kind of description", - completion=__trigger__ + 'Hellooohooo!', - urgency=ItemBase.Alert, - actions=[ - FuncAction(text="FuncAction", - callable=lambda: critical(query.string)), - ClipAction(text="ClipAction", - clipboardText="blabla"), - UrlAction(text="UrlAction", - url="https://www.google.de"), - ProcAction(text="ProcAction", - commandline=["espeak", "hello"], - cwd="~"), # optional - TermAction(text="TermAction", - commandline=["sleep", "5"], - cwd="~/git") # optional - ]) - results.append(item) - - - # Api v 0.2 - info(configLocation()) - info(cacheLocation()) - info(dataLocation()) - - return results diff --git a/api_test/plugin.svg b/api_test/plugin.svg deleted file mode 100644 index 6cbefc6d..00000000 --- a/api_test/plugin.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/arch_wiki/__init__.py b/arch_wiki/__init__.py index ed457dd1..c24c2476 100644 --- a/arch_wiki/__init__.py +++ b/arch_wiki/__init__.py @@ -1,33 +1,46 @@ # -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider -"""Search Arch Linux Wiki articles. +import json +from pathlib import Path +from time import sleep +from urllib import request, parse -Synopsis: """ +from albert import * -from albertv0 import * -from urllib import request, parse -import json -import os +md_iid = "3.0" +md_version = '2.0' +md_name = "Arch Linux Wiki" +md_description = "Search Arch Linux Wiki articles" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/arch_wiki" +md_authors = "@manuelschneid3r" -__iid__ = "PythonInterface/v0.3" -__prettyname__ = "Arch Wiki" -__version__ = "1.1" -__trigger__ = "awiki " -__author__ = "Manuel Schneider" -__dependencies__ = [] -iconPath = os.path.dirname(__file__) + "/ArchWiki.svg" -baseurl = 'https://wiki.archlinux.org/api.php' -user_agent = "org.albert.extension.python.archwiki" +class Plugin(PluginInstance, TriggerQueryHandler): + baseurl = 'https://wiki.archlinux.org/api.php' + search_url = "https://wiki.archlinux.org/index.php?search=%s" + user_agent = "org.albert.extension.python.archwiki" + iconUrls = [f"file:{Path(__file__).parent}/arch.svg"] -def handleQuery(query): - if query.isTriggered: - query.disableSort() + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) - stripped = query.string.strip() + def defaultTrigger(self): + return 'awiki ' + def handleTriggerQuery(self, query): + stripped = query.string.strip() if stripped: + + # avoid rate limiting + for _ in range(50): + sleep(0.01) + if not query.isValid: + return + results = [] params = { @@ -38,8 +51,8 @@ def handleQuery(query): 'utf8': 1, 'format': 'json' } - get_url = "%s?%s" % (baseurl, parse.urlencode(params)) - req = request.Request(get_url, headers={'User-Agent': user_agent}) + get_url = "%s?%s" % (self.baseurl, parse.urlencode(params)) + req = request.Request(get_url, headers={'User-Agent': self.user_agent}) with request.urlopen(req) as response: data = json.loads(response.read().decode()) @@ -48,28 +61,26 @@ def handleQuery(query): summary = data[2][i] url = data[3][i] - results.append(Item(id=__prettyname__, - icon=iconPath, - text=title, - subtext=summary if summary else url, - completion=title, - actions=[ - UrlAction("Open article", url), - ClipAction("Copy URL", url) - ])) + results.append(StandardItem(id=self.id(), + text=title, + subtext=summary if summary else url, + iconUrls=self.iconUrls, + actions=[ + Action("open", "Open article", lambda u=url: openUrl(u)), + Action("copy", "Copy URL", lambda u=url: setClipboardText(u)) + ])) if results: - return results - - return Item(id=__prettyname__, - icon=iconPath, - text="Search '%s'" % query.string, - subtext="No results. Start a online search on Arch Wiki", - completion=query.rawString, - actions=[UrlAction("Open search", "https://wiki.archlinux.org/index.php?search=%s" % query.string)]) + query.add(results) + else: + query.add(StandardItem(id=self.id(), + text="Search '%s'" % query.string, + subtext="No results. Start online search on Arch Wiki", + iconUrls=self.iconUrls, + actions=[Action("search", "Open search", + lambda s=query.string: openUrl(self.search_url % s))])) else: - return Item(id=__prettyname__, - icon=iconPath, - text=__prettyname__, - subtext="Enter a query to search on the Arch Wiki", - completion=query.rawString) + query.add(StandardItem(id=self.id(), + text=md_name, + iconUrls=self.iconUrls, + subtext="Enter a query to search on the Arch Wiki")) diff --git a/arch_wiki/ArchWiki.svg b/arch_wiki/arch.svg similarity index 100% rename from arch_wiki/ArchWiki.svg rename to arch_wiki/arch.svg diff --git a/aur/__init__.py b/aur/__init__.py index 5a3d0f7a..0ca8917a 100644 --- a/aur/__init__.py +++ b/aur/__init__.py @@ -1,103 +1,145 @@ # -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider -"""Query and install ArchLinux User Repository (AUR) packages. - -You can search for packages and open their URLs. This extension is also intended to be used to \ +""" +Search for packages and open their URLs. This extension is also intended to be used to \ quickly install the packages. If you are missing your favorite AUR helper tool send a PR. +""" -Synopsis: """ - -from albertv0 import * -from shutil import which +import json from datetime import datetime -from shlex import split +from pathlib import Path +from shutil import which +from time import sleep from urllib import request, parse -import json -import os -import re - -__iid__ = "PythonInterface/v0.3" -__prettyname__ = "Archlinux User Repository" -__version__ = "1.2" -__trigger__ = "aur " -__author__ = "Manuel Schneider" -__dependencies__ = [] - -iconPath = os.path.dirname(__file__)+"/arch.svg" -baseurl = 'https://aur.archlinux.org/rpc/' -install_cmdline = None - -if which("yaourt"): - install_cmdline = "yaourt -S aur/%s" -elif which("pacaur"): - install_cmdline = "pacaur -S aur/%s" - -def handleQuery(query): - if not query.isTriggered: - return - - query.disableSort() - - stripped = query.string.strip() - - if stripped: - params = { - 'v': '5', - 'type': 'search', - 'by': 'name', - 'arg': stripped - } - url = "%s?%s" % (baseurl, parse.urlencode(params)) - req = request.Request(url) - - with request.urlopen(req) as response: - data = json.loads(response.read().decode()) - if data['type'] == "error": - return Item( - id=__prettyname__, - icon=iconPath, - text="Error", - subtext=data['error'], - completion=query.rawString - ) - else: - results = [] - pattern = re.compile(query.string, re.IGNORECASE) - results_json = data['results'] - results_json.sort(key=lambda item: item['Name']) - results_json.sort(key=lambda item: len(item['Name'])) - - for entry in results_json: - name = entry['Name'] - item = Item( - id = __prettyname__, - icon = iconPath, - text = "%s %s (%s)" % (pattern.sub(lambda m: "%s" % m.group(0), name), entry['Version'], entry['NumVotes']), - completion = "%s%s" % (__trigger__, name) - ) - subtext = entry['Description'] if entry['Description'] else "[No description]" - if entry['OutOfDate']: - subtext = '[Out of date: %s] %s' % (datetime.fromtimestamp(entry['OutOfDate']).strftime("%F"), subtext) - if entry['Maintainer'] is None: - subtext = '[Orphan] %s' % subtext - item.subtext = subtext - - if install_cmdline: - tokens = split(install_cmdline % name) - item.addAction(TermAction("Install with %s" % tokens[0], tokens)) - item.addAction(TermAction("Install with %s (noconfirm)" % tokens[0], tokens + ["--noconfirm"])) - - item.addAction(UrlAction("Open AUR website", "https://aur.archlinux.org/packages/%s/" % name)) - - if entry['URL']: - item.addAction(UrlAction("Open project website", entry['URL'])) - - results.append(item) - return results - else: - return Item(id=__prettyname__, - icon=iconPath, - text=__prettyname__, - subtext="Enter a query to search the AUR", - completion=query.rawString, - actions=[UrlAction("Open AUR packages website", "https://aur.archlinux.org/packages/")]) + +from albert import * + +md_iid = "3.0" +md_version = "2.0" +md_name = "AUR" +md_description = "Query and install AUR packages" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/aur" +md_authors = "@manuelschneid3r" + + +class Plugin(PluginInstance, TriggerQueryHandler): + + aur_url = "https://aur.archlinux.org/packages/" + baseurl = 'https://aur.archlinux.org/rpc/' + iconUrls = [f"file:{Path(__file__).parent}/arch.svg"] + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + + if which("yaourt"): + self.install_cmdline = "yaourt -S aur/%s" + elif which("pacaur"): + self.install_cmdline = "pacaur -S aur/%s" + elif which("yay"): + self.install_cmdline = "yay -S aur/%s" + elif which("paru"): + self.install_cmdline = "paru -S aur/%s" + else: + info("No supported AUR helper found.") + self.install_cmdline = None + + def defaultTrigger(self): + return 'aur ' + + def configWidget(self): + return [ + { + 'type': 'label', + 'text': __doc__.strip() + } + ] + + def handleTriggerQuery(self, query): + for _ in range(50): + sleep(0.01) + if not query.isValid: + return + + stripped = query.string.strip() + if stripped: + params = { + 'v': '5', + 'type': 'search', + 'by': 'name', + 'arg': stripped + } + url = "%s?%s" % (self.baseurl, parse.urlencode(params)) + req = request.Request(url) + + with request.urlopen(req) as response: + data = json.loads(response.read().decode()) + if data['type'] == "error": + query.add(StandardItem( + id=self.id(), + text="Error", + subtext=data['error'], + iconUrls=self.iconUrls + )) + else: + results = [] + results_json = data['results'] + results_json.sort(key=lambda i: i['Name']) + results_json.sort(key=lambda i: len(i['Name'])) + + for entry in results_json: + name = entry['Name'] + item = StandardItem( + id=self.id(), + iconUrls=self.iconUrls, + text=f"{entry['Name']} {entry['Version']}" + ) + + subtext = f"⭐{entry['NumVotes']}" + if entry['Maintainer'] is None: + subtext += ', Unmaintained!' + if entry['OutOfDate']: + subtext += ', Out of date: %s' % datetime.fromtimestamp(entry['OutOfDate']).strftime("%F") + if entry['Description']: + subtext += ', %s' % entry['Description'] + item.subtext = subtext + + actions = [] + if self.install_cmdline: + pacman = self.install_cmdline.split(" ", 1)[0] + actions.append(Action( + id="inst", + text="Install using %s" % pacman, + callable=lambda n=name: runTerminal( + script=self.install_cmdline % n + " ; exec $SHELL" + ) + )) + actions.append(Action( + id="instnc", + text="Install using %s (noconfirm)" % pacman, + callable=lambda n=name: runTerminal( + script=self.install_cmdline % n + " --noconfirm ; exec $SHELL" + ) + )) + + actions.append(Action("open-aursite", "Open AUR website", + lambda n=name: openUrl(f"{self.aur_url}{n}/"))) + + if entry['URL']: + actions.append(Action("open-website", "Open project website", + lambda u=entry['URL']: openUrl(u))) + + item.actions = actions + results.append(item) + + query.add(results) + else: + query.add(StandardItem( + id=self.id(), + text=md_name, + subtext="Enter a query to search the AUR", + iconUrls=self.iconUrls, + actions=[Action("open-aur", "Open AUR packages website", lambda: openUrl(self.aur_url))] + )) diff --git a/base_converter.py b/base_converter.py deleted file mode 100644 index 23348950..00000000 --- a/base_converter.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Convert representations of numbers. - -Synopsis: - - [padding]""" - -import numpy as np - -from albertv0 import * - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Base Converter" -__version__ = "1.1" -__trigger__ = "base " -__author__ = "Manuel Schneider" -__dependencies__ = ["numpy"] - -base_keywords = {"bin": 2, "oct": 8, "dec": 10, "hex": 16} - -def buildItem(completion, src, dst, number, padding=0): - item = Item(id=__prettyname__, completion=completion) - try: - src = int(src) - dst = int(dst) - padding = int(padding) - integer = int(number, src) - item.text = np.base_repr(integer, dst) - if integer >= 0 and len(item.text) < padding: - item.text = '0'*(padding-len(item.text)) + item.text - item.subtext = "Base %s representation of %s (base %s)" % (dst, number, src) - item.addAction(ClipAction("Copy to clipboard", item.text)) - except Exception as e: - item.text = e.__class__.__name__ - item.subtext = str(e) - return item - -def handleQuery(query): - if query.isTriggered: - fields = query.string.split() - if len(fields) == 3: - return buildItem(query.rawString, fields[0], fields[1], fields[2]) - else: - item = Item(id=__prettyname__, completion=query.rawString) - item.text = __prettyname__ - item.subtext = "Enter a query in the form of \"<srcbase> <dstbase> <number>\"" - return item - else: - fields = query.string.split() - if len(fields) < 2 or fields[0] not in base_keywords: - return - src = base_keywords[fields[0]] - number = fields[1] - padding = 0 if len(fields) < 3 else fields[2] - results = [] - for dst in sorted(base_keywords.values()): - if dst == src: - continue - results.append(buildItem(query.rawString, src, dst, number, padding)) - return results diff --git a/bitwarden/__init__.py b/bitwarden/__init__.py new file mode 100644 index 00000000..87afe63a --- /dev/null +++ b/bitwarden/__init__.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- + +import time +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from subprocess import CalledProcessError, run + +from albert import * + +md_iid = "3.0" +md_version = "3.1" +md_name = "Bitwarden" +md_description = "'rbw' wrapper extension" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/bitwarden" +md_authors = ["@ovitor", "@daviddeadly", "@manuelschneid3r"] +md_bin_dependencies = ["rbw"] + +MAX_MINUTES_CACHE_TIMEOUT = 60 +DEFAULT_MINUTE_CACHE_TIMEOUT = 5 + + +@dataclass(frozen=True) +class ConfigKeys: + CACHE_TIMEOUT = "cache_timeout" + + +class Plugin(PluginInstance, TriggerQueryHandler): + _cached_items = None + _last_fetch_time = 0 + + iconUrls = [f"file:{Path(__file__).parent}/bw.svg"] + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + + self.cache_timeout = ( + self.readConfig(ConfigKeys.CACHE_TIMEOUT, int) + or DEFAULT_MINUTE_CACHE_TIMEOUT + ) + + def defaultTrigger(self): + return "bw " + + @property + def cache_timeout(self): + return int(self._cache_timeout / 60) + + @cache_timeout.setter + def cache_timeout(self, value): + self._cache_timeout = int(value * 60) + self.writeConfig(ConfigKeys.CACHE_TIMEOUT, value) + + def configWidget(self): + return [ + { + "type": "label", + "text": "Cache (result of `rbw list`) duration", + }, + { + "type": "spinbox", + "property": ConfigKeys.CACHE_TIMEOUT, + "label": f"Minutes: (max: {MAX_MINUTES_CACHE_TIMEOUT}, disable: 0)", + "widget_properties": {"maximum": MAX_MINUTES_CACHE_TIMEOUT}, + }, + ] + + def handleTriggerQuery(self, query): + results = [] + if query.string.strip().lower() == "sync": + results.append( + StandardItem( + id="sync", + text="Sync Bitwarden Vault", + iconUrls=self.iconUrls, + actions=[ + Action( + id="sync", + text="Syncing Bitwarden Vault", + callable=lambda: self._sync_vault(), + ) + ], + ) + ) + + for p in self._filter_items(query): + results.append( + StandardItem( + id=p["id"], + text=p["path"], + subtext=p["user"], + iconUrls=self.iconUrls, + actions=[ + Action( + id="copy", + text="Copy password to clipboard", + callable=lambda item=p: self._password_to_clipboard(item), + ), + Action( + id="copy-auth", + text="Copy auth code to clipboard", + callable=lambda item=p: self._code_to_clipboard(item), + ), + Action( + id="copy-username", + text="Copy username to clipboard", + callable=lambda username=p["user"]: setClipboardText( + text=username + ), + ), + Action( + id="edit", + text="Edit entry in terminal", + callable=lambda item=p: self._edit_entry(item), + ), + ], + ) + ) + + query.add(results) + + def _get_items(self): + not_first_time = self._cached_items is not None + + time_passed = time.time() - self._last_fetch_time + is_chache_fresh = time_passed < self._cache_timeout + + if not_first_time and is_chache_fresh: + return self._cached_items + + field_names = ["id", "name", "user", "folder"] + raw_items = run( + ["rbw", "list", "--fields", ",".join(field_names)], + capture_output=True, + encoding="utf-8", + check=True, + ) + + items = [] + + for line in raw_items.stdout.splitlines(): + fields = line.split("\t") + item = dict(zip(field_names, fields)) + + if item["folder"]: + item["path"] = item["folder"] + "/" + item["name"] + else: + item["path"] = item["name"] + + items.append(item) + + self._cached_items = items + self._last_fetch_time = time.time() + + return items + + def _filter_items(self, query): + passwords = self._get_items() or [] + search_fields = ["path", "user"] + # Use a set for faster membership tests + words = set(query.string.strip().lower().split()) + + filtered_passwords = [] + + for p in passwords: + match_all_words_with_any_field = all( + any(word in p[field].lower() for field in search_fields) + for word in words + ) + + if match_all_words_with_any_field: + filtered_passwords.append(p) + + return filtered_passwords + + def _sync_vault(self): + run(["rbw", "sync"], check=True) + + self._cached_items = None + self._last_fetch_time = 0 + + @staticmethod + def _password_to_clipboard(item): + rbw_id = item["id"] + + password = run( + ["rbw", "get", rbw_id], capture_output=True, encoding="utf-8", check=True + ).stdout.strip() + + setClipboardText(text=password) + + @staticmethod + def _code_to_clipboard(item): + rbw_id = item["id"] + + try: + code = run( + ["rbw", "code", rbw_id], + capture_output=True, + encoding="utf-8", + check=True, + ).stdout.strip() + except CalledProcessError as err: + code = run( + ["echo", err.__str__()], + capture_output=True, + encoding="utf-8", + check=True, + ).stdout.strip() + + setClipboardText(text=code) + + @staticmethod + def _edit_entry(item): + rbw_id = item["id"] + + runTerminal(script=f"rbw edit {rbw_id}") diff --git a/bitwarden/bw.svg b/bitwarden/bw.svg new file mode 100644 index 00000000..b487d569 --- /dev/null +++ b/bitwarden/bw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/coingecko/__init__.py b/coingecko/__init__.py new file mode 100644 index 00000000..df23f20d --- /dev/null +++ b/coingecko/__init__.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider + +from albert import * +from time import time +from urllib import request +from json import load, loads, dumps +from pathlib import Path +from threading import Thread, Event + +md_iid = "3.0" +md_version = "2.1" +md_name = "CoinGecko" +md_description = "Access CoinGecko" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/coingecko" +md_authors = "@manuelschneid3r" + + +class CoinFetcherThread(Thread): + def __init__(self, callback, path: Path): + super().__init__() + self._stop_event = Event() + self.callback = callback + self.path = path + + def _fetchCoins(self): + url = "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=250" + debug(f"Fetching data from {url}") + try: + response = request.urlopen(url, timeout=5) + if response.getcode() == 200: + json_data = loads(response.read().decode('utf-8')) + with open(self.path, 'w') as f: + f.write(dumps(json_data)) + else: + warning(f"Request failed with status code: {response.getcode()}") + except Exception as e: + warning(f"Request failed: {str(e)}") + + def run(self): + while True: + # update if older than 1h + if not self.path.is_file() or (time() - self.path.lstat().st_mtime) > 3600: + self._fetchCoins() + self.callback() + self._stop_event.wait(300) # Check every 5 mins, wakeup on stop event + if self._stop_event.is_set(): + return + + def stop(self): + self._stop_event.set() + + +class NameItem(StandardItem): + def __init__(self, + identifier: str, + name: str, + symbol: str, + rank: int, + price: float, + cap: float, + vol: float, + change24h: float): + StandardItem.__init__( + self, + id=identifier, + text=f"{name} {price} {symbol}/$", + subtext=f"#{rank}, 24h: {change24h}%, Cap: {cap:n} $, Vol: {vol:n} $", + inputActionText=str(price), + iconUrls=Plugin.iconUrls, + actions=[ + Action("show", f"Show {name} on CoinGecko", + lambda coin_id=identifier: openUrl(Plugin.coinsUrl + coin_id)), + Action("url", "Copy URL to clipboard", + lambda coin_id=identifier: setClipboardText(Plugin.coinsUrl + coin_id)) + ] + ) + self.name = name + self.symbol = symbol + + +class Plugin(PluginInstance, IndexQueryHandler): + + coinsUrl = "https://www.coingecko.com/en/coins/" + iconUrls = [f"file:{Path(__file__).parent}/coingecko.png"] + + def __init__(self): + PluginInstance.__init__(self) + IndexQueryHandler.__init__(self) + + self.items = [] + self.mtime = 0 + cache_location = self.cacheLocation() + cache_location.mkdir(parents=True, exist_ok=True) + self.coinCacheFilePath = cache_location / "coins.json" + self.thread = CoinFetcherThread(self.updateIndexItems, self.coinCacheFilePath) + self.thread.start() + + def __del__(self): + self.thread.stop() + self.thread.join() + + def defaultTrigger(self): + return 'cg ' + + def synopsis(self, query): + return "< symbol | name >" + + def updateIndexItems(self): + if self.coinCacheFilePath.is_file() and (mtime := self.coinCacheFilePath.lstat().st_mtime) > self.mtime: + self.mtime = mtime + with open(self.coinCacheFilePath) as f: + self.items.clear() + for json_object in load(f): + self.items.append(NameItem( + identifier=json_object['id'], + name=json_object['name'], + symbol=json_object['symbol'].upper(), + rank=json_object['market_cap_rank'], + price=json_object['current_price'], + cap=json_object['market_cap'], + vol=json_object['total_volume'], + change24h=json_object['price_change_percentage_24h'] + )) + + index_items = [] + for item in self.items: + index_items.append(IndexItem(item=item, string=item.name)) + index_items.append(IndexItem(item=item, string=item.symbol)) + self.setIndexItems(index_items) + + # override default trigger handling to sort by rank + def handleTriggerQuery(self, query): + m = Matcher(query.string) + query.add([item for item in self.items if m.match(item.symbol, item.name)]) diff --git a/coingecko/coingecko.png b/coingecko/coingecko.png new file mode 100644 index 00000000..ad08ef14 Binary files /dev/null and b/coingecko/coingecko.png differ diff --git a/coinmarketcap/__init__.py b/coinmarketcap/__init__.py deleted file mode 100644 index 3513cd14..00000000 --- a/coinmarketcap/__init__.py +++ /dev/null @@ -1,152 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Show and access crypto currencies on CoinmMarketCap.com. - -The values of "Change" are the hourly, daily and weekly changes of the price in percent. "Cap" is \ -the market capitalisation in USD. Volume is the volume of the last 24 hours in USD. - -Synopsis: [filter]""" - -from albertv0 import * -from threading import Thread, Event -from locale import format as lformat -from urllib import request -from urllib.parse import urlencode -import re -import os -import json - -__iid__ = "PythonInterface/v0.2" -__prettyname__ = "CoinMarketCap" -__version__ = "1.4" -__trigger__ = "cmc " -__author__ = "Manuel Schneider" -__dependencies__ = [] - -iconPath = os.path.dirname(__file__)+"/emblem-money.svg" -thread = None -coins = None - - -class Coin(): - def __init__(self, identifier, name, symbol, rank, price, - cap, vol, change_hour, change_day, change_week): - self.identifier = identifier - self.name = name - self.symbol = symbol - self.rank = rank - self.price = price - self.cap = cap - self.vol = vol - self.change_hour = change_hour - self.change_day = change_day - self.change_week = change_week - - -class UpdateThread(Thread): - def __init__(self): - super().__init__() - self._stopevent = Event() - - def run(self): - - while True: - url = "%s?%s" % ("https://api.coinmarketcap.com/v1/ticker/", urlencode({'limit': 0})) - req = request.Request(url) - with request.urlopen(req) as response: - if self._stopevent.is_set(): - return - - def colorize_float(value: str): - if value is None: - return value - elif float(value) < 0: - return "%s" % value - elif float(value) > 0: - return "%s" % value - else: - return value - - # Get coin data - data = json.loads(response.read().decode('utf-8')) - newCoins = [] - for coindata in data: - cap = coindata['market_cap_usd'] - cap = lformat("%d", float(cap), True) if cap else "?" - vol = coindata['24h_volume_usd'] - vol = lformat("%d", float(vol), True) if vol else "?" - price = coindata['price_usd'] - price_precision = "%.2f" if float(price) > 1 else "%.6f" - price = lformat(price_precision, float(price), True) if price else "?" - if "," in price: - price = price.rstrip("0").rstrip(",") - newCoins.append(Coin(identifier=coindata['id'], - name=coindata['name'], - symbol=coindata['symbol'], - rank=coindata['rank'], - price=price, - cap=cap, - vol=vol, - change_hour=colorize_float(coindata['percent_change_1h']), - change_day=colorize_float(coindata['percent_change_24h']), - change_week=colorize_float(coindata['percent_change_7d']))) - global coins - coins = newCoins - - self._stopevent.wait(900) # Sleep 15 min, wakeup on stop event - if self._stopevent.is_set(): - return - - def stop(self): - self._stop_event.set() - - -def initialize(): - thread = UpdateThread() - thread.start() - - -def finalize(): - if thread is not None: - thread.stop() - thread.join() - - -def handleQuery(query): - if not query.isTriggered or coins is None: - return - - stripped = query.string.strip().lower() - items = [] - if stripped: - pattern = re.compile(stripped, re.IGNORECASE) - for coin in coins: - if coin.name.lower().startswith(stripped) or coin.symbol.lower().startswith(stripped): - url = "https://coinmarketcap.com/currencies/%s/" % coin.identifier - items.append(Item( - id=__prettyname__, - icon=iconPath, - text="#%s %s (%s) %s$" % (coin.rank, pattern.sub(lambda m: "%s" % m.group(0), coin.name), - pattern.sub(lambda m: "%s" % m.group(0), coin.symbol), coin.price), - subtext="Change: %s/%s/%s, Cap: %s, Volume: %s" % (coin.change_hour, coin.change_day, coin.change_week, coin.cap, coin.vol), - completion=coin.price, - actions=[ - UrlAction("Show on CoinMarketCap website", url), - ClipAction('Copy URL to clipboard', url) - ] - )) - else: - for coin in coins: - url = "https://coinmarketcap.com/currencies/%s/" % coin.identifier - items.append(Item( - id=__prettyname__, - icon=iconPath, - text="#%s %s (%s) %s$" % (coin.rank, coin.name, coin.symbol, coin.price), - subtext="Change: %s/%s/%s, Cap: %s, Volume: %s" % (coin.change_hour, coin.change_day, coin.change_week, coin.cap, coin.vol), - completion=coin.price, - actions=[ - UrlAction("Show on CoinMarketCap website", url), - ClipAction('Copy URL to clipboard', url) - ] - )) - return items diff --git a/coinmarketcap/emblem-money.svg b/coinmarketcap/emblem-money.svg deleted file mode 100644 index 256f9cd8..00000000 --- a/coinmarketcap/emblem-money.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/color/__init__.py b/color/__init__.py new file mode 100644 index 00000000..482f709f --- /dev/null +++ b/color/__init__.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider + +""" +Displays a color parsed from a code, which may be in one of these formats: + +* #RGB (each of R, G, and B is a single hex digit) +* #RRGGBB +* #AARRGGBB +* #RRRGGGBBB +* #RRRRGGGGBBBB + +Note: This extension started as a prototype to test the internal color pixmap generator. However it may serve as a \ +starting point for people having a real need for color workflows. PR's welcome. +""" + +from albert import * +from string import hexdigits + +md_iid = "3.0" +md_version = "2.0" +md_name = "Color" +md_description = "Display color for color codes" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/color" +md_authors = "@manuelschneid3r" + + +class Plugin(PluginInstance, GlobalQueryHandler): + + def __init__(self): + PluginInstance.__init__(self) + GlobalQueryHandler.__init__(self) + + def defaultTrigger(self): + return '#' + + def handleGlobalQuery(self, query): + rank_items = [] + s = query.string.strip() + if s: + if s.startswith('#'): # remove hash + s = s[1:] + + # check length and hex + if any([len(s) == l for l in [3, 6, 8, 9, 12]]) and all(c in hexdigits for c in s): + rank_items.append( + RankItem( + StandardItem( + id=self.id(), + text=s, + subtext="The color for this code.", + iconUrls=[f"gen:?background=%23{s}"], + ), + 1 + ) + ) + + return rank_items + + def configWidget(self): + return [{ 'type': 'label', 'text': __doc__.strip() }] + \ No newline at end of file diff --git a/copyq.py b/copyq.py deleted file mode 100644 index f3df78a7..00000000 --- a/copyq.py +++ /dev/null @@ -1,96 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Access CopyQ clipboard. - -Synopsis: [filter]""" - -import html -import json -import re -import subprocess -from shutil import which - -from albertv0 import * - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "CopyQ" -__version__ = "1.1" -__trigger__ = "cq " -__author__ = "Manuel Schneider" -__dependencies__ = ["copyq"] - - -if which("copyq") is None: - raise Exception("'copyq' is not in $PATH.") - -iconPath = iconLookup('copyq') - -copyq_script_getAll = r""" -var result=[]; -for ( var i = 0; i < size(); ++i ) { - var obj = {}; - obj.row = i; - obj.mimetypes = str(read("?", i)).split("\n"); - obj.mimetypes.pop(); - obj.text = str(read(i)); - result.push(obj); -} -JSON.stringify(result); -""" - -copyq_script_getMatches = r""" -var result=[]; -var match = "%s"; -for ( var i = 0; i < size(); ++i ) { - if (str(read(i)).search(new RegExp(match, "i")) !== -1) { - var obj = {}; - obj.row = i; - obj.mimetypes = str(read("?", i)).split("\n"); - obj.mimetypes.pop(); - obj.text = str(read(i)); - result.push(obj); - } -} -JSON.stringify(result); -""" - -def copyq_get_matches(substring): - script = copyq_script_getMatches % substring - proc = subprocess.run(['copyq', '-'], input=script.encode(), stdout=subprocess.PIPE) - return json.loads(proc.stdout.decode()) - - -def copyq_get_all(): - proc = subprocess.run(['copyq', '-'], input=copyq_script_getAll.encode(), stdout=subprocess.PIPE) - return json.loads(proc.stdout.decode()) - - -def handleQuery(query): - if query.isTriggered: - - items = [] - pattern = re.compile(query.string, re.IGNORECASE) - json_arr = copyq_get_matches(query.string) if query.string else copyq_get_all() - for json_obj in json_arr: - row = json_obj['row'] - text = json_obj['text'] - if not text: - text = "No text" - else: - text = html.escape(" ".join(filter(None, text.replace("\n", " ").split(" ")))) - if query.string: - text = pattern.sub(lambda m: "%s" % m.group(0), text) - items.append( - Item( - id=__prettyname__, - icon=iconPath, - text=text, - subtext="%s: %s" % (row, ", ".join(json_obj['mimetypes'])), - actions=[ - ProcAction("Paste", ["copyq", "select(%s); sleep(60); paste();" % row]), - ProcAction("Copy", ["copyq", "select(%s);" % row]), - ProcAction("Remove", ["copyq", "remove(%s);" % row]), - ] - ) - ) - return items diff --git a/copyq/__init__.py b/copyq/__init__.py new file mode 100644 index 00000000..cd21adb7 --- /dev/null +++ b/copyq/__init__.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017-2024 Manuel Schneider +# Copyright (c) 2023 Oskar Haarklou Veileborg (@BarrensZeppelin) + +import json +import subprocess + +from albert import * + +md_iid = "3.0" +md_version = "2.0" +md_name = "CopyQ" +md_description = "Access CopyQ clipboard" +md_license = "BSD-2-Clause" +md_url = "https://github.com/albertlauncher/python/tree/main/copyq" +md_authors = ["@ManuelSchneid3r", "@BarrensZeppelin"] +md_bin_dependencies = ["copyq"] + + +copyq_script_getAll = r""" +var result=[]; +for ( var i = 0; i < size(); ++i ) { + var obj = {}; + obj.row = i; + obj.mimetypes = str(read("?", i)).split("\n"); + obj.mimetypes.pop(); + obj.text = str(read(i)); + result.push(obj); +} +JSON.stringify(result); +""" + +copyq_script_getMatches = r""" +var result=[]; +var match = "%s"; +for ( var i = 0; i < size(); ++i ) { + if (str(read(i)).search(new RegExp(match, "i")) !== -1) { + var obj = {}; + obj.row = i; + obj.mimetypes = str(read("?", i)).split("\n"); + obj.mimetypes.pop(); + obj.text = str(read(i)); + result.push(obj); + } +} +JSON.stringify(result); +""" + + +class Plugin(PluginInstance, TriggerQueryHandler): + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + + def defaultTrigger(self): + return "cp " + + def handleTriggerQuery(self, query): + items = [] + script = copyq_script_getMatches % query.string if query.string else copyq_script_getAll + proc = subprocess.run(["copyq", "-"], input=script.encode(), stdout=subprocess.PIPE) + json_arr = json.loads(proc.stdout.decode()) + + for json_obj in json_arr: + row = json_obj["row"] + text = json_obj["text"] + if not text: + text = "No text" + else: + text = " ".join(filter(None, text.replace("\n", " ").split(" "))) + + act = lambda s=script, r=row: ( + lambda: runDetachedProcess(["copyq", s % r]) + ) + items.append( + StandardItem( + id=self.id(), + iconUrls=["xdg:copyq"], + text=text, + subtext="%s: %s" % (row, ", ".join(json_obj["mimetypes"])), + actions=[ + Action("paste", "Paste", act("select(%s); sleep(60); paste();")), + Action("copy", "Copy", act("select(%s);")), + Action("remove", "Remove", act("remove(%s);")), + ], + ) + ) + + query.add(items) diff --git a/datetime.py b/datetime.py deleted file mode 100644 index 043c7375..00000000 --- a/datetime.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Retrieve and convert datetime strings. - -This extension provides items for 'time', 'date', 'datetime' and 'epoch' respectively 'unixtime'. \ -The latter two yield the unix timestamp and also accept a unix timestamp as parameter which will \ -be converted to a datetime string. - -Synopsis: - - [timestamp]""" - -import datetime -import time - -from albertv0 import * - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "DateTime" -__version__ = "1.0" -__author__ = "Manuel Schneider" -__dependencies__ = [] - -iconPath = iconLookup('x-office-calendar') - - -def handleQuery(query): - fields = list(filter(None, query.string.split())) - if fields: - def makeItem(text: str, subtext: str): - return Item( - id=__prettyname__, - icon=iconPath, - text=text, - subtext=subtext, - completion=query.rawString, - actions=[ClipAction("Copy to clipboard", text)] - ) - - if "date".startswith(fields[0]) and len(fields) == 1: - return makeItem(datetime.date.today().strftime("%x"), - "Current date") - elif "time".startswith(fields[0]) and len(fields) == 1: - return makeItem(datetime.datetime.now().strftime("%X"), - "Current time (local)") - elif "utc".startswith(fields[0]) and len(fields) == 1: - return makeItem(datetime.datetime.utcnow().strftime("%X"), - "Current time (UTC)") - elif "datetime".startswith(fields[0]) and len(fields) == 1: - return makeItem(datetime.datetime.now().strftime("%c"), - "Current date and time") - elif "unixtime".startswith(fields[0]) or "epoch".startswith(fields[0]): - if len(fields) == 2: - if fields[1].isdigit(): - return makeItem(time.strftime("%c", time.localtime(int(fields[1]))), - "Date and time of '%s'" % fields[1]) - else: - return Item( - id=__prettyname__, - icon=iconPath, - text="Invalid input", - subtext="Argument must be empty or numeric.", - completion=query.rawString - ) - else: - return makeItem(datetime.datetime.now().strftime("%s"), - "Current unixtime") diff --git a/dice_roll/__init__.py b/dice_roll/__init__.py new file mode 100644 index 00000000..deb9bc2c --- /dev/null +++ b/dice_roll/__init__.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Jonah Lawrence + +from __future__ import annotations + +import random +import re +from pathlib import Path + +import albert + +__doc__ = f""" +Roll any number of dice using the format `_d_`. + +Example: "roll 2d6 3d8 1d20" +""" + +md_iid = "3.0" +md_version = "2.0" +md_name = "Dice Roll" +md_description = "Roll any number of dice" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/dice_roll" +md_authors = "@DenverCoder1" + + +def get_icon_path(num_sides: int | None) -> str: + """Get the path to the icon for a die with num_sides sides. + + Args: + num_sides (Optional[int]): Number of sides on the die or None for the overall total. + + Returns: + str: The path to the icon. + """ + icons_path = Path(__file__).parent / "icons" + # get the icon for the number of sides or d20 if there is no icon for that number + icon = f"d{num_sides}" if Path(icons_path / f"d{num_sides}.svg").exists() else "d20" + # use the overall total icon if the number of sides is None + if num_sides is None: + icon = "dice" + # return the path to the icon + return str(f"file:{icons_path / f'{icon}.svg'}") + + +def roll_dice(num_dice: int, num_sides: int) -> tuple[int, list[int]]: + """Roll multiple dice with num_sides sides. + + Args: + num_dice (int): Number of dice to roll. + num_sides (int): Number of sides on each die. + + Returns: + Tuple[int, List[int]]: The total and a list of the rolls. + """ + rolls = [random.randint(1, num_sides) for _ in range(num_dice)] + return sum(rolls), rolls + + +def get_item_from_rolls( + rolls: list[int], + sum_rolls: int, + num_sides: int | None = None) -> albert.Item: + """Creates an Albert Item from a list of rolls, the total, and the number of sides. + If num_sides is not provided, an "Overall Total" summary item is created. + + Args: + rolls (List[int]): List of rolls. + sum_rolls (int): Total of all rolls. + num_sides (Optional[int]): Number of sides on each die. + + Returns: + albert.Item: The item to be added to the list of results. + """ + return albert.StandardItem( + id=get_icon_path(num_sides), + iconUrls=[get_icon_path(num_sides)], + text=( + f"Rolled {len(rolls)}d{num_sides} - Total: {sum_rolls}" + if num_sides + else f"Overall Total: {sum_rolls}" + ), + subtext=f"Rolls: {', '.join(map(str, rolls))}", + actions=[ + albert.Action( + id="copytotal", + text="Copy result to clipboard", + callable=lambda: albert.setClipboardText(text=str(sum_rolls)), + ), + albert.Action( + id="copyrolls", + text="Copy result to clipboard", + callable=lambda: albert.setClipboardText(text=", ".join(map(str, rolls))), + ), + ], + ) + + +def get_items(query_string: str) -> list[albert.Item]: + """Convert a query string of dice rolls into a list of Albert Items. + + Args: + query_string (str): The query string to be parsed. + + Returns: + List[albert.Item]: The list of items to display. + """ + results = [] + sum_all_rolls = 0 + all_rolls = [] + # get (num_dice, num_sides) pairs from query string + dice_regex = re.compile(r"(\d+)d(\d+)", re.I) + matches = dice_regex.findall(query_string) + # roll each pair + for match in matches: + num_dice, num_sides = int(match[0]), int(match[1]) + # get random numbers from 1 to num_sides for each die + sum_rolls, rolls = roll_dice(num_dice, num_sides) + # add rolls and total to aggregators + sum_all_rolls += sum_rolls + all_rolls.extend(rolls) + # create item for the dice rolls + results.append(get_item_from_rolls(rolls, sum_rolls, num_sides)) + # if there are multiple dice types, add a summary item + if len(matches) > 1: + # prepend the summary item to the list of results + results.insert(0, get_item_from_rolls(all_rolls, sum_all_rolls)) + return results + + +class Plugin(albert.PluginInstance, albert.TriggerQueryHandler): + """A plugin to roll dice""" + + def __init__(self): + albert.PluginInstance.__init__(self) + albert.TriggerQueryHandler.__init__(self) + + def synopsis(self, query): + return "d [d ...]" + + def defaultTrigger(self): + return "roll " + + def configWidget(self): + return [{ 'type': 'label', 'text': __doc__.strip() }] + + def handleTriggerQuery(self, query: albert.Query) -> None: + query_string = query.string.strip() + try: + items = get_items(query_string) + query.add(items) + except Exception: + query.add([albert.StandardItem( + id="error", + iconUrls=[get_icon_path(None)], + text="Something went wrong.", + subtext="Make sure you're using the correct format.", + )]) diff --git a/dice_roll/icons/d10.svg b/dice_roll/icons/d10.svg new file mode 100644 index 00000000..1f155355 --- /dev/null +++ b/dice_roll/icons/d10.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/dice_roll/icons/d100.svg b/dice_roll/icons/d100.svg new file mode 100644 index 00000000..4c133a01 --- /dev/null +++ b/dice_roll/icons/d100.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/dice_roll/icons/d12.svg b/dice_roll/icons/d12.svg new file mode 100644 index 00000000..d88b4cad --- /dev/null +++ b/dice_roll/icons/d12.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/dice_roll/icons/d2.svg b/dice_roll/icons/d2.svg new file mode 100644 index 00000000..fe35afa0 --- /dev/null +++ b/dice_roll/icons/d2.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/dice_roll/icons/d20.svg b/dice_roll/icons/d20.svg new file mode 100644 index 00000000..13562d7c --- /dev/null +++ b/dice_roll/icons/d20.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/dice_roll/icons/d4.svg b/dice_roll/icons/d4.svg new file mode 100644 index 00000000..c9952782 --- /dev/null +++ b/dice_roll/icons/d4.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/dice_roll/icons/d6.svg b/dice_roll/icons/d6.svg new file mode 100644 index 00000000..b0f892b2 --- /dev/null +++ b/dice_roll/icons/d6.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/dice_roll/icons/d8.svg b/dice_roll/icons/d8.svg new file mode 100644 index 00000000..381b5f80 --- /dev/null +++ b/dice_roll/icons/d8.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/dice_roll/icons/dice.svg b/dice_roll/icons/dice.svg new file mode 100644 index 00000000..880f7b9d --- /dev/null +++ b/dice_roll/icons/dice.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docker/__init__.py b/docker/__init__.py new file mode 100644 index 00000000..f6639c03 --- /dev/null +++ b/docker/__init__.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider + +from pathlib import Path + +import docker +from albert import * + +md_iid = "3.0" +md_version = "4.0" +md_name = "Docker" +md_description = "Manage docker images and containers" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/docker" +md_authors = "@manuelschneid3r" +md_bin_dependencies = "docker" +md_lib_dependencies = "docker" + + +class Plugin(PluginInstance, TriggerQueryHandler): + # Global query handler not applicable, queries take seconds sometimes + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + self.icon_urls_running = [f"file:{Path(__file__).parent}/running.png"] + self.icon_urls_stopped = [f"file:{Path(__file__).parent}/stopped.png"] + self.client = None + + def synopsis(self, query): + return "" + + def defaultTrigger(self): + return "d " + + def handleTriggerQuery(self, query): + items = [] + + if not self.client: + try: + self.client = docker.from_env() + except Exception as e: + items.append(StandardItem( + id='except', + text="Failed starting docker client", + subtext=str(e), + iconUrls=self.icon_urls_running, + )) + return items + + try: + for container in self.client.containers.list(all=True): + if query.string in container.name: + # Create dynamic actions + if container.status == 'running': + actions = [Action("stop", "Stop container", lambda c=container: c.stop()), + Action("restart", "Restart container", lambda c=container: c.restart())] + else: + actions = [Action("start", "Start container", lambda c=container: c.start())] + actions.extend([ + Action("logs", "Logs", + lambda c=container.id: runTerminal("docker logs -f %s ; exec $SHELL" % c)), + Action("remove", "Remove (forced, with volumes)", + lambda c=container: c.remove(v=True, force=True)), + Action("copy-id", "Copy id to clipboard", + lambda cid=container.id: setClipboardText(cid)) + ]) + + items.append(StandardItem( + id=container.id, + text="%s (%s)" % (container.name, ", ".join(container.image.tags)), + subtext="Container: %s" % container.id, + iconUrls=self.icon_urls_running if container.status == 'running' else self.icon_urls_stopped, + actions=actions + )) + + for image in reversed(self.client.images.list()): + for tag in sorted(image.tags, key=len): # order by resulting score + if query.string in tag: + items.append(StandardItem( + id=image.short_id, + text=", ".join(image.tags), + subtext="Image: %s" % image.id, + iconUrls=self.icon_urls_stopped, + actions=[ + # Action("run", "Run with command: %s" % query.string, + # lambda i=image, s=query.string: client.containers.run(i, s)), + Action("rmi", "Remove image", lambda i=image: i.remove()) + ] + )) + except Exception as e: + warning(str(e)) + self.client = None + + query.add(items) diff --git a/docker/running.png b/docker/running.png new file mode 100644 index 00000000..126af0bd Binary files /dev/null and b/docker/running.png differ diff --git a/docker/stopped.png b/docker/stopped.png new file mode 100644 index 00000000..4fe48bf1 Binary files /dev/null and b/docker/stopped.png differ diff --git a/duckduckgo/__init__.py b/duckduckgo/__init__.py new file mode 100644 index 00000000..3666115e --- /dev/null +++ b/duckduckgo/__init__.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider + +""" +Inline DuckDuckGo web search using the 'duckduckgo-search' library. +""" + +from albert import * +from pathlib import Path +from duckduckgo_search import DDGS +from itertools import islice +from time import sleep + +md_iid = "3.0" +md_version = "2.0" +md_name = 'DuckDuckGo' +md_description = 'Inline DuckDuckGo web search' +md_license = "MIT" +md_url = 'https://github.com/albertlauncher/python/tree/main/duckduckgo' +md_lib_dependencies = "duckduckgo-search" +md_authors = "@manuelschneid3r" + + +class Plugin(PluginInstance, TriggerQueryHandler): + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + self.ddg = DDGS() + self.iconUrls = [f"file:{Path(__file__).parent}/duckduckgo.svg"] + + def defaultTrigger(self): + return "ddg " + + def handleTriggerQuery(self, query): + + stripped = query.string.strip() + if stripped: + + # dont flood + for _ in range(25): + sleep(0.01) + if not query.isValid: + return + + for r in islice(self.ddg.text(stripped, safesearch='off'), 10): + query.add( + StandardItem( + id=self.id(), + text=r['title'], + subtext=r['body'], + iconUrls=self.iconUrls, + actions=[Action("open", "Open link", lambda u=r['href']: openUrl(u))] + ) + ) diff --git a/duckduckgo/duckduckgo.svg b/duckduckgo/duckduckgo.svg new file mode 100644 index 00000000..87f27951 --- /dev/null +++ b/duckduckgo/duckduckgo.svg @@ -0,0 +1,118 @@ + + diff --git a/emoji/__init__.py b/emoji/__init__.py new file mode 100644 index 00000000..49bfa0a3 --- /dev/null +++ b/emoji/__init__.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider + +import json +import re +import threading +import urllib.request +import builtins +from locale import getdefaultlocale +from pathlib import Path + +from albert import * + +md_iid = "3.0" +md_version = "3.1" +md_name = "Emoji" +md_description = "Find and copy emojis by name" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/emoji" +md_authors = "@manuelschneid3r" + + +class Plugin(PluginInstance, IndexQueryHandler): + + def __init__(self): + PluginInstance.__init__(self) + IndexQueryHandler.__init__(self) + self.thread = None + + self._use_derived = self.readConfig('use_derived', bool) + if self._use_derived is None: + self._use_derived = False + + def __del__(self): + if self.thread and self.thread.is_alive(): + self.thread.join() + + def defaultTrigger(self): + return ':' + + @property + def use_derived(self): + return self._use_derived + + @use_derived.setter + def use_derived(self, value): + self._use_derived = value + self.writeConfig('use_derived', value) + self.updateIndexItems() + + def configWidget(self): + return [ + { + 'type': 'checkbox', + 'property': 'use_derived', + 'label': 'Use derived emojis' + } + ] + + def updateIndexItems(self): + if self.thread and self.thread.is_alive(): + self.thread.join() + self.thread = threading.Thread(target=self.update_index_items_task) + self.thread.start() + + def update_index_items_task(self): + + def download_file(url: str, path: Path): + debug(f"Downloading {url}.") + headers = {'User-Agent': 'Mozilla/5.0'} # otherwise github returns html + request = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(request, timeout=3) as response: + if response.getcode() == 200: + debug(f"Success. Storing to {path}.") + with builtins.open(path, 'wb') as file: + file.write(response.read()) + else: + raise RuntimeError(f"Failed to download {url}. Status code: {response.getcode()}") + + def get_fully_qualified_emojis(cache_path: Path) -> list: + """Returns fully qualified emoji strings""" + + def convert_to_unicode_char(hex_code: str): + return chr(int(hex_code, 16)) + + def convert_to_unicode_str(hex_codes: str): + hex_list = hex_codes.split() + return ''.join([convert_to_unicode_char(hex_code) for hex_code in hex_list]) + + path = cache_path / 'emoji_list.txt' + if not path.is_file(): + info("Fetching emoji list.") + url = 'https://unicode.org/Public/emoji/latest/emoji-test.txt' + download_file(url, path) + + # components = set() + fully_qualified = [] + + with path.open("r") as f: + + emoji_list_re_str = r""" + ^ + (?P .*\S) + \s*;\s* + (?P \S+) + \s*\#\s* + (?P \S+) + \s* + (?P E\d+.\d+) + \s* + (?P [^:]+) + (?: : \s* (?P .+))? + \n + $ + """ + + line_re = re.compile(emoji_list_re_str, re.VERBOSE) + for line in f: + if match := line_re.match(line): + if match.group("status") == "fully-qualified": + fully_qualified.append(convert_to_unicode_str(match.group("codepoints"))) + + return fully_qualified + + def get_annotations(cache_path: Path, use_derived: bool) -> dict: + + # determine locale + + if lang := getdefaultlocale()[0]: + lang = lang[0:2] + else: + warning("Failed getting locale. There will be no localized emoji aliases.") + lang = 'en' + + # fetch localized cldr annotations 'full' + + path_full = cache_path / f'emoji_annotations_full_{lang}.json' + if not path_full.is_file(): + url = 'https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/' \ + 'cldr-annotations-full/annotations/%s/annotations.json' % lang + download_file(url, path_full) + + with path_full.open("r", encoding='utf-8') as file_full: + json_full = json.load(file_full)['annotations']['annotations'] + + if not use_derived: + return json_full + + # fetch localized cldr annotations 'derived' + + path_derived = cache_path / f'emoji_annotations_derived_{lang}.json' + if not path_derived.is_file(): + url = 'https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/' \ + 'cldr-annotations-derived-full/annotationsDerived/%s/annotations.json' % lang + download_file(url, path_derived) + + # open, read, parse, merge, return + + with path_derived.open("r", encoding='utf-8') as file_derived: + json_derived = json.load(file_derived)['annotationsDerived']['annotations'] + return json_full | json_derived + + cache_location = self.cacheLocation() + cache_location.mkdir(parents=True, exist_ok=True) + emojis = get_fully_qualified_emojis(cache_location) + annotations = get_annotations(cache_location, self.use_derived) + + def remove_redundancy(sentences): + sets_of_words = [set(sentence.lower().split()) for sentence in sentences] + unique = [] + for sow, sentence in zip(sets_of_words, sentences): + for other_sow in sets_of_words: + if sow != other_sow: + if all([any([oword.startswith(word) for oword in other_sow]) for word in sow]): + break + else: + unique.append(sentence) + return unique + + index_items = [] + for emoji in emojis: + try: + ann = annotations[emoji] + except KeyError: + try: + non_rgi_emoji = emoji.replace('\uFE0F', '') + ann = annotations[non_rgi_emoji] + except KeyError as e: + debug(f"Found no translation for {e}. Emoji will not be available.") + continue + + title = ann['tts'][0] + aliases = remove_redundancy([title.replace(':', '').replace(',', ''), *ann['default']]) + + actions = [] + if havePasteSupport(): + actions.append( + Action( + "paste", "Copy and paste to front-most window", + lambda emj=emoji: setClipboardTextAndPaste(emj) + ) + ) + + actions.append( + Action( + "copy", "Copy to clipboard", + lambda emj=emoji: setClipboardText(emj) + ) + ) + + item = StandardItem( + id=emoji, + text=title.capitalize(), + subtext=", ".join([a.capitalize() for a in aliases]), + iconUrls=[f"gen:?text={emoji}"], + actions=actions + ) + + for alias in aliases: + index_items.append(IndexItem(item=item, string=alias)) + + self.setIndexItems(index_items) diff --git a/goldendict.py b/goldendict.py deleted file mode 100644 index d77ae7b4..00000000 --- a/goldendict.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Fire up an external search in GoldenDict. - -Synopsis: """ - -from shutil import which -from subprocess import run - -from albertv0 import Item, ProcAction, iconLookup - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "GoldenDict" -__version__ = "1.0" -__trigger__ = "gd " -__author__ = "Manuel Schneider" -__dependencies__ = ["goldendict"] - -if which("goldendict") is None: - raise Exception("'goldendict' is not in $PATH.") - -iconPath = iconLookup('goldendict') - - -def handleQuery(query): - if query.isTriggered: - return Item(id=__prettyname__, - icon=iconPath, - text=__prettyname__, - subtext="Look up '%s' using %s" % (query.string, __prettyname__), - completion=query.rawString, - actions=[ProcAction("Start query in %s" % __prettyname__, - ["goldendict", query.string])]) diff --git a/goldendict/__init__.py b/goldendict/__init__.py new file mode 100644 index 00000000..c25c72bb --- /dev/null +++ b/goldendict/__init__.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017-2024 Manuel Schneider + +import os +import shutil + +from albert import * + +md_iid = "3.0" +md_version = "2.0" +md_name = "GoldenDict" +md_description = "Quick access to GoldenDict" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/goldendict" +md_authors = "@manuelschneid3r" + + +class Plugin(PluginInstance, TriggerQueryHandler): + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + + commands = [ + '/var/lib/flatpak/exports/bin/org.goldendict.GoldenDict', # flatpak + '/var/lib/flatpak/exports/bin/io.github.xiaoyifang.goldendict_ng', # flatpak ng + 'goldendict', # native + 'goldendict-ng', # native ng + ] + + executables = [e for e in [shutil.which(c) for c in commands] if e] + + if not executables: + raise RuntimeError(f'None of the GoldenDict distributions found.') + + self.executable = executables[0] + self.iconUrls = [f'xdg:{os.path.basename(self.executable)}'] + + if len(executables) > 1: + warning(f"Multiple GoldenDict commands found: {', '.join(executables)}") + warning(f"Using {self.executable}") + + def defaultTrigger(self): + return "gd " + + def handleTriggerQuery(self, query): + q = query.string.strip() + query.add( + StandardItem( + id=md_name, + text=md_name, + subtext=f"Look up '{q}' in GoldenDict", + iconUrls=self.iconUrls, + actions=[Action(md_name, md_name, lambda e=self.executable: runDetachedProcess([e, q]))], + ) + ) diff --git a/history_to_submodules.sh b/history_to_submodules.sh new file mode 100755 index 00000000..054cc79c --- /dev/null +++ b/history_to_submodules.sh @@ -0,0 +1,106 @@ +#! /usr/bin/env bash + +#set -x +set -e + +# Find history dirs +# git log --pretty=format: --name-only | grep -E '^(.archive/)?[^/]+' -o | sort -u | uniq + +git clone --bare git@github.com:albertlauncher/python.git .bare || true + +function filter ( + + if [[ $# -lt 2 ]]; then + echo "Error: At least two arguments required." + return 1 + fi + + local repo_name="$(echo $1 | tr '_' '-')" + local filter_path="$2" + shift 2 + + declare -a additional_filter_paths + if [[ $# -gt 0 ]]; then + additional_filter_paths=$(printf -- "--path %s " "$@") + fi + + echo "ℹ️" $repo_name $filter_path $additional_filter_paths + + git clone .bare $repo_name + cd $repo_name + git filter-repo --subdirectory-filter ${filter_path} ${additional_filter_paths[@]} + + gh repo create "albertlauncher/albert-plugin-python-$repo_name" --public --disable-wiki + git remote add origin "git@github.com:albertlauncher/albert-plugin-python-$repo_name.git" + git push -f --set-upstream origin main + open "https://github.com/albertlauncher/albert-plugin-python-$repo_name" + + cd .. +) + + +filter arch_wiki arch_wiki .archive/arch_wiki ArchWiki +filter atom_projects atom_projects atom_projects.py .archive/atom_projects AtomProjects.py +filter aur aur .archive/aur AUR +filter base_converter base_converter base_converter.py .archive/base_converter BaseConverter.py +filter binance binance .archive/binance Binance +filter bitfinex bitfinex .archive/bitfinex Bitfinex +filter bitwarden bitwarden bitwarden.py .archive/bitwarden +filter coingecko coingecko +filter coinmarketcap coinmarketcap .archive/coinmarketcap CoinMarketCap +filter color color +filter copyq copyq copyq.py .archive/copyq CopyQ.py +filter currency_converter currency_converter currency_converter.py .archive/currency_converter Currency.py +filter dango_emoji dango_emoji .archive/dango_emoji dangoemoji +filter dango_kao dango_kao .archive/dango_kao dangokao +filter datetime datetime datetime.py .archive/datetime DateTime.py epoch Epoch.py +filter dice_roll dice_roll .archive/dice_roll +filter docker docker .archive/docker +filter duckduckgo duckduckgo +filter emoji emoji EmojiPicker unicode_emoji .archive/unicode_emoji +filter find find find .archive/find +filter fortune fortune fortune.py .archive/fortune Fortune.py +filter gnome_dictionary gnome_dictionary gnome_dictionary.py .archive/gnome_dictionary GnomeDictionary.py +filter gnote gnote gnote.py .archive/gnote Gnote.py +filter goldendict goldendict goldendict.py .archive/goldendict GoldenDict.py +filter google_translate google_translate google_translate.py .archive/google_translate GoogleTranslate.py +filter googletrans googletrans .archive/googletrans +filter inhibit_sleep inhibit_sleep .archive/inhibit_sleep +filter ip ip ip.py .archive/ip Ip.py +filter jetbrains_projects jetbrains_projects jetbrains-projects.py .archive/jetbrains_projects JetbrainsProjects +filter kill kill kill.py .archive/kill Kill.py +filter locate locate locate.py .archive/locate Locate.py +filter lpass lpass .archive/lpass +filter mathematica_eval mathematica_eval mathematica_eval.py .archive/mathematica_eval +filter multi_google_translate multi_google_translate multi_google_translate.py .archive/multi_google_translate MultiGoogleTranslate.py +filter node_eval node_eval .archive/node_eval +filter npm npm .archive/npm Npm +filter packagist packagist .archive/packagist Packagist +filter pacman pacman pacman.py .archive/pacman Pacman.py +filter pass pass pass.py .archive/pass Pass.py +filter php_eval php_eval .archive/php_eval +filter pidgin pidgin pidgin.py .archive/pidgin Pidgin.py +filter pomodoro pomodoro .archive/pomodoro Pomodoro +filter python_eval python_eval python Python +filter rand rand .archive/rand +filter scrot scrot scrot.py Scrot.py .archive/scrot +filter syncthing syncthing +filter tex_to_unicode tex_to_unicode tex_to_unicode.py .archive/tex_to_unicode +filter texdoc texdoc .archive/texdoc +filter timer timer Timer .archive/timer +filter tomboy tomboy tomboy.py Tomboy.py .archive/tomboy +filter translators translators +filter trash trash trash.py Trash.py .archive/trash +filter unit_converter unit_converter .archive/unit_converter +filter units units units.py Units.py .archive/units +filter virtualbox virtualbox virtualbox.py VirtualBox.py .archive/virtualbox +filter vpn vpn vpn.py .archive/vpn +filter vscode_projects vscode_projects +filter wikipedia wikipedia Wikipedia Wikipedia.py +filter x_window_switcher x_window_switcher window_switcher window_switcher.py WindowSwitcher.py SwitchApp.py .archive/window_switcher +filter xkcd xkcd .archive/xkcd +filter youtube youtube youtube.py Youtube.py .archive/youtube +filter zeal zeal zeal.py Zeal.py .archive/zeal + + + diff --git a/jetbrains_projects/__init__.py b/jetbrains_projects/__init__.py index 44c9b769..06229396 100644 --- a/jetbrains_projects/__init__.py +++ b/jetbrains_projects/__init__.py @@ -1,129 +1,291 @@ # -*- coding: utf-8 -*- +# Copyright (c) 2018-2023 Thomas Queste +# Copyright (c) 2023 Valentin Maerten -"""List and open JetBrains IDE projects. +""" +This plugin allows you to quickly open projects of the Jetbrains IDEs -Synopsis: """ +- Android Studio +- Aqua +- CLion +- DataGrip +- DataSpell +- GoLand +- IntelliJ IDEA +- PhpStorm +- PyCharm +- Rider +- RubyMine +- RustRover +- WebStorm +- Writerside. -import os +Note that for this plugin to find the IDEs, a commandline launcher in $PATH is required. +Open the IDE and click Tools -> Create Command-line Launcher to add one. +""" + +from dataclasses import dataclass +from pathlib import Path +from typing import Union, List from shutil import which +from sys import platform from xml.etree import ElementTree +from albert import * + +md_iid = "3.0" +md_version = "4.1" +md_name = "Jetbrains projects" +md_description = "Open your JetBrains projects" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/jetbrains_projects" +md_authors = ["@tomsquest", "@vmaerten", "@manuelschneid3r", "@d3v2a"] + + +@dataclass +class Project: + name: str + path: str + last_opened: int + + +@dataclass +class Editor: + name: str + icon: Path + config_dir_prefix: str + binary: str + + # Rider calls recentProjects.xml -> recentSolutions.xml and in it RecentProjectsManager -> RiderRecentProjectsManager + is_rider: bool + + def __init__( + self, + name: str, + icon: Path, + config_dir_prefix: str, + binaries: list[str], + is_rider = False): + self.name = name + self.icon = icon + self.config_dir_prefix = config_dir_prefix + self.binary = self._find_binary(binaries) + self.is_rider = is_rider -from albertv0 import * - -__iid__ = "PythonInterface/v0.3" -__prettyname__ = "Jetbrains IDE Projects" -__version__ = "1.2" -__trigger__ = "jb " -__author__ = "Markus Richter, Thomas Queste" -__dependencies__ = [] - -default_icon = os.path.dirname(__file__) + "/jetbrains.svg" -HOME_DIR = os.environ["HOME"] - -paths = [ # , - ["CLion", "clion"], - ["DataGrip", "datagrip"], - ["GoLand", "goland"], - ["IntelliJIdea", - "intellij-idea-ue-bundled-jre intellij-idea-ultimate-edition idea-ce-eap idea-ue-eap idea idea-ultimate"], - ["PhpStorm", "phpstorm"], - ["PyCharm", "pycharm pycharm-eap charm"], - ["WebStorm", "webstorm"], -] - - -# find the executable path and icon of a program described by space-separated lists of possible binary-names -def find_exec(namestr: str): - for name in namestr.split(" "): - executable = which(name) - if executable: - icon = iconLookup(name) or default_icon - return executable, icon - return None - - -# parse the xml at path, return all recent project paths and the time they were last open -def get_proj(path): - r = ElementTree.parse(path).getroot() # type:ElementTree.Element - add_info = None - items = dict() - for o in r[0]: # type:ElementTree.Element - if o.attrib["name"] == 'recentPaths': - for i in o[0]: - items[i.attrib["value"]] = 0 + @staticmethod + def _find_binary(binaries: list[str]) -> Union[str, None]: + for binary in binaries: + if which(binary): + return binary + return None + def list_projects(self) -> List[Project]: + config_dir = Path.home() / ".config" + if platform == "darwin": + config_dir = Path.home() / "Library" / "Application Support" + + dirs = list(config_dir.glob(f"{self.config_dir_prefix}*/")) + if not dirs: + return [] + latest = sorted(dirs)[-1] + if not self.is_rider: + recent_projects_xml = "recentProjects.xml" else: - if o.attrib["name"] == 'additionalInfo': - add_info = o[0] - - if len(items) == 0: - return [] - - if add_info is not None: - for i in add_info: - for o in i[0][0]: - if o.attrib["name"] == 'projectOpenTimestamp': - items[i.attrib["key"]] = int(o.attrib["value"]) - return [(items[e], e.replace("$USER_HOME$", HOME_DIR)) for e in items] - - -def handleQuery(query): - if query.isTriggered: - query.disableSort() - - binaries = {} - projects = [] - - for app in paths: - config_path = "config/options/recentProjectDirectories.xml" - if app[0] == "IntelliJIdea": - config_path = "config/options/recentProjects.xml" - - # dirs contains possibly multiple directories for a program (eg. .GoLand2018.1 and .GoLand2017.3) - dirs = [f for f in os.listdir(HOME_DIR) if - os.path.isdir(os.path.join(HOME_DIR, f)) and f.startswith("." + app[0])] - # take the newest - dirs.sort(reverse=True) - if len(dirs) == 0: - continue - - config_path = os.path.join(HOME_DIR, dirs[0], config_path) - if not os.path.exists(config_path): - continue - - # extract the binary name and icon - binaries[app[0]] = find_exec(app[1]) - - # add all recently opened projects - projects.extend([[e[0], e[1], app[0]] for e in get_proj(config_path)]) - projects.sort(key=lambda s: s[0], reverse=True) - - # List all projects or the one corresponding to the query - if query.string: - projects = [p for p in projects if p[1].lower().find(query.string.lower()) != -1] - - items = [] - for p in projects: - last_update = p[0] - project_path = p[1] - project_dir = project_path.split("/")[-1] - product_name = p[2] - binary = binaries[product_name] - if not binary: - continue - - executable = binary[0] - icon = binary[1] - - items.append(Item( - id="-" + str(last_update), - icon=icon, - text=project_dir, - subtext=project_path, - completion=__trigger__ + project_dir, - actions=[ - ProcAction("Open in %s" % product_name, [executable, project_path]) - ] - )) - - return items + recent_projects_xml = "recentSolutions.xml" + return self._parse_recent_projects(Path(latest) / "options" / recent_projects_xml) + + def _parse_recent_projects(self, recent_projects_file: Path) -> list[Project]: + try: + root = ElementTree.parse(recent_projects_file).getroot() + if not self.is_rider: + entries = root.findall(".//component[@name='RecentProjectsManager']//entry[@key]") + else: + entries = root.findall(".//component[@name='RiderRecentProjectsManager']//entry[@key]") + + projects = [] + for entry in entries: + project_path = entry.attrib["key"] + project_path = project_path.replace("$USER_HOME$", str(Path.home())) + project_name = Path(project_path).name + files = Path(project_path + "/.idea").glob("*.iml") + tag_opened = entry.find(".//option[@name='projectOpenTimestamp']") + last_opened = tag_opened.attrib["value"] if tag_opened is not None and "value" in tag_opened.attrib else None + + if project_path and last_opened: + projects.append( + Project(name=project_name, path=project_path, last_opened=int(last_opened)) + ) + for file in files: + name = file.name.replace(".iml", "") + if name != project_name: + projects.append(Project(name=name, path=project_path, last_opened=int(last_opened))) + + return projects + except (ElementTree.ParseError, FileNotFoundError): + return [] + + +class Plugin(PluginInstance, TriggerQueryHandler): + + executables = [] + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + + self.fuzzy = False + + self._match_path = self.readConfig('match_path', bool) + if self._match_path is None: + self._match_path = False + + plugin_dir = Path(__file__).parent + editors = [ + Editor( + name="Android Studio", + icon=plugin_dir / "icons" / "androidstudio.svg", + config_dir_prefix="Google/AndroidStudio", + binaries=["studio", "androidstudio", "android-studio", "android-studio-canary", "jdk-android-studio", + "android-studio-system-jdk"]), + Editor( + name="Aqua", + icon=plugin_dir / "icons" / "aqua.svg", + config_dir_prefix="JetBrains/Aqua", + binaries=["aqua", "aqua-eap"]), + Editor( + name="CLion", + icon=plugin_dir / "icons" / "clion.svg", + config_dir_prefix="JetBrains/CLion", + binaries=["clion", "clion-eap"]), + Editor( + name="DataGrip", + icon=plugin_dir / "icons" / "datagrip.svg", + config_dir_prefix="JetBrains/DataGrip", + binaries=["datagrip", "datagrip-eap"]), + Editor( + name="DataSpell", + icon=plugin_dir / "icons" / "dataspell.svg", + config_dir_prefix="JetBrains/DataSpell", + binaries=["dataspell", "dataspell-eap"]), + Editor( + name="GoLand", + icon=plugin_dir / "icons" / "goland.svg", + config_dir_prefix="JetBrains/GoLand", + binaries=["goland", "goland-eap"]), + Editor( + name="IntelliJ IDEA", + icon=plugin_dir / "icons" / "idea.svg", + config_dir_prefix="JetBrains/IntelliJIdea", + binaries=["idea", "idea.sh", "idea-ultimate", "idea-ce-eap", "idea-ue-eap", "intellij-idea-ce", + "intellij-idea-ce-eap", "intellij-idea-ue-bundled-jre", "intellij-idea-ultimate-edition", + "intellij-idea-community-edition-jre", "intellij-idea-community-edition-no-jre"]), + Editor( + name="PhpStorm", + icon=plugin_dir / "icons" / "phpstorm.svg", + config_dir_prefix="JetBrains/PhpStorm", + binaries=["phpstorm", "phpstorm-eap"]), + Editor( + name="PyCharm", + icon=plugin_dir / "icons" / "pycharm.svg", + config_dir_prefix="JetBrains/PyCharm", + binaries=["charm", "pycharm", "pycharm-eap", "pycharm-professional"]), + Editor( + name="Rider", + icon=plugin_dir / "icons" / "rider.svg", + config_dir_prefix="JetBrains/Rider", + binaries=["rider", "rider-eap"], + is_rider=True), + Editor( + name="RubyMine", + icon=plugin_dir / "icons" / "rubymine.svg", + config_dir_prefix="JetBrains/RubyMine", + binaries=["rubymine", "rubymine-eap", "jetbrains-rubymine", "jetbrains-rubymine-eap"]), + Editor( + name="RustRover", + icon=plugin_dir / "icons" / "rustrover.svg", + config_dir_prefix="JetBrains/RustRover", + binaries=["rustrover", "rustrover-eap"]), + Editor( + name="WebStorm", + icon=plugin_dir / "icons" / "webstorm.svg", + config_dir_prefix="JetBrains/WebStorm", + binaries=["webstorm", "webstorm-eap"]), + Editor( + name="Writerside", + icon=plugin_dir / "icons" / "writerside.svg", + config_dir_prefix="JetBrains/Writerside", + binaries=["writerside", "writerside-eap"]), + ] + self.editors = [e for e in editors if e.binary is not None] + + @property + def match_path(self): + return self._match_path + + @match_path.setter + def match_path(self, value): + self._match_path = value + self.writeConfig('match_path', value) + + def supportsFuzzyMatching(self): + return True + + def setFuzzyMatching(self, enabled): + self.fuzzy = enabled + + def defaultTrigger(self): + return "jb " + + def handleTriggerQuery(self, query: Query): + editor_project_pairs = [] + + m = Matcher(query.string, MatchConfig(fuzzy=self.fuzzy)) + + for editor in self.editors: + for project in editor.list_projects(): + if Path(project.path).exists(): + if self._match_path: + if m.match(project.name, project.path): + editor_project_pairs.append((editor, project)) + else: + if m.match(project.name): + editor_project_pairs.append((editor, project)) + + # sort by last opened + editor_project_pairs.sort(key=lambda pair: pair[1].last_opened, reverse=True) + + query.add([self._make_item(editor, project) for editor, project in editor_project_pairs]) + + @staticmethod + def _make_item(editor: Editor, project: Project) -> Item: + return StandardItem( + id="%s-%s-%s" % (editor.binary, project.path, project.last_opened), + text=project.name, + subtext=project.path, + inputActionText=project.name, + iconUrls=["file:" + str(editor.icon)], + actions=[ + Action( + "Open", + "Open in %s" % editor.name, + lambda selected_project=project.path: runDetachedProcess( + [editor.binary, selected_project] + ), + ) + ], + ) + + def configWidget(self): + return [ + { + 'type': 'checkbox', + 'property': 'match_path', + 'label': 'Match path' + }, + { + 'type': 'label', + 'text': __doc__.strip(), + 'widget_properties': { + 'textFormat': 'Qt::MarkdownText' + } + } + ] diff --git a/jetbrains_projects/icons/androidstudio.svg b/jetbrains_projects/icons/androidstudio.svg new file mode 100644 index 00000000..98fc3c6e --- /dev/null +++ b/jetbrains_projects/icons/androidstudio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jetbrains_projects/icons/aqua.svg b/jetbrains_projects/icons/aqua.svg new file mode 100644 index 00000000..a2d7e161 --- /dev/null +++ b/jetbrains_projects/icons/aqua.svg @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jetbrains_projects/icons/clion.svg b/jetbrains_projects/icons/clion.svg new file mode 100644 index 00000000..001a2da6 --- /dev/null +++ b/jetbrains_projects/icons/clion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jetbrains_projects/icons/datagrip.svg b/jetbrains_projects/icons/datagrip.svg new file mode 100644 index 00000000..5931f912 --- /dev/null +++ b/jetbrains_projects/icons/datagrip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jetbrains_projects/icons/dataspell.svg b/jetbrains_projects/icons/dataspell.svg new file mode 100644 index 00000000..44cf2e17 --- /dev/null +++ b/jetbrains_projects/icons/dataspell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jetbrains_projects/icons/goland.svg b/jetbrains_projects/icons/goland.svg new file mode 100644 index 00000000..3640b1d8 --- /dev/null +++ b/jetbrains_projects/icons/goland.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jetbrains_projects/icons/idea.svg b/jetbrains_projects/icons/idea.svg new file mode 100644 index 00000000..04d5d615 --- /dev/null +++ b/jetbrains_projects/icons/idea.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jetbrains_projects/icons/phpstorm.svg b/jetbrains_projects/icons/phpstorm.svg new file mode 100644 index 00000000..7e3600d2 --- /dev/null +++ b/jetbrains_projects/icons/phpstorm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jetbrains_projects/icons/pycharm.svg b/jetbrains_projects/icons/pycharm.svg new file mode 100644 index 00000000..82469f36 --- /dev/null +++ b/jetbrains_projects/icons/pycharm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jetbrains_projects/icons/rider.svg b/jetbrains_projects/icons/rider.svg new file mode 100644 index 00000000..d1ab1530 --- /dev/null +++ b/jetbrains_projects/icons/rider.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jetbrains_projects/icons/rubymine.svg b/jetbrains_projects/icons/rubymine.svg new file mode 100644 index 00000000..7a68d96f --- /dev/null +++ b/jetbrains_projects/icons/rubymine.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jetbrains_projects/icons/rustrover.svg b/jetbrains_projects/icons/rustrover.svg new file mode 100644 index 00000000..bd5621c5 --- /dev/null +++ b/jetbrains_projects/icons/rustrover.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jetbrains_projects/icons/webstorm.svg b/jetbrains_projects/icons/webstorm.svg new file mode 100644 index 00000000..c55a10e4 --- /dev/null +++ b/jetbrains_projects/icons/webstorm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jetbrains_projects/icons/writerside.svg b/jetbrains_projects/icons/writerside.svg new file mode 100644 index 00000000..087eed26 --- /dev/null +++ b/jetbrains_projects/icons/writerside.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/jetbrains_projects/jetbrains.svg b/jetbrains_projects/jetbrains.svg deleted file mode 100644 index f7c7652c..00000000 --- a/jetbrains_projects/jetbrains.svg +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/kill.py b/kill.py deleted file mode 100644 index d2396722..00000000 --- a/kill.py +++ /dev/null @@ -1,51 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Kill processes. - -Unix 'kill' wrapper extension. - -Synopsis: """ - -import os -from signal import SIGKILL, SIGTERM - -from albertv0 import FuncAction, Item, iconLookup - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Kill Process" -__version__ = "1.4" -__trigger__ = "kill " -__author__ = "Benedict Dudel, Manuel Schneider" -__dependencies__ = [] - -iconPath = iconLookup('process-stop') - - -def handleQuery(query): - if query.isTriggered: - results = [] - uid = os.getuid() - for dir_entry in os.scandir('/proc'): - try: - if dir_entry.name.isdigit() and dir_entry.stat().st_uid == uid: - proc_command = open(os.path.join(dir_entry.path, 'comm'), 'r').read().strip() - if query.string in proc_command: - proc_cmdline = open(os.path.join(dir_entry.path, 'cmdline'), 'r').read().strip().replace("\0", " ") - results.append( - Item( - id="kill_%s" % proc_cmdline, - icon=iconPath, - text=proc_command.replace(query.string, "%s" % query.string), - subtext=proc_cmdline, - completion=query.rawString, - actions=[ - FuncAction("Terminate", lambda pid=int(dir_entry.name): os.kill(pid, SIGTERM)), - FuncAction("Kill", lambda pid=int(dir_entry.name): os.kill(pid, SIGKILL)) - ] - ) - ) - except FileNotFoundError: # TOCTOU dirs may disappear - continue - except IOError: # TOCTOU dirs may disappear - continue - return results diff --git a/kill/__init__.py b/kill/__init__.py new file mode 100644 index 00000000..c9c69231 --- /dev/null +++ b/kill/__init__.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022 Manuel Schneider +# Copyright (c) 2022 Benedict Dudel +# Copyright (c) 2022 Pete Hamlin + + +import os +from signal import SIGKILL, SIGTERM + +from albert import * + +md_iid = "3.0" +md_version = "2.0" +md_name = "Kill Process" +md_description = "Kill processes" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/kill" +md_authors = ["@Pete-Hamlin", "@BenedictDwudel", "@ManuelSchneid3r"] + + +class Plugin(PluginInstance, TriggerQueryHandler): + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + + def defaultTrigger(self): + return "kill " + + def handleTriggerQuery(self, query): + if not query.isValid: + return + results = [] + uid = os.getuid() + for dir_entry in os.scandir("/proc"): + try: + if dir_entry.name.isdigit() and dir_entry.stat().st_uid == uid: + proc_command = ( + open(os.path.join(dir_entry.path, "comm"), "r").read().strip() + ) + if query.string in proc_command: + debug(proc_command) + proc_cmdline = ( + open(os.path.join(dir_entry.path, "cmdline"), "r") + .read() + .strip() + .replace("\0", " ") + ) + results.append( + StandardItem( + id="kill", + iconUrls=["xdg:process-stop"], + text=proc_command, + subtext=proc_cmdline, + actions=[ + Action( + "terminate", + "Terminate process", + lambda pid=int(dir_entry.name): os.kill( + pid, SIGTERM + ), + ), + Action( + "kill", + "Kill process", + lambda pid=int(dir_entry.name): os.kill( + pid, SIGKILL + ), + ), + ], + ) + ) + except FileNotFoundError: # TOCTOU dirs may disappear + continue + except IOError: # TOCTOU dirs may disappear + continue + query.add(results) diff --git a/locate.py b/locate.py deleted file mode 100644 index 6c05d884..00000000 --- a/locate.py +++ /dev/null @@ -1,63 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Find and open files. - -Unix 'locate' wrapper extension. Note that it is up to you to ensure that the locate database is \ -up to date. - -This extensions is intended as secondary way to find files. Use the files extension for often used \ -files and fast lookups and this extension for everything else. - -Synopsis: [filter]""" - -import os -import re -import subprocess -from shutil import which - -from albertv0 import Item, TermAction, UrlAction, iconLookup - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Locate" -__version__ = "1.0" -__trigger__ = "'" -__author__ = "Manuel Schneider" -__dependencies__ = ['locate'] - -if which("locate") is None: - raise Exception("'locate' is not in $PATH.") - -for iconName in ["system-search", "search", "text-x-generic"]: - iconPath = iconLookup(iconName) - if iconPath: - break - - -def handleQuery(query): - results = [] - if query.isTriggered: - if len(query.string) > 2: - pattern = re.compile(query.string, re.IGNORECASE) - proc = subprocess.Popen(['locate', '-bi', query.string], stdout=subprocess.PIPE) - for line in proc.stdout: - path = line.decode().strip() - basename = os.path.basename(path) - results.append( - Item( - id=path, - icon=iconPath, - text=pattern.sub(lambda m: "%s" % m.group(0), basename), - subtext=path, - completion="%s%s" % (__trigger__, basename), - actions=[UrlAction("Open", "file://%s" % path)])) - else: - results.append( - Item( - id=__prettyname__, - icon=iconPath, - text="Update locate database", - subtext="Type at least three chars for a seach", - completion=query.rawString, - actions=[TermAction("Update database", ["sudo", "updatedb"])])) - - return results diff --git a/locate/__init__.py b/locate/__init__.py new file mode 100644 index 00000000..55015ba8 --- /dev/null +++ b/locate/__init__.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022-2024 Manuel Schneider + +""" +`locate` wrapper. Note that it is up to you to ensure that the locate database is \ +up to date. Pass params as necessary. The input is split using a shell lexer. +""" + + +import shlex +import subprocess +from pathlib import Path + +from albert import * + +md_iid = "3.0" +md_version = "2.0" +md_name = "Locate" +md_description = "Find and open files using locate" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/locate" +md_bin_dependencies = "locate" +md_authors = "@manuelschneid3r" + + +class Plugin(PluginInstance, TriggerQueryHandler): + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + + self.iconUrls = [ + "xdg:preferences-system-search", + "xdg:system-search", + "xdg:search", + "xdg:text-x-generic", + f"file:{Path(__file__).parent}/locate.svg" + ] + + def synopsis(self, query): + return "" + + def defaultTrigger(self): + return "'" + + def handleTriggerQuery(self, query): + if len(query.string) > 2: + + try: + args = shlex.split(query.string) + except ValueError: + return + + result = subprocess.run(['locate', *args], stdout=subprocess.PIPE, text=True) + if not query.isValid: + return + lines = sorted(result.stdout.splitlines(), reverse=True) + if not query.isValid: + return + + for path in lines: + query.add( + StandardItem( + id=path, + text=Path(path).name, + subtext=path, + iconUrls=self.iconUrls, + actions=[ + Action("open", "Open", lambda p=path: openUrl("file://%s" % p)) + ] + ) + ) + else: + query.add( + StandardItem( + id="updatedb", + text="Update locate database", + subtext="Type at least three chars for a search", + iconUrls=self.iconUrls, + actions=[ + Action("update", "Update", lambda: runTerminal("sudo updatedb")) + ] + ) + ) diff --git a/locate/locate.svg b/locate/locate.svg new file mode 100644 index 00000000..18df7995 --- /dev/null +++ b/locate/locate.svg @@ -0,0 +1,248 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mathematica_eval.py b/mathematica_eval.py deleted file mode 100644 index 3b4d8d16..00000000 --- a/mathematica_eval.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Evaluate Mathematica expressions. - -Synopsis: [expr]""" - -import subprocess -from shutil import which -from tempfile import NamedTemporaryFile - -from albertv0 import ClipAction, Item, iconLookup - -__iid__ = 'PythonInterface/v0.1' -__prettyname__ = 'Mathematica eval' -__version__ = '1.0' -__trigger__ = 'mma ' -__author__ = 'Asger Hautop Drewsen' -__dependencies__ = ['mathematica'] - -if not which('wolframscript'): - raise Exception("`wolframscript` is not in $PATH.") - -ICON_PATH = iconLookup('wolfram-mathematica') - -def handleQuery(query): - if not query.isTriggered: - return - - item = Item(completion=query.rawString, icon=ICON_PATH) - stripped = query.string.strip() - - if stripped: - with NamedTemporaryFile() as f: - f.write(bytes(stripped, 'utf-8')) - f.flush() - output = subprocess.check_output(['wolframscript', '-print', '-f', f.name]) - result = str(output.strip(), 'utf-8') - item.text = result - item.subtext = 'Result' - item.addAction(ClipAction('Copy result to clipboard', result)) - else: - item.text = '' - item.subtext = 'Type a Mathematica expression' - - return item diff --git a/pacman.py b/pacman.py deleted file mode 100644 index 63cb8ed9..00000000 --- a/pacman.py +++ /dev/null @@ -1,119 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Arch Linux Package Manager (pacman) extension. - -The extension provides a way to install, remove and search for packages in the archlinux.org \ -database. If no search query is supplied, you have the option to do a system update. \ -Otherwise albert will try to find for packages matching the filter. For more information about \ -`pacman` please have a look at: https://wiki.archlinux.org/index.php/pacman - -Synopsis: [filter]""" - -import re -import subprocess -from shutil import which - -from albertv0 import * - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "PacMan" -__version__ = "1.3" -__trigger__ = "pacman " -__author__ = "Manuel Schneider, Benedict Dudel" -__dependencies__ = ["pacman", "expac"] - -for dep in __dependencies__: - if which(dep) is None: - raise Exception("'%s' is not in $PATH." % dep) - -for iconName in ["archlinux-logo", "system-software-install"]: - iconPath = iconLookup(iconName) - if iconPath: - break - -def handleQuery(query): - if query.isTriggered: - if not query.string.strip(): - return Item( - id="%s-update" % __name__, - icon=iconPath, - text="Pacman package manager", - subtext="Enter the name of the package you are looking for", - completion=__trigger__, - actions=[ - TermAction("Update the system (no confirm)", ["sudo", "pacman", "-Syu", "--noconfirm"]), - TermAction("Update the system", ["sudo", "pacman", "-Syu"]) - ] - ) - - # Get data. Results are sorted so we can merge in O(n) - proc_s = subprocess.Popen(["expac", "-Ss", "%n\t%v\t%r\t%d\t%u\t%E", query.string], stdout=subprocess.PIPE, universal_newlines=True) - proc_q = subprocess.Popen(["expac", "-Qs", "%n", query.string], stdout=subprocess.PIPE, universal_newlines=True) - proc_q.wait() - - def next_stripped(it): - n = next(it, None) - if n: - n = n.rstrip("\n") - return n - - - items = [] - pattern = re.compile(query.string, re.IGNORECASE) - local_iter = iter(proc_q.stdout.readline, '') - next_local_package = next_stripped(local_iter) - for line in proc_s.stdout: - - # Parse data - pkg_name, pkg_vers, pkg_repo, pkg_desc, pkg_purl, pkg_deps = line.rstrip("\n").split("\t") - pkg_installed = next_local_package == pkg_name - if next_local_package == pkg_name: - next_local_package = next_stripped(local_iter) - - # Create item - item = Item( - id="%s:%s:%s" % (__name__, pkg_repo, pkg_name), - icon=iconPath, - text="%s %s [%s]" % (pattern.sub(lambda m: "%s" % m.group(0), pkg_name), pkg_vers, pkg_repo), - subtext="%s%s (%s)" % ("[Installed] " if pkg_installed else "", pattern.sub(lambda m: "%s" % m.group(0), pkg_desc), pkg_deps) if pkg_deps else pattern.sub(lambda m: "%s" % m.group(0), pkg_desc), - completion="%s%s" % (query.trigger, pkg_name) - ) - items.append(item) - - actions = [] - if pkg_installed: - item.addAction(TermAction("Remove", ["sudo", "pacman", "-Rs", pkg_name])) - item.addAction(TermAction("Reinstall", ["sudo", "pacman", "-S", pkg_name])) - else: - item.addAction(TermAction("Install", ["sudo", "pacman", "-S", pkg_name])) - item.addAction(UrlAction("Show on packages.archlinux.org", "https://www.archlinux.org/packages/%s/x86_64/%s/" % (pkg_repo, pkg_name))) - if pkg_purl: - item.addAction(UrlAction("Show project website", pkg_purl)) - - if items: - return items - else: - return Item( - id="%s-empty" % __name__, - icon=iconPath, - text="Search on archlinux.org", - subtext="No results found in the local database", - completion=__trigger__, - actions=[ - UrlAction("Search on archlinux.org", - "https://www.archlinux.org/packages/?q=%s" % query.string.strip()) - ] - ) - - elif len(query.string.strip()) > 0 and ("pacman".startswith(query.string.lower()) or "update".startswith(query.string.lower())): - return Item( - id="%s-update" % __name__, - icon=iconPath, - text="Update all packages on the system", - subtext="Synchronizes the repository databases and updates the system's packages", - completion=__trigger__, - actions=[ - TermAction("Update the system (no confirm)", ["sudo", "pacman", "-Syu", "--noconfirm"]), - TermAction("Update the system", ["sudo", "pacman", "-Syu"]) - ] - ) diff --git a/pacman/__init__.py b/pacman/__init__.py new file mode 100644 index 00000000..eb0e3e14 --- /dev/null +++ b/pacman/__init__.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider + +import subprocess +from time import sleep +import pathlib + +from albert import Action, StandardItem, PluginInstance, TriggerQueryHandler, runTerminal, openUrl + +md_iid = "3.0" +md_version = "2.0" +md_name = "PacMan" +md_description = "Search, install and remove packages" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/pacman" +md_authors = "@ManuelSchneid3r" +md_bin_dependencies = ["pacman", "expac"] + + +class Plugin(PluginInstance, TriggerQueryHandler): + + pkgs_url = "https://www.archlinux.org/packages/" + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + self.iconUrls = [ + "xdg:archlinux-logo", + "xdg:system-software-install", + f"file:{pathlib.Path(__file__).parent}/arch.svg" + ] + + def synopsis(self, query): + return "" + + def defaultTrigger(self): + return "pac " + + def handleTriggerQuery(self, query): + stripped = query.string.strip() + + # Update item on empty queries + if not stripped: + query.add(StandardItem( + id="%s-update" % self.id, + text="Pacman package manager", + subtext="Enter the package you are looking for or hit enter to update.", + iconUrls=self.iconUrls, + actions=[ + Action("up-nc", "Update packages (no confirm)", + lambda: runTerminal("sudo pacman -Syu --noconfirm")), + Action("up", "Update packages", lambda: runTerminal("sudo pacman -Syu")), + Action("up-cache", "Update pacman cache", lambda: runTerminal("sudo pacman -Sy")) + ] + )) + return + + # avoid rate limiting + for _ in range(50): + sleep(0.01) + if not query.isValid: + return + + # Get data. Results are sorted, so we can merge in O(n) + proc_s = subprocess.Popen(["expac", "-Ss", "%n\t%v\t%r\t%d\t%u\t%E", stripped], + stdout=subprocess.PIPE, universal_newlines=True) + proc_q = subprocess.Popen(["expac", "-Qs", "%n", stripped], stdout=subprocess.PIPE, universal_newlines=True) + proc_q.wait() + + items = [] + local_pkgs = set(proc_q.stdout.read().split('\n')) + remote_pkgs = [tuple(line.split('\t')) for line in proc_s.stdout.read().split('\n')[:-1]] # newline at end + + for pkg_name, pkg_vers, pkg_repo, pkg_desc, pkg_purl, pkg_deps in remote_pkgs: + if stripped not in pkg_name: + continue + + pkg_installed = True if pkg_name in local_pkgs else False + + actions = [] + if pkg_installed: + actions.extend([ + Action("rem", "Remove", lambda n=pkg_name: runTerminal("sudo pacman -Rs %s" % n)), + Action("reinst", "Reinstall", lambda n=pkg_name: runTerminal("sudo pacman -S %s" % n)) + ]) + else: + actions.append(Action("inst", "Install", lambda n=pkg_name: runTerminal("sudo pacman -S %s" % n))) + + actions.append(Action("pkg_url", "Show on packages.archlinux.org", + lambda r=pkg_repo, n=pkg_name: openUrl(f"{self.pkgs_url}{r}/x86_64/{n}/"))) + if pkg_purl: + actions.append(Action("proj_url", "Show project website", lambda u=pkg_purl: openUrl(u))) + + item = StandardItem( + id="%s_%s_%s" % (self.id, pkg_repo, pkg_name), + iconUrls=self.iconUrls, + text="%s %s [%s]" % (pkg_name, pkg_vers, pkg_repo), + subtext=f"{pkg_desc} [Installed]" if pkg_installed else f"{pkg_desc}", + inputActionText="%s%s" % (query.trigger, pkg_name), + actions=actions + ) + items.append(item) + + if items: + query.add(items) + else: + query.add(StandardItem( + id="%s-empty" % self.id, + text="Search on archlinux.org", + subtext="No results found in the local database", + iconUrls=self.iconUrls, + actions=[Action("search", "Search on archlinux.org", lambda: openUrl(f"{self.pkgs_url}?q={stripped}"))] + )) diff --git a/pacman/arch.svg b/pacman/arch.svg new file mode 100644 index 00000000..b95bef86 --- /dev/null +++ b/pacman/arch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pass.py b/pass.py deleted file mode 100644 index 95780bf7..00000000 --- a/pass.py +++ /dev/null @@ -1,100 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Manage passwords. - -This is a 'pass' wrapper extension. - -Synopsis: - generate - """ - -import fnmatch -import os -from shutil import which - -from albertv0 import * - -__iid__ = "PythonInterface/v0.3" -__prettyname__ = "Pass" -__version__ = "1.1" -__trigger__ = "pass " -__author__ = "Benedict Dudel" -__dependencies__ = ["pass"] - - -if which("pass") is None: - raise Exception("'pass' is not in $PATH.") - -HOME_DIR = os.environ["HOME"] -PASS_DIR = os.environ.get("PASSWORD_STORE_DIR", os.path.join(HOME_DIR, ".password-store/")) -ICON_PATH = iconLookup("dialog-password") - - -def handleQuery(query): - if query.isTriggered: - query.disableSort() - if query.string.strip().startswith("generate"): - return generatePassword(query) - - return showPasswords(query) - -def generatePassword(query): - location = query.string.strip()[9:] - - return [Item( - id=__prettyname__, - icon=ICON_PATH, - text="Generate a new password", - subtext="The new password will be located at %s" % location, - completion="pass %s" % query.string, - actions=[ - ProcAction("Generate", ["pass", "generate", "--clip", location, "20"]) - ] - )] - -def showPasswords(query): - passwords = [] - if query.string.strip(): - passwords = getPasswordsFromSearch(query) - else: - passwords = getPasswords() - - results = [] - for password in passwords: - name = password.split("/")[-1] - results.append( - Item( - id=password, - icon=ICON_PATH, - text=name, - subtext=password, - completion="pass %s" % password, - actions=[ - ProcAction("Copy", ["pass", "--clip", password]), - ProcAction("Edit", ["pass", "edit", password]), - ProcAction("Remove", ["pass", "rm", "--force", password]), - ] - ), - ) - - return results - -def getPasswords(): - passwords = [] - for root, dirnames, filenames in os.walk(PASS_DIR): - for filename in fnmatch.filter(filenames, "*.gpg"): - passwords.append( - os.path.join(root, filename.replace(".gpg", "")).replace(PASS_DIR, "") - ) - - return sorted(passwords, key=lambda s: s.lower()) - -def getPasswordsFromSearch(query): - passwords = [] - for password in getPasswords(): - if query.string.strip().lower() not in password.lower(): - continue - - passwords.append(password) - - return passwords diff --git a/pass/__init__.py b/pass/__init__.py new file mode 100644 index 00000000..2a0ce20a --- /dev/null +++ b/pass/__init__.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017 Benedict Dudel +# Copyright (c) 2023 Max +# Copyright (c) 2023 Pete-Hamlin + +import fnmatch +import os +from albert import * + +md_iid = "3.0" +md_version = "2.0" +md_name = "Pass" +md_description = "Manage passwords in pass" +md_license = "BSD-3" +md_url = "https://github.com/albertlauncher/python/tree/main/pass" +md_authors = ["@benedictdudel", "@maxmil", "@Pete-Hamlin"] +md_bin_dependencies = ["pass"] + +HOME_DIR = os.environ["HOME"] +PASS_DIR = os.environ.get("PASSWORD_STORE_DIR", os.path.join(HOME_DIR, ".password-store/")) + + +class Plugin(PluginInstance, TriggerQueryHandler): + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + self.iconUrls = ["xdg:dialog-password"] + self._use_otp = self.readConfig("use_otp", bool) or False + self._otp_glob = self.readConfig("otp_glob", str) or "*-otp.gpg" + + @property + def use_otp(self): + return self._use_otp + + @use_otp.setter + def use_otp(self, value): + print(f"Setting _use_otp to {value}") + self._use_otp = value + self.writeConfig("use_otp", value) + + @property + def otp_glob(self): + return self._otp_glob + + @otp_glob.setter + def otp_glob(self, value): + print(f"Setting _otp_glob to {value}") + self._otp_glob = value + self.writeConfig("otp_glob", value) + + def defaultTrigger(self): + return "pass " + + def synopsis(self, query): + return "" + + def configWidget(self): + return [ + {"type": "checkbox", "property": "use_otp", "label": "Enable pass OTP extension"}, + { + "type": "lineedit", + "property": "otp_glob", + "label": "Glob pattern for OTP passwords", + "widget_properties": {"placeholderText": "*-otp.gpg"}, + }, + ] + + def handleTriggerQuery(self, query): + if query.string.strip().startswith("generate"): + self.generatePassword(query) + elif query.string.strip().startswith("otp") and self._use_otp: + self.showOtp(query) + else: + self.showPasswords(query) + + def generatePassword(self, query): + location = query.string.strip()[9:] + + query.add( + StandardItem( + id="generate_password", + iconUrls=self.iconUrls, + text="Generate a new password", + subtext="The new password will be located at %s" % location, + inputActionText="pass %s" % query.string, + actions=[ + Action( + "generate", + "Generate", + lambda: runDetachedProcess(["pass", "generate", "--clip", location, "20"]), + ) + ], + ) + ) + + def showOtp(self, query): + otp_query = query.string.strip()[4:] + passwords = [] + if otp_query: + passwords = self.getPasswordsFromSearch(otp_query, otp=True) + else: + passwords = self.getPasswords(otp=True) + + results = [] + for password in passwords: + results.append( + StandardItem( + id=password, + iconUrls=self.iconUrls, + text=password.split("/")[-1], + subtext=password, + actions=[ + Action( + "copy", + "Copy", + lambda pwd=password: runDetachedProcess(["pass", "otp", "--clip", pwd]), + ), + ], + ), + ) + query.add(results) + + def showPasswords(self, query): + if query.string.strip(): + passwords = self.getPasswordsFromSearch(query.string) + else: + passwords = self.getPasswords() + + results = [] + for password in passwords: + name = password.split("/")[-1] + results.append( + StandardItem( + id=password, + text=name, + subtext=password, + iconUrls=self.iconUrls, + inputActionText="pass %s" % password, + actions=[ + Action( + "copy", + "Copy", + lambda pwd=password: runDetachedProcess(["pass", "--clip", pwd]), + ), + Action( + "edit", + "Edit", + lambda pwd=password: runDetachedProcess(["pass", "edit", pwd]), + ), + Action( + "remove", + "Remove", + lambda pwd=password: runDetachedProcess(["pass", "rm", "--force", pwd]), + ), + ], + ), + ) + + query.add(results) + + def getPasswords(self, otp=False): + passwords = [] + for root, dirnames, filenames in os.walk(PASS_DIR, followlinks=True): + for filename in fnmatch.filter(filenames, self._otp_glob if otp else "*.gpg"): + passwords.append(os.path.join(root, filename.replace(".gpg", "")).replace(PASS_DIR, "")) + + return sorted(passwords, key=lambda s: s.lower()) + + def getPasswordsFromSearch(self, otp_query, otp=False): + passwords = [password for password in self.getPasswords(otp) if otp_query.strip().lower() in password.lower()] + return passwords diff --git a/pomodoro/__init__.py b/pomodoro/__init__.py index 913a80ea..39dc9b35 100644 --- a/pomodoro/__init__.py +++ b/pomodoro/__init__.py @@ -1,24 +1,23 @@ # -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider -"""Set up your personal Pomodoro timer. +""" +Wiki: [Pomodoro_Technique](https://en.wikipedia.org/wiki/Pomodoro_Technique). +""" -See https://en.wikipedia.org/wiki/Pomodoro_Technique - -Synopsis: [duration [break duration [long break duration [count]]]]""" - -from albertv0 import * -from shutil import which -import subprocess import threading -import re import time -import os +from pathlib import Path + +from albert import * -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Pomodoro" -__version__ = "1.0" -__author__ = "Manuel Schneider" -__dependencies__ = [] +md_iid = "3.0" +md_version = "2.0" +md_name = "Pomodoro" +md_description = "Set up a Pomodoro timer" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/pomodoro" +md_authors = "@manuelschneid3r" class PomodoroTimer: @@ -26,26 +25,30 @@ class PomodoroTimer: def __init__(self): self.isBreak = True self.timer = None + self.notification = None + self.remainingTillLongBreak = 0 + self.endTime = 0 + self.pomodoroDuration = 0 + self.breakDuration = 0 + self.longBreakDuration = 0 + self.count = 0 def timeout(self): if self.isBreak: duration = self.pomodoroDuration * 60 self.timer = threading.Timer(duration, self.timeout) self.endTime = time.time() + duration - debug("Pomodoro start (%s min)" % self.pomodoroDuration) - playSound(1) + self.notification = Notification("PomodoroTimer", "Let's go to work!") self.timer.start() else: self.remainingTillLongBreak -= 1 if self.remainingTillLongBreak == 0: self.remainingTillLongBreak = self.count + self.notification = Notification("PomodoroTimer", "Take a long break (%s min)" % self.longBreakDuration) duration = self.longBreakDuration * 60 - playSound(3) - debug("Pomodoro long break (%s min)" % self.breakDuration) else: + self.notification = Notification("PomodoroTimer", "Take a short break (%s min)" % self.breakDuration) duration = self.breakDuration * 60 - playSound(2) - debug("Pomodoro break (%s min)" % self.breakDuration) self.endTime = time.time() + duration self.timer = threading.Timer(duration, self.timeout) self.timer.start() @@ -70,72 +73,64 @@ def isActive(self): return self.timer is not None -iconPath = os.path.dirname(__file__)+"/pomodoro.svg" -soundPath = os.path.dirname(__file__)+"/bing.wav" -pomodoro = PomodoroTimer() - - -def playSound(num): - for x in range(num): - t = threading.Timer(0.5*x, lambda: subprocess.Popen(["aplay", soundPath])) - t.start() +class Plugin(PluginInstance, TriggerQueryHandler): + default_pomodoro_duration = 25 + default_break_duration = 5 + default_longbreak_duration = 15 + default_pomodoro_count = 4 -def handleQuery(query): - tokens = query.string.split() - if tokens and "pomodoro".startswith(tokens[0].lower()): - - global pomodoro - pattern = re.compile(query.string, re.IGNORECASE) - item = Item( - id=__prettyname__, - icon=iconPath, - text=pattern.sub(lambda m: "%s" % m.group(0), "Pomodoro Timer"), - completion=query.rawString + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + self.pomodoro = PomodoroTimer() + self.iconUrls = [f"file:{Path(__file__).parent}/pomodoro.svg"] + + def defaultTrigger(self): + return 'pomo ' + + def synopsis(self, query): + return '[duration [break duration [long break duration [count]]]]' + + def configWidget(self): + return [ + { + 'type': 'label', + 'text': __doc__.strip(), + 'widget_properties': {'textFormat': 'Qt::MarkdownText'} + } + ] + + def handleTriggerQuery(self, query): + item = StandardItem( + id=self.id(), + iconUrls=self.iconUrls, ) - if len(tokens) == 1 and pomodoro.isActive(): - item.addAction(FuncAction("Stop", lambda p=pomodoro: p.stop())) - if pomodoro.isBreak: + if self.pomodoro.isActive(): + item.text = "Stop Pomodoro" + item.actions = [Action("stop", "Stop", lambda pomo=self.pomodoro: pomo.stop())] + if self.pomodoro.isBreak: whatsNext = "Pomodoro" else: - whatsNext = "Long break" if pomodoro.remainingTillLongBreak == 1 else "Short break" - item.subtext = "Stop pomodoro (Next: %s at %s)" % (whatsNext, time.strftime("%X", - time.localtime(pomodoro.endTime))) - return item - - p_duration = 25 - b_duration = 5 - lb_duration = 15 - count = 4 - - item.subtext = "Invalid parameters. Use pomodoro [duration [break duration [long break duration [count]]]]" - if len(tokens) > 1: - if not tokens[1].isdigit(): - return item - p_duration = int(tokens[1]) - - if len(tokens) > 2: - if not tokens[2].isdigit(): - return item - b_duration = int(tokens[2]) - - if len(tokens) > 3: - if not tokens[3].isdigit(): - return item - lb_duration = int(tokens[3]) - - if len(tokens) > 4: - if not tokens[4].isdigit(): - return item - count = int(tokens[4]) - - if len(tokens) > 5: - return item - - item.subtext = "Start new pomodoro timer (%s min/Break %s min/Long break %s min/Count %s)" % (p_duration, b_duration, lb_duration, count) - item.addAction(FuncAction("Start", - lambda p=p_duration, b=b_duration, lb=lb_duration, c=count: - pomodoro.start(p, b, lb, c))) - - return item + whatsNext = "Long break" if self.pomodoro.remainingTillLongBreak == 1 else "Short break" + item.subtext = "%s at %s" % (whatsNext, time.strftime("%X", time.localtime(self.pomodoro.endTime))) + query.add(item) + + else: + tokens = query.string.split() + if len(tokens) > 4 or not all([t.isdigit() for t in tokens]): + item.text = "Invalid parameters" + item.subtext = "Use %s" % self.synopsis + query.add(item) + else: + p = int(tokens[0]) if len(tokens) > 0 else self.default_pomodoro_duration + b = int(tokens[1]) if len(tokens) > 1 else self.default_break_duration + lb = int(tokens[2]) if len(tokens) > 2 else self.default_longbreak_duration + c = int(tokens[3]) if len(tokens) > 3 else self.default_pomodoro_count + + item.text = "Start Pomodoro" + item.subtext = f"{p} min, break {b} min, long break {lb} min, count {c}" + item.actions = [Action("start", "Start", + lambda _p=p, _b=b, _lb=lb, _c=c: self.pomodoro.start(_p, _b, _lb, _c))] + query.add(item) diff --git a/pomodoro/bing.wav b/pomodoro/bing.wav deleted file mode 100644 index 1e9ef103..00000000 Binary files a/pomodoro/bing.wav and /dev/null differ diff --git a/python_eval/__init__.py b/python_eval/__init__.py index 13dedabc..1acce6c4 100644 --- a/python_eval/__init__.py +++ b/python_eval/__init__.py @@ -1,45 +1,50 @@ # -*- coding: utf-8 -*- +# Copyright (c) 2017-2014 Manuel Schneider -"""Evaluate simple python expressions. +from pathlib import Path -Synopsis: """ +from albert import * -from albertv0 import * -from math import * -from builtins import pow -try: - import numpy as np -except ImportError: - pass -import os +md_iid = "3.0" +md_version = "2.0" +md_name = "Python Eval" +md_description = "Evaluate Python code" +md_license = "BSD-3" +md_url = "https://github.com/albertlauncher/python/tree/main/python_eval" +md_authors = "@manuelschneid3r" -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Python Eval" -__version__ = "1.0" -__trigger__ = "py " -__author__ = "Manuel Schneider" -__dependencies__ = [] +class Plugin(PluginInstance, TriggerQueryHandler): -iconPath = os.path.dirname(__file__)+"/python.svg" + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + self.iconUrls = [f"file:{Path(__file__).parent}/python.svg"] + def synopsis(self, query): + return "" -def handleQuery(query): - if query.isTriggered: - item = Item(id=__prettyname__, icon=iconPath, completion=query.rawString) - stripped = query.string.strip() + def defaultTrigger(self): + return "py " - if stripped == '': - item.text = "Enter a python expression" - item.subtext = "Math is in the namespace and, if installed, also Numpy as 'np'" - return item - else: + def handleTriggerQuery(self, query): + stripped = query.string.strip() + if stripped: try: result = eval(stripped) except Exception as ex: result = ex - item.text = str(result) - item.subtext = type(result).__name__ - item.addAction(ClipAction("Copy result to clipboard", str(result))) - item.addAction(FuncAction("Execute", lambda: exec(str(result)))) - return item + + result_str = str(result) + + query.add(StandardItem( + id=self.id(), + text=result_str, + subtext=type(result).__name__, + inputActionText=query.trigger + result_str, + iconUrls=self.iconUrls, + actions = [ + Action("copy", "Copy result to clipboard", lambda r=result_str: setClipboardText(r)), + Action("exec", "Execute python code", lambda r=result_str: exec(stripped)), + ] + )) diff --git a/syncthing/__init__.py b/syncthing/__init__.py new file mode 100644 index 00000000..6fde0df1 --- /dev/null +++ b/syncthing/__init__.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider + +import json +import urllib.error +import urllib.request +from pathlib import Path + +from albert import * + +md_iid = "3.0" +md_version = "3.0" +md_name = "Syncthing" +md_description = "Control the local Syncthing instance." +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/syncthing" +md_authors = "@manuelschneid3r" + + +# https://docs.syncthing.net/dev/rest.html +class Syncthing: + def __init__(self, api_key, base_url="http://localhost:8384"): + self.api_key = api_key + self.base_url = base_url + + def _request(self, method, endpoint, data=None) -> dict: + url = f"{self.base_url}{endpoint}" + headers = { + "X-API-Key": self.api_key, + "Content-Type": "application/json" + } + body = json.dumps(data).encode("utf-8") if data else None + req = urllib.request.Request(url, data=body, headers=headers, method=method) + + try: + with urllib.request.urlopen(req) as resp: + if not (200 <= resp.status < 300): + raise Exception(f"Unexpected status {resp.status}") + content = resp.read().decode() + return json.loads(content) if content else {} + except urllib.error.HTTPError as e: + raise Exception(f"HTTP {e.code}: {e.read().decode()}") + + def _get(self, endpoint): + return self._request("GET", endpoint) + + def _post(self, endpoint, data=None): + return self._request("POST", endpoint, data) + + def _patch(self, endpoint, data=None): + return self._request("PATCH", endpoint, data) + + def config(self): + return self._get('/rest/config') + + def resumeDevice(self, device_id:str): + return self._patch(f'/rest/config/devices/{device_id}', {'paused': False}) + + def pauseDevice(self, device_id:str): + return self._patch(f'/rest/config/devices/{device_id}', {'paused': True}) + + def resumeFolder(self, folder_id:str): + return self._patch(f'/rest/config/folders/{folder_id}', {'paused': False}) + + def pauseFolder(self, folder_id:str): + return self._patch(f'/rest/config/folders/{folder_id}', {'paused': True}) + + def scanFolder(self, folder_id:str): + return self._post(f'/rest/db/scan?folder={folder_id}') + + +class Plugin(PluginInstance, GlobalQueryHandler): + + config_key = 'syncthing_api_key' + icon_urls_active = [f"file:{Path(__file__).parent}/syncthing_active.svg"] + icon_urls_inactive = [f"file:{Path(__file__).parent}/syncthing_inactive.svg"] + + def __init__(self): + PluginInstance.__init__(self) + GlobalQueryHandler.__init__(self) + self.st = Syncthing(self.readConfig(self.config_key, str) or '') + + def defaultTrigger(self): + return 'st ' + + @property + def api_key(self) -> str: + return self.st.api_key + + @api_key.setter + def api_key(self, value: str): + if self.st.api_key != value: + self.st.api_key = value + self.writeConfig(self.config_key, value) + + def configWidget(self): + return [ + { + 'type': 'lineedit', + 'property': 'api_key', + 'label': 'API key', + 'widget_properties': {'tooltip': 'You can find the API key using the web frontend.'} + } + ] + + def handleTriggerQuery(self, query): + try: + super().handleTriggerQuery(query) + except Exception as e: + query.add(StandardItem(id="err", text="Error", subtext=str(e), iconUrls=self.icon_urls_active)) + + def handleGlobalQuery(self, query): + + config = self.st.config() + + devices = dict() + for d in config['devices']: + if not d['name']: + d['name'] = d['deviceID'] + d['_shared_folders'] = {} + devices[d['deviceID']] = d + + folders = dict() + for f in config['folders']: + if not f['label']: + f['label'] = f['id'] + for d in f['devices']: + devices[d['deviceID']]['_shared_folders'][f['id']] = f + folders[f['id']] = f + + results = [] + matcher = Matcher(query.string) + + # create device items + for device_id, d in devices.items(): + device_name = d['name'] + + if match := matcher.match(device_name): + device_folders = ", ".join([f['label'] for f in d['_shared_folders'].values()]) + + actions = [] + if d['paused']: + actions.append(Action("resume", "Resume", lambda did=device_id: self.st.resumeDevice(did))) + else: + actions.append(Action("pause", "Pause", lambda did=device_id: self.st.pauseDevice(did))) + + item = StandardItem( + id=device_id, + text=f"{device_name}", + subtext=f"{'PAUSED · ' if d['paused'] else ''}Device · " + f"Shared: {device_folders if device_folders else 'Nothing'}.", + iconUrls=self.icon_urls_inactive if d['paused'] else self.icon_urls_active, + actions=actions + ) + + results.append(RankItem(item, match)) + + # create folder items + for folder_id, f in folders.items(): + folder_name = f['label'] + if match := matcher.match(folder_name): + folders_devices = ", ".join([devices[d['deviceID']]['name'] for d in f['devices']]) + + actions = [] + if f['paused']: + actions.append(Action("resume", "Resume", lambda fid=folder_id: self.st.resumeFolder(fid))) + else: + actions.append(Action("pause", "Pause", lambda fid=folder_id: self.st.pauseFolder(fid))) + actions.append(Action("open", "Open", lambda p=f['path']: openFile(p))) + actions.append(Action("scan", "Scan", lambda fid=folder_id: self.st.scanFolder(fid))) + + item = StandardItem( + id=folder_id, + text=folder_name, + subtext=f"{'PAUSED · ' if f['paused'] else ''}Folder · {f['path']} · " + f"Shared with {folders_devices if folders_devices else 'nobody'}.", + iconUrls=self.icon_urls_inactive if f['paused'] else self.icon_urls_active, + actions=actions + ) + results.append(RankItem(item, match)) + + return results diff --git a/syncthing/syncthing_active.svg b/syncthing/syncthing_active.svg new file mode 100644 index 00000000..b56b7cbc --- /dev/null +++ b/syncthing/syncthing_active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/syncthing/syncthing_inactive.svg b/syncthing/syncthing_inactive.svg new file mode 100644 index 00000000..247b4d43 --- /dev/null +++ b/syncthing/syncthing_inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tex_to_unicode.py b/tex_to_unicode.py deleted file mode 100644 index abf1f5e7..00000000 --- a/tex_to_unicode.py +++ /dev/null @@ -1,63 +0,0 @@ -# -*- coding: utf-8 -*- - -'''Convert TeX mathmode commands to unicode characters. - -Synopsis: ''' - -import re -import unicodedata - -from pylatexenc.latex2text import LatexNodes2Text - -from albertv0 import * - -__iid__ = 'PythonInterface/v0.1' -__prettyname__ = 'TeX to unicode' -__version__ = '1.0' -__trigger__ = 'tex ' -__author__ = 'Asger Hautop Drewsen' -__dependencies__ = ['python-pylatexenc'] - -COMBINING_LONG_SOLIDUS_OVERLAY = '\u0338' - - -def handleQuery(query): - if not query.isTriggered: - return - - item = Item(completion=query.rawString) - stripped = query.string.strip() - - success = False - if stripped: - if not stripped.startswith('\\'): - stripped = '\\' + stripped - - # Remove double backslashes (newlines) - stripped = stripped.replace('\\\\', ' ') - - # pylatexenc doesn't support \not - stripped = stripped.replace('\\not', '@NOT@') - - # pylatexenc doesn't like backslashes at end of string - if not stripped.endswith('\\'): - n = LatexNodes2Text() - result = n.latex_to_text(stripped) - if result: - result = unicodedata.normalize('NFC', result) - result = re.sub(r'@NOT@\s*(\S)', '\\1' + COMBINING_LONG_SOLIDUS_OVERLAY, result) - result = result.replace('@NOT@', '') - result = unicodedata.normalize('NFC', result) - item.text = result - item.subtext = 'Result' - success = True - - if not success: - item.text = stripped - item.subtext = 'Type some TeX math' - success = False - - if success: - item.addAction(ClipAction('Copy result to clipboard', result)) - - return item diff --git a/tex_to_unicode/__init__.py b/tex_to_unicode/__init__.py new file mode 100644 index 00000000..629e4584 --- /dev/null +++ b/tex_to_unicode/__init__.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022 Jonah Lawrence +# Copyright (c) 2024 Manuel Schneider + +import re +import unicodedata +from pathlib import Path +from pylatexenc.latex2text import LatexNodes2Text + +from albert import * + +md_iid = "3.0" +md_version = "2.0" +md_name = "TeX to Unicode" +md_description = "Convert TeX mathmode commands to unicode characters" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/tex_to_unicode" +md_authors = ["@DenverCoder1", "@manuelschneid3r"] +md_lib_dependencies = "pylatexenc" + + +class Plugin(PluginInstance, TriggerQueryHandler): + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + self.COMBINING_LONG_SOLIDUS_OVERLAY = "\u0338" + self.iconUrls = [f"file:{Path(__file__).parent}/tex.svg"] + + def _create_item(self, text: str, subtext: str, can_copy: bool): + actions = [] + if can_copy: + actions.append( + Action( + "copy", + "Copy result to clipboard", + lambda t=text: setClipboardText(t), + ) + ) + return StandardItem( + id=self.id(), + text=text, + subtext=subtext, + iconUrls=self.iconUrls, + actions=actions, + ) + + def defaultTrigger(self): + return "tex " + + def handleTriggerQuery(self, query): + stripped = query.string.strip() + + if not stripped: + return + + if not stripped.startswith("\\"): + stripped = "\\" + stripped + + # Remove double backslashes (newlines) + stripped = stripped.replace("\\\\", " ") + + # pylatexenc doesn't support \not + stripped = stripped.replace("\\not", "@NOT@") + + n = LatexNodes2Text() + result = n.latex_to_text(stripped) + + if not result: + query.add(self._create_item(stripped, "Type some TeX math", False)) + return + + # success + result = unicodedata.normalize("NFC", result) + result = re.sub(r"@NOT@\s*(\S)", "\\1" + self.COMBINING_LONG_SOLIDUS_OVERLAY, result) + result = result.replace("@NOT@", "") + result = unicodedata.normalize("NFC", result) + query.add(self._create_item(result, "Result", True)) diff --git a/tex_to_unicode/tex.svg b/tex_to_unicode/tex.svg new file mode 100644 index 00000000..86ddf19b --- /dev/null +++ b/tex_to_unicode/tex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/timer/__init__.py b/timer/__init__.py deleted file mode 100644 index bdbd7f42..00000000 --- a/timer/__init__.py +++ /dev/null @@ -1,109 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Set up timers. - -Lists all timers when triggered. Additional arguments in the form of [[hours:]minutes:]seconds let \ -you set triggers. Empty field resolve to 0, e.g. "96::" starts a 96 hours timer. Fields exceeding \ -the maximum amount of the time interval are automatically refactorized, e.g. "9:120:3600" resolves \ -to 12 hours. - -Synopsis: [[[hours]:][minutes]:]seconds""" - -from albertv0 import * -from threading import Timer -from time import strftime, time, localtime -import os -import subprocess - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Timer" -__version__ = "1.0" -__trigger__ = "timer " -__author__ = "Manuel Schneider" -__dependencies__ = [] - -iconPath = os.path.dirname(__file__)+"/time.svg" -soundPath = os.path.dirname(__file__)+"/bing.wav" -timers = [] - - -class AlbertTimer(Timer): - - def __init__(self, interval): - - def timeout(): - subprocess.Popen(["aplay", soundPath]) - global timers - timers.remove(self) - - super().__init__(interval=interval, function=timeout) - self.interval = interval - self.begin = int(time()) - self.end = self.begin + interval - self.start() - - -def startTimer(interval): - global timers - timers.append(AlbertTimer(interval)) - - -def deleteTimer(timer): - global timers - timers.remove(timer) - timer.cancel() - - -def formatSeconds(seconds): - m, s = divmod(seconds, 60) - h, m = divmod(m, 60) - return "%02d:%02d:%02d" % (h, m, s) - - -def handleQuery(query): - if query.isTriggered: - - if query.string.strip(): - fields = query.string.strip().split(":") - if not all(field.isdigit() or field == '' for field in fields): - return Item( - id=__prettyname__, - text="Invalid input", - subtext="Enter a query in the form of '%s[[hours:]minutes:]'" % __trigger__, - icon=iconPath, - completion=query.rawString - ) - - seconds = 0 - fields.reverse() - for i in range(len(fields)): - seconds += int(fields[i] if fields[i] else 0)*(60**i) - - return Item( - id=__prettyname__, - text=formatSeconds(seconds), - subtext="Set a timer", - icon=iconPath, - completion=query.rawString, - actions=[FuncAction("Set timer", lambda sec=seconds: startTimer(sec))] - ) - - else: - # List timers - items = [] - for timer in timers: - - m, s = divmod(timer.interval, 60) - h, m = divmod(m, 60) - identifier = "%d:%02d:%02d" % (h, m, s) - - items.append(Item( - id=__prettyname__, - text="Delete timer [%s]" % identifier, - subtext="Times out %s" % strftime("%X", localtime(timer.end)), - icon=iconPath, - completion=query.rawString, - actions=[FuncAction("Delete timer", lambda timer=timer: deleteTimer(timer))] - )) - - return items diff --git a/timer/bing.wav b/timer/bing.wav deleted file mode 100644 index 1e9ef103..00000000 Binary files a/timer/bing.wav and /dev/null differ diff --git a/timer/time.svg b/timer/time.svg deleted file mode 100644 index f6cc4d0b..00000000 --- a/timer/time.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/translators/__init__.py b/translators/__init__.py new file mode 100644 index 00000000..45541e8b --- /dev/null +++ b/translators/__init__.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider + +""" +Translates text using the python package translators. See https://pypi.org/project/translators/ +""" + +from locale import getdefaultlocale +from pathlib import Path +from time import sleep + +from albert import * +import translators as ts + +md_iid = "3.0" +md_version = "2.1" +md_name = "Translator" +md_description = "Translate text using online translators" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/translators" +md_authors = "@manuelschneid3r" +md_lib_dependencies = "translators" + + +class Plugin(PluginInstance, TriggerQueryHandler): + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + + self.iconUrls = [f"file:{Path(__file__).parent}/google_translate.png"] + + self._translator = self.readConfig('translator', str) + if self._translator is None: + self._translator = 'google' + + self._lang = self.readConfig('lang', str) + if self._lang is None: + self._lang = getdefaultlocale()[0][0:2] + + try: + languages = ts.get_languages(self.translator) + self.src_languages = set(languages.keys()) + self.dst_languages = set(languages[self.lang]) + except Exception as e: + warning(str(e)) + + @property + def translator(self): + return self._translator + + @translator.setter + def translator(self, value): + self._translator = value + self.writeConfig('translator', value) + languages = ts.get_languages(self.translator) + self.src_languages = set(languages.keys()) + self.dst_languages = set(languages[self.lang]) + + @property + def lang(self): + return self._lang + + @lang.setter + def lang(self, value): + self._lang = value + self.writeConfig('lang', value) + + def defaultTrigger(self): + return 'tr ' + + def configWidget(self): + return [ + { + 'type': 'label', + 'text': __doc__.strip(), + }, + { + 'type': 'combobox', + 'property': 'translator', + 'label': 'Translator', + 'items': ts.translators_pool + }, + { + 'type': 'lineedit', + 'property': 'lang', + 'label': 'Default language', + } + ] + + def synopsis(self, s): + return "[[from] to] text" + + def handleTriggerQuery(self, query): + stripped = query.string.strip() + if stripped: + for _ in range(50): + sleep(0.01) + if not query.isValid: + return + + if len(splits := stripped.split(maxsplit=2)) == 3 \ + and splits[0] in self.src_languages and splits[1] in self.dst_languages: + src, dst, text = splits + elif len(splits := stripped.split(maxsplit=1)) == 2 and splits[0] in self.src_languages: + src, dst, text = 'auto', splits[0], splits[1] + else: + src, dst, text = 'auto', self.lang, stripped + + try: + translation = ts.translate_text(query_text=text, + translator=self.translator, + from_language=src, + to_language=dst, + timeout=5) + + actions = [] + if havePasteSupport(): + actions.append( + Action( + "paste", "Copy to clipboard and paste to front-most window", + lambda t=translation: setClipboardTextAndPaste(t) + ) + ) + + actions.append( + Action("copy", "Copy to clipboard", + lambda t=translation: setClipboardText(t)) + ) + + query.add(StandardItem( + id=self.id(), + text=translation, + subtext=f"{src.upper()} > {dst.upper()}", + iconUrls=self.iconUrls, + actions=actions + )) + + except Exception as e: + + query.add(StandardItem( + id=self.id(), + text="Error", + subtext=str(e), + iconUrls=self.iconUrls + )) + + warning(str(e)) \ No newline at end of file diff --git a/translators/google_translate.png b/translators/google_translate.png new file mode 100644 index 00000000..63617cf5 Binary files /dev/null and b/translators/google_translate.png differ diff --git a/trash.py b/trash.py deleted file mode 100644 index a684c331..00000000 --- a/trash.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Open virtual trash location. - -This extension provides a single item which opens the systems virtual trash \ -location in your default file manager. - -Synopsis: """ - -import re - -from albertv0 import Item, UrlAction, iconLookup - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Trash" -__version__ = "1.0" -__author__ = "Manuel Schneider" -iconPath = iconLookup("user-trash-full") - -def handleQuery(query): - if query.string.strip() and "trash".startswith(query.string.lower()): - pattern = re.compile(query.string, re.IGNORECASE) - return Item(id="trash-open", - icon=iconPath, - text=pattern.sub(lambda m: "%s" % m.group(0), "Trash"), - subtext="Show trash folder", - completion="trash", - actions=[UrlAction("Show", "trash:///")]) diff --git a/unit_converter/__init__.py b/unit_converter/__init__.py new file mode 100644 index 00000000..cd26cf22 --- /dev/null +++ b/unit_converter/__init__.py @@ -0,0 +1,464 @@ +# -*- coding: utf-8 -*- + +""" +Unit converter based on the [Pint Python library](https://pint.readthedocs.io/en/stable/). + +Usage examples: +- `convert 180 minutes to hrs` +- `convert 100 km to miles` +- `convert 88 mph to kph` +- `convert 32 degrees F to C` +- `convert 3.14159 rad to degrees` +- `convert 100 USD to EUR` +""" + +import json +import re +import traceback +from datetime import datetime +from pathlib import Path +from typing import Any, Optional +from urllib.error import URLError +from urllib.request import urlopen + +import inflect +import pint +from albert import * + +md_iid = "3.0" +md_version = "1.8" +md_name = "Unit Converter" +md_description = "Convert between units" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/unit_converter" +md_lib_dependencies = ["pint", "inflect"] +md_authors = ["@DenverCoder1", "@Pete-Hamlin"] + + +class ConversionResult: + """A class to represent the result of a unit conversion""" + + def __init__( + self, + from_amount: float, + from_unit: str, + to_amount: float, + to_unit: str, + dimensionality: str, + source: str = "", + ): + """Initialize the ConversionResult + + Args: + from_amount (float): The amount to convert from + from_unit (str): The unit to convert from + to_amount (float): The resulting amount + to_unit (str): The unit converted to + dimensionality (str): The dimensionality of the result + source (str): The source of the conversion for attribution + """ + self.from_amount = from_amount + self.from_unit = from_unit + self.to_amount = to_amount + self.to_unit = to_unit + self.dimensionality = dimensionality + self.source = source + self.display_names: dict[str, str] = Plugin.config["display_names"] + self.inflect_engine = inflect.engine() + + def __pluralize_unit(self, unit: str) -> str: + """Pluralize the unit + + Args: + unit (str): The unit to pluralize + + Returns: + str: The pluralized unit + """ + # if all characters are uppercase, don't pluralize + if unit.isupper(): + return unit + return self.inflect_engine.plural(unit) + + def __display_unit_name(self, amount: float, unit: str) -> str: + """Display the name of the unit with plural if necessary + + Args: + amount (float): The amount to display + unit (str): The unit to display + + Returns: + str: The name of the unit + """ + unit = self.__pluralize_unit(unit) if amount != 1 else unit + return self.display_names.get(unit, unit) + + @staticmethod + def __format_float(num: float) -> str: + """Format a float to remove trailing zeros and avoid scientific notation + + Args: + num (float): The number to format + + Returns: + str: The formatted number + """ + # format the float to remove trailing zeros and decimal point + precision: int = Plugin.config["precision"] + return f"{num:.{precision}f}".rstrip("0").rstrip(".") + + @property + def formatted_result(self) -> str: + """Return the formatted result amount and unit""" + units = self.__display_unit_name(self.to_amount, self.to_unit) + return f"{self.__format_float(self.to_amount)} {units}" + + @property + def formatted_from(self) -> str: + """Return the formatted from amount and unit""" + units = self.__display_unit_name(self.from_amount, self.from_unit) + result = f"{self.__format_float(self.from_amount)} {units}" + if self.source: + result += f" ({self.source})" + return result + + @property + def icon(self) -> str: + """Return the icon for the result's dimensionality""" + # strip characters from the dimensionality if not alphanumeric or underscore + dimensionality = re.sub(r"\W", "", self.dimensionality) + return f"{dimensionality}.svg" + + def __repr__(self): + """Return the representation of the result""" + return f"{self.formatted_from} = {self.formatted_result}" + + +class UnitConverter: + """Base class for unit converters""" + + def __init__(self): + """Initialize the UnitConverter""" + self.aliases: dict[str, str] = Plugin.config["aliases"] + + def convert(self, amount: float, from_unit: str, to_unit: str) -> ConversionResult: + """Convert a unit to another unit + + Args: + amount (float): The amount to convert + from_unit (str): The unit to convert from + to_unit (str): The unit to convert to + + Returns: + ConversionResult: Object containing information about the conversion result + """ + raise NotImplementedError + + +class StandardUnitConverter(UnitConverter): + """Class to convert standard units of measurement""" + + def __init__(self): + """Initialize the StandardUnitConverter""" + self.units = pint.UnitRegistry() + super().__init__() + + def _get_unit(self, unit: str) -> pint.Unit: + """Check if the unit is a valid unit and return it + If any aliases are found, replace the unit with the alias + If the unit is not valid, check if making it lowercase will fix it + If not, raise the UndefinedUnitError + + Args: + unit (str): The unit to check + + Returns: + pint.Unit: The unit + + Raises: + pint.errors.UndefinedUnitError: If the unit is not valid + """ + unit = self.aliases.get(unit, unit) + if unit in self.units: + # return the unit if it is valid + return self.units.__getattr__(unit) + # check if the lowercase version is a valid unit + return self.units.__getattr__(unit.lower()) + + def convert(self, amount: float, from_unit: str, to_unit: str) -> ConversionResult: + """Convert a unit to another unit + + Args: + amount (float): The amount to convert + from_unit (str): The unit to convert from + to_unit (str): The unit to convert to + + Returns: + ConversionResult: Object containing information about the conversion result + + Raises: + pint.errors.UndefinedUnitError: If the unit is not valid + pint.errors.DimensionalityError: If the units are not compatible + """ + input_unit = self.units.Quantity(amount, self._get_unit(from_unit)) + output_unit = self._get_unit(to_unit) + result = input_unit.to(output_unit) + return ConversionResult( + from_amount=float(amount), + from_unit=str(self._get_unit(from_unit)), + to_amount=result.magnitude, + to_unit=str(result.units), + dimensionality=str(self.units._get_dimensionality(result.units)), + ) + + +class UnknownCurrencyError(Exception): + """Exception to raise when an unknown currency is passed to convert""" + + def __init__(self, currency: str): + """Initialize the UnknownCurrencyError + + Args: + currency (str): The unknown currency + """ + self.currency = currency + super().__init__(f"Unknown currency: {currency}") + + +class CurrencyConverter(UnitConverter): + """Class to convert currencies""" + + API_URL = "https://open.er-api.com/v6/latest/USD" + ATTRIBUTION = "Rates by https://www.exchangerate-api.com" + + def __init__(self): + """Initialize the CurrencyConverter""" + self.last_update = datetime.now() + self.currencies = self._get_currencies() + super().__init__() + + def _get_currencies(self) -> dict[str, float]: + """Get the currencies from the API + + Returns: + dict[str, float]: The currencies + """ + try: + with urlopen(self.API_URL) as response: + data = json.loads(response.read().decode("utf-8")) + if not data or "rates" not in data: + info("No currencies found") + return {} + info(f"Currencies updated") + return data["rates"] + except URLError as error: + warning(f"Error getting currencies: {error}") + return {} + + def get_currency(self, currency: str) -> Optional[str]: + """Get the currency name normalized using aliases and capitalization + + Args: + currency (str): The currency to normalize + + Returns: + Optional[str]: The currency name or None if not found + """ + # update the currencies every 24 hours + if not self.currencies or (datetime.now() - self.last_update).days >= 1: + self.currencies = self._get_currencies() + self.last_update = datetime.now() + currency = self.aliases.get(currency, currency).upper() + return currency if currency in self.currencies else None + + def convert(self, amount: float, from_unit: str, to_unit: str) -> ConversionResult: + """Convert a currency to another currency + + Args: + amount (float): The amount to convert + from_unit (str): The currency to convert from + to_unit (str): The currency to convert to + + Returns: + ConversionResult: Object containing information about the conversion result + + Raises: + UnknownCurrencyError: If the currency is not valid + """ + # get the currency rates + from_currency = self.get_currency(from_unit) + to_currency = self.get_currency(to_unit) + # convert the currency + if from_currency is None: + raise UnknownCurrencyError(from_unit) + if to_currency is None: + raise UnknownCurrencyError(to_unit) + from_rate = self.currencies[from_currency] + to_rate = self.currencies[to_currency] + result = amount * to_rate / from_rate + return ConversionResult( + from_amount=float(amount), + from_unit=from_currency, + to_amount=result, + to_unit=to_currency, + dimensionality="currency", + source=self.ATTRIBUTION, + ) + + +class Plugin(PluginInstance, GlobalQueryHandler): + """The plugin class""" + + config: dict[str, Any] = { + # Maximum number of decimal places for precision + "precision": 12, + # Unit aliases to replace when parsing + # Units may be added here to override the default behavior or create aliases for existing units + # The alias is the key, the string to replace it with is the value + "aliases": { + "sec": "second", + "kph": "km/hour", + "km/h": "km/hour", + "mph": "mile/hour", + "degrees F": "degF", + "degrees C": "degC", + "F": "degF", + "C": "degC", + }, + # Display names for units + # Units may be added here to override the default display names + # The string version of the unit is the key, the display name to replace with is the value + # Both the unpluralized and the pluralized version should be included + "display_names": { + "degree_Celsius": "°C", + "degree_Celsiuses": "°C", + "degree_Fahrenheit": "°F", + "degree_Fahrenheits": "°F", + "mile / hour": "mph", + "mile / hours": "mph", + "kilometer / hour": "km/h", + "kilometer / hours": "km/h", + "kilometer_per_hour": "km/h", + "kilometer_per_hours": "km/h", + }, + } + + def __init__(self): + PluginInstance.__init__(self) + GlobalQueryHandler.__init__(self) + + self.unit_convert_regex = re.compile( + r"(?P-?\d+\.?\d*)\s?(?P.*)\s(?:to|in)\s(?P.*)", + re.I, + ) + self.unit_converter = StandardUnitConverter() + self.currency_converter = CurrencyConverter() + + def defaultTrigger(self): + return "convert " + + def synopsis(self, query): + return " to " + + def handleTriggerQuery(self, query: Query) -> None: + if query_string := query.string.strip(): + items = self.match_query(query_string) + query.add(items) + + def handleGlobalQuery(self, query): + return [RankItem(item=item, score=1) for item in self.match_query(query.string.strip())] + + def match_query(self, query_string: str): + match = self.unit_convert_regex.fullmatch(query_string) + if match: + try: + return self._get_items( + float(match.group("from_amount")), + match.group("from_unit").strip(), + match.group("to_unit").strip(), + ) + except Exception as error: + warning(f"Error: {error}") + tb = "".join(traceback.format_exception(error.__class__, error, error.__traceback__)) + warning(tb) + info("Something went wrong. Make sure you're using the correct format.") + return [] + + @staticmethod + def _create_item(text: str, subtext: str, icon: str = "") -> Item: + """Create an Item from a text and subtext + + Args: + text (str): The text to display + subtext (str): The subtext to display + icon (Optional[str]): The icon to display. If not specified, the default icon will be used + + Returns: + Item: The item to be added to the list of results + """ + icon_path = Path(__file__).parent / "icons" / icon + if not icon or not icon_path.exists(): + warning(f"Icon {icon} does not exist") + icon_path = Path(__file__).parent / "icons" / "unit_converter.svg" + return StandardItem( + id=str(icon_path), + iconUrls=["file:" + str(icon_path)], + text=text, + subtext=subtext, + actions=[ + Action( + id="copy", + text="Copy result to clipboard", + callable=lambda: setClipboardText(text=text), + ) + ], + ) + + def _get_converter(self, from_unit: str, to_unit: str) -> UnitConverter: + """Get the converter to use + + Args: + from_unit (str): The unit to convert from + to_unit (str): The unit to convert to + + Returns: + UnitConverter: The converter to use + """ + if ( + self.currency_converter.get_currency(from_unit) is not None + and self.currency_converter.get_currency(to_unit) is not None + ): + return self.currency_converter + return self.unit_converter + + def _get_items(self, amount: float, from_unit: str, to_unit: str) -> list[Item]: + """Generate the Albert items to display for the query + + Args: + amount (float): The amount to convert from + from_unit (str): The unit to convert from + to_unit (str): The unit to convert to + + Returns: + List[Item]: The list of items to display + """ + try: + converter = self._get_converter(from_unit, to_unit) + result = converter.convert(amount, from_unit, to_unit) + # return the result + return [ + self._create_item( + result.formatted_result, + f"Converted from {result.formatted_from}", + result.icon, + ) + ] + except pint.errors.DimensionalityError as e: + warning(f"DimensionalityError: {e}") + return [self._create_item(f"Unable to convert {amount} {from_unit} to {to_unit}", str(e))] + except pint.errors.UndefinedUnitError as e: + warning(f"UndefinedUnitError: {e}") + return [] + except UnknownCurrencyError as e: + warning(f"UnknownCurrencyError: {e}") + return [] diff --git a/unit_converter/icons/currency.svg b/unit_converter/icons/currency.svg new file mode 100644 index 00000000..51a9f40c --- /dev/null +++ b/unit_converter/icons/currency.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/unit_converter/icons/current.svg b/unit_converter/icons/current.svg new file mode 100644 index 00000000..b7cd3ad6 --- /dev/null +++ b/unit_converter/icons/current.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/unit_converter/icons/length.svg b/unit_converter/icons/length.svg new file mode 100644 index 00000000..b3af30a7 --- /dev/null +++ b/unit_converter/icons/length.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/unit_converter/icons/lengthtime.svg b/unit_converter/icons/lengthtime.svg new file mode 100644 index 00000000..edaf5548 --- /dev/null +++ b/unit_converter/icons/lengthtime.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/unit_converter/icons/luminosity.svg b/unit_converter/icons/luminosity.svg new file mode 100644 index 00000000..1f937881 --- /dev/null +++ b/unit_converter/icons/luminosity.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/unit_converter/icons/mass.svg b/unit_converter/icons/mass.svg new file mode 100644 index 00000000..981faac3 --- /dev/null +++ b/unit_converter/icons/mass.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/unit_converter/icons/printing_unit.svg b/unit_converter/icons/printing_unit.svg new file mode 100644 index 00000000..e284337b --- /dev/null +++ b/unit_converter/icons/printing_unit.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/unit_converter/icons/substance.svg b/unit_converter/icons/substance.svg new file mode 100644 index 00000000..5859d59a --- /dev/null +++ b/unit_converter/icons/substance.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/unit_converter/icons/temperature.svg b/unit_converter/icons/temperature.svg new file mode 100644 index 00000000..595e9d53 --- /dev/null +++ b/unit_converter/icons/temperature.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/unit_converter/icons/time.svg b/unit_converter/icons/time.svg new file mode 100644 index 00000000..d8c97c46 --- /dev/null +++ b/unit_converter/icons/time.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/unit_converter/icons/unit_converter.svg b/unit_converter/icons/unit_converter.svg new file mode 100644 index 00000000..e040bd91 --- /dev/null +++ b/unit_converter/icons/unit_converter.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/units.py b/units.py deleted file mode 100644 index 40a6a871..00000000 --- a/units.py +++ /dev/null @@ -1,62 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Convert units. - -This extension is a wrapper for the (extremely) powerful GNU units tool. Note that spaces are \ -interpreted as separators, i.e. dont use spaces between numbers and units. - -Synopsis: - [dst] - to """ - -import re -import subprocess as sp -from shutil import which - -from albertv0 import * - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "GNU Units" -__version__ = "1.1" -__trigger__ = "units " -__author__ = "Manuel Schneider" -__dependencies__ = ["units"] - -if which("units") is None: - raise Exception("'units' is not in $PATH.") - -icon = iconLookup('calc') -if not icon: - icon = ":python_module" - -regex = re.compile(r"(\S+)(?:\s+to)\s+(\S+)") - -def handleQuery(query): - - if query.isTriggered: - args = query.string.split() - item = Item(id='python.gnu_units', icon=icon, completion=query.rawString) - if args: - try: - item.text = sp.check_output(['units', '-t'] + query.string.split(), stderr=sp.STDOUT).decode().strip() - item.addAction(ClipAction("Copy to clipboard", item.text)) - except sp.CalledProcessError as e: - item.text = e.stdout.decode().strip().partition('\n')[0] - item.subtext = "Result of 'units -t %s'" % query.string - else: - item.text = "Empty input" - item.subtext = "Enter something to convert" - return item - - else: - match = regex.fullmatch(query.string.strip()) - if match: - args = match.group(1, 2) - try: - item = Item(id='python.gnu_units', icon=icon, completion=query.rawString) - item.text = sp.check_output(['units', '-t'] + list(args)).decode().strip() - item.subtext = "Result of 'units -t %s %s'" % args - item.addAction(ClipAction("Copy to clipboard", item.text)) - return item - except sp.CalledProcessError as e: - pass diff --git a/virtualbox.py b/virtualbox.py deleted file mode 100644 index 396136e4..00000000 --- a/virtualbox.py +++ /dev/null @@ -1,121 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Manage your VirtualBox machines. - -Synopsis: - - [filter]""" - -from virtualbox import Session, VirtualBox -from virtualbox.library import (LockType, MachineState, OleErrorInvalidarg, - OleErrorUnexpected, VBoxErrorFileError, - VBoxErrorHostError, - VBoxErrorInvalidObjectState, - VBoxErrorInvalidVmState, VBoxErrorIprtError, - VBoxErrorObjectNotFound) - -from albertv0 import FuncAction, Item, critical, iconLookup - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Virtual Box" -__version__ = "1.2" -__trigger__ = "vbox " -__author__ = "Manuel Schneider" -__dependencies__ = ['pyvbox'] - -vbox = None - -for iconName in ["virtualbox", "unknown"]: - iconPath = iconLookup(iconName) - if iconPath: - break - -def initialize(): - global vbox - vbox = VirtualBox() - -def finalize(): - pass - -def startVm(vm): - try: - with Session() as session: - vm.launch_vm_process(session, 'gui', '') - except OleErrorUnexpected as e: - warning("OleErrorUnexpected") - except OleErrorInvalidarg as e: - warning("OleErrorInvalidarg") - except VBoxErrorObjectNotFound as e: - warning("VBoxErrorObjectNotFound") - except VBoxErrorInvalidObjectState as e: - warning("VBoxErrorInvalidObjectState") - except VBoxErrorInvalidVmState as e: - warning("VBoxErrorInvalidVmState") - except VBoxErrorIprtError as e: - warning("VBoxErrorIprtError") - except VBoxErrorHostError as e: - warning("VBoxErrorHostError") - except VBoxErrorFileError as e: - warning("VBoxErrorFileError") - -def acpiPowerVm(vm): - with vm.create_session(LockType.shared) as session: - session.console.power_button() - -def stopVm(vm): - with vm.create_session(LockType.shared) as session: - session.console.power_down() - -def saveVm(vm): - with vm.create_session(LockType.shared) as session: - session.machine.save_state() - -def discardSavedVm(vm): - with vm.create_session(LockType.shared) as session: - session.machine.discard_save_state(True); - -def resumeVm(vm): - with vm.create_session(LockType.shared) as session: - session.console.resume() - -def pauseVm(vm): - with vm.create_session(LockType.shared) as session: - session.console.pause() - -def buildVmItem(vm): - item = Item( - id=vm.__uuid__, - icon=iconPath, - text=vm.name, - subtext="{vm.state}".format(vm=vm), - completion=vm.name - ) - - if vm.state == MachineState.powered_off: #1 - item.addAction(FuncAction(text="Start virtual machine", callable=lambda: startVm(vm))) - if vm.state == MachineState.saved: #2 - item.addAction(FuncAction(text="Restore virtual machine", callable=lambda: startVm(vm))) - item.addAction(FuncAction(text="Discard saved state", callable=lambda: discardSavedVm(vm))) - if vm.state == MachineState.aborted: #4 - item.addAction(FuncAction(text="Start virtual machine", callable=lambda: startVm(vm))) - if vm.state == MachineState.running: #5 - item.addAction(FuncAction(text="Save virtual machine", callable=lambda: saveVm(vm))) - item.addAction(FuncAction(text="Power off via ACPI event (Power button)", callable=lambda: acpiPowerVm(vm))) - item.addAction(FuncAction(text="Turn off virtual machine", callable=lambda: stopVm(vm))) - item.addAction(FuncAction(text="Pause virtual machine", callable=lambda: pauseVm(vm))) - if vm.state == MachineState.paused: #6 - item.addAction(FuncAction(text="Resume virtual machine", callable=lambda: resumeVm(vm))) - - return item - - -def handleQuery(query): - pattern = query.string.strip().lower() - results = [] - try: - for vm in vbox.machines: - if (pattern and pattern in vm.name.lower() or not pattern and query.isTriggered): - results.append(buildVmItem(vm)) - except Exception as e: - critical(str(e)) - return results diff --git a/virtualbox/__init__.py b/virtualbox/__init__.py new file mode 100644 index 00000000..ad39ebbf --- /dev/null +++ b/virtualbox/__init__.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider +""" +This plugin is based on [virtualbox-python](https://pypi.org/project/virtualbox/) and needs the 'vboxapi' module which +is part of the VirtualBox SDK. Some distributions package the SDK, e.g. Arch has +[virtualbox-sdk](https://archlinux.org/packages/extra/x86_64/virtualbox-sdk/). +""" + +import virtualbox +from virtualbox.library import LockType, MachineState + +from albert import * + +md_iid = "3.0" +md_version = "2.0" +md_name = "VirtualBox" +md_description = "Manage your VirtualBox machines" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/virtualbox" +md_authors = "@manuelschneid3r" +md_lib_dependencies = ['virtualbox'] + + +def startVm(vm): + try: + with virtualbox.Session() as session: + progress = vm.launch_vm_process(session, 'gui', []) + progress.wait_for_completion() + except Exception as e: + warning(str(e)) + + +def acpiPowerVm(vm): + with vm.create_session(LockType.shared) as session: + session.console.power_button() + + +def stopVm(vm): + with vm.create_session(LockType.shared) as session: + session.console.power_down() + + +def saveVm(vm): + with vm.create_session(LockType.shared) as session: + session.machine.save_state() + + +def discardSavedVm(vm): + with vm.create_session(LockType.shared) as session: + session.machine.discard_save_state(True) + + +def resumeVm(vm): + with vm.create_session(LockType.shared) as session: + session.console.resume() + + +def pauseVm(vm): + with vm.create_session(LockType.shared) as session: + session.console.pause() + + +class Plugin(PluginInstance, TriggerQueryHandler): + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + self.iconUrls = ["xdg:virtualbox", ":unknown"] + + def defaultTrigger(self): + return 'vbox ' + + def synopsis(self, query): + return "" + + def configWidget(self): + return [ + { + 'type': 'label', + 'text': __doc__.strip(), + 'widget_properties': { + 'textFormat': 'Qt::MarkdownText' + } + } + ] + + def handleTriggerQuery(self, query): + items = [] + pattern = query.string.strip().lower() + try: + for vm in filter(lambda vm: pattern in vm.name.lower(), virtualbox.VirtualBox().machines): + actions = [] + if vm.state == MachineState.powered_off or vm.state == MachineState.aborted: # 1 # 4 + actions.append(Action("startvm", "Start virtual machine", lambda m=vm: startVm(m))) + if vm.state == MachineState.saved: # 2 + actions.append(Action("restorevm", "Start saved virtual machine", lambda m=vm: startVm(m))) + actions.append(Action("discardvm", "Discard saved state", lambda m=vm: discardSavedVm(m))) + if vm.state == MachineState.running: # 5 + actions.append(Action("savevm", "Save virtual machine", lambda m=vm: saveVm(m))) + actions.append(Action("poweroffvm", "Power off via ACPI event (Power button)", lambda m=vm: acpiPowerVm(m))) + actions.append(Action("stopvm", "Turn off virtual machine", lambda m=vm: stopVm(m))) + actions.append(Action("pausevm", "Pause virtual machine", lambda m=vm: pauseVm(m))) + if vm.state == MachineState.paused: # 6 + actions.append(Action("resumevm", "Resume virtual machine", lambda m=vm: resumeVm(m))) + + items.append( + StandardItem( + id=vm.__uuid__, + text=vm.name, + subtext="{vm.state}".format(vm=vm), + inputActionText=vm.name, + iconUrls=self.iconUrls, + actions=actions + ) + ) + except Exception as e: + warning(str(e)) + + query.add(items) diff --git a/vscode_projects/__init__.py b/vscode_projects/__init__.py new file mode 100644 index 00000000..7ed263c9 --- /dev/null +++ b/vscode_projects/__init__.py @@ -0,0 +1,550 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Sharsie + +import os +import json +from pathlib import Path +from dataclasses import dataclass +from albert import * + +md_iid = "3.0" +md_version = "1.10" +md_name = "VSCode projects" +md_description = "Open VSCode projects" +md_url = "https://github.com/albertlauncher/python/tree/master/vscode_projects" +md_license = "MIT" +md_bin_dependencies = ["code"] +md_authors = ["@Sharsie"] + +@dataclass +class Project: + displayName: str + name: str + path: str + tags: list[str] + + +@dataclass +class SearchResult: + project: Project + # priority is used to sort returned results + priority: int + # sortIndex is a decision maker when two search results have same priority + sortIndex: int + + +@dataclass +class CachedConfig: + projects: list[Project] + mTime: float + + +class Plugin(PluginInstance, TriggerQueryHandler): + # Possible locations for Code configuration + _configStoragePaths = [ + os.path.join(os.environ["HOME"], ".config/Code/storage.json"), + os.path.join(os.environ["HOME"], + ".config/Code/User/globalStorage/storage.json"), + ] + + # Possible locations for Project Manager extension configuration + _configProjectManagerPaths = [ + os.path.join( + os.environ["HOME"], ".config/Code/User/globalStorage/alefragnani.project-manager/projects.json") + ] + + # Indicates whether results from the Recent list in VSCode should be searched + _recentEnabled = True + + # Indicates whether projects from Project Manager extension should be searched + _projectManagerEnabled = False + + # Defines sorting priorities for results + _sortPriority = { + "PMName": 1, + "PMPath": 5, + "PMTag": 10, + "Recent": 15 + } + + # Holds cached data from the json configurations + _configCache: dict[str, CachedConfig] = {} + + # Overrides the command to open projects + _terminalCommand = "" + + # Setting indicating whether results from the Recent list in VSCode should be searched + @property + def recentEnabled(self): + return self._recentEnabled + + @recentEnabled.setter + def recentEnabled(self, value): + self._recentEnabled = value + self.writeConfig("recentEnabled", value) + + # Setting indicating whether projects in Project Manager extension should be searched + @property + def projectManagerEnabled(self): + return self._projectManagerEnabled + + @projectManagerEnabled.setter + def projectManagerEnabled(self, value): + self._projectManagerEnabled = value + self.writeConfig("projectManagerEnabled", value) + + found = False + for p in self._configProjectManagerPaths: + if os.path.exists(p): + found = True + break + + if found == False: + warning( + "Project Manager search was enabled, but configuration file was not found") + notif = Notification( + title=self.name, + text=f"Configuration file was not found for the Project Manager extension. Please make sure the extension is installed." + ) + notif.send() + + # Priority settings for project manager results using name search + @property + def priorityPMName(self): + return self._sortPriority["PMName"] + + @priorityPMName.setter + def priorityPMName(self, value): + self._sortPriority["PMName"] = value + self.writeConfig("priorityPMName", value) + + # Priority settings for project manager results using path search + @property + def priorityPMPath(self): + return self._sortPriority["PMPath"] + + @priorityPMPath.setter + def priorityPMPath(self, value): + self._sortPriority["PMPath"] = value + self.writeConfig("priorityPMPath", value) + + # Priority settings for project manager results using tag search + @property + def priorityPMTag(self): + return self._sortPriority["PMTag"] + + @priorityPMTag.setter + def priorityPMTag(self, value): + self._sortPriority["PMTag"] = value + self.writeConfig("priorityPMTag", value) + + # Priority settings for recently opened files + @property + def priorityRecent(self): + return self._sortPriority["Recent"] + + @priorityRecent.setter + def priorityRecent(self, value): + self._sortPriority["Recent"] = value + self.writeConfig("priorityRecent", value) + + # Setting for custom command when opening resulted items + @property + def terminalCommand(self): + return self._terminalCommand + + @terminalCommand.setter + def terminalCommand(self, value): + self._terminalCommand = value + self.writeConfig("terminalCommand", value) + + def defaultTrigger(self): + return "code " + + def synopsis(self, query): + return "project name or path" + + def __init__(self): + self.iconUrls = [f"file:{Path(__file__).parent}/icon.svg"] + + PluginInstance.__init__(self) + + TriggerQueryHandler.__init__(self) + + configFound = False + + for p in self._configStoragePaths: + if os.path.exists(p): + configFound = True + break + + if not configFound: + warning("Could not find any VSCode configuration directory") + + self._initConfiguration() + + def configWidget(self): + return [ + { + "type": "label", + "text": """Recent files are sorted in order found in the VSCode configuration. +Sort order with Project Manager can be adjusted, lower number = higher priority = displays first. +With all priorities equal, PM results will take precedence over recents.""" + }, + { + "type": "label", + "text": """ +PM extension: https://marketplace.visualstudio.com/items?itemName=alefragnani.project-manager +""" + }, + + { + "type": "checkbox", + "property": "recentEnabled", + "label": "Search in Recent files" + }, + { + "type": "checkbox", + "property": "projectManagerEnabled", + "label": "Search in Project Manager extension" + }, + { + "type": "spinbox", + "property": "priorityPMName", + "label": "Priority: Project Manager entries matched by name", + "widget_properties": { + "minimum": 1, + "maximum": 99, + }, + }, + { + "type": "spinbox", + "property": "priorityPMPath", + "label": "Priority: Project Manager entries matched by path", + "widget_properties": { + "minimum": 1, + "maximum": 99, + }, + }, + { + "type": "spinbox", + "property": "priorityPMTag", + "label": "Priority: Project Manager entries matched by tag", + "widget_properties": { + "minimum": 1, + "maximum": 99, + }, + }, + { + "type": "spinbox", + "property": "priorityRecent", + "label": "Priority: Recent entries", + "widget_properties": { + "minimum": 1, + "maximum": 99, + }, + }, + { + "type": "label", + "text": """ +The way VSCode is opened can be overriden through terminal command. +Terminal will enter the working directory of the project upon selection, execute the command and then close itself. + +Usecase with direnv - To load direnv environment before opening VSCode, enter the following custom command: direnv exec . code . + +Usecase with single VSCode instance - To reuse the VSCode window instead of opening a new one, enter the following custom command: code -r .""" + }, + { + "type": "lineedit", + "property": "terminalCommand", + "label": "Run custom command in the workdir of selected item" + }, + ] + + def _initConfiguration(self): + # Recent search + recentEnabled = self.readConfig('recentEnabled', bool) + if recentEnabled is None: + self._recentEnabled = True + self.writeConfig("recentEnabled", True) + else: + self._recentEnabled = recentEnabled + + # Project Manager search + foundPM = False + for p in self._configProjectManagerPaths: + if os.path.exists(p): + foundPM = True + break + + projectManagerEnabled = self.readConfig('projectManagerEnabled', bool) + if projectManagerEnabled is None: + # If not configured, check if the project manager configuration file exists and if so, enable PM search + if foundPM: + self._projectManagerEnabled = True + self.writeConfig("projectManagerEnabled", True) + else: + self._projectManagerEnabled = False + else: + self._projectManagerEnabled = projectManagerEnabled + + # Priority settings + for p in self._sortPriority: + prio = self.readConfig(f"priority{p}", int) + if prio is None: + self.writeConfig(f"priority{p}", self._sortPriority[p]) + else: + self._sortPriority[p] = prio + + # Terminal command setting + terminalCommand = self.readConfig('terminalCommand', str) + if terminalCommand is not None: + self._terminalCommand = terminalCommand + + def handleTriggerQuery(self, query): + if not query.isValid: + return + + if query.string == "": + return + + matcher = Matcher(query.string) + + results: dict[str, SearchResult] = {} + + if self.recentEnabled: + results = self._searchInRecentFiles(matcher, results) + + if self.projectManagerEnabled: + results = self._searchInProjectManager(matcher, results) + + sortedItems = sorted(results.values(), key=lambda item: "%s_%s_%s" % ( + '{:03d}'.format(item.priority), '{:03d}'.format(item.sortIndex), item.project.name), reverse=False) + + items: list[StandardItem] = [] + for i in sortedItems: + items.append(self._createItem(i.project, query)) + + query.add(items) + + # Creates an item for the query based on the project and plugin settings + def _createItem(self, project: Project, query: Query) -> StandardItem: + actions: list[Action] = [] + + if self.terminalCommand != "": + actions.append( + Action( + id="open-terminal", + text=f"Run terminal command in project's workdir: {self.terminalCommand}", + callable=lambda: runTerminal(f"cd {project.path} && {self.terminalCommand}") + ) + ) + + actions.append( + Action( + id="open-code", + text="Open with VSCode", + callable=lambda: runDetachedProcess( + ["code", project.path]), + ) + ) + + subtext = "" + + if len(project.tags) > 0: + subtext = "<" + ",".join(project.tags) + "> " + + return StandardItem( + id=project.path, + text=project.displayName, + subtext=f"{subtext}{project.path}", + iconUrls=self.iconUrls, + inputActionText=project.displayName, + actions=actions, + ) + + def _searchInRecentFiles(self, matcher: Matcher, results: dict[str, SearchResult]) -> dict[str, SearchResult]: + sortIndex = 1 + + for path in self._configStoragePaths: + c = self._getStorageConfig(path) + for proj in c.projects: + # Resolve sym links to get unique results + resolvedPath = str(Path(proj.path).resolve()) + if matcher.match(proj.name) or matcher.match(proj.path) or matcher.match(resolvedPath): + results[resolvedPath] = self._getHigherPriorityResult( + SearchResult( + project=proj, + priority=self.priorityRecent, + sortIndex=sortIndex + ), + results.get(resolvedPath), + ) + + if results.get(resolvedPath) is not None: + sortIndex += 1 + + return results + + def _searchInProjectManager(self, matcher: Matcher, results: dict[str, SearchResult]) -> dict[str, SearchResult]: + for path in self._configProjectManagerPaths: + c = self._getProjectManagerConfig(path) + for proj in c.projects: + # Resolve sym links to get unique results + resolvedPath = str(Path(proj.path).resolve()) + if matcher.match(proj.name): + results[resolvedPath] = self._getHigherPriorityResult( + SearchResult( + project=proj, + priority=self.priorityPMName, + sortIndex=0 if matcher.match(proj.name).isExactMatch() else 1 + ), + results.get(resolvedPath), + ) + + if matcher.match(proj.path) or matcher.match(resolvedPath): + results[resolvedPath] = self._getHigherPriorityResult( + SearchResult( + project=proj, + priority=self.priorityPMPath, + sortIndex=1 + ), + results.get(resolvedPath), + ) + + for tag in proj.tags: + if matcher.match(tag): + results[resolvedPath] = self._getHigherPriorityResult( + SearchResult( + project=proj, + priority=self.priorityPMTag, + sortIndex=1 + ), + results.get(resolvedPath), + ) + break + + return results + + # Compares the search results to return the one with higher priority + # For nitpickers: higher priorty = lower number + def _getHigherPriorityResult(self, current: SearchResult, prev: SearchResult | None) -> SearchResult: + if prev is None or current.priority < prev.priority or (current.priority == prev.priority and current.sortIndex < prev.sortIndex): + return current + + return prev + + def _getStorageConfig(self, path: str) -> CachedConfig: + c: CachedConfig = self._configCache.get(path, CachedConfig([], 0)) + + if not os.path.exists(path): + return c + + mTime = os.stat(path).st_mtime + + if mTime == c.mTime: + return c + + c.mTime = mTime + + with open(path) as configFile: + # Load the storage json + storageConfig = json.loads(configFile.read()) + + if ( + "lastKnownMenubarData" in storageConfig + and "menus" in storageConfig["lastKnownMenubarData"] + and "File" in storageConfig["lastKnownMenubarData"]["menus"] + and "items" in storageConfig["lastKnownMenubarData"]["menus"]["File"] + ): + # These are all the menu items in File dropdown + for menuItem in storageConfig["lastKnownMenubarData"]["menus"]["File"]["items"]: + # Cannot safely detect proper menu item, as menu item IDs change over time + # Instead we will search all submenus and check for IDs inside the submenu items + if ( + not "id" in menuItem + or not "submenu" in menuItem + or not "items" in menuItem["submenu"] + ): + continue + + for submenuItem in menuItem["submenu"]["items"]: + # Check of submenu item with id "openRecentFolder" and make sure it contains necessarry keys + if ( + not "id" in submenuItem + or submenuItem['id'] != "openRecentFolder" + or not "enabled" in submenuItem + or submenuItem["enabled"] != True + or not "label" in submenuItem + or not "uri" in submenuItem + or not "path" in submenuItem["uri"] + ): + continue + + # Get the full path to the project + recentPath = submenuItem["uri"]["path"] + if not os.path.exists(recentPath): + continue + + displayName = recentPath.split("/")[-1] + + # Inject the project + c.projects.append(Project( + displayName=displayName, + name=displayName, + path=recentPath, + tags=[], + )) + + self._configCache[path] = c + + return c + + def _getProjectManagerConfig(self, path: str) -> CachedConfig: + c = self._configCache.get(path, CachedConfig([], 0)) + + if not os.path.exists(path): + return c + + mTime = os.stat(path).st_mtime + + if mTime == c.mTime: + return c + + c.mTime = mTime + + with open(path) as configFile: + configuredProjects = json.loads(configFile.read()) + + for p in configuredProjects: + # Make sure we have necessarry keys + if ( + not "rootPath" in p + or not "name" in p + or not "enabled" in p + or p["enabled"] != True + ): + continue + + # Grab the path to the project + rootPath = p["rootPath"] + if os.path.exists(rootPath) == False: + continue + + project = Project( + displayName=p["name"], + name=p["name"], + path=rootPath, + tags=[], + ) + + # Search against the query string + if "tags" in p: + for tag in p["tags"]: + project.tags.append(tag) + + c.projects.append(project) + + self._configCache[path] = c + + return c diff --git a/vscode_projects/icon.svg b/vscode_projects/icon.svg new file mode 100644 index 00000000..c453e633 --- /dev/null +++ b/vscode_projects/icon.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wikipedia/__init__.py b/wikipedia/__init__.py index bd59ff68..af92e2f6 100644 --- a/wikipedia/__init__.py +++ b/wikipedia/__init__.py @@ -1,97 +1,161 @@ # -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider -"""Search Wikipedia articles. -Synopsis: """ - -from albertv0 import * +from albert import * from locale import getdefaultlocale +from socket import timeout +from time import sleep from urllib import request, parse import json -import time -import os - -__iid__ = "PythonInterface/v0.3" -__prettyname__ = "Wikipedia" -__version__ = "1.4" -__trigger__ = "wiki " -__author__ = "Manuel Schneider" -__dependencies__ = [] - -iconPath = iconLookup('wikipedia') -if not iconPath: - iconPath = os.path.dirname(__file__)+"/wikipedia.svg" -baseurl = 'https://en.wikipedia.org/w/api.php' -user_agent = "org.albert.extension.python.wikipedia" -limit = 20 - - -def initialize(): - global baseurl - params = { - 'action': 'query', - 'meta': 'siteinfo', - 'utf8': 1, - 'siprop': 'languages', - 'format': 'json' - } - - get_url = "%s?%s" % (baseurl, parse.urlencode(params)) - req = request.Request(get_url, headers={'User-Agent': user_agent}) - with request.urlopen(req) as response: - data = json.loads(response.read().decode('utf-8')) - languages = [lang['code'] for lang in data['query']['languages']] - local_lang_code = getdefaultlocale()[0][0:2] - if local_lang_code in languages: - baseurl = baseurl.replace("en", local_lang_code) - - -def handleQuery(query): - if query.isTriggered: - query.disableSort() - - # avoid rate limiting - time.sleep(0.1) - if not query.isValid: - return - - stripped = query.string.strip() - - if stripped: +from pathlib import Path + +md_iid = "3.0" +md_version = "3.0" +md_name = "Wikipedia" +md_description = "Search Wikipedia articles" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/wikipedia" +md_authors = "@manuelschneid3r" + +class Plugin(PluginInstance, TriggerQueryHandler): + + baseurl = 'https://en.wikipedia.org/w/api.php' + searchUrl = 'https://%s.wikipedia.org/wiki/Special:Search/%s' + user_agent = "org.albert.wikipedia" + limit = 20 + iconUrls = [f"file:{Path(__file__).parent}/wikipedia.png"] + + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + + self.fbh = FBH(self) + self.fuzzy = False + + self.local_lang_code = getdefaultlocale()[0] + if self.local_lang_code: + self.local_lang_code = self.local_lang_code[0:2] + else: + self.local_lang_code = 'en' + warning("Failed getting language code. Using 'en'.") + + params = { + 'action': 'query', + 'meta': 'siteinfo', + 'utf8': 1, + 'siprop': 'languages', + 'format': 'json' + } + + get_url = "%s?%s" % (self.baseurl, parse.urlencode(params)) + req = request.Request(get_url, headers={'User-Agent': self.user_agent}) + try: + with request.urlopen(req, timeout=5) as response: + data = json.loads(response.read().decode('utf-8')) + languages = [lang['code'] for lang in data['query']['languages']] + if self.local_lang_code in languages: + self.baseurl = self.baseurl.replace("en", self.local_lang_code) + except timeout: + warning('Error getting languages - socket timed out. Defaulting to EN.') + except Exception as error: + warning('Error getting languages (%s). Defaulting to EN.' % error) + + def extensions(self): + return [self, self.fbh] + + def defaultTrigger(self): + return "wiki " + + def supportsFuzzyMatching(self): + return True + + def setFuzzyMatching(self, enabled: bool): + self.fuzzy = enabled + + def handleTriggerQuery(self, query): + if stripped := query.string.strip(): + + # avoid rate limiting + for _ in range(50): + sleep(0.01) + if not query.isValid: + return + results = [] params = { 'action': 'opensearch', 'search': stripped, - 'limit': limit, + 'limit': self.limit, 'utf8': 1, - 'format': 'json' + 'format': 'json', + 'profile': 'fuzzy' if self.fuzzy else 'normal' } - get_url = "%s?%s" % (baseurl, parse.urlencode(params)) - req = request.Request(get_url, headers={'User-Agent': user_agent}) + get_url = "%s?%s" % (self.baseurl, parse.urlencode(params)) + req = request.Request(get_url, headers={'User-Agent': self.user_agent}) with request.urlopen(req) as response: data = json.loads(response.read().decode('utf-8')) - for i in range(0, min(limit, len(data[1]))): + for i in range(0, min(self.limit, len(data[1]))): title = data[1][i] summary = data[2][i] url = data[3][i] - - results.append(Item(id=__prettyname__, - icon=iconPath, - text=title, - subtext=summary if summary else url, - completion=title, - actions=[ - UrlAction("Open article on Wikipedia", url), - ClipAction("Copy URL", url) - ])) - - return results + results.append( + StandardItem( + id=self.id(), + text=title, + subtext=summary if summary else url, + iconUrls=self.iconUrls, + actions=[ + Action("open", "Open article on Wikipedia", lambda u=url: openUrl(u)), + Action("copy", "Copy URL to clipboard", lambda u=url: setClipboardText(u)) + ] + ) + ) + + if not results: + results.append(self.createFallbackItem(stripped)) + + query.add(results) else: - return Item(id=__prettyname__, - icon=iconPath, - text=__prettyname__, - subtext="Enter a query to search on Wikipedia", - completion=query.rawString) + query.add( + StandardItem( + id=self.id(), + text=self.name(), + subtext="Enter a query to search on Wikipedia", + iconUrls=self.iconUrls + ) + ) + + def createFallbackItem(self, q: str) -> Item: + return StandardItem( + id=self.id(), + text=self.name(), + subtext="Search '%s' on Wikipedia" % q, + iconUrls=self.iconUrls, + actions=[ + Action("wiki_search", "Search on Wikipedia", + lambda url=self.searchUrl % (self.local_lang_code, q): openUrl(url)) + ] + ) + + +class FBH(FallbackHandler): + + def __init__(self, p: Plugin): + FallbackHandler.__init__(self) + self.plugin = p + + def id(self): + return "wikipedia.fallbacks" + + def name(self): + return md_name + + def description(self): + return md_description + + def fallbacks(self, q :str): + return [self.plugin.createFallbackItem(q)] diff --git a/wikipedia/wikipedia.png b/wikipedia/wikipedia.png new file mode 100644 index 00000000..a2a40bbd Binary files /dev/null and b/wikipedia/wikipedia.png differ diff --git a/wikipedia/wikipedia.svg b/wikipedia/wikipedia.svg deleted file mode 100644 index f082c72f..00000000 --- a/wikipedia/wikipedia.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/window_switcher.py b/window_switcher.py deleted file mode 100644 index afec45c8..00000000 --- a/window_switcher.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- - -"""List and manage X11 windows. - -Synopsis: """ - -import subprocess -from collections import namedtuple -from shutil import which - -from albertv0 import Item, ProcAction, iconLookup - -Window = namedtuple("Window", ["wid", "desktop", "wm_class", "host", "wm_name"]) - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Window Switcher" -__version__ = "1.4" -__author__ = "Ed Perez, Manuel Schneider" -__dependencies__ = ["wmctrl"] - -if which("wmctrl") is None: - raise Exception("'wmctrl' is not in $PATH.") - -def handleQuery(query): - stripped = query.string.strip().lower() - if stripped: - results = [] - for line in subprocess.check_output(['wmctrl', '-l', '-x']).splitlines(): - win = Window(*[token.decode() for token in line.split(None,4)]) - if win.desktop != "-1" and stripped in win.wm_class.split('.')[0].lower(): - results.append(Item(id="%s%s" % (__prettyname__, win.wm_class), - icon=iconLookup(win.wm_class.split('.')[0]), - text="%s - Desktop %s" % (win.wm_class.split('.')[-1].replace('-',' '), win.desktop), - subtext=win.wm_name, - actions=[ProcAction("Switch Window", - ["wmctrl", '-i', '-a', win.wid] ), - ProcAction("Move window to this desktop", - ["wmctrl", '-i', '-R', win.wid] ), - ProcAction("Close the window gracefully.", - ["wmctrl", '-c', win.wid])])) - return results diff --git a/x_window_switcher/__init__.py b/x_window_switcher/__init__.py new file mode 100644 index 00000000..00c180b9 --- /dev/null +++ b/x_window_switcher/__init__.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + +import subprocess +from collections import namedtuple +from albert import * + +md_iid = "3.0" +md_version = "0.6.0" +md_name = "X Window Switcher" +md_description = "Switch X11 Windows" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/x_window_switcher" +md_bin_dependencies = "wmctrl" +md_authors = ["Ed Perez", "Manuel S.", "dshoreman", "nopsqi"] + +Window = namedtuple("Window", ["wid", "desktop", "wm_class", "host", "wm_name"]) + + +class Plugin(PluginInstance, TriggerQueryHandler): + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + + # Check for X session and wmctrl availability + try: + subprocess.check_call(["wmctrl", "-m"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except FileNotFoundError: + raise Exception("wmctrl not found. Please install wmctrl.") + except subprocess.CalledProcessError: + raise Exception("Unable to communicate with X11 window manager. This plugin requires a running X session.") + + def defaultTrigger(self): + return 'w ' + + def handleTriggerQuery(self, query): + try: + for line in subprocess.check_output(['wmctrl', '-l', '-x']).splitlines(): + win = Window(*parseWindow(line)) + + if win.desktop == "-1": + continue + + win_instance, win_class = win.wm_class.replace(' ', '-').split('.', 1) + + m = Matcher(query.string) + if not query.string or m.match(win_instance + ' ' + win_class + ' ' + win.wm_name): + query.add(StandardItem( + id="%s%s" % (md_name, win.wm_class), + iconUrls=["xdg:%s" % win_instance], + text="%s - Desktop %s" % (win_class.replace('-', ' '), win.desktop), + subtext=win.wm_name, + actions=[Action("switch", + "Switch Window", + lambda w=win: runDetachedProcess(["wmctrl", '-i', '-a', w.wid])), + Action("move", + "Move window to this desktop", + lambda w=win: runDetachedProcess(["wmctrl", '-i', '-R', w.wid])), + Action("close", + "Close the window gracefully.", + lambda w=win: runDetachedProcess(["wmctrl", '-c', w.wid]))] + )) + except subprocess.CalledProcessError as e: + warning(f"Error executing wmctrl: {str(e)}") + + +def parseWindow(line): + win_id, desktop, rest = line.decode().split(None, 2) + win_class, rest = rest.split(' ', 1) + host, title = rest.strip().split(None, 1) + + return [win_id, desktop, win_class, host, title] diff --git a/youtube.py b/youtube.py deleted file mode 100644 index a6317ca8..00000000 --- a/youtube.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Query and open YouTube videos and channels. - -Synopsis: """ - -import json -import re -import time -from os import path -from urllib.parse import urlencode -from urllib.request import Request, urlopen - -from albertv0 import Item, UrlAction, iconLookup - -__iid__ = 'PythonInterface/v0.1' -__prettyname__ = 'Youtube' -__version__ = '1.0' -__trigger__ = 'yt ' -__author__ = 'Manuel Schneider' -__icon__ = iconLookup('youtube') # path.dirname(__file__) + '/icons/YouTube.png' - -HEADERS = { - 'User-Agent': ( - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)' - ' Chrome/62.0.3202.62 Safari/537.36' - ) -} - -re_videos = re.compile(r"^\s*window\[\"ytInitialData\"\] = (.*);$", re.MULTILINE) - -def handleQuery(query): - if query.isTriggered and query.string.strip(): - - # avoid rate limiting - time.sleep(0.2) - if not query.isValid: - return - - url_values = urlencode({'search_query': query.string.strip()}) - url = 'https://www.youtube.com/results?%s' % url_values - req = Request(url=url, headers=HEADERS) - with urlopen(req) as response: - match = re.search(re_videos, response.read().decode()) - if match: - results = json.loads(match.group(1)) - results = results['contents']['twoColumnSearchResultsRenderer']['primaryContents']['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'] - items = [] - for result in results: - for type, data in result.items(): - try: - if type == 'videoRenderer': - id = data['videoId'] - subtext = 'Video' - if 'lengthText' in data: - subtext = subtext + " | %s" % data['lengthText']['simpleText'].strip() - if 'shortViewCountText' in data: - subtext = subtext + " | %s" % data['shortViewCountText']['simpleText'].strip() - if 'publishedTimeText' in data: - subtext = subtext + " | %s" % data['publishedTimeText']['simpleText'].strip() - actions=[ UrlAction('Watch on Youtube', 'https://youtube.com/watch?v=%s' % id) ] - elif type == 'channelRenderer': - id = data['channelId'] - subtext = 'Channel' - if 'videoCountText' in data: - subtext = subtext + " | %s" % data['videoCountText']['simpleText'].strip() - if 'subscriberCountText' in data: - subtext = subtext + " | %s" % data['subscriberCountText']['simpleText'].strip() - actions=[ UrlAction('Show on Youtube', 'https://www.youtube.com/channel/%s' % id) ] - else: - continue - except Exception as e: - critical(e) - critical(json.dumps(result, indent=4)) - - item = Item(id=__prettyname__, - icon=data['thumbnail']['thumbnails'][0]['url'].split('?', 1)[0] if data['thumbnail']['thumbnails'] else __icon__, - text=data['title']['simpleText'], - subtext=subtext, - completion=query.rawString, - actions=actions - ) - items.append(item) - return items diff --git a/zeal.py b/zeal.py deleted file mode 100644 index 059bbc03..00000000 --- a/zeal.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Open and search in Zeal offline docs. - - Synopsis: """ - -from shutil import which -from subprocess import run - -from albertv0 import * - -__iid__ = "PythonInterface/v0.1" -__prettyname__ = "Zeal" -__version__ = "1.0" -__trigger__ = "zl " -__author__ = "Manuel Schneider" -__dependencies__ = ["zeal"] - -if which("zeal") is None: - raise Exception("'zeal' is not in $PATH.") - -iconPath = iconLookup('zeal') - - -def handleQuery(query): - if query.isTriggered: - return Item( - id=__prettyname__, - icon=iconPath, - text=__prettyname__, - subtext="Look up %s" % __prettyname__, - completion=query.rawString, - actions=[ProcAction("Start query in %s" % __prettyname__, - ["zeal", query.string])] - ) diff --git a/zeal/__init__.py b/zeal/__init__.py new file mode 100644 index 00000000..1339a7d9 --- /dev/null +++ b/zeal/__init__.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Manuel Schneider + +import albert + +md_iid = "3.0" +md_version = "3.0" +md_name = "Zeal" +md_description = "Search in Zeal docs" +md_license = "MIT" +md_url = "https://github.com/albertlauncher/python/tree/main/zeal" +md_authors = "@manuelschneid3r" +md_bin_dependencies = ['zeal'] + +def createItem(query: str): + return albert.StandardItem( + id=md_name, + text=md_name, + subtext=f"Search '{query}' in Zeal", + iconUrls=["xdg:zeal"], + actions=[albert.Action("zeal", "Search in Zeal", + lambda q=query: albert.runDetachedProcess(['zeal', q]))] + ) + +class FBH(albert.FallbackHandler): + + def id(self): + return "zeal_fbh" + + def name(self): + return md_name + + def description(self): + return md_description + + def fallbacks(self, s): + return [createItem(s)] if s else [] + + +class Plugin(albert.PluginInstance, albert.TriggerQueryHandler): + + def __init__(self): + albert.PluginInstance.__init__(self) + albert.TriggerQueryHandler.__init__(self) + self.fbh = FBH() + + def defaultTrigger(self): + return "z " + + def extensions(self): + return [self, self.fbh] + + def handleTriggerQuery(self, query): + if stripped := query.string.strip(): + query.add(createItem(stripped))