diff --git a/.bzrignore b/.bzrignore index b91d4fc93..6377150e0 100644 --- a/.bzrignore +++ b/.bzrignore @@ -6,6 +6,8 @@ *.ropeproject *.e4* .eric4project +.komodotools +*.komodoproject list openlp.org 2.0.e4* documentation/build/html diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index 1014c994d..d67c05c42 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -194,6 +194,7 @@ class Manager(object): db_ver, up_ver = upgrade_db(self.db_url, upgrade_mod) except (SQLAlchemyError, DBAPIError): log.exception('Error loading database: %s', self.db_url) + return if db_ver > up_ver: critical_error_message_box( translate('OpenLP.Manager', 'Database Error'), @@ -215,7 +216,7 @@ class Manager(object): Save an object to the database :param object_instance: The object to save - :param commit: Commit the session with this object + :param commit: Commit the session with this object """ for try_count in range(3): try: diff --git a/openlp/core/lib/renderer.py b/openlp/core/lib/renderer.py index 233af3784..71a1f6058 100644 --- a/openlp/core/lib/renderer.py +++ b/openlp/core/lib/renderer.py @@ -248,6 +248,9 @@ class Renderer(OpenLPMixin, RegistryMixin, RegistryProperties): elif item.is_capable(ItemCapabilities.CanSoftBreak): pages = [] if '[---]' in text: + # Remove two or more option slide breaks next to each other (causing infinite loop). + while '\n[---]\n[---]\n' in text: + text = text.replace('\n[---]\n[---]\n', '\n[---]\n') while True: slides = text.split('\n[---]\n', 2) # If there are (at least) two occurrences of [---] we use the first two slides (and neglect the last @@ -392,7 +395,7 @@ class Renderer(OpenLPMixin, RegistryMixin, RegistryProperties): off when displayed. :param lines: The text to be fitted on the slide split into lines. - :param line_end: The text added after each line. Either ``u' '`` or ``u'
``. + :param line_end: The text added after each line. Either ``' '`` or ``'
``. """ formatted = [] previous_html = '' @@ -416,7 +419,7 @@ class Renderer(OpenLPMixin, RegistryMixin, RegistryProperties): processed word by word. This is sometimes need for **bible** verses. :param lines: The text to be fitted on the slide split into lines. - :param line_end: The text added after each line. Either ``u' '`` or ``u'
``. This is needed for **bibles**. + :param line_end: The text added after each line. Either ``' '`` or ``'
``. This is needed for **bibles**. """ formatted = [] previous_html = '' @@ -453,7 +456,7 @@ class Renderer(OpenLPMixin, RegistryMixin, RegistryProperties): """ Tests the given text for not closed formatting tags and returns a tuple consisting of three unicode strings:: - (u'{st}{r}Text text text{/r}{/st}', u'{st}{r}', u'') + ('{st}{r}Text text text{/r}{/st}', '{st}{r}', '') The first unicode string is the text, with correct closing tags. The second unicode string are OpenLP's opening formatting tags and the third unicode string the html opening formatting tags. @@ -500,8 +503,8 @@ class Renderer(OpenLPMixin, RegistryMixin, RegistryProperties): The text contains html. :param raw_list: The elements which do not fit on a slide and needs to be processed using the binary chop. The elements can contain formatting tags. - :param separator: The separator for the elements. For lines this is ``u'
'`` and for words this is ``u' '``. - :param line_end: The text added after each "element line". Either ``u' '`` or ``u'
``. This is needed for + :param separator: The separator for the elements. For lines this is ``'
'`` and for words this is ``' '``. + :param line_end: The text added after each "element line". Either ``' '`` or ``'
``. This is needed for bibles. """ smallest_index = 0 diff --git a/openlp/core/ui/firsttimeform.py b/openlp/core/ui/firsttimeform.py index f1b875e6b..b9b5f5997 100644 --- a/openlp/core/ui/firsttimeform.py +++ b/openlp/core/ui/firsttimeform.py @@ -114,10 +114,10 @@ class FirstTimeForm(QtGui.QWizard, Ui_FirstTimeWizard, RegistryProperties): """ Run the wizard. """ - self.setDefaults() + self.set_defaults() return QtGui.QWizard.exec_(self) - def setDefaults(self): + def set_defaults(self): """ Set up display at start of theme edit. """ diff --git a/openlp/core/ui/mainwindow.py b/openlp/core/ui/mainwindow.py index 59bfb32af..982fe649d 100644 --- a/openlp/core/ui/mainwindow.py +++ b/openlp/core/ui/mainwindow.py @@ -1334,7 +1334,7 @@ class MainWindow(QtGui.QMainWindow, Ui_MainWindow, RegistryProperties): if self.copy_data: log.info('Copying data to new path') try: - self.showStatusMessage( + self.show_status_message( translate('OpenLP.MainWindow', 'Copying OpenLP data to new data directory location - %s ' '- Please wait for copy to finish').replace('%s', self.new_data_path)) dir_util.copy_tree(old_data_path, self.new_data_path) @@ -1364,8 +1364,7 @@ class MainWindow(QtGui.QMainWindow, Ui_MainWindow, RegistryProperties): args = [] for a in self.arguments: args.extend([a]) - for arg in args: - filename = arg + for filename in args: if not isinstance(filename, str): filename = str(filename, sys.getfilesystemencoding()) if filename.endswith(('.osz', '.oszl')): diff --git a/openlp/core/ui/pluginform.py b/openlp/core/ui/pluginform.py index 91b98b97a..78bdee4a5 100644 --- a/openlp/core/ui/pluginform.py +++ b/openlp/core/ui/pluginform.py @@ -30,7 +30,6 @@ The actual plugin view form """ import logging -import os from PyQt4 import QtGui diff --git a/openlp/core/ui/shortcutlistform.py b/openlp/core/ui/shortcutlistform.py index 4b64c3b54..dbfbbd439 100644 --- a/openlp/core/ui/shortcutlistform.py +++ b/openlp/core/ui/shortcutlistform.py @@ -244,10 +244,10 @@ class ShortcutListForm(QtGui.QDialog, Ui_ShortcutListDialog, RegistryProperties) self.primary_push_button.setChecked(False) self.alternate_push_button.setChecked(False) else: - if action.defaultShortcuts: - primary_label_text = action.defaultShortcuts[0].toString() - if len(action.defaultShortcuts) == 2: - alternate_label_text = action.defaultShortcuts[1].toString() + if action.default_shortcuts: + primary_label_text = action.default_shortcuts[0].toString() + if len(action.default_shortcuts) == 2: + alternate_label_text = action.default_shortcuts[1].toString() shortcuts = self._action_shortcuts(action) # We do not want to loose pending changes, that is why we have to keep the text when, this function has not # been triggered by a signal. @@ -292,7 +292,7 @@ class ShortcutListForm(QtGui.QDialog, Ui_ShortcutListDialog, RegistryProperties) self._adjust_button(self.alternate_push_button, False, text='') for category in self.action_list.categories: for action in category.actions: - self.changed_actions[action] = action.defaultShortcuts + self.changed_actions[action] = action.default_shortcuts self.refresh_shortcut_list() def on_default_radio_button_clicked(self, toggled): @@ -306,7 +306,7 @@ class ShortcutListForm(QtGui.QDialog, Ui_ShortcutListDialog, RegistryProperties) if action is None: return temp_shortcuts = self._action_shortcuts(action) - self.changed_actions[action] = action.defaultShortcuts + self.changed_actions[action] = action.default_shortcuts self.refresh_shortcut_list() primary_button_text = '' alternate_button_text = '' @@ -357,8 +357,8 @@ class ShortcutListForm(QtGui.QDialog, Ui_ShortcutListDialog, RegistryProperties) return shortcuts = self._action_shortcuts(action) new_shortcuts = [] - if action.defaultShortcuts: - new_shortcuts.append(action.defaultShortcuts[0]) + if action.default_shortcuts: + new_shortcuts.append(action.default_shortcuts[0]) # We have to check if the primary default shortcut is available. But we only have to check, if the action # has a default primary shortcut (an "empty" shortcut is always valid and if the action does not have a # default primary shortcut, then the alternative shortcut (not the default one) will become primary @@ -383,8 +383,8 @@ class ShortcutListForm(QtGui.QDialog, Ui_ShortcutListDialog, RegistryProperties) new_shortcuts = [] if shortcuts: new_shortcuts.append(shortcuts[0]) - if len(action.defaultShortcuts) == 2: - new_shortcuts.append(action.defaultShortcuts[1]) + if len(action.default_shortcuts) == 2: + new_shortcuts.append(action.default_shortcuts[1]) if len(new_shortcuts) == 2: if not self._validiate_shortcut(action, new_shortcuts[1]): return diff --git a/openlp/core/ui/themeform.py b/openlp/core/ui/themeform.py index d9b61e117..321071c49 100644 --- a/openlp/core/ui/themeform.py +++ b/openlp/core/ui/themeform.py @@ -90,7 +90,7 @@ class ThemeForm(QtGui.QWizard, Ui_ThemeWizard, RegistryProperties): self.footer_font_combo_box.activated.connect(self.update_theme) self.footer_size_spin_box.valueChanged.connect(self.update_theme) - def setDefaults(self): + def set_defaults(self): """ Set up display at start of theme edit. """ @@ -261,7 +261,7 @@ class ThemeForm(QtGui.QWizard, Ui_ThemeWizard, RegistryProperties): log.debug('Editing theme %s' % self.theme.theme_name) self.temp_background_filename = '' self.update_theme_allowed = False - self.setDefaults() + self.set_defaults() self.update_theme_allowed = True self.theme_name_label.setVisible(not edit) self.theme_name_edit.setVisible(not edit) diff --git a/openlp/core/ui/wizard.py b/openlp/core/ui/wizard.py index 05951d14a..5815457b5 100644 --- a/openlp/core/ui/wizard.py +++ b/openlp/core/ui/wizard.py @@ -197,7 +197,7 @@ class OpenLPWizard(QtGui.QWizard, RegistryProperties): """ Run the wizard. """ - self.setDefaults() + self.set_defaults() return QtGui.QWizard.exec_(self) def reject(self): diff --git a/openlp/core/utils/actions.py b/openlp/core/utils/actions.py index 29f2d279b..d81e16b2e 100644 --- a/openlp/core/utils/actions.py +++ b/openlp/core/utils/actions.py @@ -65,20 +65,14 @@ class CategoryActionList(object): self.index = 0 self.actions = [] - def __getitem__(self, key): - """ - Implement the __getitem__() method to make this class a dictionary type - """ - for weight, action in self.actions: - if action.text() == key: - return action - raise KeyError('Action "%s" does not exist.' % key) - - def __contains__(self, item): + def __contains__(self, key): """ Implement the __contains__() method to make this class a dictionary type """ - return item in self + for weight, action in self.actions: + if action == key: + return True + return False def __len__(self): """ @@ -103,23 +97,14 @@ class CategoryActionList(object): self.index += 1 return self.actions[self.index - 1][1] - def has_key(self, key): - """ - Implement the has_key() method to make this class a dictionary type - """ - for weight, action in self.actions: - if action.text() == key: - return True - return False - - def append(self, name): + def append(self, action): """ Append an action """ weight = 0 if self.actions: weight = self.actions[-1][0] + 1 - self.add(name, weight) + self.add(action, weight) def add(self, action, weight=0): """ @@ -128,14 +113,15 @@ class CategoryActionList(object): self.actions.append((weight, action)) self.actions.sort(key=lambda act: act[0]) - def remove(self, remove_action): + def remove(self, action): """ Remove an action """ - for action in self.actions: - if action[1] == remove_action: - self.actions.remove(action) + for item in self.actions: + if item[1] == action: + self.actions.remove(item) return + raise ValueError('Action "%s" does not exist.' % action) class CategoryList(object): @@ -184,9 +170,9 @@ class CategoryList(object): self.index += 1 return self.categories[self.index - 1] - def has_key(self, key): + def __contains__(self, key): """ - Implement the has_key() method to make this class like a dictionary + Implement the __contains__() method to make this class like a dictionary """ for category in self.categories: if category.name == key: @@ -200,10 +186,7 @@ class CategoryList(object): weight = 0 if self.categories: weight = self.categories[-1].weight + 1 - if actions: - self.add(name, weight, actions) - else: - self.add(name, weight) + self.add(name, weight, actions) def add(self, name, weight=0, actions=None): """ @@ -226,6 +209,8 @@ class CategoryList(object): for category in self.categories: if category.name == name: self.categories.remove(category) + return + raise ValueError('Category "%s" does not exist.' % name) class ActionList(object): @@ -270,7 +255,7 @@ class ActionList(object): settings = Settings() settings.beginGroup('shortcuts') # Get the default shortcut from the config. - action.defaultShortcuts = settings.get_default_value(action.objectName()) + action.default_shortcuts = settings.get_default_value(action.objectName()) if weight is None: self.categories[category].actions.append(action) else: diff --git a/openlp/plugins/bibles/forms/bibleimportform.py b/openlp/plugins/bibles/forms/bibleimportform.py index ee5bee2d0..79b0bc699 100644 --- a/openlp/plugins/bibles/forms/bibleimportform.py +++ b/openlp/plugins/bibles/forms/bibleimportform.py @@ -465,7 +465,7 @@ class BibleImportForm(OpenLPWizard): self.license_details_page.registerField('license_copyright', self.copyright_edit) self.license_details_page.registerField('license_permissions', self.permissions_edit) - def setDefaults(self): + def set_defaults(self): """ Set default values for the wizard pages. """ diff --git a/openlp/plugins/bibles/forms/bibleupgradeform.py b/openlp/plugins/bibles/forms/bibleupgradeform.py index d9936dfe6..09c0942b7 100644 --- a/openlp/plugins/bibles/forms/bibleupgradeform.py +++ b/openlp/plugins/bibles/forms/bibleupgradeform.py @@ -307,7 +307,7 @@ class BibleUpgradeForm(OpenLPWizard): if self.currentPage() == self.progress_page: return True - def setDefaults(self): + def set_defaults(self): """ Set default values for the wizard pages. """ diff --git a/openlp/plugins/remotes/lib/httprouter.py b/openlp/plugins/remotes/lib/httprouter.py index 5a10a14ae..4241b34dc 100644 --- a/openlp/plugins/remotes/lib/httprouter.py +++ b/openlp/plugins/remotes/lib/httprouter.py @@ -149,11 +149,11 @@ class HttpRouter(RegistryProperties): """ Initialise the router stack and any other variables. """ - authcode = "%s:%s" % (Settings().value('remotes/user id'), Settings().value('remotes/password')) + auth_code = "%s:%s" % (Settings().value('remotes/user id'), Settings().value('remotes/password')) try: - self.auth = base64.b64encode(authcode) + self.auth = base64.b64encode(auth_code) except TypeError: - self.auth = base64.b64encode(authcode.encode()).decode() + self.auth = base64.b64encode(auth_code.encode()).decode() self.routes = [ ('^/$', {'function': self.serve_file, 'secure': False}), ('^/(stage)$', {'function': self.serve_file, 'secure': False}), @@ -376,7 +376,6 @@ class HttpRouter(RegistryProperties): Examines the extension of the file and determines what the content_type should be, defaults to text/plain Returns the extension and the content_type """ - content_type = 'text/plain' ext = os.path.splitext(file_name)[1] content_type = FILE_TYPES.get(ext, 'text/plain') return ext, content_type @@ -439,7 +438,7 @@ class HttpRouter(RegistryProperties): if plugin.status == PluginStatus.Active: try: text = json.loads(self.request_data)['request']['text'] - except KeyError as ValueError: + except KeyError: return self.do_http_error() text = urllib.parse.unquote(text) self.alerts_manager.emit(QtCore.SIGNAL('alerts_text'), [text]) @@ -453,6 +452,7 @@ class HttpRouter(RegistryProperties): """ Perform an action on the slide controller. """ + log.debug("controller_text var = %s" % var) current_item = self.live_controller.service_item data = [] if current_item: @@ -488,7 +488,7 @@ class HttpRouter(RegistryProperties): if self.request_data: try: data = json.loads(self.request_data)['request']['id'] - except KeyError as ValueError: + except KeyError: return self.do_http_error() log.info(data) # This slot expects an int within a list. @@ -547,7 +547,7 @@ class HttpRouter(RegistryProperties): """ try: text = json.loads(self.request_data)['request']['text'] - except KeyError as ValueError: + except KeyError: return self.do_http_error() text = urllib.parse.unquote(text) plugin = self.plugin_manager.get_plugin_by_name(plugin_name) @@ -563,12 +563,12 @@ class HttpRouter(RegistryProperties): Go live on an item of type ``plugin``. """ try: - id = json.loads(self.request_data)['request']['id'] - except KeyError as ValueError: + request_id = json.loads(self.request_data)['request']['id'] + except KeyError: return self.do_http_error() plugin = self.plugin_manager.get_plugin_by_name(plugin_name) if plugin.status == PluginStatus.Active and plugin.media_item: - plugin.media_item.emit(QtCore.SIGNAL('%s_go_live' % plugin_name), [id, True]) + plugin.media_item.emit(QtCore.SIGNAL('%s_go_live' % plugin_name), [request_id, True]) return self.do_http_success() def add_to_service(self, plugin_name): @@ -576,11 +576,11 @@ class HttpRouter(RegistryProperties): Add item of type ``plugin_name`` to the end of the service. """ try: - id = json.loads(self.request_data)['request']['id'] - except KeyError as ValueError: + request_id = json.loads(self.request_data)['request']['id'] + except KeyError: return self.do_http_error() plugin = self.plugin_manager.get_plugin_by_name(plugin_name) if plugin.status == PluginStatus.Active and plugin.media_item: - item_id = plugin.media_item.create_item_from_id(id) + item_id = plugin.media_item.create_item_from_id(request_id) plugin.media_item.emit(QtCore.SIGNAL('%s_add_to_service' % plugin_name), [item_id, True]) self.do_http_success() diff --git a/openlp/plugins/remotes/lib/httpserver.py b/openlp/plugins/remotes/lib/httpserver.py index 22d0349f8..9a904090d 100644 --- a/openlp/plugins/remotes/lib/httpserver.py +++ b/openlp/plugins/remotes/lib/httpserver.py @@ -40,7 +40,7 @@ import time from PyQt4 import QtCore -from openlp.core.common import AppLocation, Settings +from openlp.core.common import AppLocation, Settings, RegistryProperties from openlp.plugins.remotes.lib import HttpRouter @@ -94,13 +94,18 @@ class HttpThread(QtCore.QThread): """ self.http_server.start_server() + def stop(self): + log.debug("stop called") + self.http_server.stop = True -class OpenLPServer(): + +class OpenLPServer(RegistryProperties): def __init__(self): """ Initialise the http server, and start the server of the correct type http / https """ - log.debug('Initialise httpserver') + super(OpenLPServer, self).__init__() + log.debug('Initialise OpenLP') self.settings_section = 'remotes' self.http_thread = HttpThread(self) self.http_thread.start() @@ -110,32 +115,49 @@ class OpenLPServer(): Start the correct server and save the handler """ address = Settings().value(self.settings_section + '/ip address') - if Settings().value(self.settings_section + '/https enabled'): + self.address = address + self.is_secure = Settings().value(self.settings_section + '/https enabled') + self.needs_authentication = Settings().value(self.settings_section + '/authentication enabled') + if self.is_secure: port = Settings().value(self.settings_section + '/https port') - self.httpd = HTTPSServer((address, port), CustomHandler) - log.debug('Started ssl httpd...') + self.port = port + self.start_server_instance(address, port, HTTPSServer) else: port = Settings().value(self.settings_section + '/port') - loop = 1 - while loop < 3: - try: - self.httpd = ThreadingHTTPServer((address, port), CustomHandler) - except OSError: - loop += 1 - time.sleep(0.1) - except: - log.error('Failed to start server ') - log.debug('Started non ssl httpd...') + self.port = port + self.start_server_instance(address, port, ThreadingHTTPServer) if hasattr(self, 'httpd') and self.httpd: self.httpd.serve_forever() else: log.debug('Failed to start server') + def start_server_instance(self, address, port, server_class): + """ + Start the server + + :param address: The server address + :param port: The run port + :param server_class: the class to start + """ + loop = 1 + while loop < 4: + try: + self.httpd = server_class((address, port), CustomHandler) + log.debug("Server started for class %s %s %d" % (server_class, address, port)) + except OSError: + log.debug("failed to start http server thread state %d %s" % + (loop, self.http_thread.isRunning())) + loop += 1 + time.sleep(0.1) + except: + log.error('Failed to start server ') + def stop_server(self): """ Stop the server """ - self.http_thread.exit(0) + if self.http_thread.isRunning(): + self.http_thread.stop() self.httpd = None log.debug('Stopped the server.') diff --git a/openlp/plugins/remotes/lib/remotetab.py b/openlp/plugins/remotes/lib/remotetab.py index d6b96cc1c..4db25cfc2 100644 --- a/openlp/plugins/remotes/lib/remotetab.py +++ b/openlp/plugins/remotes/lib/remotetab.py @@ -32,7 +32,7 @@ import os.path from PyQt4 import QtCore, QtGui, QtNetwork from openlp.core.common import AppLocation, Settings, translate -from openlp.core.lib import SettingsTab +from openlp.core.lib import SettingsTab, build_icon ZERO_URL = '0.0.0.0' @@ -234,6 +234,7 @@ class RemoteTab(SettingsTab): """ Load the configuration and update the server configuration if necessary """ + self.is_secure = Settings().value(self.settings_section + '/https enabled') self.port_spin_box.setValue(Settings().value(self.settings_section + '/port')) self.https_port_spin_box.setValue(Settings().value(self.settings_section + '/https port')) self.address_edit.setText(Settings().value(self.settings_section + '/ip address')) @@ -263,9 +264,7 @@ class RemoteTab(SettingsTab): Settings().value(self.settings_section + '/port') != self.port_spin_box.value() or \ Settings().value(self.settings_section + '/https port') != self.https_port_spin_box.value() or \ Settings().value(self.settings_section + '/https enabled') != \ - self.https_settings_group_box.isChecked() or \ - Settings().value(self.settings_section + '/authentication enabled') != \ - self.user_login_group_box.isChecked(): + self.https_settings_group_box.isChecked(): self.settings_form.register_post_process('remotes_config_updated') Settings().setValue(self.settings_section + '/port', self.port_spin_box.value()) Settings().setValue(self.settings_section + '/https port', self.https_port_spin_box.value()) @@ -275,6 +274,7 @@ class RemoteTab(SettingsTab): Settings().setValue(self.settings_section + '/authentication enabled', self.user_login_group_box.isChecked()) Settings().setValue(self.settings_section + '/user id', self.user_id.text()) Settings().setValue(self.settings_section + '/password', self.password.text()) + self.generate_icon() def on_twelve_hour_check_box_changed(self, check_state): """ @@ -290,3 +290,25 @@ class RemoteTab(SettingsTab): Invert the HTTP group box based on Https group settings """ self.http_settings_group_box.setEnabled(not self.https_settings_group_box.isChecked()) + + def generate_icon(self): + """ + Generate icon for main window + """ + self.remote_server_icon.hide() + icon = QtGui.QImage(':/remote/network_server.png') + icon = icon.scaled(80, 80, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) + if self.is_secure: + overlay = QtGui.QImage(':/remote/network_ssl.png') + overlay = overlay.scaled(60, 60, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) + painter = QtGui.QPainter(icon) + painter.drawImage(0, 0, overlay) + painter.end() + if Settings().value(self.settings_section + '/authentication enabled'): + overlay = QtGui.QImage(':/remote/network_auth.png') + overlay = overlay.scaled(60, 60, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) + painter = QtGui.QPainter(icon) + painter.drawImage(20, 0, overlay) + painter.end() + self.remote_server_icon.setPixmap(QtGui.QPixmap.fromImage(icon)) + self.remote_server_icon.show() diff --git a/openlp/plugins/remotes/remoteplugin.py b/openlp/plugins/remotes/remoteplugin.py index d3dc6e58a..582192df4 100644 --- a/openlp/plugins/remotes/remoteplugin.py +++ b/openlp/plugins/remotes/remoteplugin.py @@ -28,7 +28,8 @@ ############################################################################### import logging -import time + +from PyQt4 import QtGui from openlp.core.lib import Plugin, StringContent, translate, build_icon from openlp.plugins.remotes.lib import RemoteTab, OpenLPServer @@ -67,6 +68,21 @@ class RemotesPlugin(Plugin): log.debug('initialise') super(RemotesPlugin, self).initialise() self.server = OpenLPServer() + if not hasattr(self, 'remote_server_icon'): + self.remote_server_icon = QtGui.QLabel(self.main_window.status_bar) + size_policy = QtGui.QSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) + size_policy.setHorizontalStretch(0) + size_policy.setVerticalStretch(0) + size_policy.setHeightForWidth(self.remote_server_icon.sizePolicy().hasHeightForWidth()) + self.remote_server_icon.setSizePolicy(size_policy) + self.remote_server_icon.setFrameShadow(QtGui.QFrame.Plain) + self.remote_server_icon.setLineWidth(1) + self.remote_server_icon.setScaledContents(True) + self.remote_server_icon.setFixedSize(20, 20) + self.remote_server_icon.setObjectName('remote_server_icon') + self.main_window.status_bar.insertPermanentWidget(2, self.remote_server_icon) + self.settings_tab.remote_server_icon = self.remote_server_icon + self.settings_tab.generate_icon() def finalise(self): """ @@ -104,9 +120,11 @@ class RemotesPlugin(Plugin): def config_update(self): """ - Called when Config is changed to restart the server on new address or port + Called when Config is changed to requests a restart with the server on new address or port """ log.debug('remote config changed') - self.finalise() - time.sleep(0.5) - self.initialise() + QtGui.QMessageBox.information(self.main_window, + translate('RemotePlugin', 'Server Config Change'), + translate('RemotePlugin', 'Server configuration changes will require a restart ' + 'to take effect.'), + QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Ok)) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index c99dee4a7..c411c8c1c 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -264,7 +264,7 @@ class DuplicateSongRemovalForm(OpenLPWizard, RegistryProperties): self.break_search = True self.plugin.media_item.on_search_text_button_clicked() - def setDefaults(self): + def set_defaults(self): """ Set default form values for the song import wizard. """ diff --git a/openlp/plugins/songs/forms/editsongdialog.py b/openlp/plugins/songs/forms/editsongdialog.py index f2ef5af06..d0fb51a2d 100644 --- a/openlp/plugins/songs/forms/editsongdialog.py +++ b/openlp/plugins/songs/forms/editsongdialog.py @@ -118,13 +118,18 @@ class Ui_EditSongDialog(object): self.authors_group_box.setObjectName('authors_group_box') self.authors_layout = QtGui.QVBoxLayout(self.authors_group_box) self.authors_layout.setObjectName('authors_layout') - self.author_add_layout = QtGui.QHBoxLayout() + self.author_add_layout = QtGui.QVBoxLayout() self.author_add_layout.setObjectName('author_add_layout') + self.author_type_layout = QtGui.QHBoxLayout() + self.author_type_layout.setObjectName('author_type_layout') self.authors_combo_box = create_combo_box(self.authors_group_box, 'authors_combo_box') self.author_add_layout.addWidget(self.authors_combo_box) + self.author_types_combo_box = create_combo_box(self.authors_group_box, 'author_types_combo_box', editable=False) + self.author_type_layout.addWidget(self.author_types_combo_box) self.author_add_button = QtGui.QPushButton(self.authors_group_box) self.author_add_button.setObjectName('author_add_button') - self.author_add_layout.addWidget(self.author_add_button) + self.author_type_layout.addWidget(self.author_add_button) + self.author_add_layout.addLayout(self.author_type_layout) self.authors_layout.addLayout(self.author_add_layout) self.authors_list_view = QtGui.QListWidget(self.authors_group_box) self.authors_list_view.setAlternatingRowColors(True) @@ -330,7 +335,7 @@ class Ui_EditSongDialog(object): translate('SongsPlugin.EditSongForm', 'Warning: You have not entered a verse order.') -def create_combo_box(parent, name): +def create_combo_box(parent, name, editable=True): """ Utility method to generate a standard combo box for this dialog. @@ -340,7 +345,7 @@ def create_combo_box(parent, name): combo_box = QtGui.QComboBox(parent) combo_box.setSizeAdjustPolicy(QtGui.QComboBox.AdjustToMinimumContentsLength) combo_box.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Fixed) - combo_box.setEditable(True) + combo_box.setEditable(editable) combo_box.setInsertPolicy(QtGui.QComboBox.NoInsert) combo_box.setObjectName(name) return combo_box diff --git a/openlp/plugins/songs/forms/editsongform.py b/openlp/plugins/songs/forms/editsongform.py index 60c6eae78..1814655ea 100644 --- a/openlp/plugins/songs/forms/editsongform.py +++ b/openlp/plugins/songs/forms/editsongform.py @@ -42,7 +42,7 @@ from openlp.core.common import Registry, RegistryProperties, AppLocation, UiStri from openlp.core.lib import FileDialog, PluginStatus, MediaType, create_separated_list from openlp.core.lib.ui import set_case_insensitive_completer, critical_error_message_box, find_and_set_in_combo_box from openlp.plugins.songs.lib import VerseType, clean_song -from openlp.plugins.songs.lib.db import Book, Song, Author, Topic, MediaFile +from openlp.plugins.songs.lib.db import Book, Song, Author, AuthorSong, AuthorType, Topic, MediaFile from openlp.plugins.songs.lib.ui import SongStrings from openlp.plugins.songs.lib.xml import SongXML from openlp.plugins.songs.forms.editsongdialog import Ui_EditSongDialog @@ -122,12 +122,12 @@ class EditSongForm(QtGui.QDialog, Ui_EditSongDialog, RegistryProperties): combo.setItemData(row, obj.id) set_case_insensitive_completer(cache, combo) - def _add_author_to_list(self, author): + def _add_author_to_list(self, author, author_type): """ Add an author to the author list. """ - author_item = QtGui.QListWidgetItem(str(author.display_name)) - author_item.setData(QtCore.Qt.UserRole, author.id) + author_item = QtGui.QListWidgetItem(author.get_display_name(author_type)) + author_item.setData(QtCore.Qt.UserRole, (author.id, author_type)) self.authors_list_view.addItem(author_item) def _extract_verse_order(self, verse_order): @@ -217,8 +217,8 @@ class EditSongForm(QtGui.QDialog, Ui_EditSongDialog, RegistryProperties): if self.authors_list_view.count() == 0: self.song_tab_widget.setCurrentIndex(1) self.authors_list_view.setFocus() - critical_error_message_box( - message=translate('SongsPlugin.EditSongForm', 'You need to have an author for this song.')) + critical_error_message_box(message=translate('SongsPlugin.EditSongForm', + 'You need to have an author for this song.')) return False if self.verse_order_edit.text(): result = self._validate_verse_list(self.verse_order_edit.text(), self.verse_list_widget.rowCount()) @@ -302,6 +302,15 @@ class EditSongForm(QtGui.QDialog, Ui_EditSongDialog, RegistryProperties): self.authors.append(author.display_name) set_case_insensitive_completer(self.authors, self.authors_combo_box) + # Types + self.author_types_combo_box.clear() + self.author_types_combo_box.addItem('') + # Don't iterate over the dictionary to give them this specific order + self.author_types_combo_box.addItem(AuthorType.Types[AuthorType.Words], AuthorType.Words) + self.author_types_combo_box.addItem(AuthorType.Types[AuthorType.Music], AuthorType.Music) + self.author_types_combo_box.addItem(AuthorType.Types[AuthorType.WordsAndMusic], AuthorType.WordsAndMusic) + self.author_types_combo_box.addItem(AuthorType.Types[AuthorType.Translation], AuthorType.Translation) + def load_topics(self): """ Load the topics into the combobox. @@ -454,10 +463,8 @@ class EditSongForm(QtGui.QDialog, Ui_EditSongDialog, RegistryProperties): self.tag_rows() # clear the results self.authors_list_view.clear() - for author in self.song.authors: - author_name = QtGui.QListWidgetItem(str(author.display_name)) - author_name.setData(QtCore.Qt.UserRole, author.id) - self.authors_list_view.addItem(author_name) + for author_song in self.song.authors_songs: + self._add_author_to_list(author_song.author, author_song.author_type) # clear the results self.topics_list_view.clear() for topic in self.song.topics: @@ -496,6 +503,7 @@ class EditSongForm(QtGui.QDialog, Ui_EditSongDialog, RegistryProperties): """ item = int(self.authors_combo_box.currentIndex()) text = self.authors_combo_box.currentText().strip(' \r\n\t') + author_type = self.author_types_combo_box.itemData(self.author_types_combo_box.currentIndex()) # This if statement is for OS X, which doesn't seem to work well with # the QCompleter auto-completion class. See bug #812628. if text in self.authors: @@ -513,7 +521,7 @@ class EditSongForm(QtGui.QDialog, Ui_EditSongDialog, RegistryProperties): author = Author.populate(first_name=text.rsplit(' ', 1)[0], last_name=text.rsplit(' ', 1)[1], display_name=text) self.manager.save_object(author) - self._add_author_to_list(author) + self._add_author_to_list(author, author_type) self.load_authors() self.authors_combo_box.setCurrentIndex(0) else: @@ -521,11 +529,11 @@ class EditSongForm(QtGui.QDialog, Ui_EditSongDialog, RegistryProperties): elif item > 0: item_id = (self.authors_combo_box.itemData(item)) author = self.manager.get_object(Author, item_id) - if self.authors_list_view.findItems(str(author.display_name), QtCore.Qt.MatchExactly): + if self.authors_list_view.findItems(author.get_display_name(author_type), QtCore.Qt.MatchExactly): critical_error_message_box( message=translate('SongsPlugin.EditSongForm', 'This author is already in the list.')) else: - self._add_author_to_list(author) + self._add_author_to_list(author, author_type) self.authors_combo_box.setCurrentIndex(0) else: QtGui.QMessageBox.warning( @@ -905,13 +913,13 @@ class EditSongForm(QtGui.QDialog, Ui_EditSongDialog, RegistryProperties): else: self.song.theme_name = None self._process_lyrics() - self.song.authors = [] + self.song.authors_songs = [] for row in range(self.authors_list_view.count()): item = self.authors_list_view.item(row) - author_id = (item.data(QtCore.Qt.UserRole)) - author = self.manager.get_object(Author, author_id) - if author is not None: - self.song.authors.append(author) + author_song = AuthorSong() + author_song.author_id = item.data(QtCore.Qt.UserRole)[0] + author_song.author_type = item.data(QtCore.Qt.UserRole)[1] + self.song.authors_songs.append(author_song) self.song.topics = [] for row in range(self.topics_list_view.count()): item = self.topics_list_view.item(row) diff --git a/openlp/plugins/songs/forms/songimportform.py b/openlp/plugins/songs/forms/songimportform.py index 27f0d9343..21569a034 100644 --- a/openlp/plugins/songs/forms/songimportform.py +++ b/openlp/plugins/songs/forms/songimportform.py @@ -304,7 +304,7 @@ class SongImportForm(OpenLPWizard, RegistryProperties): """ self.source_page.emit(QtCore.SIGNAL('completeChanged()')) - def setDefaults(self): + def set_defaults(self): """ Set default form values for the song import wizard. """ diff --git a/openlp/plugins/songs/lib/__init__.py b/openlp/plugins/songs/lib/__init__.py index c15ce0b73..124629e6d 100644 --- a/openlp/plugins/songs/lib/__init__.py +++ b/openlp/plugins/songs/lib/__init__.py @@ -390,7 +390,7 @@ def clean_song(manager, song): verses = SongXML().get_verses(song.lyrics) song.search_lyrics = ' '.join([clean_string(verse[1]) for verse in verses]) # The song does not have any author, add one. - if not song.authors: + if not song.authors and not song.authors_songs: # Need to check both relations name = SongStrings.AuthorUnknown author = manager.get_object_filtered(Author, Author.display_name == name) if author is None: diff --git a/openlp/plugins/songs/lib/db.py b/openlp/plugins/songs/lib/db.py index c3965e2ed..91649c951 100644 --- a/openlp/plugins/songs/lib/db.py +++ b/openlp/plugins/songs/lib/db.py @@ -35,19 +35,52 @@ import re from sqlalchemy import Column, ForeignKey, Table, types from sqlalchemy.orm import mapper, relation, reconstructor -from sqlalchemy.sql.expression import func +from sqlalchemy.sql.expression import func, text from openlp.core.lib.db import BaseModel, init_db from openlp.core.utils import get_natural_key +from openlp.core.lib import translate class Author(BaseModel): """ Author model """ + def get_display_name(self, author_type=None): + if author_type: + return "%s (%s)" % (self.display_name, AuthorType.Types[author_type]) + return self.display_name + + +class AuthorSong(BaseModel): + """ + Relationship between Authors and Songs (many to many). + Need to define this relationship table explicit to get access to the + Association Object (author_type). + http://docs.sqlalchemy.org/en/latest/orm/relationships.html#association-object + """ pass +class AuthorType(object): + """ + Enumeration for Author types. + They are defined by OpenLyrics: http://openlyrics.info/dataformat.html#authors + + The 'words+music' type is not an official type, but is provided for convenience. + """ + Words = 'words' + Music = 'music' + WordsAndMusic = 'words+music' + Translation = 'translation' + Types = { + Words: translate('OpenLP.Ui', 'Words'), + Music: translate('OpenLP.Ui', 'Music'), + WordsAndMusic: translate('OpenLP.Ui', 'Words and Music'), + Translation: translate('OpenLP.Ui', 'Translation') + } + + class Book(BaseModel): """ Book model @@ -67,6 +100,7 @@ class Song(BaseModel): """ Song model """ + def __init__(self): self.sort_key = [] @@ -120,6 +154,7 @@ def init_schema(url): * author_id * song_id + * author_type **media_files Table** * id @@ -230,7 +265,8 @@ def init_schema(url): authors_songs_table = Table( 'authors_songs', metadata, Column('author_id', types.Integer(), ForeignKey('authors.id'), primary_key=True), - Column('song_id', types.Integer(), ForeignKey('songs.id'), primary_key=True) + Column('song_id', types.Integer(), ForeignKey('songs.id'), primary_key=True), + Column('author_type', types.String(), primary_key=True, nullable=False, server_default=text('""')) ) # Definition of the "songs_topics" table @@ -241,10 +277,15 @@ def init_schema(url): ) mapper(Author, authors_table) + mapper(AuthorSong, authors_songs_table, properties={ + 'author': relation(Author) + }) mapper(Book, song_books_table) mapper(MediaFile, media_files_table) mapper(Song, songs_table, properties={ - 'authors': relation(Author, backref='songs', secondary=authors_songs_table, lazy=False), + # Use the authors_songs relation when you need access to the 'author_type' attribute. + 'authors_songs': relation(AuthorSong, cascade="all, delete-orphan"), + 'authors': relation(Author, secondary=authors_songs_table), 'book': relation(Book, backref='songs'), 'media_files': relation(MediaFile, backref='songs', order_by=media_files_table.c.weight), 'topics': relation(Topic, backref='songs', secondary=songs_topics_table) diff --git a/openlp/plugins/songs/lib/ewimport.py b/openlp/plugins/songs/lib/ewimport.py index ca201279a..faa4122c8 100644 --- a/openlp/plugins/songs/lib/ewimport.py +++ b/openlp/plugins/songs/lib/ewimport.py @@ -34,13 +34,13 @@ EasyWorship song databases into the current installation database. import os import struct import re +import zlib from openlp.core.lib import translate from openlp.plugins.songs.lib import VerseType from openlp.plugins.songs.lib import retrieve_windows_encoding, strip_rtf from .songimport import SongImport -RTF_STRIPPING_REGEX = re.compile(r'\{\\tx[^}]*\}') # regex: at least two newlines, can have spaces between them SLIDE_BREAK_REGEX = re.compile(r'\n *?\n[\n ]*') NUMBER_REGEX = re.compile(r'[0-9]+') @@ -77,9 +77,121 @@ class EasyWorshipSongImport(SongImport): def do_import(self): """ - Import the songs + Determines the type of file to import and calls the appropiate method + """ + if self.import_source.lower().endswith('ews'): + self.import_ews() + else: + self.import_db() - :return: + def import_ews(self): + """ + Import the songs from service file + The full spec of the ews files can be found here: + https://github.com/meinders/lithium-ews/blob/master/docs/ews%20file%20format.md + or here: http://wiki.openlp.org/Development:EasyWorship_EWS_Format + """ + # Open ews file if it exists + if not os.path.isfile(self.import_source): + log.debug('Given ews file does not exists.') + return + # Make sure there is room for at least a header and one entry + if os.path.getsize(self.import_source) < 892: + log.debug('Given ews file is to small to contain valid data.') + return + # Take a stab at how text is encoded + self.encoding = 'cp1252' + self.encoding = retrieve_windows_encoding(self.encoding) + if not self.encoding: + log.debug('No encoding set.') + return + self.ews_file = open(self.import_source, 'rb') + # EWS header, version '1.6'/' 3'/' 5': + # Offset Field Data type Length Details + # -------------------------------------------------------------------------------------------------- + # 0 Filetype string 38 Specifies the file type and version. + # "EasyWorship Schedule File Version 1.6" or + # "EasyWorship Schedule File Version 3" or + # "EasyWorship Schedule File Version 5" + # 40/48/56 Entry count int32le 4 Number of items in the schedule + # 44/52/60 Entry length int16le 2 Length of schedule entries: 0x0718 = 1816 + # Get file version + type, = struct.unpack('<38s', self.ews_file.read(38)) + version = type.decode()[-3:] + # Set fileposition based on filetype/version + file_pos = 0 + if version == ' 5': + file_pos = 56 + elif version == ' 3': + file_pos = 48 + elif version == '1.6': + file_pos = 40 + else: + log.debug('Given ews file is of unknown version.') + return + entry_count = self.get_i32(file_pos) + entry_length = self.get_i16(file_pos+4) + file_pos += 6 + self.import_wizard.progress_bar.setMaximum(entry_count) + # Loop over songs + for i in range(entry_count): + # Load EWS entry metadata: + # Offset Field Data type Length Details + # ------------------------------------------------------------------------------------------------ + # 0 Title cstring 50 + # 307 Author cstring 50 + # 358 Copyright cstring 100 + # 459 Administrator cstring 50 + # 800 Content pointer int32le 4 Position of the content for this entry. + # 820 Content type int32le 4 0x01 = Song, 0x02 = Scripture, 0x03 = Presentation, + # 0x04 = Video, 0x05 = Live video, 0x07 = Image, + # 0x08 = Audio, 0x09 = Web + # 1410 Song number cstring 10 + self.set_defaults() + self.title = self.get_string(file_pos + 0, 50) + authors = self.get_string(file_pos + 307, 50) + copyright = self.get_string(file_pos + 358, 100) + admin = self.get_string(file_pos + 459, 50) + cont_ptr = self.get_i32(file_pos + 800) + cont_type = self.get_i32(file_pos + 820) + self.ccli_number = self.get_string(file_pos + 1410, 10) + # Only handle content type 1 (songs) + if cont_type != 1: + file_pos += entry_length + continue + # Load song content + # Offset Field Data type Length Details + # ------------------------------------------------------------------------------------------------ + # 0 Length int32le 4 Length (L) of content, including the compressed content + # and the following fields (14 bytes total). + # 4 Content string L-14 Content compressed with deflate. + # Checksum int32be 4 Alder-32 checksum. + # (unknown) 4 0x51 0x4b 0x03 0x04 + # Content length int32le 4 Length of content after decompression + content_length = self.get_i32(cont_ptr) + deflated_content = self.get_bytes(cont_ptr + 4, content_length - 10) + deflated_length = self.get_i32(cont_ptr + 4 + content_length - 6) + inflated_content = zlib.decompress(deflated_content, 15, deflated_length) + if copyright: + self.copyright = copyright + if admin: + if copyright: + self.copyright += ', ' + self.copyright += translate('SongsPlugin.EasyWorshipSongImport', + 'Administered by %s') % admin + # Set the SongImport object members. + self.set_song_import_object(authors, inflated_content) + if self.stop_import_flag: + break + if not self.finish(): + self.log_error(self.import_source) + # Set file_pos for next entry + file_pos += entry_length + self.ews_file.close() + + def import_db(self): + """ + Import the songs from the database """ # Open the DB and MB files if they exist import_source_mb = self.import_source.replace('.DB', '.MB') @@ -176,7 +288,6 @@ class EasyWorshipSongImport(SongImport): ccli = self.get_field(fi_ccli) authors = self.get_field(fi_author) words = self.get_field(fi_words) - # Set the SongImport object members. if copy: self.copyright = copy.decode() if admin: @@ -187,55 +298,11 @@ class EasyWorshipSongImport(SongImport): if ccli: self.ccli_number = ccli.decode() if authors: - # Split up the authors - author_list = authors.split(b'/') - if len(author_list) < 2: - author_list = authors.split(b';') - if len(author_list) < 2: - author_list = authors.split(b',') - for author_name in author_list: - self.add_author(author_name.decode().strip()) - if words: - # Format the lyrics - result = strip_rtf(words.decode(), self.encoding) - if result is None: - return - words, self.encoding = result - verse_type = VerseType.tags[VerseType.Verse] - for verse in SLIDE_BREAK_REGEX.split(words): - verse = verse.strip() - if not verse: - continue - verse_split = verse.split('\n', 1) - first_line_is_tag = False - # EW tags: verse, chorus, pre-chorus, bridge, tag, - # intro, ending, slide - for tag in VerseType.tags + ['tag', 'slide']: - tag = tag.lower() - ew_tag = verse_split[0].strip().lower() - if ew_tag.startswith(tag): - verse_type = tag[0] - if tag == 'tag' or tag == 'slide': - verse_type = VerseType.tags[VerseType.Other] - first_line_is_tag = True - number_found = False - # check if tag is followed by number and/or note - if len(ew_tag) > len(tag): - match = NUMBER_REGEX.search(ew_tag) - if match: - number = match.group() - verse_type += number - number_found = True - match = NOTE_REGEX.search(ew_tag) - if match: - self.comments += ew_tag + '\n' - if not number_found: - verse_type += '1' - break - self.add_verse(verse_split[-1].strip() if first_line_is_tag else verse, verse_type) - if len(self.comments) > 5: - self.comments += str(translate('SongsPlugin.EasyWorshipSongImport', - '\n[above are Song Tags with notes imported from EasyWorship]')) + authors = authors.decode() + else: + authors = '' + # Set the SongImport object members. + self.set_song_import_object(authors, words) if self.stop_import_flag: break if not self.finish(): @@ -243,12 +310,69 @@ class EasyWorshipSongImport(SongImport): db_file.close() self.memo_file.close() + def set_song_import_object(self, authors, words): + """ + Set the SongImport object members. + + :param authors: String with authons + :param words: Bytes with rtf-encoding + """ + if authors: + # Split up the authors + author_list = authors.split('/') + if len(author_list) < 2: + author_list = authors.split(';') + if len(author_list) < 2: + author_list = authors.split(',') + for author_name in author_list: + self.add_author(author_name.strip()) + if words: + # Format the lyrics + result = strip_rtf(words.decode(), self.encoding) + if result is None: + return + words, self.encoding = result + verse_type = VerseType.tags[VerseType.Verse] + for verse in SLIDE_BREAK_REGEX.split(words): + verse = verse.strip() + if not verse: + continue + verse_split = verse.split('\n', 1) + first_line_is_tag = False + # EW tags: verse, chorus, pre-chorus, bridge, tag, + # intro, ending, slide + for tag in VerseType.tags + ['tag', 'slide']: + tag = tag.lower() + ew_tag = verse_split[0].strip().lower() + if ew_tag.startswith(tag): + verse_type = tag[0] + if tag == 'tag' or tag == 'slide': + verse_type = VerseType.tags[VerseType.Other] + first_line_is_tag = True + number_found = False + # check if tag is followed by number and/or note + if len(ew_tag) > len(tag): + match = NUMBER_REGEX.search(ew_tag) + if match: + number = match.group() + verse_type += number + number_found = True + match = NOTE_REGEX.search(ew_tag) + if match: + self.comments += ew_tag + '\n' + if not number_found: + verse_type += '1' + break + self.add_verse(verse_split[-1].strip() if first_line_is_tag else verse, verse_type) + if len(self.comments) > 5: + self.comments += str(translate('SongsPlugin.EasyWorshipSongImport', + '\n[above are Song Tags with notes imported from EasyWorship]')) + def find_field(self, field_name): """ Find a field in the descriptions :param field_name: field to find - :return: """ return [i for i, x in enumerate(self.field_descriptions) if x.name == field_name][0] @@ -285,7 +409,7 @@ class EasyWorshipSongImport(SongImport): Extract the field :param field_desc_index: Field index value - :return: + :return: The field value """ field = self.fields[field_desc_index] field_desc = self.field_descriptions[field_desc_index] @@ -323,3 +447,52 @@ class EasyWorshipSongImport(SongImport): return self.memo_file.read(blob_size) else: return 0 + + def get_bytes(self, pos, length): + """ + Get bytes from ews_file + + :param pos: Position to read from + :param length: Bytes to read + :return: Bytes read + """ + self.ews_file.seek(pos) + return self.ews_file.read(length) + + def get_string(self, pos, length): + """ + Get string from ews_file + + :param pos: Position to read from + :param length: Characters to read + :return: String read + """ + bytes = self.get_bytes(pos, length) + mask = '<' + str(length) + 's' + byte_str, = struct.unpack(mask, bytes) + return byte_str.decode('unicode-escape').replace('\0', '').strip() + + def get_i16(self, pos): + """ + Get short int from ews_file + + :param pos: Position to read from + :return: Short integer read + """ + + bytes = self.get_bytes(pos, 2) + mask = '`` - OpenLP does not support the attribute *type* and *lang*. + OpenLP does not support the attribute *lang*. ```` This property is not supported. @@ -269,10 +269,18 @@ class OpenLyrics(object): 'verseOrder', properties, song.verse_order.lower()) if song.ccli_number: self._add_text_to_element('ccliNo', properties, song.ccli_number) - if song.authors: + if song.authors_songs: authors = etree.SubElement(properties, 'authors') - for author in song.authors: - self._add_text_to_element('author', authors, author.display_name) + for author_song in song.authors_songs: + element = self._add_text_to_element('author', authors, author_song.author.display_name) + if author_song.author_type: + # Handle the special case 'words+music': Need to create two separate authors for that + if author_song.author_type == AuthorType.WordsAndMusic: + element.set('type', AuthorType.Words) + element = self._add_text_to_element('author', authors, author_song.author.display_name) + element.set('type', AuthorType.Music) + else: + element.set('type', author_song.author_type) book = self.manager.get_object_filtered(Book, Book.id == song.song_book_id) if book is not None: book = book.name @@ -501,16 +509,20 @@ class OpenLyrics(object): if hasattr(properties, 'authors'): for author in properties.authors.author: display_name = self._text(author) + author_type = author.get('type', '') if display_name: - authors.append(display_name) - for display_name in authors: + authors.append((display_name, author_type)) + for (display_name, author_type) in authors: author = self.manager.get_object_filtered(Author, Author.display_name == display_name) if author is None: # We need to create a new author, as the author does not exist. author = Author.populate(display_name=display_name, last_name=display_name.split(' ')[-1], first_name=' '.join(display_name.split(' ')[:-1])) - song.authors.append(author) + author_song = AuthorSong() + author_song.author = author + author_song.author_type = author_type + song.authors_songs.append(author_song) def _process_cclinumber(self, properties, song): """ diff --git a/resources/images/network_auth.png b/resources/images/network_auth.png new file mode 100644 index 000000000..45e7a5c17 Binary files /dev/null and b/resources/images/network_auth.png differ diff --git a/resources/images/network_server.png b/resources/images/network_server.png new file mode 100644 index 000000000..25b95f3b0 Binary files /dev/null and b/resources/images/network_server.png differ diff --git a/resources/images/network_ssl.png b/resources/images/network_ssl.png new file mode 100644 index 000000000..1169de67a Binary files /dev/null and b/resources/images/network_ssl.png differ diff --git a/resources/images/openlp-2.qrc b/resources/images/openlp-2.qrc index 6af0e77a5..79036f08f 100644 --- a/resources/images/openlp-2.qrc +++ b/resources/images/openlp-2.qrc @@ -149,6 +149,11 @@ messagebox_info.png messagebox_warning.png + + network_server.png + network_ssl.png + network_auth.png + song_usage_active.png song_usage_inactive.png diff --git a/scripts/jenkins_script.py b/scripts/jenkins_script.py index aaee9a71b..eeafbfe23 100644 --- a/scripts/jenkins_script.py +++ b/scripts/jenkins_script.py @@ -148,7 +148,7 @@ class JenkinsTrigger(object): def get_repo_name(): """ - This returns the name of branch of the wokring directory. For example it returns *lp:~googol/openlp/render*. + This returns the name of branch of the working directory. For example it returns *lp:~googol/openlp/render*. """ # Run the bzr command. bzr = Popen(('bzr', 'info'), stdout=PIPE, stderr=PIPE) @@ -198,7 +198,7 @@ def main(): jenkins_trigger = JenkinsTrigger(token) try: jenkins_trigger.trigger_build() - except HTTPError as e: + except HTTPError: print('Wrong token.') return # Open the browser before printing the output. diff --git a/tests/functional/openlp_core_lib/test_file_dialog.py b/tests/functional/openlp_core_lib/test_file_dialog.py index 3120f48fa..ab7663a83 100644 --- a/tests/functional/openlp_core_lib/test_file_dialog.py +++ b/tests/functional/openlp_core_lib/test_file_dialog.py @@ -53,8 +53,8 @@ class TestFileDialog(TestCase): self.mocked_os.rest() self.mocked_qt_gui.reset() - # GIVEN: A List of known values as a return value from QFileDialog.getOpenFileNames and a list of valid - # file names. + # GIVEN: A List of known values as a return value from QFileDialog.getOpenFileNames and a list of valid file + # names. self.mocked_qt_gui.QFileDialog.getOpenFileNames.return_value = [ '/Valid File', '/url%20encoded%20file%20%231', '/non-existing'] self.mocked_os.path.exists.side_effect = lambda file_name: file_name in [ diff --git a/tests/functional/openlp_core_utils/test_actions.py b/tests/functional/openlp_core_utils/test_actions.py index 2868f8555..4958c4677 100644 --- a/tests/functional/openlp_core_utils/test_actions.py +++ b/tests/functional/openlp_core_utils/test_actions.py @@ -35,9 +35,107 @@ from PyQt4 import QtGui, QtCore from openlp.core.common import Settings from openlp.core.utils import ActionList +from openlp.core.utils.actions import CategoryActionList +from tests.functional import MagicMock from tests.helpers.testmixin import TestMixin +class TestCategoryActionList(TestCase): + def setUp(self): + """ + Create an instance and a few example actions. + """ + self.action1 = MagicMock() + self.action1.text.return_value = 'first' + self.action2 = MagicMock() + self.action2.text.return_value = 'second' + self.list = CategoryActionList() + + def tearDown(self): + """ + Clean up + """ + del self.list + + def contains_test(self): + """ + Test the __contains__() method + """ + # GIVEN: The list. + # WHEN: Add an action + self.list.append(self.action1) + + # THEN: The actions should (not) be in the list. + self.assertTrue(self.action1 in self.list) + self.assertFalse(self.action2 in self.list) + + def len_test(self): + """ + Test the __len__ method + """ + # GIVEN: The list. + # WHEN: Do nothing. + # THEN: Check the length. + self.assertEqual(len(self.list), 0, "The length should be 0.") + + # GIVEN: The list. + # WHEN: Append an action. + self.list.append(self.action1) + + # THEN: Check the length. + self.assertEqual(len(self.list), 1, "The length should be 1.") + + def append_test(self): + """ + Test the append() method + """ + # GIVEN: The list. + # WHEN: Append an action. + self.list.append(self.action1) + self.list.append(self.action2) + + # THEN: Check if the actions are in the list and check if they have the correct weights. + self.assertTrue(self.action1 in self.list) + self.assertTrue(self.action2 in self.list) + self.assertEqual(self.list.actions[0], (0, self.action1)) + self.assertEqual(self.list.actions[1], (1, self.action2)) + + def add_test(self): + """ + Test the add() method + """ + # GIVEN: The list and weights. + action1_weight = 42 + action2_weight = 41 + + # WHEN: Add actions and their weights. + self.list.add(self.action1, action1_weight) + self.list.add(self.action2, action2_weight) + + # THEN: Check if they were added and have the specified weights. + self.assertTrue(self.action1 in self.list) + self.assertTrue(self.action2 in self.list) + # Now check if action1 is second and action2 is first (due to their weights). + self.assertEqual(self.list.actions[0], (41, self.action2)) + self.assertEqual(self.list.actions[1], (42, self.action1)) + + def remove_test(self): + """ + Test the remove() method + """ + # GIVEN: The list + self.list.append(self.action1) + + # WHEN: Delete an item from the list. + self.list.remove(self.action1) + + # THEN: Now the element should not be in the list anymore. + self.assertFalse(self.action1 in self.list) + + # THEN: Check if an exception is raised when trying to remove a not present action. + self.assertRaises(ValueError, self.list.remove, self.action2) + + class TestActionList(TestCase, TestMixin): """ Test the ActionList class diff --git a/tests/functional/openlp_plugins/songs/test_ewimport.py b/tests/functional/openlp_plugins/songs/test_ewimport.py index 182a6b04a..49bc367cd 100644 --- a/tests/functional/openlp_plugins/songs/test_ewimport.py +++ b/tests/functional/openlp_plugins/songs/test_ewimport.py @@ -69,6 +69,20 @@ SONG_TEST_DATA = [ 'Just to bow and receive a new blessing,\nIn the beautiful garden of prayer.', 'v3')], 'verse_order_list': []}] +EWS_SONG_TEST_DATA =\ + {'title': 'Vi pløjed og vi så\'de', + 'authors': ['Matthias Claudius'], + 'verses': + [('Vi pløjed og vi så\'de\nvor sæd i sorten jord,\nså bad vi ham os hjælpe,\nsom højt i Himlen bor,\n' + 'og han lod snefald hegne\nmod frosten barsk og hård,\nhan lod det tø og regne\nog varme mildt i vår.', + 'v1'), + ('Alle gode gaver\nde kommer ovenned,\nså tak da Gud, ja, pris dog Gud\nfor al hans kærlighed!', 'c1'), + ('Han er jo den, hvis vilje\nopholder alle ting,\nhan klæder markens lilje\nog runder himlens ring,\n' + 'ham lyder vind og vove,\nham rører ravnes nød,\nhvi skulle ej hans småbørn\nda og få dagligt brød?', 'v2'), + ('Ja, tak, du kære Fader,\nså mild, så rig, så rund,\nfor korn i hæs og lader,\nfor godt i allen stund!\n' + 'Vi kan jo intet give,\nsom nogen ting er værd,\nmen tag vort stakkels hjerte,\nså ringe som det er!', + 'v3')]} + class EasyWorshipSongImportLogger(EasyWorshipSongImport): """ @@ -357,9 +371,9 @@ class TestEasyWorshipSongImport(TestCase): self.assertIsNone(importer.do_import(), 'do_import should return None when db_size is less than 0x800') mocked_retrieve_windows_encoding.assert_call(encoding) - def file_import_test(self): + def db_file_import_test(self): """ - Test the actual import of real song files and check that the imported data is correct. + Test the actual import of real song database files and check that the imported data is correct. """ # GIVEN: Test files with a mocked out SongImport class, a mocked out "manager", a mocked out "import_wizard", @@ -386,10 +400,11 @@ class TestEasyWorshipSongImport(TestCase): # WHEN: Importing each file importer.import_source = os.path.join(TEST_PATH, 'Songs.DB') + import_result = importer.do_import() # THEN: do_import should return none, the song data should be as expected, and finish should have been # called. - self.assertIsNone(importer.do_import(), 'do_import should return None when it has completed') + self.assertIsNone(import_result, 'do_import should return None when it has completed') for song_data in SONG_TEST_DATA: title = song_data['title'] author_calls = song_data['authors'] @@ -411,3 +426,44 @@ class TestEasyWorshipSongImport(TestCase): self.assertEqual(importer.verse_order_list, verse_order_list, 'verse_order_list for %s should be %s' % (title, verse_order_list)) mocked_finish.assert_called_with() + + def ews_file_import_test(self): + """ + Test the actual import of song from ews file and check that the imported data is correct. + """ + + # GIVEN: Test files with a mocked out SongImport class, a mocked out "manager", a mocked out "import_wizard", + # and mocked out "author", "add_copyright", "add_verse", "finish" methods. + with patch('openlp.plugins.songs.lib.ewimport.SongImport'), \ + patch('openlp.plugins.songs.lib.ewimport.retrieve_windows_encoding') \ + as mocked_retrieve_windows_encoding: + mocked_retrieve_windows_encoding.return_value = 'cp1252' + mocked_manager = MagicMock() + mocked_import_wizard = MagicMock() + mocked_add_author = MagicMock() + mocked_add_verse = MagicMock() + mocked_finish = MagicMock() + mocked_title = MagicMock() + mocked_finish.return_value = True + importer = EasyWorshipSongImportLogger(mocked_manager) + importer.import_wizard = mocked_import_wizard + importer.stop_import_flag = False + importer.add_author = mocked_add_author + importer.add_verse = mocked_add_verse + importer.title = mocked_title + importer.finish = mocked_finish + importer.topics = [] + + # WHEN: Importing ews file + importer.import_source = os.path.join(TEST_PATH, 'test1.ews') + import_result = importer.do_import() + + # THEN: do_import should return none, the song data should be as expected, and finish should have been + # called. + title = EWS_SONG_TEST_DATA['title'] + self.assertIsNone(import_result, 'do_import should return None when it has completed') + self.assertIn(title, importer._title_assignment_list, 'title for should be "%s"' % title) + mocked_add_author.assert_any_call(EWS_SONG_TEST_DATA['authors'][0]) + for verse_text, verse_tag in EWS_SONG_TEST_DATA['verses']: + mocked_add_verse.assert_any_call(verse_text, verse_tag) + mocked_finish.assert_called_with() diff --git a/tests/functional/openlp_plugins/songs/test_mediaitem.py b/tests/functional/openlp_plugins/songs/test_mediaitem.py index 2b5f02483..308881c2e 100644 --- a/tests/functional/openlp_plugins/songs/test_mediaitem.py +++ b/tests/functional/openlp_plugins/songs/test_mediaitem.py @@ -10,6 +10,7 @@ from PyQt4 import QtCore, QtGui from openlp.core.common import Registry, Settings from openlp.core.lib import ServiceItem from openlp.plugins.songs.lib.mediaitem import SongMediaItem +from openlp.plugins.songs.lib.db import AuthorType from tests.functional import patch, MagicMock from tests.helpers.testmixin import TestMixin @@ -45,10 +46,12 @@ class TestMediaItem(TestCase, TestMixin): # GIVEN: A Song and a Service Item mock_song = MagicMock() mock_song.title = 'My Song' + mock_song.authors_songs = [] mock_author = MagicMock() mock_author.display_name = 'my author' - mock_song.authors = [] - mock_song.authors.append(mock_author) + mock_author_song = MagicMock() + mock_author_song.author = mock_author + mock_song.authors_songs.append(mock_author_song) mock_song.copyright = 'My copyright' service_item = ServiceItem(None) @@ -56,7 +59,7 @@ class TestMediaItem(TestCase, TestMixin): author_list = self.media_item.generate_footer(service_item, mock_song) # THEN: I get the following Array returned - self.assertEqual(service_item.raw_footer, ['My Song', 'my author', 'My copyright'], + self.assertEqual(service_item.raw_footer, ['My Song', 'Written by: my author', 'My copyright'], 'The array should be returned correctly with a song, one author and copyright') self.assertEqual(author_list, ['my author'], 'The author list should be returned correctly with one author') @@ -68,13 +71,25 @@ class TestMediaItem(TestCase, TestMixin): # GIVEN: A Song and a Service Item mock_song = MagicMock() mock_song.title = 'My Song' + mock_song.authors_songs = [] mock_author = MagicMock() mock_author.display_name = 'my author' - mock_song.authors = [] - mock_song.authors.append(mock_author) + mock_author_song = MagicMock() + mock_author_song.author = mock_author + mock_author_song.author_type = AuthorType.Music + mock_song.authors_songs.append(mock_author_song) mock_author = MagicMock() mock_author.display_name = 'another author' - mock_song.authors.append(mock_author) + mock_author_song = MagicMock() + mock_author_song.author = mock_author + mock_author_song.author_type = AuthorType.Words + mock_song.authors_songs.append(mock_author_song) + mock_author = MagicMock() + mock_author.display_name = 'translator' + mock_author_song = MagicMock() + mock_author_song.author = mock_author + mock_author_song.author_type = AuthorType.Translation + mock_song.authors_songs.append(mock_author_song) mock_song.copyright = 'My copyright' service_item = ServiceItem(None) @@ -82,22 +97,19 @@ class TestMediaItem(TestCase, TestMixin): author_list = self.media_item.generate_footer(service_item, mock_song) # THEN: I get the following Array returned - self.assertEqual(service_item.raw_footer, ['My Song', 'my author and another author', 'My copyright'], + self.assertEqual(service_item.raw_footer, ['My Song', 'Words: another author', 'Music: my author', + 'Translation: translator', 'My copyright'], 'The array should be returned correctly with a song, two authors and copyright') - self.assertEqual(author_list, ['my author', 'another author'], + self.assertEqual(author_list, ['another author', 'my author', 'translator'], 'The author list should be returned correctly with two authors') def build_song_footer_base_ccli_test(self): """ - Test build songs footer with basic song and two authors + Test build songs footer with basic song and a CCLI number """ # GIVEN: A Song and a Service Item and a configured CCLI license mock_song = MagicMock() mock_song.title = 'My Song' - mock_author = MagicMock() - mock_author.display_name = 'my author' - mock_song.authors = [] - mock_song.authors.append(mock_author) mock_song.copyright = 'My copyright' service_item = ServiceItem(None) Settings().setValue('core/ccli number', '1234') @@ -106,7 +118,7 @@ class TestMediaItem(TestCase, TestMixin): self.media_item.generate_footer(service_item, mock_song) # THEN: I get the following Array returned - self.assertEqual(service_item.raw_footer, ['My Song', 'my author', 'My copyright', 'CCLI License: 1234'], + self.assertEqual(service_item.raw_footer, ['My Song', 'My copyright', 'CCLI License: 1234'], 'The array should be returned correctly with a song, an author, copyright and ccli') # WHEN: I amend the CCLI value @@ -114,5 +126,5 @@ class TestMediaItem(TestCase, TestMixin): self.media_item.generate_footer(service_item, mock_song) # THEN: I would get an amended footer string - self.assertEqual(service_item.raw_footer, ['My Song', 'my author', 'My copyright', 'CCLI License: 4321'], + self.assertEqual(service_item.raw_footer, ['My Song', 'My copyright', 'CCLI License: 4321'], 'The array should be returned correctly with a song, an author, copyright and amended ccli') diff --git a/tests/interfaces/openlp_core_ui/test_shortcutlistform.py b/tests/interfaces/openlp_core_ui/test_shortcutlistform.py new file mode 100644 index 000000000..472bce03f --- /dev/null +++ b/tests/interfaces/openlp_core_ui/test_shortcutlistform.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2014 Raoul Snyman # +# Portions copyright (c) 2008-2014 Tim Bentley, Gerald Britton, Jonathan # +# Corwin, Samuel Findlay, Michael Gorven, Scott Guerrieri, Matthias Hub, # +# Meinert Jordan, Armin Köhler, Erik Lundin, Edwin Lunando, Brian T. Meyer. # +# Joshua Miller, Stevan Pettit, Andreas Preikschat, Mattias Põldaru, # +# Christian Richter, Philip Ridout, Simon Scudder, Jeffrey Smith, # +# Maikel Stuivenberg, Martin Thompson, Jon Tibble, Dave Warnock, # +# Frode Woldsund, Martin Zibricky, Patrick Zimmermann # +# --------------------------------------------------------------------------- # +# This program 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; version 2 of the License. # +# # +# This program 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 this program; if not, write to the Free Software Foundation, Inc., 59 # +# Temple Place, Suite 330, Boston, MA 02111-1307 USA # +############################################################################### +""" +Package to test the openlp.core.ui.shortcutform package. +""" +from unittest import TestCase + +from PyQt4 import QtCore, QtGui, QtTest + +from openlp.core.common import Registry +from openlp.core.ui.shortcutlistform import ShortcutListForm +from tests.interfaces import patch +from tests.helpers.testmixin import TestMixin + + +class TestShortcutform(TestCase, TestMixin): + + def setUp(self): + """ + Create the UI + """ + Registry.create() + self.get_application() + self.main_window = QtGui.QMainWindow() + Registry().register('main_window', self.main_window) + self.form = ShortcutListForm() + + def tearDown(self): + """ + Delete all the C++ objects at the end so that we don't have a segfault + """ + del self.form + del self.main_window + + def adjust_button_test(self): + """ + Test the _adjust_button() method + """ + # GIVEN: A button. + button = QtGui.QPushButton() + checked = True + enabled = True + text = "new!" + + # WHEN: Call the method. + with patch('PyQt4.QtGui.QPushButton.setChecked') as mocked_check_method: + self.form._adjust_button(button, checked, enabled, text) + + # THEN: The button should be changed. + self.assertEqual(button.text(), text, "The text should match.") + mocked_check_method.assert_called_once_with(True) + self.assertEqual(button.isEnabled(), enabled, "The button should be disabled.") \ No newline at end of file diff --git a/tests/resources/easyworshipsongs/test1.ews b/tests/resources/easyworshipsongs/test1.ews new file mode 100644 index 000000000..2cb9676f1 Binary files /dev/null and b/tests/resources/easyworshipsongs/test1.ews differ