1# coding=utf-8
2#
3# Copyright (C) 2018 Martin Owens <doctormo@gmail.com>
4#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; either version 2 of the License, or
8# (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18#
19"""
20Parsing inx files for checking and generating.
21"""
22
23import os
24from inspect import isclass
25from importlib import util
26from lxml import etree
27
28from .base import InkscapeExtension
29from .utils import Boolean
30
31NSS = {
32    'inx': 'http://www.inkscape.org/namespace/inkscape/extension',
33    'inkscape': 'http://www.inkscape.org/namespaces/inkscape',
34}
35SSN = dict([(b, a) for (a, b) in NSS.items()])
36
37class InxLookup(etree.CustomElementClassLookup):
38    """Custom inx xml file lookup"""
39    def lookup(self, node_type, document, namespace, name):  # pylint: disable=unused-argument
40        if name == 'param':
41            return ParamElement
42        return InxElement
43
44INX_PARSER = etree.XMLParser()
45INX_PARSER.set_element_class_lookup(InxLookup())
46
47class InxFile:
48    """Open an INX file and provide useful functions"""
49    name = property(lambda self: self.xml._text('name'))
50    ident = property(lambda self: self.xml._text('id'))
51    slug = property(lambda self: self.ident.split('.')[-1].title().replace('_', ''))
52    kind = property(lambda self: self.metadata['type'])
53    warnings = property(lambda self: sorted(list(set(self.xml.warnings))))
54
55    def __init__(self, filename):
56        if isinstance(filename, str) and '<' in filename:
57            filename = filename.encode('utf8')
58        if isinstance(filename, bytes) and b'<' in filename:
59            self.filename = None
60            self.doc = etree.ElementTree(etree.fromstring(filename, parser=INX_PARSER))
61        else:
62            self.filename = os.path.basename(filename)
63            self.doc = etree.parse(filename, parser=INX_PARSER)
64        self.xml = self.doc.getroot()
65        self.xml.warnings = []
66
67    def __repr__(self):
68        return f"<inx '{self.filename}' '{self.name}'>"
69
70    @property
71    def script(self):
72        """Returns information about the called script"""
73        command = self.xml.find_one('script/command')
74        if command is None:
75            return {}
76        return {
77            'interpreter': command.get('interpreter', None),
78            'location': command.get('location', None),
79            'script': command.text,
80        }
81
82    @property
83    def extension_class(self):
84        """Attempt to get the extension class"""
85        script = self.script.get('script', None)
86        if script is not None:
87            name = script[:-3].replace('/', '.')
88            spec = util.spec_from_file_location(name, script)
89            mod = util.module_from_spec(spec)
90            spec.loader.exec_module(mod)
91            for value in mod.__dict__.values():
92                if 'Base' not in name and isclass(value) and  value.__module__ == name \
93                    and issubclass(value, InkscapeExtension):
94                    return value
95        return None
96
97    @property
98    def metadata(self):
99        """Returns information about what type of extension this is"""
100        effect = self.xml.find_one('effect')
101        output = self.xml.find_one('output')
102        inputs = self.xml.find_one('input')
103        data = {}
104        if effect is not None:
105            template = self.xml.find_one('inkscape:templateinfo')
106            if template is not None:
107                data['type'] = 'template'
108                data['desc'] = self.xml._text('templateinfo/shortdesc', nss='inkscape')
109                data['author'] = self.xml._text('templateinfo/author', nss='inkscape')
110            else:
111                data['type'] = 'effect'
112                data['preview'] = Boolean(effect.get('needs-live-preview', 'true'))
113                data['objects'] = effect._text('object-type', 'all')
114        elif inputs is not None:
115            data['type'] = 'input'
116            data['extension'] = inputs._text('extension')
117            data['mimetype'] = inputs._text('mimetype')
118            data['tooltip'] = inputs._text('filetypetooltip')
119            data['name'] = inputs._text('filetypename')
120        elif output is not None:
121            data['type'] = 'output'
122            data['dataloss'] = Boolean(output._text('dataloss', 'false'))
123            data['extension'] = output._text('extension')
124            data['mimetype'] = output._text('mimetype')
125            data['tooltip'] = output._text('filetypetooltip')
126            data['name'] = output._text('filetypename')
127        return data
128
129    @property
130    def menu(self):
131        """Return the menu this effect ends up in"""
132        def _recurse_menu(parent):
133            for child in parent.xpath('submenu'):
134                yield child.get('name')
135                for subchild in _recurse_menu(child):
136                    yield subchild
137                break # Not more than one menu chain?
138        menu = self.xml.find_one('effect/effects-menu')
139        return list(_recurse_menu(menu)) + [self.name]
140
141    @property
142    def params(self):
143        """Get all params at all levels"""
144        # Returns any params at any levels
145        return list(self.xml.xpath('//param'))
146
147class InxElement(etree.ElementBase):
148    def set_warning(self, msg):
149        root = self.get_root()
150        if hasattr(root, 'warnings'):
151            root.warnings.append(msg)
152
153    def get_root(self):
154        """Get the root document element from any element descendent"""
155        if self.getparent() is not None:
156            return self.getparent().get_root()
157        return self
158
159    def get_default_prefix(self):
160        tag = self.get_root().tag
161        if '}' in tag:
162            (url, tag) = tag[1:].split('}', 1)
163            return SSN.get(url, 'inx')
164        self.set_warning("No inx xml prefix.")
165        return None # no default prefix
166
167    def apply_nss(self, xpath, nss=None):
168        """Add prefixes to any xpath string"""
169        if nss is None:
170            nss = self.get_default_prefix()
171        def _process(seg):
172            if ':' in seg or not seg or not nss:
173                return seg
174            return f"{nss}:{seg}"
175        return "/".join([_process(seg) for seg in xpath.split("/")])
176
177    def xpath(self, xpath, nss=None):
178        """Namespace specific xpath searches"""
179        return super().xpath(self.apply_nss(xpath, nss=nss), namespaces=NSS)
180
181    def find_one(self, name, nss=None):
182        """Return the first element matching the given name"""
183        for elem in self.xpath(name, nss=nss):
184            return elem
185        return None
186
187    def _text(self, name, default=None, nss=None):
188        """Get text content agnostically"""
189        for pref in ('', '_'):
190            elem = self.find_one(pref + name, nss=nss)
191            if elem is not None and elem.text:
192                if pref == '_':
193                    self.set_warning(f"Use of old translation scheme: <_{name}...>")
194                return elem.text
195        return default
196
197class ParamElement(InxElement):
198    """
199    A param in an inx file.
200    """
201    name = property(lambda self: self.get('name'))
202    param_type = property(lambda self: self.get('type', 'string'))
203
204    @property
205    def options(self):
206        """Return a list of option values"""
207        if self.param_type == 'notebook':
208            return [option.get('name')
209                for option in self.xpath('page')]
210        return [option.get('value')
211            for option in self.xpath('option')]
212
213    def __repr__(self):
214        return f"<param name='{self.name}' type='{self.param_type}'>"
215