Add cli convert subcommand, from raw KLE to JSON (#6898)
authorCody Bender <50554676+cfbender@users.noreply.github.com>
Wed, 13 Nov 2019 04:55:41 +0000 (21:55 -0700)
committerskullydazed <skullydazed@users.noreply.github.com>
Wed, 13 Nov 2019 04:55:41 +0000 (20:55 -0800)
* Add initial pass at KLE convert

* Add cli log on convert

* Move kle2xy, add absolute filepath arg support

* Add overwrite flag, and context sensitive conversion

* Update docs/cli.md

* Fix converter.py typo

* Add convert unit test

* Rename to kle2qmk

* Rename subcommand

* Rename subcommand to kle2json

* Change tests to cover rename

* Rename in __init__.py

* Update CLI docs with new subcommand name

* Fix from suggestions in PR #6898

* Help with cases of case sensitivity

* Update cli.md

* Use angle brackets to indicate required option

* Make the output text more accurate

docs/cli.md
lib/python/kle2xy.py [new file with mode: 0644]
lib/python/qmk/cli/__init__.py
lib/python/qmk/cli/kle2json.py [new file with mode: 0755]
lib/python/qmk/converter.py [new file with mode: 0644]
lib/python/qmk/tests/kle.txt [new file with mode: 0644]
lib/python/qmk/tests/test_cli_commands.py
requirements.txt

index e655b0e..fb7d17d 100644 (file)
@@ -135,6 +135,28 @@ Creates a keymap.c from a QMK Configurator export.
 qmk json-keymap [-o OUTPUT] filename
 ```
 
+## `qmk kle2json`
+
+This command allows you to convert from raw KLE data to QMK Configurator JSON. It accepts either an absolute file path, or a file name in the current directory. By default it will not overwrite `info.json` if it is already present. Use the `-f` or `--force` flag to overwrite.
+
+**Usage**:
+
+```
+qmk kle2json [-f] <filename>
+```
+
+**Examples**:
+
+```
+$ qmk kle2json kle.txt 
+☒ File info.json already exists, use -f or --force to overwrite.
+```
+
+```
+$ qmk kle2json -f kle.txt -f
+Ψ Wrote out to info.json
+```
+
 ## `qmk list-keyboards`
 
 This command lists all the keyboards currently defined in `qmk_firmware`
diff --git a/lib/python/kle2xy.py b/lib/python/kle2xy.py
new file mode 100644 (file)
index 0000000..ea16a4b
--- /dev/null
@@ -0,0 +1,155 @@
+""" Original code from https://github.com/skullydazed/kle2xy
+"""
+
+import hjson
+from decimal import Decimal
+
+class KLE2xy(list):
+    """Abstract interface for interacting with a KLE layout.
+    """
+    def __init__(self, layout=None, name='', invert_y=True):
+        super(KLE2xy, self).__init__()
+
+        self.name = name
+        self.invert_y = invert_y
+        self.key_width = Decimal('19.05')
+        self.key_skel = {
+            'decal': False,
+            'border_color': 'none',
+            'keycap_profile': '',
+            'keycap_color': 'grey',
+            'label_color': 'black',
+            'label_size': 3,
+            'label_style': 4,
+            'width': Decimal('1'), 'height': Decimal('1'),
+            'x': Decimal('0'), 'y': Decimal('0')
+        }
+        self.rows = Decimal(0)
+        self.columns = Decimal(0)
+
+        if layout:
+            self.parse_layout(layout)
+
+    @property
+    def width(self):
+        """Returns the width of the keyboard plate.
+        """
+        return (Decimal(self.columns) * self.key_width) + self.key_width/2
+
+    @property
+    def height(self):
+        """Returns the height of the keyboard plate.
+        """
+        return (self.rows * self.key_width) + self.key_width/2
+
+    @property
+    def size(self):
+        """Returns the size of the keyboard plate.
+        """
+        return (self.width, self.height)
+
+    def attrs(self, properties):
+        """Parse the keyboard properties dictionary.
+        """
+        # FIXME: Store more than just the keyboard name.
+        if 'name' in properties:
+            self.name = properties['name']
+
+    def parse_layout(self, layout):
+        # Wrap this in a dictionary so hjson will parse KLE raw data
+        layout = '{"layout": [' + layout + ']}'
+        layout = hjson.loads(layout)['layout']
+
+        # Initialize our state machine
+        current_key = self.key_skel.copy()
+        current_row = Decimal(0)
+        current_col = Decimal(0)
+        current_x = 0
+        current_y = self.key_width / 2
+
+        if isinstance(layout[0], dict):
+            self.attrs(layout[0])
+            layout = layout[1:]
+
+        for row_num, row in enumerate(layout):
+            self.append([])
+
+            # Process the current row
+            for key in row:
+                if isinstance(key, dict):
+                    if 'w' in key and key['w'] != Decimal(1):
+                        current_key['width'] = Decimal(key['w'])
+                    if 'w2' in key and 'h2' in key and key['w2'] == 1.5 and key['h2'] == 1:
+                        # FIXME: ISO Key uses these params: {x:0.25,w:1.25,h:2,w2:1.5,h2:1,x2:-0.25}
+                        current_key['isoenter'] = True
+                    if 'h' in key and key['h'] != Decimal(1):
+                        current_key['height'] = Decimal(key['h'])
+                    if 'a' in key:
+                        current_key['label_style'] = self.key_skel['label_style'] = int(key['a'])
+                        if current_key['label_style'] < 0:
+                            current_key['label_style'] = 0
+                        elif current_key['label_style'] > 9:
+                            current_key['label_style'] = 9
+                    if 'f' in key:
+                        font_size = int(key['f'])
+                        if font_size > 9:
+                            font_size = 9
+                        elif font_size < 1:
+                            font_size = 1
+                        current_key['label_size'] = self.key_skel['label_size'] = font_size
+                    if 'p' in key:
+                        current_key['keycap_profile'] = self.key_skel['keycap_profile'] = key['p']
+                    if 'c' in key:
+                        current_key['keycap_color'] = self.key_skel['keycap_color'] = key['c']
+                    if 't' in key:
+                        # FIXME: Need to do better validation, plus figure out how to support multiple colors
+                        if '\n' in key['t']:
+                            key['t'] = key['t'].split('\n')[0]
+                        if key['t'] == "0":
+                            key['t'] = "#000000"
+                        current_key['label_color'] = self.key_skel['label_color'] = key['t']
+                    if 'x' in key:
+                        current_col += Decimal(key['x'])
+                        current_x += Decimal(key['x']) * self.key_width
+                    if 'y' in key:
+                        current_row += Decimal(key['y'])
+                        current_y += Decimal(key['y']) * self.key_width
+                    if 'd' in key:
+                        current_key['decal'] = True
+
+                else:
+                    current_key['name'] = key
+                    current_key['row'] = current_row
+                    current_key['column'] = current_col
+
+                    # Determine the X center
+                    x_center = (current_key['width'] * self.key_width) / 2
+                    current_x += x_center
+                    current_key['x'] = current_x
+                    current_x += x_center
+
+                    # Determine the Y center
+                    y_center = (current_key['height'] * self.key_width) / 2
+                    y_offset = y_center - (self.key_width / 2)
+                    current_key['y'] = (current_y + y_offset)
+
+                    # Tend to our row/col count
+                    current_col += current_key['width']
+                    if current_col > self.columns:
+                        self.columns = current_col
+
+                    # Invert the y-axis if neccesary
+                    if self.invert_y:
+                        current_key['y'] = -current_key['y']
+
+                    # Store this key
+                    self[-1].append(current_key)
+                    current_key = self.key_skel.copy()
+
+            # Move to the next row
+            current_x = 0
+            current_y += self.key_width
+            current_col = Decimal(0)
+            current_row += Decimal(1)
+            if current_row > self.rows:
+                self.rows = Decimal(current_row)
index e41cc3d..1b83e78 100644 (file)
@@ -10,6 +10,7 @@ from . import doctor
 from . import hello
 from . import json
 from . import list
+from . import kle2json
 from . import new
 from . import pyformat
 from . import pytest
diff --git a/lib/python/qmk/cli/kle2json.py b/lib/python/qmk/cli/kle2json.py
new file mode 100755 (executable)
index 0000000..22eb515
--- /dev/null
@@ -0,0 +1,79 @@
+"""Convert raw KLE to JSON
+
+"""
+import json
+import os
+from pathlib import Path
+from argparse import FileType
+from decimal import Decimal
+from collections import OrderedDict
+
+from milc import cli
+from kle2xy import KLE2xy
+
+from qmk.converter import kle2qmk
+
+
+class CustomJSONEncoder(json.JSONEncoder):
+    def default(self, obj):
+        try:
+            if isinstance(obj, Decimal):
+                if obj % 2 in (Decimal(0), Decimal(1)):
+                    return int(obj)
+                return float(obj)
+        except TypeError:
+            pass
+        return JSONEncoder.default(self, obj)
+
+
+@cli.argument('filename', help='The KLE raw txt to convert')
+@cli.argument('-f', '--force', action='store_true', help='Flag to overwrite current info.json')
+@cli.subcommand('Convert a KLE layout to a Configurator JSON')
+def kle2json(cli):
+    """Convert a KLE layout to QMK's layout format.
+    """        # If filename is a path
+    if cli.args.filename.startswith("/") or cli.args.filename.startswith("./"):
+        file_path = Path(cli.args.filename)
+    # Otherwise assume it is a file name
+    else:
+        file_path = Path(os.environ['ORIG_CWD'], cli.args.filename)
+    # Check for valid file_path for more graceful failure
+    if not file_path.exists():
+        return cli.log.error('File {fg_cyan}%s{style_reset_all} was not found.', str(file_path))
+    out_path = file_path.parent
+    raw_code = file_path.open().read()
+    # Check if info.json exists, allow overwrite with force
+    if Path(out_path, "info.json").exists() and not cli.args.force:
+        cli.log.error('File {fg_cyan}%s/info.json{style_reset_all} already exists, use -f or --force to overwrite.', str(out_path))
+        return False;
+    try:
+        # Convert KLE raw to x/y coordinates (using kle2xy package from skullydazed)
+        kle = KLE2xy(raw_code)
+    except Exception as e:
+        cli.log.error('Could not parse KLE raw data: %s', raw_code)
+        cli.log.exception(e)
+        # FIXME: This should be better
+        return cli.log.error('Could not parse KLE raw data.')
+    keyboard = OrderedDict(
+        keyboard_name=kle.name,
+        url='',
+        maintainer='qmk',
+        width=kle.columns,
+        height=kle.rows,
+        layouts={'LAYOUT': {
+            'layout': 'LAYOUT_JSON_HERE'
+        }},
+    )
+    # Initialize keyboard with json encoded from ordered dict
+    keyboard = json.dumps(keyboard, indent=4, separators=(
+        ', ', ': '), sort_keys=False, cls=CustomJSONEncoder)
+    # Initialize layout with kle2qmk from converter module
+    layout = json.dumps(kle2qmk(kle), separators=(
+        ', ', ':'), cls=CustomJSONEncoder)
+    # Replace layout in keyboard json
+    keyboard = keyboard.replace('"LAYOUT_JSON_HERE"', layout)
+    # Write our info.json
+    file = open(str(out_path) + "/info.json", "w")
+    file.write(keyboard)
+    file.close()
+    cli.log.info('Wrote out {fg_cyan}%s/info.json', str(out_path))
diff --git a/lib/python/qmk/converter.py b/lib/python/qmk/converter.py
new file mode 100644 (file)
index 0000000..bbd3531
--- /dev/null
@@ -0,0 +1,33 @@
+"""Functions to convert to and from QMK formats
+"""
+from collections import OrderedDict
+
+
+def kle2qmk(kle):
+    """Convert a KLE layout to QMK's layout format.
+    """
+    layout = []
+
+    for row in kle:
+        for key in row:
+            if key['decal']:
+                continue
+
+            qmk_key = OrderedDict(
+                label="",
+                x=key['column'],
+                y=key['row'],
+            )
+
+            if key['width'] != 1:
+                qmk_key['w'] = key['width']
+            if key['height'] != 1:
+                qmk_key['h'] = key['height']
+            if 'name' in key and key['name']:
+                qmk_key['label'] = key['name'].split('\n', 1)[0]
+            else:
+                del (qmk_key['label'])
+
+            layout.append(qmk_key)
+
+    return layout
diff --git a/lib/python/qmk/tests/kle.txt b/lib/python/qmk/tests/kle.txt
new file mode 100644 (file)
index 0000000..862a899
--- /dev/null
@@ -0,0 +1,5 @@
+["¬\n`","!\n1","\"\n2","£\n3","$\n4","%\n5","^\n6","&\n7","*\n8","(\n9",")\n0","_\n-","+\n=",{w:2},"Backspace"],
+[{w:1.5},"Tab","Q","W","E","R","T","Y","U","I","O","P","{\n[","}\n]",{x:0.25,w:1.25,h:2,w2:1.5,h2:1,x2:-0.25},"Enter"],
+[{w:1.75},"Caps Lock","A","S","D","F","G","H","J","K","L",":\n;","@\n'","~\n#"],
+[{w:1.25},"Shift","|\n\\","Z","X","C","V","B","N","M","<\n,",">\n.","?\n/",{w:2.75},"Shift"],
+[{w:1.25},"Ctrl",{w:1.25},"Win",{w:1.25},"Alt",{a:7,w:6.25},"",{a:4,w:1.25},"AltGr",{w:1.25},"Win",{w:1.25},"Menu",{w:1.25},"Ctrl"]
index 55b8d25..d91af99 100644 (file)
@@ -19,6 +19,8 @@ def test_config():
     assert result.returncode == 0
     assert 'general.color' in result.stdout
 
+def test_kle2json():
+    assert check_subcommand('kle2json', 'kle.txt', '-f').returncode == 0
 
 def test_doctor():
     result = check_subcommand('doctor')
index f6257e3..aa6ee1b 100644 (file)
@@ -3,3 +3,4 @@
 appdirs
 argcomplete
 colorama
+hjson