~ruther/qmk_firmware

7329c2d02d38f40a23d38f789de34057fd2acd42 — Cody Bender 5 years ago 00fb1bd
Add cli convert subcommand, from raw KLE to JSON (#6898)

* 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
M docs/cli.md => docs/cli.md +22 -0
@@ 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`

A lib/python/kle2xy.py => lib/python/kle2xy.py +155 -0
@@ 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)

M lib/python/qmk/cli/__init__.py => lib/python/qmk/cli/__init__.py +1 -0
@@ 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

A lib/python/qmk/cli/kle2json.py => lib/python/qmk/cli/kle2json.py +79 -0
@@ 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))

A lib/python/qmk/converter.py => lib/python/qmk/converter.py +33 -0
@@ 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

A lib/python/qmk/tests/kle.txt => lib/python/qmk/tests/kle.txt +5 -0
@@ 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"]

M lib/python/qmk/tests/test_cli_commands.py => lib/python/qmk/tests/test_cli_commands.py +2 -0
@@ 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')

M requirements.txt => requirements.txt +1 -0
@@ 3,3 3,4 @@
appdirs
argcomplete
colorama
hjson