improve extension
This commit is contained in:
		
							parent
							
								
									c5d7ab5c94
								
							
						
					
					
						commit
						a362ff244e
					
				
					 4 changed files with 177 additions and 68 deletions
				
			
		| 
						 | 
				
			
			@ -2,6 +2,7 @@
 | 
			
		|||
Download the latest unicode tables from  https://www.unicode.org and create a .txt file
 | 
			
		||||
containing all the names, blocks and character codes
 | 
			
		||||
"""
 | 
			
		||||
import sys
 | 
			
		||||
import os
 | 
			
		||||
import logging
 | 
			
		||||
from urllib import request
 | 
			
		||||
| 
						 | 
				
			
			@ -9,13 +10,18 @@ from urllib import request
 | 
			
		|||
curr_path = os.path.dirname(__file__)
 | 
			
		||||
logging.basicConfig(level=logging.DEBUG)
 | 
			
		||||
 | 
			
		||||
# Be compatible with both python 2 and 3
 | 
			
		||||
if sys.version_info[0] >= 3:
 | 
			
		||||
    unichr = chr
 | 
			
		||||
 | 
			
		||||
BASE_URL = "https://www.unicode.org/Public/UCD/latest/ucd"
 | 
			
		||||
 | 
			
		||||
def get_blocks():
 | 
			
		||||
    """ Download the info file for Unicode blocks.
 | 
			
		||||
    """
 | 
			
		||||
    logging.info("Downloading block data...")
 | 
			
		||||
    req = request.urlopen("https://www.unicode.org/Public/UCD/latest/ucd/Blocks.txt")
 | 
			
		||||
    content = req.read().decode()
 | 
			
		||||
    with request.urlopen(f"{BASE_URL}/Blocks.txt") as req:
 | 
			
		||||
        content = req.read().decode()
 | 
			
		||||
    logging.info("Done")
 | 
			
		||||
    return content
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -24,10 +30,8 @@ def get_data():
 | 
			
		|||
    """ Download the info file for Unicode blocks.
 | 
			
		||||
    """
 | 
			
		||||
    logging.info("Downloading character data...")
 | 
			
		||||
    req = request.urlopen(
 | 
			
		||||
        "https://www.unicode.org/Public/UCD/latest/ucd/UnicodeData.txt"
 | 
			
		||||
    )
 | 
			
		||||
    content = req.read().decode()
 | 
			
		||||
    with request.urlopen(f"{BASE_URL}/UnicodeData.txt") as req:
 | 
			
		||||
        content = req.read().decode()
 | 
			
		||||
    logging.info("Done")
 | 
			
		||||
    return content
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -61,18 +65,19 @@ def load_blocks():
 | 
			
		|||
        [start, end] = indices[half]
 | 
			
		||||
        if start > code:
 | 
			
		||||
            return locate_block(code, left, right=half)
 | 
			
		||||
        elif end < code:
 | 
			
		||||
        if end < code:
 | 
			
		||||
            return locate_block(code, half, right=right)
 | 
			
		||||
        else:
 | 
			
		||||
            return blocks[half]
 | 
			
		||||
        return blocks[half]
 | 
			
		||||
 | 
			
		||||
    return locate_block
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    """ Read the character and block data and unite them to a text file containing the following fields:
 | 
			
		||||
    `<character name>   <character comment> <code>  <block name>`
 | 
			
		||||
    seperated by tab characters.
 | 
			
		||||
def main(out: str = "unicode_list.txt"):
 | 
			
		||||
    """Create the file with Unicode characters.
 | 
			
		||||
 | 
			
		||||
    Read the character and block data and unite them to a text file
 | 
			
		||||
    containing the following fields, separated by tab characters:
 | 
			
		||||
    `<character name> <character comment> <code> <block name>`
 | 
			
		||||
    """
 | 
			
		||||
    get_block = load_blocks()
 | 
			
		||||
    characters = clean(get_data())
 | 
			
		||||
| 
						 | 
				
			
			@ -90,7 +95,7 @@ def main():
 | 
			
		|||
        try:
 | 
			
		||||
            num = int(code, 16)
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            logging.warn("Could not convert " + code)
 | 
			
		||||
            logging.warning("Could not convert %s", code)
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        # Find the character's block
 | 
			
		||||
