#!/usr/bin/env python3 # == Rapid Develop Macros in LibreOffice == # ~ This file is part of ZAZ. # ~ https://git.elmau.net/elmau/zaz # ~ ZAZ is free software: you can redistribute it and/or modify # ~ it under the terms of the GNU General Public License as published by # ~ the Free Software Foundation, either version 3 of the License, or # ~ (at your option) any later version. # ~ ZAZ is distributed in the hope that it will be useful, # ~ but WITHOUT ANY WARRANTY; without even the implied warranty of # ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # ~ GNU General Public License for more details. # ~ You should have received a copy of the GNU General Public License # ~ along with ZAZ. If not, see . import argparse import os import py_compile import re import sys import zipfile from datetime import datetime from pathlib import Path from shutil import copyfile from subprocess import call from xml.etree import ElementTree as ET from xml.dom.minidom import parseString from conf import ( DATA, DIRS, DOMAIN, EXTENSION, FILES, INFO, PATHS, TYPE_EXTENSION, USE_LOCALES, log) EASYMACRO = 'easymacro.py' class LiboXML(object): CONTEXT = { 'calc': 'com.sun.star.sheet.SpreadsheetDocument', 'writer': 'com.sun.star.text.TextDocument', 'impress': 'com.sun.star.presentation.PresentationDocument', 'draw': 'com.sun.star.drawing.DrawingDocument', 'base': 'com.sun.star.sdb.OfficeDatabaseDocument', 'math': 'com.sun.star.formula.FormulaProperties', 'basic': 'com.sun.star.script.BasicIDE', } TYPES = { 'py': 'application/vnd.sun.star.uno-component;type=Python', 'pyc': 'application/binary', 'zip': 'application/binary', 'xcu': 'application/vnd.sun.star.configuration-data', 'rdb': 'application/vnd.sun.star.uno-typelibrary;type=RDB', 'xcs': 'application/vnd.sun.star.configuration-schema', 'help': 'application/vnd.sun.star.help', 'component': 'application/vnd.sun.star.uno-components', } NS_MANIFEST = { 'manifest_version': '1.2', 'manifest': 'urn:oasis:names:tc:opendocument:xmlns:manifest:1.0', 'xmlns:loext': 'urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0', } NS_DESCRIPTION = { 'xmlns': 'http://openoffice.org/extensions/description/2006', 'xmlns:xlink': 'http://www.w3.org/1999/xlink', 'xmlns:d': 'http://openoffice.org/extensions/description/2006', } NS_ADDONS = { 'xmlns:xs': 'http://www.w3.org/2001/XMLSchema', 'xmlns:oor': 'http://openoffice.org/2001/registry', } NS_UPDATE = { 'xmlns': 'http://openoffice.org/extensions/update/2006', 'xmlns:d': 'http://openoffice.org/extensions/description/2006', 'xmlns:xlink': 'http://www.w3.org/1999/xlink', } def __init__(self): self._manifest = None self._paths = [] self._path_images = '' self._toolbars = [] def _save_path(self, attr): self._paths.append(attr['{{{}}}full-path'.format(self.NS_MANIFEST['manifest'])]) return def _clean(self, name, nodes): has_words = re.compile('\\w') if not re.search(has_words, str(nodes.tail)): nodes.tail = '' if not re.search(has_words, str(nodes.text)): nodes.text = '' for node in nodes: if name == 'manifest': self._save_path(node.attrib) if not re.search(has_words, str(node.tail)): node.tail = '' if not re.search(has_words, str(node.text)): node.text = '' return def new_manifest(self, data): attr = { 'manifest:version': self.NS_MANIFEST['manifest_version'], 'xmlns:manifest': self.NS_MANIFEST['manifest'], 'xmlns:loext': self.NS_MANIFEST['xmlns:loext'], } self._manifest = ET.Element('manifest:manifest', attr) return self.add_data_manifest(data) def parse_manifest(self, data): ET.register_namespace('manifest', self.NS_MANIFEST['manifest']) self._manifest = ET.fromstring(data) attr = {'xmlns:loext': self.NS_MANIFEST['xmlns:loext']} self._manifest.attrib.update(**attr) self._clean('manifest', self._manifest) return def add_data_manifest(self, data): node_name = 'manifest:file-entry' attr = { 'manifest:full-path': '', 'manifest:media-type': '', } for path in data: if path in self._paths: continue ext = path.split('.')[-1] attr['manifest:full-path'] = path attr['manifest:media-type'] = self.TYPES.get(ext, '') ET.SubElement(self._manifest, node_name, attr) return self._get_xml(self._manifest) def new_description(self, data): doc = ET.Element('description', self.NS_DESCRIPTION) key = 'identifier' ET.SubElement(doc, key, data[key]) key = 'version' ET.SubElement(doc, key, data[key]) key = 'display-name' node = ET.SubElement(doc, key) for k, v in data[key].items(): sn = ET.SubElement(node, 'name', {'lang': k}) sn.text = v node = ET.SubElement(doc, 'extension-description') for k in data[key].keys(): attr = { 'lang': k, 'xlink:href': f'description/desc_{k}.txt', } ET.SubElement(node, 'src', attr) key = 'icon' node = ET.SubElement(doc, key) attr = {'xlink:href': f"images/{data[key]}"} ET.SubElement(node, 'default', attr) key = 'publisher' node = ET.SubElement(doc, key) for k, v in data[key].items(): attr = { 'xlink:href': v['link'], 'lang': k, } sn = ET.SubElement(node, 'name', attr) sn.text = v['text'] key = 'display-name' node = ET.SubElement(doc, 'registration') attr = { 'accept-by': 'user', 'suppress-on-update': 'true', } node = ET.SubElement(node, 'simple-license', attr) for k in data[key].keys(): attr = { 'xlink:href': f"{DIRS['registration']}/license_{k}.txt", 'lang': k } ET.SubElement(node, 'license-text', attr) if data['update']: node = ET.SubElement(doc, 'update-information') ET.SubElement(node, 'src', {'xlink:href': data['update']}) return self._get_xml(doc) def _get_context(self, args): if not args: return '' context = ','.join([self.CONTEXT[v] for v in args.split(',')]) return context def _add_node_value(self, node, name, value='_self'): attr = {'oor:name': name, 'oor:type': 'xs:string'} sn = ET.SubElement(node, 'prop', attr) sn = ET.SubElement(sn, 'value') sn.text = value return def _add_menu(self, id_extension, node, index, menu, in_menu_bar=True): if in_menu_bar: attr = { 'oor:name': index, 'oor:op': 'replace', } subnode = ET.SubElement(node, 'node', attr) else: subnode = node attr = {'oor:name': 'Title', 'oor:type': 'xs:string'} sn1 = ET.SubElement(subnode, 'prop', attr) for k, v in menu['title'].items(): sn2 = ET.SubElement(sn1, 'value', {'xml:lang': k}) sn2.text = v value = self._get_context(menu['context']) self._add_node_value(subnode, 'Context', value) if 'submenu' in menu: sn = ET.SubElement(subnode, 'node', {'oor:name': 'Submenu'}) for i, m in enumerate(menu['submenu']): self._add_menu(id_extension, sn, f'{index}.s{i}', m) if m.get('toolbar', False): self._toolbars.append(m) return value = f"service:{id_extension}?{menu['argument']}" self._add_node_value(subnode, 'URL', value) self._add_node_value(subnode, 'Target') value = f"%origin%/{self._path_images}/{menu['icon']}" self._add_node_value(subnode, 'ImageIdentifier', value) return def new_addons(self, id_extension, data): in_menu_bar = data['parent'] == 'OfficeMenuBar' self._path_images = data['images'] attr = { 'oor:name': 'Addons', 'oor:package': 'org.openoffice.Office', } attr.update(self.NS_ADDONS) doc = ET.Element('oor:component-data', attr) parent = ET.SubElement(doc, 'node', {'oor:name': 'AddonUI'}) node = ET.SubElement(parent, 'node', {'oor:name': data['parent']}) op = 'fuse' if in_menu_bar: op = 'replace' attr = {'oor:name': id_extension, 'oor:op': op} node = ET.SubElement(node, 'node', attr) if in_menu_bar: attr = {'oor:name': 'Title', 'oor:type': 'xs:string'} subnode = ET.SubElement(node, 'prop', attr) for k, v in data['main'].items(): sn = ET.SubElement(subnode, 'value', {'xml:lang': k}) sn.text = v self._add_node_value(node, 'Target') node = ET.SubElement(node, 'node', {'oor:name': 'Submenu'}) for i, menu in enumerate(data['menus']): self._add_menu(id_extension, node, f'm{i}', menu, in_menu_bar) if menu.get('toolbar', False): self._toolbars.append(menu) if self._toolbars: attr = {'oor:name': 'OfficeToolBar'} toolbar = ET.SubElement(parent, 'node', attr) attr = {'oor:name': id_extension, 'oor:op': 'replace'} toolbar = ET.SubElement(toolbar, 'node', attr) for t, menu in enumerate(self._toolbars): self._add_menu(id_extension, toolbar, f't{t}', menu) return self._get_xml(doc) def _add_shortcut(self, node, key, id_extension, arg): attr = {'oor:name': key, 'oor:op': 'fuse'} subnode = ET.SubElement(node, 'node', attr) subnode = ET.SubElement(subnode, 'prop', {'oor:name': 'Command'}) subnode = ET.SubElement(subnode, 'value', {'xml:lang': 'en-US'}) subnode.text = f"service:{id_extension}?{arg}" return def _get_acceleartors(self, menu): if 'submenu' in menu: for m in menu['submenu']: return self._get_acceleartors(m) if not menu.get('shortcut', ''): return '' return menu def new_accelerators(self, id_extension, menus): attr = { 'oor:name': 'Accelerators', 'oor:package': 'org.openoffice.Office', } attr.update(self.NS_ADDONS) doc = ET.Element('oor:component-data', attr) parent = ET.SubElement(doc, 'node', {'oor:name': 'PrimaryKeys'}) data = [] for m in menus: info = self._get_acceleartors(m) if info: data.append(info) node_global = None node_modules = None for m in data: if m['context']: if node_modules is None: node_modules = ET.SubElement( parent, 'node', {'oor:name': 'Modules'}) for app in m['context'].split(','): node = ET.SubElement( node_modules, 'node', {'oor:name': self.CONTEXT[app]}) self._add_shortcut( node, m['shortcut'], id_extension, m['argument']) else: if node_global is None: node_global = ET.SubElement( parent, 'node', {'oor:name': 'Global'}) self._add_shortcut( node_global, m['shortcut'], id_extension, m['argument']) return self._get_xml(doc) def new_update(self, extension, url_oxt): doc = ET.Element('description', self.NS_UPDATE) ET.SubElement(doc, 'identifier', {'value': extension['id']}) ET.SubElement(doc, 'version', {'value': extension['version']}) node = ET.SubElement(doc, 'update-download') ET.SubElement(node, 'src', {'xlink:href': url_oxt}) node = ET.SubElement(doc, 'release-notes') return self._get_xml(doc) def _get_xml(self, doc): xml = parseString(ET.tostring(doc, encoding='utf-8')) return xml.toprettyxml(indent=' ', encoding='utf-8').decode('utf-8') def _exists(path): return os.path.exists(path) def _join(*paths): return os.path.join(*paths) def _mkdir(path): return Path(path).mkdir(parents=True, exist_ok=True) def _save(path, data): with open(path, 'w') as f: f.write(data) return def _get_files(path, filters=''): paths = [] if filters in ('*', '*.*'): filters = '' for folder, _, files in os.walk(path): if filters: pattern = re.compile(r'\.(?:{})$'.format(filters), re.IGNORECASE) paths += [_join(folder, f) for f in files if pattern.search(f)] else: paths += files return paths def _compress_oxt(): log.info('Compress OXT extension...') path_oxt = _join(DIRS['files'], FILES['oxt']) z = zipfile.ZipFile(path_oxt, 'w', compression=zipfile.ZIP_DEFLATED) root_len = len(os.path.abspath(DIRS['source'])) for root, dirs, files in os.walk(DIRS['source']): relative = os.path.abspath(root)[root_len:] for f in files: fullpath = _join(root, f) file_name = _join(relative, f) if file_name == FILES['idl']: continue z.write(fullpath, file_name, zipfile.ZIP_DEFLATED) z.close() log.info('Extension OXT created sucesfully...') return def _install_and_test(): path_oxt = (_join(DIRS['files'], FILES['oxt']),) call(PATHS['install'] + path_oxt) log.info('Install extension sucesfully...') log.info('Start LibreOffice...') call(PATHS['soffice']) return def _validate_new(): path_source = DIRS['source'] if not _exists(path_source): return True msg = f'Path: {path_source}, exists, delete first' log.error(msg) return False def _create_new_directories(): path_source = DIRS['source'] _mkdir(path_source) path = _join(path_source, DIRS['meta']) _mkdir(path) path = _join(path_source, DIRS['description']) _mkdir(path) path = _join(path_source, DIRS['images']) _mkdir(path) path = _join(path_source, DIRS['registration']) _mkdir(path) path = _join(path_source, DIRS['office']) _mkdir(path) if FILES['easymacro'] or DIRS['pythonpath']: path = _join(path_source, 'pythonpath') _mkdir(path) path = DIRS['files'] if not _exists(path): _mkdir(path) msg = 'Created directories...' log.info(msg) return def _create_new_files(): path_source = DIRS['source'] for k, v in INFO.items(): file_name = f'license_{k}.txt' path = _join(path_source, DIRS['registration'], file_name) _save(path, v['license']) if TYPE_EXTENSION > 1: path = _join(path_source, FILES['idl']) _save(path, DATA['idl']) path = _join(path_source, FILES['py']) _save(path, DATA['py']) msg = 'Created files...' log.info(msg) return def _validate_update(): if TYPE_EXTENSION == 1: return True if not _exists(PATHS['idlc']): msg = 'Binary: "idlc" not found' log.error(msg) return False if not _exists(PATHS['include']): msg = 'Directory: "include" not found' log.error(msg) return False if not _exists(PATHS['regmerge']): msg = 'Binary: "regmerge" not found' log.error(msg) return False path = _join(DIRS['source'], FILES['idl']) if not _exists(path): msg = f'File: "{FILES["idl"]}" not found' log.error(msg) return False return True def _compile_idl(): if TYPE_EXTENSION == 1: return log.info('Compilate IDL...') path_rdb = _join(DIRS['source'], FILES['rdb']) path_urd = _join(DIRS['source'], FILES['urd']) path = _join(DIRS['source'], FILES['idl']) call([PATHS['idlc'], '-I', PATHS['include'], path]) call([PATHS['regmerge'], path_rdb, '/UCR', path_urd]) os.remove(path_urd) log.info('Compilate IDL sucesfully...') return def _update_files(): path_files = DIRS['files'] if not _exists(path_files): _mkdir(path_files) path_source = DIRS['source'] for k, v in INFO.items(): file_name = FILES['ext_desc'].format(k) path = _join(path_source, DIRS['description'], file_name) _save(path, v['description']) path_logo = EXTENSION['icon'][0] if _exists(path_logo): file_name = EXTENSION['icon'][1] path = _join(path_source, DIRS['images'], file_name) copyfile(path_logo, path) files = os.listdir(DIRS['images']) for f in files: if f[-3:].lower() == 'bmp': source = _join(DIRS['images'], f) target = _join(path_source, DIRS['images'], f) copyfile(source, target) if FILES['easymacro']: source = EASYMACRO target = _join(path_source, 'pythonpath', source) copyfile(source, target) xml = LiboXML() path = _join(path_source, DIRS['meta'], FILES['manifest']) data = xml.new_manifest(DATA['manifest']) _save(path, data) path = _join(path_source, FILES['description']) data = xml.new_description(DATA['description']) _save(path, data) if TYPE_EXTENSION == 1: path = _join(path_source, FILES['addons']) data = xml.new_addons(EXTENSION['id'], DATA['addons']) _save(path, data) path = _join(path_source, DIRS['office']) _mkdir(path) path = _join(path_source, DIRS['office'], FILES['shortcut']) data = xml.new_accelerators(EXTENSION['id'], DATA['addons']['menus']) _save(path, data) if TYPE_EXTENSION == 3: path = _join(path_source, FILES['addin']) _save(path, DATA['addin']) if USE_LOCALES: msg = "Don't forget generate DOMAIN.pot for locales" for lang in EXTENSION['languages']: path = _join(path_source, DIRS['locales'], lang, 'LC_MESSAGES') Path(path).mkdir(parents=True, exist_ok=True) log.info(msg) if DATA['update']: path_xml = _join(path_files, FILES['update']) data = xml.new_update(EXTENSION, DATA['update']) _save(path_xml, data) _compile_idl() return def _create(): if not _validate_new(): return _create_new_directories() _create_new_files() _update_files() msg = f"New extension: {EXTENSION['name']} make sucesfully...\n" msg += '\tNow, you can install and test: zaz.py -i' log.info(msg) return def _get_info_path(path): path, filename = os.path.split(path) name, extension = os.path.splitext(filename) return (path, filename, name, extension) def _zip_embed(source, files): PATH = 'Scripts/python/' FILE_PYC = 'easymacro.pyc' p, f, name, e = _get_info_path(source) now = datetime.now().strftime('_%Y%m%d_%H%M%S') path_source = _join(p, name + now + e) copyfile(source, path_source) target = source py_compile.compile(EASYMACRO, FILE_PYC) xml = LiboXML() path_easymacro = PATH + FILE_PYC names = [f[1] for f in files] + [path_easymacro] nodes = [] with zipfile.ZipFile(target, 'w', compression=zipfile.ZIP_DEFLATED) as zt: with zipfile.ZipFile(path_source, compression=zipfile.ZIP_DEFLATED) as zs: for name in zs.namelist(): if FILES['manifest'] in name: path_manifest = name xml_manifest = zs.open(name).read() elif name in names: continue else: zt.writestr(name, zs.open(name).read()) data = [] for path, name in files: data.append(name) zt.write(path, name) zt.write(FILE_PYC, path_easymacro) data.append(path_easymacro) xml.parse_manifest(xml_manifest) xml_manifest = xml.add_data_manifest(data) zt.writestr(path_manifest, xml_manifest) os.unlink(FILE_PYC) return def _embed(args): PATH = 'Scripts/python' PYTHONPATH = 'pythonpath' doc = args.document if not doc: msg = '-d/--document Path file to embed is mandatory' log.error(msg) return if not _exists(doc): msg = 'Path file not exists' log.error(msg) return files = [] if args.files: files = args.files.split(',') source = _join(PATHS['profile'], PATH) content = os.listdir(source) if PYTHONPATH in content: content.remove(PYTHONPATH) if files: files = [(_join(source, f), _join(PATH, f)) for f in files if f in content] else: files = [(_join(source, f), _join(PATH, f)) for f in content] _zip_embed(doc, files) log.info('Embedded macros successfully...') return def _locales(args): if args.files: files = args.files.split(',') else: files = _get_files(DIRS['source'], 'py') paths = ' '.join([f for f in files if not EASYMACRO in f]) path_pot = _join(DIRS['source'], DIRS['locales'], '{}.pot'.format(DOMAIN)) call([PATHS['gettext'], '-o', path_pot, paths]) log.info('POT generate successfully...') return def _update(): path_locales = _join(DIRS['source'], DIRS['locales']) path_pot = _join(DIRS['source'], DIRS['locales'], '{}.pot'.format(DOMAIN)) if not _exists(path_pot): log.error('Not exists file POT...') return files = _get_files(path_locales, 'po') if not files: log.error('First, generate files PO...') return for f in files: call([PATHS['msgmerge'], '-U', f, path_pot]) log.info('\tUpdate: {}'.format(f)) log.info('Locales update successfully...') return def _new(args): if not args.target: msg = 'Add argument target: -t PATH_TARGET' log.error(msg) return if not args.name: msg = 'Add argument name: -n name-new-extension' log.error(msg) return path = _join(args.target, args.name) _mkdir(path) _mkdir(_join(path, 'files')) _mkdir(_join(path, 'images')) path_logo = 'images/pymacros.png' copyfile(path_logo, _join(path, 'images/logo.png')) copyfile('zaz.py', _join(path, 'zaz.py')) copyfile(EASYMACRO, _join(path, 'easymacro.py')) copyfile('conf.py.example', _join(path, 'conf.py')) msg = 'Folders and files copy successfully for new extension.' log.info(msg) msg = f'Change to folder: {path}' log.info(msg) return def main(args): if args.new: _new(args) return if args.update: _update() return if args.locales: _locales(args) return if args.embed: _embed(args) return if args.create: _create() return if not _validate_update(): return if not args.only_compress: _update_files() _compress_oxt() if args.install: _install_and_test() log.info('Extension make successfully...') return def _process_command_line_arguments(): parser = argparse.ArgumentParser( description='Make LibreOffice extensions') parser.add_argument('-new', '--new', dest='new', action='store_true', default=False, required=False) parser.add_argument('-t', '--target', dest='target', default='') parser.add_argument('-n', '--name', dest='name', default='', required=False) parser.add_argument('-c', '--create', dest='create', action='store_true', default=False, required=False) parser.add_argument('-i', '--install', dest='install', action='store_true', default=False, required=False) parser.add_argument('-e', '--embed', dest='embed', action='store_true', default=False, required=False) parser.add_argument('-d', '--document', dest='document', default='') parser.add_argument('-f', '--files', dest='files', default='') parser.add_argument('-l', '--locales', dest='locales', action='store_true', default=False, required=False) parser.add_argument('-u', '--update', dest='update', action='store_true', default=False, required=False) parser.add_argument('-oc', '--only_compress', dest='only_compress', action='store_true', default=False, required=False) return parser.parse_args() if __name__ == '__main__': args = _process_command_line_arguments() main(args)