| 
						 | 
				
			
			@ -98,12 +103,23 @@ def main():
 | 
			
		|||
        if blk is not None:
 | 
			
		||||
            output.append("\t".join((name, comment, code, blk)))
 | 
			
		||||
        else:
 | 
			
		||||
            logging.warn("Code %s not found in any block, char: %s", num, unichr(num))
 | 
			
		||||
            logging.warning("Code %s not found in any block, char: %s", num, unichr(num))
 | 
			
		||||
            output.append(name + "\t" + comment + "\t" + code + "\t")
 | 
			
		||||
 | 
			
		||||
    with open("unicode_list.txt", "w") as target:
 | 
			
		||||
    with open(out, "w", encoding="utf-8") as target:
 | 
			
		||||
        target.write("\n".join(output))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    main()
 | 
			
		||||
    import argparse
 | 
			
		||||
    parser = argparse.ArgumentParser()
 | 
			
		||||
    parser.add_argument(
 | 
			
		||||
        "path",
 | 
			
		||||
        type=str,
 | 
			
		||||
        help="the output path where to save the Unicode list.",
 | 
			
		||||
        default="unicode_list.txt",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    args = parser.parse_args()
 | 
			
		||||
 | 
			
		||||
    main(args.path)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										173
									
								
								main.py
									
										
									
									
									
								
							
							
						
						
									
										173
									
								
								main.py
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -1,18 +1,24 @@
 | 
			
		|||
import os
 | 
			
		||||
import sys
 | 
			
		||||
import codecs
 | 
			
		||||
from os.path import join
 | 
			
		||||
import time
 | 
			
		||||
import math
 | 
			
		||||
import shutil
 | 
			
		||||
import html.entities
 | 
			
		||||
import asyncio
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Dict, Optional
 | 
			
		||||
from operator import itemgetter
 | 
			
		||||
 | 
			
		||||
import subprocess # for pip autoinstallation
 | 
			
		||||
 | 
			
		||||
from ulauncher.search.SortedList import SortedList
 | 
			
		||||
from ulauncher.utils.fuzzy_search import get_score
 | 
			
		||||
from ulauncher.api.client.Extension import Extension
 | 
			
		||||
from ulauncher.api.client.EventListener import EventListener
 | 
			
		||||
from ulauncher.api.shared.event import KeywordQueryEvent, ItemEnterEvent
 | 
			
		||||
from ulauncher.api.shared.event import KeywordQueryEvent
 | 
			
		||||
from ulauncher.api.shared.item.ExtensionResultItem import ExtensionResultItem
 | 
			
		||||
from ulauncher.api.shared.action.RenderResultListAction import RenderResultListAction
 | 
			
		||||
from ulauncher.api.shared.action.CopyToClipboardAction import CopyToClipboardAction
 | 
			
		||||
from ulauncher.api.shared.action.HideWindowAction import HideWindowAction
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
# Be compatible with both python 2 and 3
 | 
			
		||||
if sys.version_info[0] >= 3:
 | 
			
		||||
| 
						 | 
				
			
			@ -27,108 +33,173 @@ ICON_TEMPLATE = """
 | 
			
		|||
</svg>
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# For pip autoinstallation
 | 
			
		||||
def ensure_import(package):
 | 
			
		||||
    try:
 | 
			
		||||
        return __import__(package)
 | 
			
		||||
    except ImportError:
 | 
			
		||||
        subprocess.call([sys.executable, "-m", "pip", "install", "--user", package])
 | 
			
		||||
    return __import__(package)
 | 
			
		||||
ExtensionPreferences = Dict[str, str]
 | 
			
		||||
UnicodeCharPreferences = Dict[str, int]
 | 
			
		||||
 | 
			
		||||
# For HTML entity conversion
 | 
			
		||||
htmlentities = ensure_import("htmlentities")
 | 
			
		||||
 | 
			
		||||
class UnicodeChar:
 | 
			
		||||
    """ Container class for unicode characters
 | 
			
		||||
    """
 | 
			
		||||
    """Container class for unicode characters."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, name, comment, block, code):
 | 
			
		||||
        self.name = name if name != '<control>' else comment
 | 
			
		||||
        self.name = name if name != "<control>" else comment
 | 
			
		||||
        self.comment = comment
 | 
			
		||||
        self.block = block
 | 
			
		||||
        self.code = code
 | 
			
		||||
        self.character = unichr(int(code, 16))
 | 
			
		||||
 | 
			
		||||
    def get_search_name(self):
 | 
			
		||||
        """ Called by `ulauncher.search.SortedList` to get the string
 | 
			
		||||
        that should be used in searches
 | 
			
		||||
        """
 | 
			
		||||
        return ' '.join([self.character, self.code, self.name, self.comment])
 | 
			
		||||
        """Called to get the string that should be used in searches."""
 | 
			
		||||
        return " ".join([self.character, self.code, self.name, self.comment])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UnicodeCharExtension(Extension):
 | 
			
		||||
    unicode_path: str = "unicode_list.txt"
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        super(UnicodeCharExtension, self).__init__()
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        check_cache_dir()
 | 
			
		||||
        self._load_character_table()
 | 
			
		||||
        self.subscribe(KeywordQueryEvent, KeywordQueryEventListener())
 | 
			
		||||
 | 
			
		||||
    def get_filename(self) -> str:
 | 
			
		||||
        """Default filename of the Unicode list."""
 | 
			
		||||
        return os.path.join(FILE_PATH, self.unicode_path)
 | 
			
		||||
 | 
			
		||||
    def _load_character_table(self):
 | 
			
		||||
        """ Read the data file and load to memory
 | 
			
		||||
        """
 | 
			
		||||
        """Read the data file and load to memory."""
 | 
			
		||||
        filename = self.get_filename()
 | 
			
		||||
 | 
			
		||||
        self.character_list = []
 | 
			
		||||
        with open(join(FILE_PATH, "unicode_list.txt"), "r") as f:
 | 
			
		||||
        with open(filename, "r", encoding="utf-8") as f:
 | 
			
		||||
            for line in f.readlines():
 | 
			
		||||
                name, comment, code, block = line.strip().split("\t")
 | 
			
		||||
                character = UnicodeChar(name, comment, block, code)
 | 
			
		||||
                self.character_list.append(character)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    async def refresh_unicode_list(path: str, preferences: UnicodeCharPreferences):
 | 
			
		||||
        """Check if the Unicode list file needs refresh."""
 | 
			
		||||
        # Get timestamp of the last time the file was modified
 | 
			
		||||
        timestamp = os.path.getmtime(path)
 | 
			
		||||
        # Number of days since the file was modified
 | 
			
		||||
        age = math.floor((time.time() - timestamp) / 3600)
 | 
			
		||||
 | 
			
		||||
        update_interval = preferences["update_interval"]
 | 
			
		||||
 | 
			
		||||
        if 0 < update_interval < age:
 | 
			
		||||
            await UnicodeCharExtension.update_unicode_list(path)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    async def update_unicode_list(path: str):
 | 
			
		||||
        """Re-generate an old Unicode list file."""
 | 
			
		||||
        # Save the file to a backup file if there is no backup.
 | 
			
		||||
        backup = path + ".bkp"
 | 
			
		||||
        if not os.path.isfile(backup):
 | 
			
		||||
            logger.info("backup the file with Unicode list to: %s", backup)
 | 
			
		||||
            shutil.copyfile(path, backup)
 | 
			
		||||
 | 
			
		||||
        import generate_character_list
 | 
			
		||||
 | 
			
		||||
        # Regenerate file with unicode list
 | 
			
		||||
        logger.info("regenerate the file with Unicode list: %s", path)
 | 
			
		||||
        generate_character_list.main(path)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def get_preferences(
 | 
			
		||||
        input_preferences: ExtensionPreferences,
 | 
			
		||||
    ) -> UnicodeCharPreferences:
 | 
			
		||||
        """Parse preferences to the correct types."""
 | 
			
		||||
        preferences: UnicodeCharPreferences = {
 | 
			
		||||
            "result_limit": int(input_preferences["result_limit"]),
 | 
			
		||||
            "min_score": int(input_preferences["min_score"]),
 | 
			
		||||
            "update_interval": int(input_preferences["update_interval"]),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return preferences
 | 
			
		||||
 | 
			
		||||
    def search(self, query: str, preferences: UnicodeCharPreferences):
 | 
			
		||||
        """Return a list of result sorted by relevance to the query."""
 | 
			
		||||
        limit = preferences["result_limit"]
 | 
			
		||||
        min_score = preferences["min_score"]
 | 
			
		||||
 | 
			
		||||
        results = []
 | 
			
		||||
        for c in self.character_list:
 | 
			
		||||
            score = get_score(query, c.get_search_name())
 | 
			
		||||
            if score >= min_score:
 | 
			
		||||
                results.append((score, c))
 | 
			
		||||
                results = sorted(results, reverse=True, key=itemgetter(0))
 | 
			
		||||
                if len(results) > limit:
 | 
			
		||||
                    results = results[:limit]
 | 
			
		||||
 | 
			
		||||
        return [c for (s, c) in results]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class KeywordQueryEventListener(EventListener):
 | 
			
		||||
    def on_event(self, event, extension):
 | 
			
		||||
        preferences = extension.get_preferences(extension.preferences)
 | 
			
		||||
        # Re-generate unicode list if it is too old.
 | 
			
		||||
        coro = extension.refresh_unicode_list(extension.get_filename(), preferences)
 | 
			
		||||
        # start the event loop and execute the coroutine
 | 
			
		||||
        asyncio.run(coro)
 | 
			
		||||
 | 
			
		||||
        items = []
 | 
			
		||||
        arg = event.get_argument()
 | 
			
		||||
        if arg:
 | 
			
		||||
            result_list = SortedList(arg, min_score=99, limit=10)
 | 
			
		||||
            result_list.extend(extension.character_list)
 | 
			
		||||
            for char in result_list:
 | 
			
		||||
        query = event.get_argument().strip()
 | 
			
		||||
        if query:
 | 
			
		||||
            # Return best characters matching the query, ordered by score.
 | 
			
		||||
            results = extension.search(query, preferences)
 | 
			
		||||
            for char in results:
 | 
			
		||||
                image_path = get_character_icon(char)
 | 
			
		||||
                encoded = htmlentities.encode(char.character)
 | 
			
		||||
                if "&" in encoded:
 | 
			
		||||
                    sep = " - "
 | 
			
		||||
                    html = encoded
 | 
			
		||||
                else:
 | 
			
		||||
                    sep = ""
 | 
			
		||||
                    html = ""
 | 
			
		||||
                html_val = html_encode(char.character)
 | 
			
		||||
                html_str = ""
 | 
			
		||||
                if html_val:
 | 
			
		||||
                    html_str = f" - HTML: {html_val}"
 | 
			
		||||
 | 
			
		||||
                items.append(
 | 
			
		||||
                    ExtensionResultItem(
 | 
			
		||||
                        icon=image_path,
 | 
			
		||||
                        name=char.name.capitalize() + " - " + char.character,
 | 
			
		||||
                        description=char.block + " - Alt+Enter: " + html + sep + "Code: U+" + char.code,
 | 
			
		||||
                        name=f"{char.name.capitalize()} - {char.character}",
 | 
			
		||||
                        description=f"{char.block}{html_str} - Alt+Enter: U+{char.code}",
 | 
			
		||||
                        on_enter=CopyToClipboardAction(char.character),
 | 
			
		||||
                        on_alt_enter=CopyToClipboardAction(html),
 | 
			
		||||
                        on_alt_enter=CopyToClipboardAction(char.code),
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
        return RenderResultListAction(items)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def html_encode(char: str) -> Optional[str]:
 | 
			
		||||
    """Get the html encoded str corresponding to the unicode char, if it exist."""
 | 
			
		||||
    if ord(char) in html.entities.codepoint2name:
 | 
			
		||||
        html_var = html.entities.codepoint2name[ord(char)]
 | 
			
		||||
        return f"&{html_var};"
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_character_icon(char):
 | 
			
		||||
    """ Check if there is an existing icon for this character and return its path
 | 
			
		||||
    """Check if there is an existing icon for this character and return its path
 | 
			
		||||
    or create a new one and return its path.
 | 
			
		||||
    """
 | 
			
		||||
    path = FILE_PATH + "images/cache/icon_%s.svg" % char.code
 | 
			
		||||
    path = os.path.join(FILE_PATH, f"images/cache/icon_{char.code}.svg")
 | 
			
		||||
    if os.path.isfile(path):
 | 
			
		||||
        return path
 | 
			
		||||
    return create_character_icon(char)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_character_icon(char, font="sans-serif"):
 | 
			
		||||
    """ Create an SVG file containing the unicode glyph for char to be used
 | 
			
		||||
    """Create an SVG file containing the unicode glyph for char to be used
 | 
			
		||||
    as a result icon.
 | 
			
		||||
 | 
			
		||||
    Note: this could be avoided by providing a gtk.PixBuf without creating a file,
 | 
			
		||||
    but ulauncher pickles the returned results, so it doesn't work currently.
 | 
			
		||||
    """
 | 
			
		||||
    icon = ICON_TEMPLATE.replace("{symbol}", char.character).replace("{font}", font)
 | 
			
		||||
    with codecs.open(
 | 
			
		||||
        os.path.join(FILE_PATH, "images/cache/icon_%s.svg" % char.code), "w", "utf-8"
 | 
			
		||||
    ) as target:
 | 
			
		||||
    icon = ICON_TEMPLATE.format(symbol=char.character, font=font)
 | 
			
		||||
    path = os.path.join(FILE_PATH, f"images/cache/icon_{char.code}.svg")
 | 
			
		||||
    with codecs.open(path, "w", "utf-8") as target:
 | 
			
		||||
        target.write(icon)
 | 
			
		||||
    return os.path.join(FILE_PATH, "images/cache/icon_%s.svg" % char.code)
 | 
			
		||||
    return path
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_cache_dir(path="images/cache"):
 | 
			
		||||
    """ Check if the cache directory exists and if not create it.
 | 
			
		||||
    """
 | 
			
		||||
    """Check if the cache directory exists and if not create it."""
 | 
			
		||||
    path = os.path.join(FILE_PATH, path)
 | 
			
		||||
    if not os.path.isdir(path):
 | 
			
		||||
        os.mkdir(path)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,12 +8,33 @@
 | 
			
		|||
    "query_debounce": 0.5
 | 
			
		||||
  },
 | 
			
		||||
  "preferences": [
 | 
			
		||||
    {
 | 
			
		||||
      "id": "result_limit",
 | 
			
		||||
      "type": "input",
 | 
			
		||||
      "name": "Result limit",
 | 
			
		||||
      "description": "Number of results that should be returned.",
 | 
			
		||||
      "default_value": "10"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "id": "min_score",
 | 
			
		||||
      "type": "input",
 | 
			
		||||
      "name": "Minimum score",
 | 
			
		||||
      "description": "Only display results with a matching score higher than this. If too high, e.g. higher than 200, no results will be shown.",
 | 
			
		||||
      "default_value": "0"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "id": "symbol",
 | 
			
		||||
      "type": "keyword",
 | 
			
		||||
      "name": "Symbol",
 | 
			
		||||
      "description": "Search symbols in ASCII and Unicode. Enter to copy the symbol, alt+enter to copy the HTML entity. Dark mode friendly.",
 | 
			
		||||
      "default_value": "sym"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "id": "update_interval",
 | 
			
		||||
      "type": "input",
 | 
			
		||||
      "name": "Update interval",
 | 
			
		||||
      "description": "Time interval (in days) after which the cached list of unicode symbols is updated by downloading the newest list. Set to -1 to never update the list.",
 | 
			
		||||
      "default_value": "90"
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
[
 | 
			
		||||
  { "required_api_version": "^1.0.0", "commit": "API_v1" },
 | 
			
		||||
  { "required_api_version": "^2.0.0", "commit": "master" }
 | 
			
		||||
  { "required_api_version": "^3.0.0", "commit": "api-v3" }
 | 
			
		||||
]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Reference in a new issue