From 1cf314007393dc2abff6bc7de6e599590e6892ec Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Wed, 1 Jan 2014 00:27:27 +0200 Subject: [PATCH 1/7] Reworked SongSelect branch after a major refactor in trunk. To recap: - Added a standalone CCLI SongSelect importer - Add icons to the import and export menu items - Move Theme import and export up with Settings --- openlp/core/lib/__init__.py | 1 + openlp/core/lib/historycombobox.py | 91 ++++ openlp/core/ui/__init__.py | 35 +- openlp/core/ui/mainwindow.py | 32 +- openlp/plugins/songs/forms/editsongdialog.py | 23 +- .../plugins/songs/forms/songselectdialog.py | 251 ++++++++++ openlp/plugins/songs/forms/songselectform.py | 473 ++++++++++++++++++ openlp/plugins/songs/songsplugin.py | 115 +++-- resources/images/general_back.png | Bin 0 -> 907 bytes resources/images/general_find.png | Bin 0 -> 942 bytes resources/images/openlp-2.qrc | 2 + 11 files changed, 934 insertions(+), 89 deletions(-) create mode 100644 openlp/core/lib/historycombobox.py create mode 100644 openlp/plugins/songs/forms/songselectdialog.py create mode 100755 openlp/plugins/songs/forms/songselectform.py create mode 100644 resources/images/general_back.png create mode 100644 resources/images/general_find.png diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index b5f2b1cee..d80978901 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -345,3 +345,4 @@ from .dockwidget import OpenLPDockWidget from .imagemanager import ImageManager from .renderer import Renderer from .mediamanageritem import MediaManagerItem +from .historycombobox import HistoryComboBox diff --git a/openlp/core/lib/historycombobox.py b/openlp/core/lib/historycombobox.py new file mode 100644 index 000000000..b6d06ff9f --- /dev/null +++ b/openlp/core/lib/historycombobox.py @@ -0,0 +1,91 @@ +# -*- 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 # +############################################################################### +""" +The :mod:`~openlp.core.lib.historycombobox` module contains the HistoryComboBox widget +""" + +from PyQt4 import QtCore, QtGui + + +class HistoryComboBox(QtGui.QComboBox): + """ + The :class:`~openlp.core.lib.historycombobox.HistoryComboBox` widget emulates the QLineEdit ``returnPressed`` signal + for when the :kbd:`Enter` or :kbd:`Return` keys are pressed, and saves anything that is typed into the edit box into + its list. + """ + returnPressed = QtCore.pyqtSignal() + + def __init__(self, parent=None): + """ + Initialise the combo box, setting duplicates to False and the insert policy to insert items at the top. + + :param parent: The parent widget + """ + super().__init__(parent) + self.setDuplicatesEnabled(False) + self.setEditable(True) + self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred) + self.setInsertPolicy(QtGui.QComboBox.InsertAtTop) + + def keyPressEvent(self, event): + """ + Override the inherited keyPressEvent method to emit the ``returnPressed`` signal and to save the current text to + the dropdown list. + + :param event: The keyboard event + """ + # Handle Enter and Return ourselves + if event.key() == QtCore.Qt.Key_Enter or event.key() == QtCore.Qt.Key_Return: + # Emit the returnPressed signal + self.returnPressed.emit() + # Save the current text to the dropdown list + if self.currentText() and self.findText(self.currentText()) == -1: + self.insertItem(0, self.currentText()) + # Let the parent handle any keypress events + super().keyPressEvent(event) + + def focusOutEvent(self, event): + """ + Override the inherited focusOutEvent to save the current text to the dropdown list. + + :param event: The focus event + """ + # Save the current text to the dropdown list + if self.currentText() and self.findText(self.currentText()) == -1: + self.insertItem(0, self.currentText()) + # Let the parent handle any keypress events + super().focusOutEvent(event) + + def getItems(self): + """ + Get all the items from the history + + :return: A list of strings + """ + return [self.itemText(i) for i in range(self.count())] diff --git a/openlp/core/ui/__init__.py b/openlp/core/ui/__init__.py index 677c65ab1..b2b29ebe9 100644 --- a/openlp/core/ui/__init__.py +++ b/openlp/core/ui/__init__.py @@ -29,6 +29,7 @@ """ The :mod:`ui` module provides the core user interface for OpenLP """ +from PyQt4 import QtGui class HideMode(object): @@ -77,6 +78,29 @@ class DisplayControllerType(object): Plugin = 2 +class SingleColumnTableWidget(QtGui.QTableWidget): + """ + Class to for a single column table widget to use for the verse table widget. + """ + + def __init__(self, parent): + """ + Constructor + """ + super(SingleColumnTableWidget, self).__init__(parent) + self.horizontalHeader().setVisible(False) + self.setColumnCount(1) + + def resizeEvent(self, event): + """ + Resize the first column together with the widget. + """ + QtGui.QTableWidget.resizeEvent(self, event) + if self.columnCount(): + self.setColumnWidth(0, event.size().width()) + self.resizeRowsToContents() + + from .firsttimeform import FirstTimeForm from .firsttimelanguageform import FirstTimeLanguageForm from .themelayoutform import ThemeLayoutForm @@ -102,8 +126,9 @@ from .servicemanager import ServiceManager from .thememanagerhelper import ThemeManagerHelper from .thememanager import ThemeManager -__all__ = ['SplashScreen', 'AboutForm', 'SettingsForm', 'MainDisplay', 'SlideController', 'ServiceManager', - 'ThemeManager', 'MediaDockManager', 'ServiceItemEditForm', 'FirstTimeForm', 'FirstTimeLanguageForm', 'ThemeForm', - 'ThemeLayoutForm', 'FileRenameForm', 'StartTimeForm', 'MainDisplay', 'Display', 'ServiceNoteForm', - 'SlideController', 'DisplayController', 'GeneralTab', 'ThemesTab', 'AdvancedTab', 'PluginForm', - 'FormattingTagForm', 'ShortcutListForm', 'FormattingTagController', 'ThemeManagerHelper'] +__all__ = ['SplashScreen', 'AboutForm', 'SettingsForm', 'MainDisplay', 'SlideController', 'ServiceManager', 'ThemeForm', + 'ThemeManager', 'MediaDockManager', 'ServiceItemEditForm', 'FirstTimeForm', 'FirstTimeLanguageForm', + 'Display', 'ServiceNoteForm', 'ThemeLayoutForm', 'FileRenameForm', 'StartTimeForm', 'MainDisplay', + 'SlideController', 'DisplayController', 'GeneralTab', 'ThemesTab', 'AdvancedTab', 'PluginForm', + 'FormattingTagForm', 'ShortcutListForm', 'FormattingTagController', 'ThemeManagerHelper', + 'SingleColumnTableWidget'] diff --git a/openlp/core/ui/mainwindow.py b/openlp/core/ui/mainwindow.py index 46c318061..71bc99ff7 100644 --- a/openlp/core/ui/mainwindow.py +++ b/openlp/core/ui/mainwindow.py @@ -119,8 +119,10 @@ class Ui_MainWindow(object): self.recent_files_menu = QtGui.QMenu(self.file_menu) self.recent_files_menu.setObjectName('recentFilesMenu') self.file_import_menu = QtGui.QMenu(self.file_menu) + self.file_import_menu.setIcon(build_icon(u':/general/general_import.png')) self.file_import_menu.setObjectName('file_import_menu') self.file_export_menu = QtGui.QMenu(self.file_menu) + self.file_export_menu.setIcon(build_icon(u':/general/general_export.png')) self.file_export_menu.setObjectName('file_export_menu') # View Menu self.view_menu = QtGui.QMenu(self.menu_bar) @@ -230,7 +232,7 @@ class Ui_MainWindow(object): main_window, 'modeDefaultItem', checked=False, category=UiStrings().ViewMode, can_shortcuts=True) self.mode_setup_item = create_action(main_window, 'modeSetupItem', checked=False, category=UiStrings().ViewMode, can_shortcuts=True) - self.mode_live_item = create_action(main_window, 'modeLiveItem', checked=True, category=UiStrings().ViewMode, + self.mode_live_item = create_action(main_window, 'modeLiveItem', checked=True, category=UiStrings().ViewMode, can_shortcuts=True) self.mode_group = QtGui.QActionGroup(main_window) self.mode_group.addAction(self.mode_default_item) @@ -239,10 +241,10 @@ class Ui_MainWindow(object): self.mode_default_item.setChecked(True) action_list.add_category(UiStrings().Tools, CategoryOrder.standard_menu) self.tools_add_tool_item = create_action(main_window, - 'toolsAddToolItem', icon=':/tools/tools_add.png', + 'toolsAddToolItem', icon=':/tools/tools_add.png', category=UiStrings().Tools, can_shortcuts=True) self.tools_open_data_folder = create_action(main_window, - 'toolsOpenDataFolder', icon=':/general/general_open.png', + 'toolsOpenDataFolder', icon=':/general/general_open.png', category=UiStrings().Tools, can_shortcuts=True) self.tools_first_time_wizard = create_action(main_window, 'toolsFirstTimeWizard', icon=':/general/general_revert.png', @@ -268,24 +270,24 @@ class Ui_MainWindow(object): language_item = create_action(main_window, key, checked=qm_list[key] == saved_language) add_actions(self.language_group, [language_item]) self.settings_shortcuts_item = create_action(main_window, 'settingsShortcutsItem', - icon=':/system/system_configure_shortcuts.png', + icon=':/system/system_configure_shortcuts.png', category=UiStrings().Settings, can_shortcuts=True) # Formatting Tags were also known as display tags. self.formatting_tag_item = create_action(main_window, 'displayTagItem', - icon=':/system/tag_editor.png', category=UiStrings().Settings, + icon=':/system/tag_editor.png', category=UiStrings().Settings, can_shortcuts=True) self.settings_configure_item = create_action(main_window, 'settingsConfigureItem', - icon=':/system/system_settings.png', can_shortcuts=True, + icon=':/system/system_settings.png', can_shortcuts=True, category=UiStrings().Settings) # Give QT Extra Hint that this is the Preferences Menu Item self.settings_configure_item.setMenuRole(QtGui.QAction.PreferencesRole) - self.settings_import_item = create_action(main_window, 'settingsImportItem', + self.settings_import_item = create_action(main_window, 'settingsImportItem', category=UiStrings().Import, can_shortcuts=True) - self.settings_export_item = create_action(main_window, 'settingsExportItem', + self.settings_export_item = create_action(main_window, 'settingsExportItem', category=UiStrings().Export, can_shortcuts=True) action_list.add_category(UiStrings().Help, CategoryOrder.standard_menu) self.about_item = create_action(main_window, 'aboutItem', icon=':/system/system_about.png', - can_shortcuts=True, category=UiStrings().Help, + can_shortcuts=True, category=UiStrings().Help, triggers=self.on_about_item_clicked) # Give QT Extra Hint that this is an About Menu Item self.about_item.setMenuRole(QtGui.QAction.AboutRole) @@ -302,13 +304,13 @@ class Ui_MainWindow(object): self.web_site_item = create_action(main_window, 'webSiteItem', can_shortcuts=True, category=UiStrings().Help) # Shortcuts not connected to buttons or menu entries. self.search_shortcut_action = create_action(main_window, - 'searchShortcut', can_shortcuts=True, + 'searchShortcut', can_shortcuts=True, category=translate('OpenLP.MainWindow', 'General'), triggers=self.on_search_shortcut_triggered) - add_actions(self.file_import_menu, (self.settings_import_item, None, self.import_theme_item, - self.import_language_item)) - add_actions(self.file_export_menu, (self.settings_export_item, None, self.export_theme_item, - self.export_language_item)) + add_actions(self.file_import_menu, (self.settings_import_item, self.import_theme_item, + self.import_language_item, None)) + add_actions(self.file_export_menu, (self.settings_export_item, self.export_theme_item, + self.export_language_item, None)) add_actions(self.file_menu, (self.file_new_item, self.file_open_item, self.file_save_item, self.file_save_as_item, self.recent_files_menu.menuAction(), None, self.file_import_menu.menuAction(), self.file_export_menu.menuAction(), None, self.print_service_order_item, @@ -650,7 +652,7 @@ class MainWindow(QtGui.QMainWindow, Ui_MainWindow): 'Time Wizard?\n\nRe-running this wizard may make changes to your ' 'current OpenLP configuration and possibly add songs to your ' '#existing songs list and change your default theme.'), - QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Yes | + QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Yes | QtGui.QMessageBox.No), QtGui.QMessageBox.No) if answer == QtGui.QMessageBox.No: diff --git a/openlp/plugins/songs/forms/editsongdialog.py b/openlp/plugins/songs/forms/editsongdialog.py index d0fda031e..71d7df8c0 100644 --- a/openlp/plugins/songs/forms/editsongdialog.py +++ b/openlp/plugins/songs/forms/editsongdialog.py @@ -32,6 +32,7 @@ from PyQt4 import QtCore, QtGui from openlp.core.common import UiStrings, translate from openlp.core.lib import build_icon from openlp.core.lib.ui import create_button_box, create_button +from openlp.core.ui import SingleColumnTableWidget from openlp.plugins.songs.lib.ui import SongStrings @@ -346,25 +347,3 @@ def create_combo_box(parent, name): combo_box.setInsertPolicy(QtGui.QComboBox.NoInsert) combo_box.setObjectName(name) return combo_box - - -class SingleColumnTableWidget(QtGui.QTableWidget): - """ - Class to for a single column table widget to use for the verse table widget. - """ - def __init__(self, parent): - """ - Constructor - """ - super(SingleColumnTableWidget, self).__init__(parent) - self.horizontalHeader().setVisible(False) - self.setColumnCount(1) - - def resizeEvent(self, event): - """ - Resize the first column together with the widget. - """ - QtGui.QTableWidget.resizeEvent(self, event) - if self.columnCount(): - self.setColumnWidth(0, event.size().width()) - self.resizeRowsToContents() diff --git a/openlp/plugins/songs/forms/songselectdialog.py b/openlp/plugins/songs/forms/songselectdialog.py new file mode 100644 index 000000000..5eda9c497 --- /dev/null +++ b/openlp/plugins/songs/forms/songselectdialog.py @@ -0,0 +1,251 @@ +# -*- 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 # +############################################################################### +""" +The :mod:`~openlp.plugins.songs.forms.songselectdialog` module contains the user interface code for the dialog +""" + +from PyQt4 import QtCore, QtGui + +from openlp.core.lib import HistoryComboBox, translate, build_icon +from openlp.core.ui import SingleColumnTableWidget + + +class Ui_SongSelectDialog(object): + """ + The actual Qt components that make up the dialog. + """ + def setup_ui(self, songselect_dialog): + songselect_dialog.setObjectName('songselect_dialog') + songselect_dialog.resize(616, 378) + self.songselect_layout = QtGui.QVBoxLayout(songselect_dialog) + self.songselect_layout.setSpacing(0) + self.songselect_layout.setMargin(0) + self.songselect_layout.setObjectName('songselect_layout') + self.stacked_widget = QtGui.QStackedWidget(songselect_dialog) + self.stacked_widget.setObjectName('stacked_widget') + self.login_page = QtGui.QWidget() + self.login_page.setObjectName('login_page') + self.login_layout = QtGui.QFormLayout(self.login_page) + self.login_layout.setContentsMargins(120, 100, 120, 100) + self.login_layout.setSpacing(8) + self.login_layout.setObjectName('login_layout') + self.notice_layout = QtGui.QHBoxLayout() + self.notice_layout.setObjectName('notice_layout') + self.notice_label = QtGui.QLabel(self.login_page) + self.notice_label.setWordWrap(True) + self.notice_label.setObjectName('notice_label') + self.notice_layout.addWidget(self.notice_label) + self.login_layout.setLayout(0, QtGui.QFormLayout.SpanningRole, self.notice_layout) + self.username_label = QtGui.QLabel(self.login_page) + self.username_label.setObjectName('usernameLabel') + self.login_layout.setWidget(1, QtGui.QFormLayout.LabelRole, self.username_label) + self.username_edit = QtGui.QLineEdit(self.login_page) + self.username_edit.setObjectName('usernameEdit') + self.login_layout.setWidget(1, QtGui.QFormLayout.FieldRole, self.username_edit) + self.password_label = QtGui.QLabel(self.login_page) + self.password_label.setObjectName('passwordLabel') + self.login_layout.setWidget(2, QtGui.QFormLayout.LabelRole, self.password_label) + self.password_edit = QtGui.QLineEdit(self.login_page) + self.password_edit.setEchoMode(QtGui.QLineEdit.Password) + self.password_edit.setObjectName('passwordEdit') + self.login_layout.setWidget(2, QtGui.QFormLayout.FieldRole, self.password_edit) + self.save_password_checkbox = QtGui.QCheckBox(self.login_page) + self.save_password_checkbox.setTristate(False) + self.save_password_checkbox.setObjectName('save_password_checkbox') + self.login_layout.setWidget(3, QtGui.QFormLayout.FieldRole, self.save_password_checkbox) + self.login_button_layout = QtGui.QHBoxLayout() + self.login_button_layout.setSpacing(8) + self.login_button_layout.setContentsMargins(0, -1, -1, -1) + self.login_button_layout.setObjectName('login_button_layout') + self.login_spacer = QtGui.QWidget(self.login_page) + self.login_spacer.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum) + self.login_spacer.setObjectName('login_spacer') + self.login_button_layout.addWidget(self.login_spacer) + self.login_progress_bar = QtGui.QProgressBar(self.login_page) + self.login_progress_bar.setMinimum(0) + self.login_progress_bar.setMaximum(3) + self.login_progress_bar.setValue(0) + self.login_progress_bar.setMinimumWidth(200) + self.login_progress_bar.setVisible(False) + self.login_button_layout.addWidget(self.login_progress_bar) + self.login_button = QtGui.QPushButton(self.login_page) + self.login_button.setIcon(build_icon(':/songs/song_author_edit.png')) + self.login_button.setObjectName('login_button') + self.login_button_layout.addWidget(self.login_button) + self.login_layout.setLayout(4, QtGui.QFormLayout.SpanningRole, self.login_button_layout) + self.stacked_widget.addWidget(self.login_page) + self.search_page = QtGui.QWidget() + self.search_page.setObjectName('search_page') + self.search_layout = QtGui.QVBoxLayout(self.search_page) + self.search_layout.setSpacing(8) + self.search_layout.setMargin(8) + self.search_layout.setObjectName('search_layout') + self.search_input_layout = QtGui.QHBoxLayout() + self.search_input_layout.setSpacing(8) + self.search_input_layout.setObjectName('search_input_layout') + self.search_label = QtGui.QLabel(self.search_page) + self.search_label.setObjectName('search_label') + self.search_input_layout.addWidget(self.search_label) + self.search_combobox = HistoryComboBox(self.search_page) + self.search_combobox.setObjectName('search_combobox') + self.search_input_layout.addWidget(self.search_combobox) + self.search_button = QtGui.QPushButton(self.search_page) + self.search_button.setIcon(build_icon(':/general/general_find.png')) + self.search_button.setObjectName('search_button') + self.search_input_layout.addWidget(self.search_button) + self.search_layout.addLayout(self.search_input_layout) + self.search_progress_bar = QtGui.QProgressBar(self.search_page) + self.search_progress_bar.setMinimum(0) + self.search_progress_bar.setMaximum(3) + self.search_progress_bar.setValue(0) + self.search_progress_bar.setVisible(False) + self.search_layout.addWidget(self.search_progress_bar) + self.search_results_widget = QtGui.QListWidget(self.search_page) + self.search_results_widget.setProperty("showDropIndicator", False) + self.search_results_widget.setAlternatingRowColors(True) + self.search_results_widget.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection) + self.search_results_widget.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) + self.search_results_widget.setObjectName('search_results_widget') + self.search_layout.addWidget(self.search_results_widget) + self.result_count_label = QtGui.QLabel(self.search_page) + self.result_count_label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignCenter) + self.result_count_label.setObjectName('result_count_label') + self.search_layout.addWidget(self.result_count_label) + self.view_layout = QtGui.QHBoxLayout() + self.view_layout.setSpacing(8) + self.view_layout.setObjectName('view_layout') + self.logout_button = QtGui.QPushButton(self.search_page) + self.logout_button.setIcon(build_icon(':/songs/song_author_edit.png')) + self.view_layout.addWidget(self.logout_button) + self.view_spacer = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum) + self.view_layout.addItem(self.view_spacer) + self.view_button = QtGui.QPushButton(self.search_page) + self.view_button.setIcon(build_icon(':/songs/song_search_all.png')) + self.view_button.setObjectName('view_button') + self.view_layout.addWidget(self.view_button) + self.search_layout.addLayout(self.view_layout) + self.stacked_widget.addWidget(self.search_page) + self.song_page = QtGui.QWidget() + self.song_page.setObjectName('song_page') + self.song_layout = QtGui.QGridLayout(self.song_page) + self.song_layout.setMargin(8) + self.song_layout.setSpacing(8) + self.song_layout.setObjectName('song_layout') + self.title_label = QtGui.QLabel(self.song_page) + self.title_label.setObjectName('title_label') + self.song_layout.addWidget(self.title_label, 0, 0, 1, 1) + self.title_edit = QtGui.QLineEdit(self.song_page) + self.title_edit.setReadOnly(True) + self.title_edit.setObjectName('title_edit') + self.song_layout.addWidget(self.title_edit, 0, 1, 1, 1) + self.authors_label = QtGui.QLabel(self.song_page) + self.authors_label.setObjectName('authors_label') + self.song_layout.addWidget(self.authors_label, 0, 2, 1, 1) + self.author_list_widget = QtGui.QListWidget(self.song_page) + self.author_list_widget.setObjectName('author_list_widget') + self.song_layout.addWidget(self.author_list_widget, 0, 3, 3, 1) + self.copyright_label = QtGui.QLabel(self.song_page) + self.copyright_label.setObjectName('copyright_label') + self.song_layout.addWidget(self.copyright_label, 1, 0, 1, 1) + self.copyright_edit = QtGui.QLineEdit(self.song_page) + self.copyright_edit.setReadOnly(True) + self.copyright_edit.setObjectName('copyright_edit') + self.song_layout.addWidget(self.copyright_edit, 1, 1, 1, 1) + self.ccli_label = QtGui.QLabel(self.song_page) + self.ccli_label.setObjectName('ccli_label') + self.song_layout.addWidget(self.ccli_label, 2, 0, 1, 1) + self.ccli_edit = QtGui.QLineEdit(self.song_page) + self.ccli_edit.setReadOnly(True) + self.ccli_edit.setObjectName('ccli_edit') + self.song_layout.addWidget(self.ccli_edit, 2, 1, 1, 1) + self.lyrics_label = QtGui.QLabel(self.song_page) + self.lyrics_label.setAlignment(QtCore.Qt.AlignLeading | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) + self.lyrics_label.setObjectName('lyrics_label') + self.song_layout.addWidget(self.lyrics_label, 3, 0, 1, 1) + self.lyrics_table_widget = SingleColumnTableWidget(self.song_page) + self.lyrics_table_widget.setObjectName('lyrics_table_widget') + self.lyrics_table_widget.setRowCount(0) + self.song_layout.addWidget(self.lyrics_table_widget, 3, 1, 1, 3) + self.song_progress_bar = QtGui.QProgressBar(self.song_page) + self.song_progress_bar.setMinimum(0) + self.song_progress_bar.setMaximum(3) + self.song_progress_bar.setValue(0) + self.song_progress_bar.setVisible(False) + self.song_layout.addWidget(self.song_progress_bar, 4, 0, 1, 4) + self.import_layout = QtGui.QHBoxLayout() + self.import_layout.setObjectName('import_layout') + self.back_button = QtGui.QPushButton(self.song_page) + self.back_button.setIcon(build_icon(':/general/general_back.png')) + self.back_button.setObjectName('back_button') + self.import_layout.addWidget(self.back_button) + self.import_spacer = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum) + self.import_layout.addItem(self.import_spacer) + self.import_button = QtGui.QPushButton(self.song_page) + self.import_button.setIcon(build_icon(':/general/general_import.png')) + self.import_button.setObjectName('import_button') + self.import_layout.addWidget(self.import_button) + self.song_layout.addLayout(self.import_layout, 5, 0, 1, 5) + self.stacked_widget.addWidget(self.song_page) + self.songselect_layout.addWidget(self.stacked_widget) + self.username_label.setBuddy(self.username_edit) + self.password_label.setBuddy(self.password_edit) + self.title_label.setBuddy(self.title_edit) + self.authors_label.setBuddy(self.author_list_widget) + self.copyright_label.setBuddy(self.copyright_edit) + self.ccli_label.setBuddy(self.ccli_edit) + self.lyrics_label.setBuddy(self.lyrics_table_widget) + + self.retranslate_ui(songselect_dialog) + self.stacked_widget.setCurrentIndex(0) + + def retranslate_ui(self, songselect_dialog): + """ + Translate the GUI. + """ + songselect_dialog.setWindowTitle(translate('SongsPlugin.SongSelectForm', 'CCLI SongSelect Importer')) + self.notice_label.setText( + translate('SongsPlugin.SongSelectForm', 'Note: ' + 'An Internet connection is required in order to import songs from CCLI SongSelect.') + ) + self.username_label.setText(translate('SongsPlugin.SongSelectForm', 'Username:')) + self.password_label.setText(translate('SongsPlugin.SongSelectForm', 'Password:')) + self.save_password_checkbox.setText(translate('SongsPlugin.SongSelectForm', 'Save username and password')) + self.login_button.setText(translate('SongsPlugin.SongSelectForm', 'Login')) + self.search_label.setText(translate('SongsPlugin.SongSelectForm', 'Search Text:')) + self.search_button.setText(translate('SongsPlugin.SongSelectForm', 'Search')) + self.result_count_label.setText(translate('SongsPlugin.SongSelectForm', 'Found %s song(s)') % 0) + self.logout_button.setText(translate('SongsPlugin.SongSelectForm', 'Logout')) + self.view_button.setText(translate('SongsPlugin.SongSelectForm', 'View')) + self.title_label.setText(translate('SongsPlugin.SongSelectForm', 'Title:')) + self.authors_label.setText(translate('SongsPlugin.SongSelectForm', 'Author(s):')) + self.copyright_label.setText(translate('SongsPlugin.SongSelectForm', 'Copyright:')) + self.ccli_label.setText(translate('SongsPlugin.SongSelectForm', 'CCLI Number:')) + self.lyrics_label.setText(translate('SongsPlugin.SongSelectForm', 'Lyrics:')) + self.back_button.setText(translate('SongsPlugin.SongSelectForm', 'Back')) + self.import_button.setText(translate('SongsPlugin.SongSelectForm', 'Import')) diff --git a/openlp/plugins/songs/forms/songselectform.py b/openlp/plugins/songs/forms/songselectform.py new file mode 100755 index 000000000..f9efb70bc --- /dev/null +++ b/openlp/plugins/songs/forms/songselectform.py @@ -0,0 +1,473 @@ +# -*- 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 # +############################################################################### +""" +The :mod:`~openlp.plugins.songs.forms.songselectform` module contains the GUI for the SongSelect importer +""" + +import logging +from http.cookiejar import CookieJar +from urllib.parse import urlencode +from urllib.request import HTTPCookieProcessor, HTTPError, build_opener +from html.parser import HTMLParser +from time import sleep + +from PyQt4 import QtCore, QtGui +from bs4 import BeautifulSoup, NavigableString +from openlp.core import Settings + +from openlp.core.common import Registry +from openlp.core.lib import translate +from openlp.plugins.songs.lib import VerseType, clean_song +from openlp.plugins.songs.forms.songselectdialog import Ui_SongSelectDialog +from openlp.plugins.songs.lib.db import Author, Song +from openlp.plugins.songs.lib.xml import SongXML + +USER_AGENT = 'Mozilla/5.0 (Linux; U; Android 4.0.3; en-us; GT-I9000 ' \ + 'Build/IML74K) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 ' \ + 'Mobile Safari/534.30' +BASE_URL = 'https://mobile.songselect.com' +LOGIN_URL = BASE_URL + '/account/login' +LOGOUT_URL = BASE_URL + '/account/logout' +SEARCH_URL = BASE_URL + '/search/results' + +log = logging.getLogger(__name__) + + +class SearchWorker(QtCore.QObject): + """ + Run the actual SongSelect search, and notify the GUI when we find each song. + """ + show_info = QtCore.pyqtSignal(str, str) + found_song = QtCore.pyqtSignal(dict) + finished = QtCore.pyqtSignal(list) + quit = QtCore.pyqtSignal() + + def __init__(self, opener, params): + super().__init__() + self.opener = opener + self.params = params + self.html_parser = HTMLParser() + + def _search_and_parse_results(self, params): + params = urlencode(params) + results_page = BeautifulSoup(self.opener.open(SEARCH_URL + '?' + params).read(), 'lxml') + search_results = results_page.find_all('li', 'result pane') + songs = [] + for result in search_results: + song = { + 'title': self.html_parser.unescape(result.find('h3').string), + 'authors': [self.html_parser.unescape(author.string) for author in result.find_all('li')], + 'link': BASE_URL + result.find('a')['href'] + } + self.found_song.emit(song) + songs.append(song) + return songs + + def start(self): + """ + Run a search and then parse the results page of the search. + """ + songs = self._search_and_parse_results(self.params) + search_results = [] + self.params['page'] = 1 + total = 0 + while songs: + search_results.extend(songs) + self.params['page'] += 1 + total += len(songs) + if total >= 1000: + self.show_info.emit( + translate('SongsPlugin.SongSelectForm', 'More than 1000 results'), + translate('SongsPlugin.SongSelectForm', 'Your search has returned more than 1000 results, it has ' + 'been stopped. Please refine your search to fetch better ' + 'results.')) + break + songs = self._search_and_parse_results(self.params) + self.finished.emit(search_results) + self.quit.emit() + + +class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): + """ + The :class:`SongSelectForm` class is the SongSelect dialog. + """ + + def __init__(self, parent=None, plugin=None, db_manager=None): + QtGui.QDialog.__init__(self, parent) + self.setup_ui(self) + self.thread = None + self.worker = None + self.song_count = 0 + self.song = None + self.plugin = plugin + self.db_manager = db_manager + self.html_parser = HTMLParser() + self.opener = build_opener(HTTPCookieProcessor(CookieJar())) + self.opener.addheaders = [('User-Agent', USER_AGENT)] + self.save_password_checkbox.toggled.connect(self.on_save_password_checkbox_toggled) + self.login_button.clicked.connect(self.on_login_button_clicked) + self.search_button.clicked.connect(self.on_search_button_clicked) + self.search_combobox.returnPressed.connect(self.on_search_button_clicked) + self.logout_button.clicked.connect(self.done) + self.search_results_widget.itemDoubleClicked.connect(self.on_search_results_widget_double_clicked) + self.search_results_widget.itemSelectionChanged.connect(self.on_search_results_widget_selection_changed) + self.view_button.clicked.connect(self.on_view_button_clicked) + self.back_button.clicked.connect(self.on_back_button_clicked) + self.import_button.clicked.connect(self.on_import_button_clicked) + + def exec_(self): + """ + Execute the dialog. This method sets everything back to its initial + values. + """ + self.stacked_widget.setCurrentIndex(0) + self.username_edit.setEnabled(True) + self.password_edit.setEnabled(True) + self.save_password_checkbox.setEnabled(True) + self.search_combobox.clearEditText() + self.search_combobox.clear() + self.search_results_widget.clear() + self.view_button.setEnabled(False) + if Settings().contains(self.plugin.settings_section + '/songselect password'): + self.username_edit.setText(Settings().value(self.plugin.settings_section + '/songselect username')) + self.password_edit.setText(Settings().value(self.plugin.settings_section + '/songselect password')) + self.save_password_checkbox.setChecked(True) + if Settings().contains(self.plugin.settings_section + '/songselect searches'): + self.search_combobox.addItems( + Settings().value(self.plugin.settings_section + '/songselect searches').split('|')) + self.username_edit.setFocus() + return QtGui.QDialog.exec_(self) + + def done(self, r): + """ + Log out of SongSelect. + + :param r: The result of the dialog. + """ + log.debug('Closing SongSelectForm') + if self.stacked_widget.currentIndex() > 0: + progress_dialog = QtGui.QProgressDialog( + translate('SongsPlugin.SongSelectForm', 'Logging out...'), '', 0, 2, self) + progress_dialog.setWindowModality(QtCore.Qt.WindowModal) + progress_dialog.setCancelButton(None) + progress_dialog.setValue(1) + progress_dialog.show() + progress_dialog.setFocus() + self.main_window.application.process_events() + sleep(0.5) + self.main_window.application.process_events() + self.opener.open(LOGOUT_URL) + self.main_window.application.process_events() + progress_dialog.setValue(2) + return QtGui.QDialog.done(self, r) + + def _get_main_window(self): + if not hasattr(self, '_main_window'): + self._main_window = Registry().get('main_window') + return self._main_window + + main_window = property(_get_main_window) + + def _view_song(self, current_item): + if not current_item: + return + else: + current_item = current_item.data(QtCore.Qt.UserRole) + self.song_progress_bar.setVisible(True) + self.import_button.setEnabled(False) + self.back_button.setEnabled(False) + self.title_edit.setText('') + self.title_edit.setEnabled(False) + self.copyright_edit.setText('') + self.copyright_edit.setEnabled(False) + self.ccli_edit.setText('') + self.ccli_edit.setEnabled(False) + self.author_list_widget.clear() + self.author_list_widget.setEnabled(False) + self.lyrics_table_widget.clear() + self.lyrics_table_widget.setRowCount(0) + self.lyrics_table_widget.setEnabled(False) + self.stacked_widget.setCurrentIndex(2) + song = {} + for key, value in current_item.items(): + song[key] = value + self.song_progress_bar.setValue(1) + self.main_window.application.process_events() + song_page = BeautifulSoup(self.opener.open(song['link']).read(), 'lxml') + self.song_progress_bar.setValue(2) + self.main_window.application.process_events() + try: + lyrics_page = BeautifulSoup(self.opener.open(song['link'] + '/lyrics').read(), 'lxml') + except HTTPError: + lyrics_page = None + self.song_progress_bar.setValue(3) + self.main_window.application.process_events() + song['copyright'] = '/'.join([li.string for li in song_page.find('ul', 'copyright').find_all('li')]) + song['copyright'] = self.html_parser.unescape(song['copyright']) + song['ccli_number'] = song_page.find('ul', 'info').find('li').string.split(':')[1].strip() + song['verses'] = [] + if lyrics_page: + verses = lyrics_page.find('section', 'lyrics').find_all('p') + verse_labels = lyrics_page.find('section', 'lyrics').find_all('h3') + for counter in range(len(verses)): + verse = {'label': verse_labels[counter].string, 'lyrics': ''} + for v in verses[counter].contents: + if isinstance(v, NavigableString): + verse['lyrics'] = verse['lyrics'] + v.string + else: + verse['lyrics'] += '\n' + verse['lyrics'] = verse['lyrics'].strip(' \n\r\t') + song['verses'].append(self.html_parser.unescape(verse)) + self.title_edit.setText(song['title']) + self.copyright_edit.setText(song['copyright']) + self.ccli_edit.setText(song['ccli_number']) + for author in song['authors']: + QtGui.QListWidgetItem(self.html_parser.unescape(author), self.author_list_widget) + for counter, verse in enumerate(song['verses']): + log.debug('Verse type: %s', verse['label']) + self.lyrics_table_widget.setRowCount(self.lyrics_table_widget.rowCount() + 1) + item = QtGui.QTableWidgetItem(verse['lyrics']) + item.setData(QtCore.Qt.UserRole, verse['label']) + item.setFlags(item.flags() ^ QtCore.Qt.ItemIsEditable) + self.lyrics_table_widget.setItem(counter, 0, item) + self.lyrics_table_widget.setVerticalHeaderLabels([verse['label'] for verse in song['verses']]) + self.lyrics_table_widget.resizeRowsToContents() + self.title_edit.setEnabled(True) + self.copyright_edit.setEnabled(True) + self.ccli_edit.setEnabled(True) + self.author_list_widget.setEnabled(True) + self.lyrics_table_widget.setEnabled(True) + self.lyrics_table_widget.repaint() + self.import_button.setEnabled(True) + self.back_button.setEnabled(True) + self.song_progress_bar.setVisible(False) + self.song_progress_bar.setValue(0) + self.song = song + self.main_window.application.process_events() + + def on_save_password_checkbox_toggled(self, checked): + """ + Show a warning dialog when the user toggles the save checkbox on or off. + + :param checked: If the combobox is checked or not + """ + if checked and self.login_page.isVisible(): + answer = QtGui.QMessageBox.question( + self, translate('SongsPlugin.SongSelectForm', 'Save Username and Password'), + translate('SongsPlugin.SongSelectForm', 'WARNING: Saving your username and password is INSECURE, your ' + 'password is stored in PLAIN TEXT. Click Yes to save your ' + 'password or No to cancel this.'), + QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Yes | QtGui.QMessageBox.No), QtGui.QMessageBox.No) + if answer == QtGui.QMessageBox.No: + self.save_password_checkbox.setChecked(False) + + def on_login_button_clicked(self): + """ + Log the user in to SongSelect. + """ + self.username_edit.setEnabled(False) + self.password_edit.setEnabled(False) + self.save_password_checkbox.setEnabled(False) + self.login_button.setEnabled(False) + self.login_spacer.setVisible(False) + self.login_progress_bar.setVisible(True) + self.login_progress_bar.setValue(1) + self.main_window.application.process_events() + login_page = BeautifulSoup(self.opener.open(LOGIN_URL).read(), 'lxml') + self.login_progress_bar.setValue(2) + self.main_window.application.process_events() + token_input = login_page.find('input', attrs={'name': '__RequestVerificationToken'}) + data = urlencode({ + '__RequestVerificationToken': token_input['value'], + 'UserName': self.username_edit.text(), + 'Password': self.password_edit.text(), + 'RememberMe': 'false' + }) + posted_page = BeautifulSoup(self.opener.open(LOGIN_URL, data.encode('utf-8')).read(), 'lxml') + self.login_progress_bar.setValue(3) + self.main_window.application.process_events() + if posted_page.find('input', attrs={'name': '__RequestVerificationToken'}): + QtGui.QMessageBox.critical( + self, + translate('SongsPlugin.SongSelectForm', 'Error Logging In'), + translate('SongsPlugin.SongSelectForm', + 'There was a problem logging in, perhaps your username or password is incorrect?') + ) + else: + if self.save_password_checkbox.isChecked(): + Settings().setValue(self.plugin.settings_section + '/songselect username', self.username_edit.text()) + Settings().setValue(self.plugin.settings_section + '/songselect password', self.password_edit.text()) + else: + Settings().remove(self.plugin.settings_section + '/songselect username') + Settings().remove(self.plugin.settings_section + '/songselect password') + self.stacked_widget.setCurrentIndex(1) + self.login_progress_bar.setVisible(False) + self.login_progress_bar.setValue(0) + self.login_spacer.setVisible(True) + self.login_button.setEnabled(True) + self.search_combobox.setFocus() + self.main_window.application.process_events() + + def on_search_button_clicked(self): + """ + Run a search on SongSelect. + """ + self.view_button.setEnabled(False) + self.search_button.setEnabled(False) + self.search_progress_bar.setVisible(True) + self.search_progress_bar.setMinimum(0) + self.search_progress_bar.setMaximum(0) + self.search_progress_bar.setValue(0) + self.search_results_widget.clear() + self.result_count_label.setText(translate('SongsPlugin.SongSelectForm', 'Found %s song(s)') % self.song_count) + self.main_window.application.process_events() + self.song_count = 0 + search_history = self.search_combobox.getItems() + Settings().setValue(self.plugin.settings_section + '/songselect searches', '|'.join(search_history)) + + # Create thread and run search + self.thread = QtCore.QThread() + self.worker = SearchWorker(self.opener, {'SearchTerm': self.search_combobox.currentText(), + 'allowredirect': 'false'}) + self.worker.moveToThread(self.thread) + self.thread.started.connect(self.worker.start) + self.worker.show_info.connect(self.on_search_show_info) + self.worker.found_song.connect(self.on_search_found_song) + self.worker.finished.connect(self.on_search_finished) + self.worker.quit.connect(self.thread.quit) + self.worker.quit.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + self.thread.start() + + def on_search_show_info(self, title, message): + """ + Show an informational message from the search thread + :param title: + :param message: + """ + QtGui.QMessageBox.information(self, title, message) + + def on_search_found_song(self, song): + """ + Add a song to the list when one is found. + :param song: + """ + log.debug('SongSelect (title = "%s"), (link = "%s")', song['title'], song['link']) + self.song_count += 1 + self.result_count_label.setText(translate('SongsPlugin.SongSelectForm', 'Found %s song(s)') % self.song_count) + item_title = song['title'] + ' (' + ', '.join(song['authors']) + ')' + song_item = QtGui.QListWidgetItem(item_title, self.search_results_widget) + song_item.setData(QtCore.Qt.UserRole, song) + + def on_search_finished(self, songs): + """ + Slot which is called when the search is completed. + :param songs: + """ + self.main_window.application.process_events() + self.search_progress_bar.setVisible(False) + self.search_button.setEnabled(True) + self.main_window.application.process_events() + + def on_search_results_widget_selection_changed(self): + """ + Enable or disable the view button when the selection changes. + """ + self.view_button.setEnabled(len(self.search_results_widget.selectedItems()) > 0) + + def on_view_button_clicked(self): + """ + View a song from SongSelect. + """ + self._view_song(self.search_results_widget.currentItem()) + + def on_search_results_widget_double_clicked(self, current_item): + """ + View a song from SongSelect + :param current_item: + """ + self._view_song(current_item) + + def on_back_button_clicked(self): + """ + Go back to the search page. + """ + self.stacked_widget.setCurrentIndex(1) + self.search_combobox.setFocus() + + def on_import_button_clicked(self): + """ + Import a song from SongSelect. + """ + song = Song.populate( + title=self.song['title'], + copyright=self.song['copyright'], + ccli_number=self.song['ccli_number'] + ) + song_xml = SongXML() + verse_order = [] + for verse in self.song['verses']: + verse_type, verse_number = verse['label'].split(' ')[:2] + verse_type = VerseType.from_loose_input(verse_type) + verse_number = int(verse_number) + song_xml.add_verse_to_lyrics( + VerseType.tags[verse_type], + verse_number, + verse['lyrics'] + ) + verse_order.append('%s%s' % (VerseType.tags[verse_type], verse_number)) + song.verse_order = ' '.join(verse_order) + song.lyrics = song_xml.extract_xml() + clean_song(self.db_manager, song) + self.db_manager.save_object(song) + song.authors = [] + for author_name in self.song['authors']: + #author_name = unicode(author_name) + author = self.db_manager.get_object_filtered(Author, Author.display_name == author_name) + if not author: + author = Author.populate( + first_name=author_name.rsplit(' ', 1)[0], + last_name=author_name.rsplit(' ', 1)[1], + display_name=author_name + ) + song.authors.append(author) + self.db_manager.save_object(song) + question_dialog = QtGui.QMessageBox() + question_dialog.setWindowTitle(translate('SongsPlugin.SongSelectForm', 'Song Imported')) + question_dialog.setText(translate('SongsPlugin.SongSelectForm', 'Your song has been imported, would you like ' + 'to exit now, or import more songs?')) + question_dialog.addButton(QtGui.QPushButton(translate('SongsPlugin.SongSelectForm', 'Import More Songs')), + QtGui.QMessageBox.YesRole) + question_dialog.addButton(QtGui.QPushButton(translate('SongsPlugin.SongSelectForm', 'Exit Now')), + QtGui.QMessageBox.NoRole) + if question_dialog.exec_() == QtGui.QMessageBox.Yes: + self.on_back_button_clicked() + else: + self.main_window.application.process_events() + self.done(QtGui.QDialog.Accepted) diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index 36fd92237..4c6dc8106 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -38,11 +38,13 @@ import sqlite3 from PyQt4 import QtCore, QtGui -from openlp.core.common import UiStrings, translate +from openlp.core.common import UiStrings, Registry, translate from openlp.core.lib import Plugin, StringContent, build_icon from openlp.core.lib.db import Manager from openlp.core.lib.ui import create_action from openlp.core.utils.actions import ActionList +from openlp.plugins.songs.forms.duplicatesongremovalform import DuplicateSongRemovalForm +from openlp.plugins.songs.forms.songselectform import SongSelectForm from openlp.plugins.songs.lib import clean_song, upgrade from openlp.plugins.songs.lib.db import init_schema, Song from openlp.plugins.songs.lib.mediaitem import SongSearch @@ -50,30 +52,29 @@ from openlp.plugins.songs.lib.importer import SongFormat from openlp.plugins.songs.lib.olpimport import OpenLPSongImport from openlp.plugins.songs.lib.mediaitem import SongMediaItem from openlp.plugins.songs.lib.songstab import SongsTab -from openlp.plugins.songs.forms.duplicatesongremovalform import DuplicateSongRemovalForm log = logging.getLogger(__name__) __default_settings__ = { - 'songs/db type': 'sqlite', - 'songs/last search type': SongSearch.Entire, - 'songs/last import type': SongFormat.OpenLyrics, - 'songs/update service on edit': False, - 'songs/search as type': False, - 'songs/add song from service': True, - 'songs/display songbar': True, - 'songs/last directory import': '', - 'songs/last directory export': '' - } + 'songs/db type': 'sqlite', + 'songs/last search type': SongSearch.Entire, + 'songs/last import type': SongFormat.OpenLyrics, + 'songs/update service on edit': False, + 'songs/search as type': False, + 'songs/add song from service': True, + 'songs/display songbar': True, + 'songs/last directory import': '', + 'songs/last directory export': '', + 'songs/songselect username': '', + 'songs/songselect password': '', + 'songs/songselect searches': '' +} class SongsPlugin(Plugin): """ - This is the number 1 plugin, if importance were placed on any - plugins. This plugin enables the user to create, edit and display - songs. Songs are divided into verses, and the verse order can be - specified. Authors, topics and song books can be assigned to songs - as well. + This plugin enables the user to create, edit and display songs. Songs are divided into verses, and the verse order + can be specified. Authors, topics and song books can be assigned to songs as well. """ log.info('Song Plugin loaded') @@ -86,6 +87,7 @@ class SongsPlugin(Plugin): self.weight = -10 self.icon_path = ':/plugins/plugin_songs.png' self.icon = build_icon(self.icon_path) + self.songselect_form = None def check_pre_conditions(self): """ @@ -94,8 +96,12 @@ class SongsPlugin(Plugin): return self.manager.session is not None def initialise(self): + """ + Initialise the plugin + """ log.info('Songs Initialising') super(SongsPlugin, self).initialise() + self.songselect_form = SongSelectForm(Registry().get('main_window'), self, self.manager) self.song_import_item.setVisible(True) self.song_export_item.setVisible(True) self.tools_reindex_item.setVisible(True) @@ -108,28 +114,27 @@ class SongsPlugin(Plugin): def add_import_menu_item(self, import_menu): """ - Give the Songs plugin the opportunity to add items to the - **Import** menu. + Give the Songs plugin the opportunity to add items to the **Import** menu. - ``import_menu`` - The actual **Import** menu item, so that your actions can - use it as their parent. + :param import_menu: The actual **Import** menu item, so that your actions can use it as their parent. """ # Main song import menu item - will eventually be the only one - self.song_import_item = create_action(import_menu, 'songImportItem', - text=translate('SongsPlugin', '&Song'), - tooltip=translate('SongsPlugin', 'Import songs using the import wizard.'), - triggers=self.on_song_import_item_clicked) + self.song_import_item = create_action(import_menu, 'songImportItem', text=translate('SongsPlugin', '&Song'), + tooltip=translate('SongsPlugin', 'Import songs using the import wizard.'), + triggers=self.on_song_import_item_clicked) import_menu.addAction(self.song_import_item) + self.import_songselect_item = create_action( + import_menu, 'import_songselect_item', text=translate('SongsPlugin', 'CCLI SongSelect'), + statustip=translate('SongsPlugin', 'Import songs from CCLI\'s SongSelect service.'), + triggers=self.on_import_songselect_item_triggered + ) + import_menu.addAction(self.import_songselect_item) def add_export_menu_Item(self, export_menu): """ - Give the Songs plugin the opportunity to add items to the - **Export** menu. + Give the Songs plugin the opportunity to add items to the **Export** menu. - ``export_menu`` - The actual **Export** menu item, so that your actions can - use it as their parent. + :param export_menu: The actual **Export** menu item, so that your actions can use it as their parent. """ # Main song import menu item - will eventually be the only one self.song_export_item = create_action(export_menu, 'songExportItem', @@ -140,12 +145,9 @@ class SongsPlugin(Plugin): def add_tools_menu_item(self, tools_menu): """ - Give the Songs plugin the opportunity to add items to the - **Tools** menu. + Give the Songs plugin the opportunity to add items to the **Tools** menu. - ``tools_menu`` - The actual **Tools** menu item, so that your actions can - use it as their parent. + :param tools_menu: The actual **Tools** menu item, so that your actions can use it as their parent. """ log.info('add tools menu') self.tools_reindex_item = create_action(tools_menu, 'toolsReindexItem', @@ -165,11 +167,11 @@ class SongsPlugin(Plugin): """ Rebuild each song. """ - maxSongs = self.manager.get_object_count(Song) - if maxSongs == 0: + max_songs = self.manager.get_object_count(Song) + if max_songs == 0: return progress_dialog = QtGui.QProgressDialog(translate('SongsPlugin', 'Reindexing songs...'), UiStrings().Cancel, - 0, maxSongs, self.main_window) + 0, max_songs, self.main_window) progress_dialog.setWindowTitle(translate('SongsPlugin', 'Reindexing songs')) progress_dialog.setWindowModality(QtCore.Qt.WindowModal) songs = self.manager.get_all_objects(Song) @@ -185,42 +187,61 @@ class SongsPlugin(Plugin): """ DuplicateSongRemovalForm(self).exec_() + def on_import_songselect_item_triggered(self): + """ + Run the SongSelect importer. + """ + self.songselect_form.exec_() + self.media_item.on_search_text_button_clicked() + def on_song_import_item_clicked(self): + """ + Run the song import wizard. + """ if self.media_item: self.media_item.on_import_click() def on_song_export_item_clicked(self): + """ + Run the song export wizard. + """ if self.media_item: self.media_item.on_export_click() def about(self): + """ + Provides information for the plugin manager to display. + + :return: A translatable string with some basic information about the Songs plugin + """ return translate('SongsPlugin', 'Songs Plugin' - '
The songs plugin provides the ability to display and manage songs.') + '
The songs plugin provides the ability to display and manage songs.') def uses_theme(self, theme): """ Called to find out if the song plugin is currently using a theme. - Returns True if the theme is being used, otherwise returns False. + :param theme: The theme to check for usage + :return: True if the theme is being used, otherwise returns False """ if self.manager.get_all_objects(Song, Song.theme_name == theme): return True return False - def rename_theme(self, oldTheme, newTheme): + def rename_theme(self, old_theme, new_theme): """ Renames a theme the song plugin is using making the plugin use the new name. - ``oldTheme`` + ``old_theme`` The name of the theme the plugin should stop using. - ``newTheme`` + ``new_theme`` The new name the plugin should now use. """ - songsUsingTheme = self.manager.get_all_objects(Song, Song.theme_name == oldTheme) - for song in songsUsingTheme: - song.theme_name = newTheme + songs_using_theme = self.manager.get_all_objects(Song, Song.theme_name == old_theme) + for song in songs_using_theme: + song.theme_name = new_theme self.manager.save_object(song) def importSongs(self, format, **kwargs): diff --git a/resources/images/general_back.png b/resources/images/general_back.png new file mode 100644 index 0000000000000000000000000000000000000000..8230340bbb6bfef48e10f8e2079f5cb80b8e66ec GIT binary patch literal 907 zcmV;619bd}P)j;;EmaOqG|jHDZgh1o2e9X_zzv(yH6kdqH_fVDvtwNo z67d+$rElP`DxS9W(wZ1y?HgCmu4$Y*1%h+9+RV+24jvhJ*@BWM#*dA+Y+f~sR+g6_ z?-fBXEbeAeF>kv;s0m`k6^+>d2>KqJme8UJc24<#NfnqGcr+p2n+uqfPukAfGP zMdn@(Jm1GfF~?<45Q_VvtRyD(9=*^3E%HN?wp`q5TcmZx+={ZgaVZ$43BxczX=n;U zhL0$vA`2n3Id~9&F&}MLI`H=HSz>-SMkRncTkWV_jnhlv*aUf9rD;i+rlsL3f)h$Y zcm$PFMqnOr1dPGcQGL!0e9O3DL)!$&S=Zz-!HoJ^8$M$IA&A8QOArmG+C;-CXb6VV zAD|>q^aVZ-xajT6;C;_0XwEmVlmQfH%FX-e`BZ@8l)`o#*tSAEL10-S92zR2Kp><- zz$FSr7Uf>_Jmg)Du#rj(gAHiqUUUwo>Q|f}EU@zaTz-*PWW-|j#-wD zCZ?-5qeu|KgoxHsDfoIXY_6TOkdJnMqUy#yRZ;|Pl~vWvGv_QoDpiJLGJ!-quHhC; z)4-_%&Efolm`M;Tq1Q=_t*n4j^47=h>+1+cQ00Q1klf#`f8R3;m#lOq)-@oR(C|b& zs`}*qX0Xi55zkYMpiFlf{ZFs9xr6=l?{!{&^yFbzG!3SXC9a=MsD;r)@e2*Sxcypk zvF!1~uH)Czr^G;iUjzp+@XwSSzj#S$-O@MBM_SdSru7P==#Q@DLmxj7yC$9O{a_3Y hxzGLVyAJ#}`U0KDUItL)W}5&2002ovPDHLkV1gOknz;Y~ literal 0 HcmV?d00001 diff --git a/resources/images/general_find.png b/resources/images/general_find.png new file mode 100644 index 0000000000000000000000000000000000000000..1b7a252803dd641279df3e3644458dfd6236afea GIT binary patch literal 942 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H3?x5i&EW)6%*9TgAsieWw;%dH0CG7CJR*yM z%CCbkqm#z$3ZS55iEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$QVa}?O9FgC zT>t<74^)o{^z`)f_4UQX#H6I81Ox;y6+lFUgoN1H*#!j!S;2srnHfkkGBU!|17(41 zCMG7JxB?ge)c{3AL_~mw14&U)Q9eFCYinyA9UXHpu(!82G&FQ}b`A*%DJ?Ap8Xg=R z6dDq2V`kLd-RSSjPM}?~qWp4la`Hl4K)s5Jib_gK$^r~3QetXqYPvFly1Kds>WXFxd=?fK zc6N4-j*c!0%U$yCFn9H*sG>^ z7^Ed7rU$uZCMV>WFy`my7lgT$xGR=hFjcsSR#a3}$2!+I^46pV)YsQHH#fJowsv-Q zc6D|2_4Q3iFqtr6!sN-5XC+zAO14^3p4n_7CO&R(@<-PWx;b{;r*{OsAY=Pz8ke&_znS06ro{rdg; zkDot({`&p<&!4}4|Neu5#9LdY08@aLr;B4q#jULqe1i@dFt}Y-$yHq9p&P()s^Rep z>6<%>j6Je_FSw5#w+*J9U$hD`;NseM zwr8a(R@OxN$NqiSD(}y#mh$M!(tK@UCS$jM0`)!|2bav(mKN*26Ro{|Q;}6_%kSb4 zUF&H+Ezj-QSBEYvJMC(`n?FG(V(S5)mg0$LoDXhLj1^SoOuLv-vzh19+00_0zGK_p wPh=~}x@si1{vex^?&HbFf0{ks_A>q>tI>mI%Ql^-pq$Cz>FVdQ&MBb@0Ez8GlK=n! literal 0 HcmV?d00001 diff --git a/resources/images/openlp-2.qrc b/resources/images/openlp-2.qrc index 42af83f79..6af0e77a5 100644 --- a/resources/images/openlp-2.qrc +++ b/resources/images/openlp-2.qrc @@ -61,6 +61,8 @@ general_email.png general_revert.png general_clone.png + general_find.png + general_back.png slide_close.png From e2fcd3e8cb3bcc815f0651b41ee5f0f55dd567f3 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Wed, 1 Jan 2014 00:56:02 +0200 Subject: [PATCH 2/7] - Fixed up an import issue - Removed old code no longer necessary (from 1.9.4) - PEP8 --- openlp/plugins/songs/lib/__init__.py | 296 +++++++++------------------ 1 file changed, 102 insertions(+), 194 deletions(-) diff --git a/openlp/plugins/songs/lib/__init__.py b/openlp/plugins/songs/lib/__init__.py index 8a74691d4..45dfc5460 100644 --- a/openlp/plugins/songs/lib/__init__.py +++ b/openlp/plugins/songs/lib/__init__.py @@ -53,64 +53,41 @@ APOSTROPHE = re.compile('[\'`’ʻ′]', re.UNICODE) # \# - where # is a single non-alpha character, representing a special symbol # { or } - marking the beginning/end of a group # a run of characters without any \ { } or end-of-line -PATTERN = re.compile(r"(\\\*)?\\([a-z]{1,32})(-?\d{1,10})?[ ]?|\\'([0-9a-f]{2})|\\([^a-z*])|([{}])|[\r\n]+|([^\\{}\r\n]+)", re.I) +PATTERN = re.compile( + r"(\\\*)?\\([a-z]{1,32})(-?\d{1,10})?[ ]?|\\'([0-9a-f]{2})|\\([^a-z*])|([{}])|[\r\n]+|([^\\{}\r\n]+)", re.I) # RTF control words which specify a "destination" to be ignored. DESTINATIONS = frozenset(( - 'aftncn', 'aftnsep', 'aftnsepc', 'annotation', 'atnauthor', - 'atndate', 'atnicn', 'atnid', 'atnparent', 'atnref', 'atntime', - 'atrfend', 'atrfstart', 'author', 'background', 'bkmkend', - 'bkmkstart', 'blipuid', 'buptim', 'category', - 'colorschememapping', 'colortbl', 'comment', 'company', 'creatim', - 'datafield', 'datastore', 'defchp', 'defpap', 'do', 'doccomm', - 'docvar', 'dptxbxtext', 'ebcend', 'ebcstart', 'factoidname', - 'falt', 'fchars', 'ffdeftext', 'ffentrymcr', 'ffexitmcr', - 'ffformat', 'ffhelptext', 'ffl', 'ffname', 'ffstattext', - 'file', 'filetbl', 'fldinst', 'fldtype', 'fname', - 'fontemb', 'fontfile', 'footer', 'footerf', 'footerl', 'footerr', - 'footnote', 'formfield', 'ftncn', 'ftnsep', 'ftnsepc', 'g', - 'generator', 'gridtbl', 'header', 'headerf', 'headerl', - 'headerr', 'hl', 'hlfr', 'hlinkbase', 'hlloc', 'hlsrc', 'hsv', - 'htmltag', 'info', 'keycode', 'keywords', 'latentstyles', - 'lchars', 'levelnumbers', 'leveltext', 'lfolevel', 'linkval', - 'list', 'listlevel', 'listname', 'listoverride', - 'listoverridetable', 'listpicture', 'liststylename', 'listtable', - 'listtext', 'lsdlockedexcept', 'macc', 'maccPr', 'mailmerge', - 'maln', 'malnScr', 'manager', 'margPr', 'mbar', 'mbarPr', - 'mbaseJc', 'mbegChr', 'mborderBox', 'mborderBoxPr', 'mbox', - 'mboxPr', 'mchr', 'mcount', 'mctrlPr', 'md', 'mdeg', 'mdegHide', - 'mden', 'mdiff', 'mdPr', 'me', 'mendChr', 'meqArr', 'meqArrPr', - 'mf', 'mfName', 'mfPr', 'mfunc', 'mfuncPr', 'mgroupChr', - 'mgroupChrPr', 'mgrow', 'mhideBot', 'mhideLeft', 'mhideRight', - 'mhideTop', 'mhtmltag', 'mlim', 'mlimloc', 'mlimlow', - 'mlimlowPr', 'mlimupp', 'mlimuppPr', 'mm', 'mmaddfieldname', - 'mmath', 'mmathPict', 'mmathPr', 'mmaxdist', 'mmc', 'mmcJc', - 'mmconnectstr', 'mmconnectstrdata', 'mmcPr', 'mmcs', - 'mmdatasource', 'mmheadersource', 'mmmailsubject', 'mmodso', - 'mmodsofilter', 'mmodsofldmpdata', 'mmodsomappedname', - 'mmodsoname', 'mmodsorecipdata', 'mmodsosort', 'mmodsosrc', - 'mmodsotable', 'mmodsoudl', 'mmodsoudldata', 'mmodsouniquetag', - 'mmPr', 'mmquery', 'mmr', 'mnary', 'mnaryPr', 'mnoBreak', - 'mnum', 'mobjDist', 'moMath', 'moMathPara', 'moMathParaPr', - 'mopEmu', 'mphant', 'mphantPr', 'mplcHide', 'mpos', 'mr', - 'mrad', 'mradPr', 'mrPr', 'msepChr', 'mshow', 'mshp', 'msPre', - 'msPrePr', 'msSub', 'msSubPr', 'msSubSup', 'msSubSupPr', 'msSup', - 'msSupPr', 'mstrikeBLTR', 'mstrikeH', 'mstrikeTLBR', 'mstrikeV', - 'msub', 'msubHide', 'msup', 'msupHide', 'mtransp', 'mtype', - 'mvertJc', 'mvfmf', 'mvfml', 'mvtof', 'mvtol', 'mzeroAsc', - 'mzFrodesc', 'mzeroWid', 'nesttableprops', 'nextfile', - 'nonesttables', 'objalias', 'objclass', 'objdata', 'object', - 'objname', 'objsect', 'objtime', 'oldcprops', 'oldpprops', - 'oldsprops', 'oldtprops', 'oleclsid', 'operator', 'panose', - 'password', 'passwordhash', 'pgp', 'pgptbl', 'picprop', 'pict', - 'pn', 'pnseclvl', 'pntext', 'pntxta', 'pntxtb', 'printim', - 'private', 'propname', 'protend', 'protstart', 'protusertbl', - 'pxe', 'result', 'revtbl', 'revtim', 'rsidtbl', 'rxe', 'shp', - 'shpgrp', 'shpinst', 'shppict', 'shprslt', 'shptxt', 'sn', 'sp', - 'staticval', 'stylesheet', 'subject', 'sv', 'svb', 'tc', - 'template', 'themedata', 'title', 'txe', 'ud', 'upr', - 'userprops', 'wgrffmtfilter', 'windowcaption', 'writereservation', - 'writereservhash', 'xe', 'xform', 'xmlattrname', 'xmlattrvalue', - 'xmlclose', 'xmlname', 'xmlnstbl', 'xmlopen')) + 'aftncn', 'aftnsep', 'aftnsepc', 'annotation', 'atnauthor', 'atndate', 'atnicn', 'atnid', 'atnparent', 'atnref', + 'atntime', 'atrfend', 'atrfstart', 'author', 'background', 'bkmkend', 'bkmkstart', 'blipuid', 'buptim', 'category', + 'colorschememapping', 'colortbl', 'comment', 'company', 'creatim', 'datafield', 'datastore', 'defchp', 'defpap', + 'do', 'doccomm', 'docvar', 'dptxbxtext', 'ebcend', 'ebcstart', 'factoidname', 'falt', 'fchars', 'ffdeftext', + 'ffentrymcr', 'ffexitmcr', 'ffformat', 'ffhelptext', 'ffl', 'ffname', 'ffstattext', 'file', 'filetbl', 'fldinst', + 'fldtype', 'fname', 'fontemb', 'fontfile', 'footer', 'footerf', 'footerl', 'footerr', 'footnote', 'formfield', + 'ftncn', 'ftnsep', 'ftnsepc', 'g', 'generator', 'gridtbl', 'header', 'headerf', 'headerl', 'headerr', 'hl', 'hlfr', + 'hlinkbase', 'hlloc', 'hlsrc', 'hsv', 'htmltag', 'info', 'keycode', 'keywords', 'latentstyles', 'lchars', + 'levelnumbers', 'leveltext', 'lfolevel', 'linkval', 'list', 'listlevel', 'listname', 'listoverride', + 'listoverridetable', 'listpicture', 'liststylename', 'listtable', 'listtext', 'lsdlockedexcept', 'macc', 'maccPr', + 'mailmerge', 'maln', 'malnScr', 'manager', 'margPr', 'mbar', 'mbarPr', 'mbaseJc', 'mbegChr', 'mborderBox', + 'mborderBoxPr', 'mbox', 'mboxPr', 'mchr', 'mcount', 'mctrlPr', 'md', 'mdeg', 'mdegHide', 'mden', 'mdiff', 'mdPr', + 'me', 'mendChr', 'meqArr', 'meqArrPr', 'mf', 'mfName', 'mfPr', 'mfunc', 'mfuncPr', 'mgroupChr', 'mgroupChrPr', + 'mgrow', 'mhideBot', 'mhideLeft', 'mhideRight', 'mhideTop', 'mhtmltag', 'mlim', 'mlimloc', 'mlimlow', 'mlimlowPr', + 'mlimupp', 'mlimuppPr', 'mm', 'mmaddfieldname', 'mmath', 'mmathPict', 'mmathPr', 'mmaxdist', 'mmc', 'mmcJc', + 'mmconnectstr', 'mmconnectstrdata', 'mmcPr', 'mmcs', 'mmdatasource', 'mmheadersource', 'mmmailsubject', 'mmodso', + 'mmodsofilter', 'mmodsofldmpdata', 'mmodsomappedname', 'mmodsoname', 'mmodsorecipdata', 'mmodsosort', 'mmodsosrc', + 'mmodsotable', 'mmodsoudl', 'mmodsoudldata', 'mmodsouniquetag', 'mmPr', 'mmquery', 'mmr', 'mnary', 'mnaryPr', + 'mnoBreak', 'mnum', 'mobjDist', 'moMath', 'moMathPara', 'moMathParaPr', 'mopEmu', 'mphant', 'mphantPr', 'mplcHide', + 'mpos', 'mr', 'mrad', 'mradPr', 'mrPr', 'msepChr', 'mshow', 'mshp', 'msPre', 'msPrePr', 'msSub', 'msSubPr', + 'msSubSup', 'msSubSupPr', 'msSup', 'msSupPr', 'mstrikeBLTR', 'mstrikeH', 'mstrikeTLBR', 'mstrikeV', 'msub', + 'msubHide', 'msup', 'msupHide', 'mtransp', 'mtype', 'mvertJc', 'mvfmf', 'mvfml', 'mvtof', 'mvtol', 'mzeroAsc', + 'mzFrodesc', 'mzeroWid', 'nesttableprops', 'nextfile', 'nonesttables', 'objalias', 'objclass', 'objdata', 'object', + 'objname', 'objsect', 'objtime', 'oldcprops', 'oldpprops', 'oldsprops', 'oldtprops', 'oleclsid', 'operator', + 'panose', 'password', 'passwordhash', 'pgp', 'pgptbl', 'picprop', 'pict', 'pn', 'pnseclvl', 'pntext', 'pntxta', + 'pntxtb', 'printim', 'private', 'propname', 'protend', 'protstart', 'protusertbl', 'pxe', 'result', 'revtbl', + 'revtim', 'rsidtbl', 'rxe', 'shp', 'shpgrp', 'shpinst', 'shppict', 'shprslt', 'shptxt', 'sn', 'sp', 'staticval', + 'stylesheet', 'subject', 'sv', 'svb', 'tc', 'template', 'themedata', 'title', 'txe', 'ud', 'upr', 'userprops', + 'wgrffmtfilter', 'windowcaption', 'writereservation', 'writereservhash', 'xe', 'xform', 'xmlattrname', + 'xmlattrvalue', 'xmlclose', 'xmlname', 'xmlnstbl', 'xmlopen' +)) # Translation of some special characters. SPECIAL_CHARS = { '\n': '\n', @@ -142,7 +119,8 @@ SPECIAL_CHARS = { 'ltrmark': '\u200E', 'rtlmark': '\u200F', 'zwj': '\u200D', - 'zwnj': '\u200C'} + 'zwnj': '\u200C' +} CHARSET_MAPPING = { '0': 'cp1252', '128': 'cp932', @@ -156,7 +134,8 @@ CHARSET_MAPPING = { '186': 'cp1257', '204': 'cp1251', '222': 'cp874', - '238': 'cp1250'} + '238': 'cp1250' +} class VerseType(object): @@ -171,14 +150,7 @@ class VerseType(object): Ending = 5 Other = 6 - names = [ - 'Verse', - 'Chorus', - 'Bridge', - 'Pre-Chorus', - 'Intro', - 'Ending', - 'Other'] + names = ['Verse', 'Chorus', 'Bridge', 'Pre-Chorus', 'Intro', 'Ending', 'Other'] tags = [name[0].lower() for name in names] translated_names = [ @@ -197,11 +169,9 @@ class VerseType(object): """ Return the translated UPPERCASE tag for a given tag, used to show translated verse tags in UI - ``verse_tag`` - The string to return a VerseType for - - ``default`` - Default return value if no matching tag is found + :param verse_tag: The string to return a VerseType for + :param default: Default return value if no matching tag is found + :return: A translated UPPERCASE tag """ verse_tag = verse_tag[0].lower() for num, tag in enumerate(VerseType.tags): @@ -217,11 +187,9 @@ class VerseType(object): """ Return the translated name for a given tag - ``verse_tag`` - The string to return a VerseType for - - ``default`` - Default return value if no matching tag is found + :param verse_tag: The string to return a VerseType for + :param default: Default return value if no matching tag is found + :return: Translated name for the given tag """ verse_tag = verse_tag[0].lower() for num, tag in enumerate(VerseType.tags): @@ -237,11 +205,9 @@ class VerseType(object): """ Return the VerseType for a given tag - ``verse_tag`` - The string to return a VerseType for - - ``default`` - Default return value if no matching tag is found + :param verse_tag: The string to return a VerseType for + :param default: Default return value if no matching tag is found + :return: A VerseType of the tag """ verse_tag = verse_tag[0].lower() for num, tag in enumerate(VerseType.tags): @@ -257,11 +223,9 @@ class VerseType(object): """ Return the VerseType for a given tag - ``verse_tag`` - The string to return a VerseType for - - ``default`` - Default return value if no matching tag is found + :param verse_tag: The string to return a VerseType for + :param default: Default return value if no matching tag is found + :return: The VerseType of a translated tag """ verse_tag = verse_tag[0].lower() for num, tag in enumerate(VerseType.translated_tags): @@ -277,11 +241,9 @@ class VerseType(object): """ Return the VerseType for a given string - ``verse_name`` - The string to return a VerseType for - - ``default`` - Default return value if no matching tag is found + :param verse_name: The string to return a VerseType for + :param default: Default return value if no matching tag is found + :return: The VerseType determined from the string """ verse_name = verse_name.lower() for num, name in enumerate(VerseType.names): @@ -294,8 +256,8 @@ class VerseType(object): """ Return the VerseType for a given string - ``verse_name`` - The string to return a VerseType for + :param verse_name: The string to return a VerseType for + :return: A VerseType """ verse_name = verse_name.lower() for num, translation in enumerate(VerseType.translated_names): @@ -307,11 +269,9 @@ class VerseType(object): """ Return the VerseType for a given string - ``verse_name`` - The string to return a VerseType for - - ``default`` - Default return value if no matching tag is found + :param verse_name: The string to return a VerseType for + :param default: Default return value if no matching tag is found + :return: A VerseType """ if len(verse_name) > 1: verse_index = VerseType.from_translated_string(verse_name) @@ -331,22 +291,21 @@ def retrieve_windows_encoding(recommendation=None): Determines which encoding to use on an information source. The process uses both automated detection, which is passed to this method as a recommendation, and user confirmation to return an encoding. - ``recommendation`` - A recommended encoding discovered programmatically for the user to confirm. + :param recommendation: A recommended encoding discovered programmatically for the user to confirm. + :return: A list of recommended encodings, or None """ # map chardet result to compatible windows standard code page - codepage_mapping = {'IBM866': 'cp866', 'TIS-620': 'cp874', - 'SHIFT_JIS': 'cp932', 'GB2312': 'cp936', 'HZ-GB-2312': 'cp936', - 'EUC-KR': 'cp949', 'Big5': 'cp950', 'ISO-8859-2': 'cp1250', - 'windows-1250': 'cp1250', 'windows-1251': 'cp1251', - 'windows-1252': 'cp1252', 'ISO-8859-7': 'cp1253', - 'windows-1253': 'cp1253', 'ISO-8859-8': 'cp1255', - 'windows-1255': 'cp1255'} + codepage_mapping = {'IBM866': 'cp866', 'TIS-620': 'cp874', 'SHIFT_JIS': 'cp932', 'GB2312': 'cp936', + 'HZ-GB-2312': 'cp936', 'EUC-KR': 'cp949', 'Big5': 'cp950', 'ISO-8859-2': 'cp1250', + 'windows-1250': 'cp1250', 'windows-1251': 'cp1251', 'windows-1252': 'cp1252', + 'ISO-8859-7': 'cp1253', 'windows-1253': 'cp1253', 'ISO-8859-8': 'cp1255', + 'windows-1255': 'cp1255'} if recommendation in codepage_mapping: recommendation = codepage_mapping[recommendation] # Show dialog for encoding selection - encodings = [('cp1256', translate('SongsPlugin', 'Arabic (CP-1256)')), + encodings = [ + ('cp1256', translate('SongsPlugin', 'Arabic (CP-1256)')), ('cp1257', translate('SongsPlugin', 'Baltic (CP-1257)')), ('cp1250', translate('SongsPlugin', 'Central European (CP-1250)')), ('cp1251', translate('SongsPlugin', 'Cyrillic (CP-1251)')), @@ -359,7 +318,8 @@ def retrieve_windows_encoding(recommendation=None): ('cp950', translate('SongsPlugin', 'Traditional Chinese (CP-950)')), ('cp1254', translate('SongsPlugin', 'Turkish (CP-1254)')), ('cp1258', translate('SongsPlugin', 'Vietnam (CP-1258)')), - ('cp1252', translate('SongsPlugin', 'Western European (CP-1252)'))] + ('cp1252', translate('SongsPlugin', 'Western European (CP-1252)')) + ] recommended_index = -1 if recommendation: for index in range(len(encodings)): @@ -367,17 +327,20 @@ def retrieve_windows_encoding(recommendation=None): recommended_index = index break if recommended_index > -1: - choice = QtGui.QInputDialog.getItem(None, + choice = QtGui.QInputDialog.getItem( + None, translate('SongsPlugin', 'Character Encoding'), translate('SongsPlugin', 'The codepage setting is responsible\n' - 'for the correct character representation.\nUsually you are fine with the preselected choice.'), + 'for the correct character representation.\n' + 'Usually you are fine with the preselected choice.'), [pair[1] for pair in encodings], recommended_index, False) else: - choice = QtGui.QInputDialog.getItem(None, + choice = QtGui.QInputDialog.getItem( + None, translate('SongsPlugin', 'Character Encoding'), translate('SongsPlugin', 'Please choose the character encoding.\n' - 'The encoding is responsible for the correct character representation.'), - [pair[1] for pair in encodings], 0, False) + 'The encoding is responsible for the correct character representation.'), + [pair[1] for pair in encodings], 0, False) if not choice[1]: return None return next(filter(lambda item: item[1] == choice[0], encodings))[0] @@ -386,6 +349,9 @@ def retrieve_windows_encoding(recommendation=None): def clean_string(string): """ Strips punctuation from the passed string to assist searching. + + :param string: The string to clean + :return: A clean string """ return WHITESPACE.sub(' ', APOSTROPHE.sub('', string)).lower() @@ -393,6 +359,9 @@ def clean_string(string): def clean_title(title): """ Cleans the song title by removing Unicode control chars groups C0 & C1, as well as any trailing spaces. + + :param title: The song title to clean + :return: A clean title """ return CONTROL_CHARS.sub('', title).rstrip() @@ -402,11 +371,8 @@ def clean_song(manager, song): Cleans the search title, rebuilds the search lyrics, adds a default author if the song does not have one and other clean ups. This should always called when a new song is added or changed. - ``manager`` - The song's manager. - - ``song`` - The song object. + :param manager: The song database manager object. + :param song: The song object. """ from .xml import SongXML @@ -419,55 +385,10 @@ def clean_song(manager, song): else: song.alternate_title = '' song.search_title = clean_string(song.title) + '@' + clean_string(song.alternate_title) - # Only do this, if we the song is a 1.9.4 song (or older). - if song.lyrics.find('') != -1: - # Remove the old "language" attribute from lyrics tag (prior to 1.9.5). This is not very important, but this - # keeps the database clean. This can be removed when everybody has cleaned his songs. - song.lyrics = song.lyrics.replace('', '') - verses = SongXML().get_verses(song.lyrics) - song.search_lyrics = ' '.join([clean_string(verse[1]) - for verse in verses]) - # We need a new and clean SongXML instance. - sxml = SongXML() - # Rebuild the song's verses, to remove any wrong verse names (for example translated ones), which might have - # been added prior to 1.9.5. - # List for later comparison. - compare_order = [] - for verse in verses: - verse_type = VerseType.tags[VerseType.from_loose_input(verse[0]['type'])] - sxml.add_verse_to_lyrics( - verse_type, - verse[0]['label'], - verse[1], - verse[0].get('lang') - ) - compare_order.append(('%s%s' % (verse_type, verse[0]['label'])).upper()) - if verse[0]['label'] == '1': - compare_order.append(verse_type.upper()) - song.lyrics = str(sxml.extract_xml(), 'utf-8') - # Rebuild the verse order, to convert translated verse tags, which might have been added prior to 1.9.5. - if song.verse_order: - order = CONTROL_CHARS.sub('', song.verse_order).strip().split() - else: - order = [] - new_order = [] - for verse_def in order: - verse_type = VerseType.tags[ - VerseType.from_loose_input(verse_def[0])] - if len(verse_def) > 1: - new_order.append(('%s%s' % (verse_type, verse_def[1:])).upper()) - else: - new_order.append(verse_type.upper()) - song.verse_order = ' '.join(new_order) - # Check if the verse order contains tags for verses which do not exist. - for order in new_order: - if order not in compare_order: - song.verse_order = '' - break - else: - verses = SongXML().get_verses(song.lyrics) - song.search_lyrics = ' '.join([clean_string(verse[1]) - for verse in verses]) + if isinstance(song.lyrics, bytes): + song.lyrics = str(song.lyrics, encoding='utf8') + 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: @@ -484,17 +405,10 @@ def get_encoding(font, font_table, default_encoding, failed=False): """ Finds an encoding to use. Asks user, if necessary. - ``font`` - The number of currently active font. - - ``font_table`` - Dictionary of fonts and respective encodings. - - ``default_encoding`` - The default encoding to use when font_table is empty or no font is used. - - ``failed`` - A boolean indicating whether the previous encoding didn't work. + :param font: The number of currently active font. + :param font_table: Dictionary of fonts and respective encodings. + :param default_encoding: The default encoding to use when font_table is empty or no font is used. + :param failed: A boolean indicating whether the previous encoding didn't work. """ encoding = None if font in font_table: @@ -512,14 +426,11 @@ def strip_rtf(text, default_encoding=None): """ This function strips RTF control structures and returns an unicode string. - Thanks to Markus Jarderot (MizardX) for this code, used by permission. - http://stackoverflow.com/questions/188545 + Thanks to Markus Jarderot (MizardX) for this code, used by permission. http://stackoverflow.com/questions/188545 - ``text`` - RTF-encoded text, a string. - - ``default_encoding`` - Default encoding to use when no encoding is specified. + :param text: RTF-encoded text, a string. + :param default_encoding: Default encoding to use when no encoding is specified. + :return: A tuple ``(text, encoding)`` where ``text`` is the clean text and ``encoding`` is the detected encoding """ # Current font is the font tag we last met. font = '' @@ -620,20 +531,17 @@ def strip_rtf(text, default_encoding=None): def delete_song(song_id, song_plugin): """ - Deletes a song from the database. Media files associated to the song - are removed prior to the deletion of the song. + Deletes a song from the database. Media files associated to the song are removed prior to the deletion of the song. - ``song_id`` - The ID of the song to delete. - - ``song_plugin`` - The song plugin instance. + :param song_id: The ID of the song to delete. + :param song_plugin: The song plugin instance. """ + save_path = '' media_files = song_plugin.manager.get_all_objects(MediaFile, MediaFile.song_id == song_id) for media_file in media_files: try: os.remove(media_file.file_name) - except: + except OSError: log.exception('Could not remove file: %s', media_file.file_name) try: save_path = os.path.join(AppLocation.get_section_data_path(song_plugin.name), 'audio', str(song_id)) From d2a5e8a4c44a454754f1ecbe1112c54c59a293e7 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Thu, 27 Feb 2014 23:36:33 +0200 Subject: [PATCH 3/7] Refactor SongSelect search into a separate class, and start writing some tests for that class. --- openlp/plugins/songs/forms/songselectform.py | 187 ++++----------- openlp/plugins/songs/lib/songselect.py | 201 ++++++++++++++++ .../songs/test_songbeamerimport.py | 28 +-- .../openlp_plugins/songs/test_songselect.py | 224 ++++++++++++++++++ .../songs/test_songshowplusimport.py | 12 +- tests/helpers/__init__.py | 31 +++ tests/helpers/songfileimport.py | 1 + 7 files changed, 526 insertions(+), 158 deletions(-) create mode 100644 openlp/plugins/songs/lib/songselect.py create mode 100644 tests/functional/openlp_plugins/songs/test_songselect.py create mode 100644 tests/helpers/__init__.py diff --git a/openlp/plugins/songs/forms/songselectform.py b/openlp/plugins/songs/forms/songselectform.py index f9efb70bc..a340978e1 100755 --- a/openlp/plugins/songs/forms/songselectform.py +++ b/openlp/plugins/songs/forms/songselectform.py @@ -31,30 +31,15 @@ The :mod:`~openlp.plugins.songs.forms.songselectform` module contains the GUI fo """ import logging -from http.cookiejar import CookieJar -from urllib.parse import urlencode -from urllib.request import HTTPCookieProcessor, HTTPError, build_opener -from html.parser import HTMLParser from time import sleep from PyQt4 import QtCore, QtGui -from bs4 import BeautifulSoup, NavigableString -from openlp.core import Settings +from openlp.core import Settings from openlp.core.common import Registry from openlp.core.lib import translate -from openlp.plugins.songs.lib import VerseType, clean_song from openlp.plugins.songs.forms.songselectdialog import Ui_SongSelectDialog -from openlp.plugins.songs.lib.db import Author, Song -from openlp.plugins.songs.lib.xml import SongXML - -USER_AGENT = 'Mozilla/5.0 (Linux; U; Android 4.0.3; en-us; GT-I9000 ' \ - 'Build/IML74K) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 ' \ - 'Mobile Safari/534.30' -BASE_URL = 'https://mobile.songselect.com' -LOGIN_URL = BASE_URL + '/account/login' -LOGOUT_URL = BASE_URL + '/account/logout' -SEARCH_URL = BASE_URL + '/search/results' +from openlp.plugins.songs.lib.songselect import SongSelectImport log = logging.getLogger(__name__) @@ -65,53 +50,36 @@ class SearchWorker(QtCore.QObject): """ show_info = QtCore.pyqtSignal(str, str) found_song = QtCore.pyqtSignal(dict) - finished = QtCore.pyqtSignal(list) + finished = QtCore.pyqtSignal() quit = QtCore.pyqtSignal() - def __init__(self, opener, params): + def __init__(self, importer, search_text): super().__init__() - self.opener = opener - self.params = params - self.html_parser = HTMLParser() - - def _search_and_parse_results(self, params): - params = urlencode(params) - results_page = BeautifulSoup(self.opener.open(SEARCH_URL + '?' + params).read(), 'lxml') - search_results = results_page.find_all('li', 'result pane') - songs = [] - for result in search_results: - song = { - 'title': self.html_parser.unescape(result.find('h3').string), - 'authors': [self.html_parser.unescape(author.string) for author in result.find_all('li')], - 'link': BASE_URL + result.find('a')['href'] - } - self.found_song.emit(song) - songs.append(song) - return songs + self.importer = importer + self.search_text = search_text def start(self): """ Run a search and then parse the results page of the search. """ - songs = self._search_and_parse_results(self.params) - search_results = [] - self.params['page'] = 1 - total = 0 - while songs: - search_results.extend(songs) - self.params['page'] += 1 - total += len(songs) - if total >= 1000: - self.show_info.emit( - translate('SongsPlugin.SongSelectForm', 'More than 1000 results'), - translate('SongsPlugin.SongSelectForm', 'Your search has returned more than 1000 results, it has ' - 'been stopped. Please refine your search to fetch better ' - 'results.')) - break - songs = self._search_and_parse_results(self.params) - self.finished.emit(search_results) + songs = self.importer.search(self.search_text, 1000, self._found_song_callback) + if len(songs) >= 1000: + self.show_info.emit( + translate('SongsPlugin.SongSelectForm', 'More than 1000 results'), + translate('SongsPlugin.SongSelectForm', 'Your search has returned more than 1000 results, it has ' + 'been stopped. Please refine your search to fetch better ' + 'results.')) + self.finished.emit() self.quit.emit() + def _found_song_callback(self, song): + """ + A callback used by the paginate function to notify watching processes when it finds a song. + + :param song: The song that was found + """ + self.found_song.emit(song) + class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): """ @@ -126,10 +94,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): self.song_count = 0 self.song = None self.plugin = plugin - self.db_manager = db_manager - self.html_parser = HTMLParser() - self.opener = build_opener(HTTPCookieProcessor(CookieJar())) - self.opener.addheaders = [('User-Agent', USER_AGENT)] + self.song_select_importer = SongSelectImport(db_manager) self.save_password_checkbox.toggled.connect(self.on_save_password_checkbox_toggled) self.login_button.clicked.connect(self.on_login_button_clicked) self.search_button.clicked.connect(self.on_search_button_clicked) @@ -182,7 +147,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): self.main_window.application.process_events() sleep(0.5) self.main_window.application.process_events() - self.opener.open(LOGOUT_URL) + self.song_select_importer.logout() self.main_window.application.process_events() progress_dialog.setValue(2) return QtGui.QDialog.done(self, r) @@ -194,6 +159,14 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): main_window = property(_get_main_window) + def _update_login_progress(self): + self.login_progress_bar.setValue(self.login_progress_bar.value() + 1) + self.main_window.application.process_events() + + def _update_song_progress(self): + self.song_progress_bar.setValue(self.song_progress_bar.value() + 1) + self.main_window.application.process_events() + def _view_song(self, current_item): if not current_item: return @@ -217,40 +190,17 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): song = {} for key, value in current_item.items(): song[key] = value - self.song_progress_bar.setValue(1) + self.song_progress_bar.setValue(0) self.main_window.application.process_events() - song_page = BeautifulSoup(self.opener.open(song['link']).read(), 'lxml') - self.song_progress_bar.setValue(2) - self.main_window.application.process_events() - try: - lyrics_page = BeautifulSoup(self.opener.open(song['link'] + '/lyrics').read(), 'lxml') - except HTTPError: - lyrics_page = None - self.song_progress_bar.setValue(3) - self.main_window.application.process_events() - song['copyright'] = '/'.join([li.string for li in song_page.find('ul', 'copyright').find_all('li')]) - song['copyright'] = self.html_parser.unescape(song['copyright']) - song['ccli_number'] = song_page.find('ul', 'info').find('li').string.split(':')[1].strip() - song['verses'] = [] - if lyrics_page: - verses = lyrics_page.find('section', 'lyrics').find_all('p') - verse_labels = lyrics_page.find('section', 'lyrics').find_all('h3') - for counter in range(len(verses)): - verse = {'label': verse_labels[counter].string, 'lyrics': ''} - for v in verses[counter].contents: - if isinstance(v, NavigableString): - verse['lyrics'] = verse['lyrics'] + v.string - else: - verse['lyrics'] += '\n' - verse['lyrics'] = verse['lyrics'].strip(' \n\r\t') - song['verses'].append(self.html_parser.unescape(verse)) + # Get the full song + self.song_select_importer.get_song(song, self._update_song_progress) + # Update the UI self.title_edit.setText(song['title']) self.copyright_edit.setText(song['copyright']) self.ccli_edit.setText(song['ccli_number']) for author in song['authors']: - QtGui.QListWidgetItem(self.html_parser.unescape(author), self.author_list_widget) + QtGui.QListWidgetItem(author, self.author_list_widget) for counter, verse in enumerate(song['verses']): - log.debug('Verse type: %s', verse['label']) self.lyrics_table_widget.setRowCount(self.lyrics_table_widget.rowCount() + 1) item = QtGui.QTableWidgetItem(verse['lyrics']) item.setData(QtCore.Qt.UserRole, verse['label']) @@ -296,23 +246,12 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): self.save_password_checkbox.setEnabled(False) self.login_button.setEnabled(False) self.login_spacer.setVisible(False) + self.login_progress_bar.setValue(0) self.login_progress_bar.setVisible(True) - self.login_progress_bar.setValue(1) self.main_window.application.process_events() - login_page = BeautifulSoup(self.opener.open(LOGIN_URL).read(), 'lxml') - self.login_progress_bar.setValue(2) - self.main_window.application.process_events() - token_input = login_page.find('input', attrs={'name': '__RequestVerificationToken'}) - data = urlencode({ - '__RequestVerificationToken': token_input['value'], - 'UserName': self.username_edit.text(), - 'Password': self.password_edit.text(), - 'RememberMe': 'false' - }) - posted_page = BeautifulSoup(self.opener.open(LOGIN_URL, data.encode('utf-8')).read(), 'lxml') - self.login_progress_bar.setValue(3) - self.main_window.application.process_events() - if posted_page.find('input', attrs={'name': '__RequestVerificationToken'}): + # Log the user in + if not self.song_select_importer.login( + self.username_edit.text(), self.password_edit.text(), self._update_login_progress): QtGui.QMessageBox.critical( self, translate('SongsPlugin.SongSelectForm', 'Error Logging In'), @@ -340,10 +279,10 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): """ self.view_button.setEnabled(False) self.search_button.setEnabled(False) - self.search_progress_bar.setVisible(True) self.search_progress_bar.setMinimum(0) self.search_progress_bar.setMaximum(0) self.search_progress_bar.setValue(0) + self.search_progress_bar.setVisible(True) self.search_results_widget.clear() self.result_count_label.setText(translate('SongsPlugin.SongSelectForm', 'Found %s song(s)') % self.song_count) self.main_window.application.process_events() @@ -353,8 +292,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): # Create thread and run search self.thread = QtCore.QThread() - self.worker = SearchWorker(self.opener, {'SearchTerm': self.search_combobox.currentText(), - 'allowredirect': 'false'}) + self.worker = SearchWorker(self.song_select_importer, self.search_combobox.currentText()) self.worker.moveToThread(self.thread) self.thread.started.connect(self.worker.start) self.worker.show_info.connect(self.on_search_show_info) @@ -378,16 +316,16 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): Add a song to the list when one is found. :param song: """ - log.debug('SongSelect (title = "%s"), (link = "%s")', song['title'], song['link']) self.song_count += 1 self.result_count_label.setText(translate('SongsPlugin.SongSelectForm', 'Found %s song(s)') % self.song_count) item_title = song['title'] + ' (' + ', '.join(song['authors']) + ')' song_item = QtGui.QListWidgetItem(item_title, self.search_results_widget) song_item.setData(QtCore.Qt.UserRole, song) - def on_search_finished(self, songs): + def on_search_finished(self): """ Slot which is called when the search is completed. + :param songs: """ self.main_window.application.process_events() @@ -410,6 +348,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): def on_search_results_widget_double_clicked(self, current_item): """ View a song from SongSelect + :param current_item: """ self._view_song(current_item) @@ -425,39 +364,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): """ Import a song from SongSelect. """ - song = Song.populate( - title=self.song['title'], - copyright=self.song['copyright'], - ccli_number=self.song['ccli_number'] - ) - song_xml = SongXML() - verse_order = [] - for verse in self.song['verses']: - verse_type, verse_number = verse['label'].split(' ')[:2] - verse_type = VerseType.from_loose_input(verse_type) - verse_number = int(verse_number) - song_xml.add_verse_to_lyrics( - VerseType.tags[verse_type], - verse_number, - verse['lyrics'] - ) - verse_order.append('%s%s' % (VerseType.tags[verse_type], verse_number)) - song.verse_order = ' '.join(verse_order) - song.lyrics = song_xml.extract_xml() - clean_song(self.db_manager, song) - self.db_manager.save_object(song) - song.authors = [] - for author_name in self.song['authors']: - #author_name = unicode(author_name) - author = self.db_manager.get_object_filtered(Author, Author.display_name == author_name) - if not author: - author = Author.populate( - first_name=author_name.rsplit(' ', 1)[0], - last_name=author_name.rsplit(' ', 1)[1], - display_name=author_name - ) - song.authors.append(author) - self.db_manager.save_object(song) + self.song_select_importer.save_song(self.song) question_dialog = QtGui.QMessageBox() question_dialog.setWindowTitle(translate('SongsPlugin.SongSelectForm', 'Song Imported')) question_dialog.setText(translate('SongsPlugin.SongSelectForm', 'Your song has been imported, would you like ' diff --git a/openlp/plugins/songs/lib/songselect.py b/openlp/plugins/songs/lib/songselect.py new file mode 100644 index 000000000..5d9840bcb --- /dev/null +++ b/openlp/plugins/songs/lib/songselect.py @@ -0,0 +1,201 @@ +# -*- 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 # +############################################################################### +""" +The :mod:`~openlp.plugins.songs.lib.songselect` module contains the SongSelect importer itself. +""" +import logging +from http.cookiejar import CookieJar +from urllib.parse import urlencode +from urllib.request import HTTPCookieProcessor, HTTPError, build_opener +from html.parser import HTMLParser + +from bs4 import BeautifulSoup, NavigableString + +from openlp.plugins.songs.lib import Song, VerseType, clean_song, Author +from openlp.plugins.songs.lib.xml import SongXML + +USER_AGENT = 'Mozilla/5.0 (Linux; U; Android 4.0.3; en-us; GT-I9000 ' \ + 'Build/IML74K) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 ' \ + 'Mobile Safari/534.30' +BASE_URL = 'https://mobile.songselect.com' +LOGIN_URL = BASE_URL + '/account/login' +LOGOUT_URL = BASE_URL + '/account/logout' +SEARCH_URL = BASE_URL + '/search/results' + +log = logging.getLogger(__name__) + + +class SongSelectImport(object): + """ + The :class:`~openlp.plugins.songs.lib.songselect.SongSelectImport` class contains all the code which interfaces + with CCLI's SongSelect service and downloads the songs. + """ + def __init__(self, db_manager): + """ + Set up the song select importer + + :param db_manager: The song database manager + """ + self.db_manager = db_manager + self.html_parser = HTMLParser() + self.opener = build_opener(HTTPCookieProcessor(CookieJar())) + self.opener.addheaders = [('User-Agent', USER_AGENT)] + + def login(self, username, password, callback=None): + """ + Log the user into SongSelect. This method takes a username and password, and runs ``callback()`` at various + points which can be used to give the user some form of feedback. + + :param username: SongSelect username + :param password: SongSelect password + :param callback: Method to notify of progress. + :return: True on success, False on failure. + """ + if callback: + callback() + login_page = BeautifulSoup(self.opener.open(LOGIN_URL).read(), 'lxml') + if callback: + callback() + token_input = login_page.find('input', attrs={'name': '__RequestVerificationToken'}) + data = urlencode({ + '__RequestVerificationToken': token_input['value'], + 'UserName': username, + 'Password': password, + 'RememberMe': 'false' + }) + posted_page = BeautifulSoup(self.opener.open(LOGIN_URL, data.encode('utf-8')).read(), 'lxml') + if callback: + callback() + return not posted_page.find('input', attrs={'name': '__RequestVerificationToken'}) + + def logout(self): + """ + Log the user out of SongSelect + """ + self.opener.open(LOGOUT_URL) + + def search(self, search_text, max_results, callback=None): + """ + Set up a search. + + :param search_text: The text to search for. + :param max_results: Maximum number of results to fetch. + :param callback: A method which is called when each song is found, with the song as a parameter. + :return: List of songs + """ + params = {'allowredirect': 'false', 'SearchTerm': search_text} + current_page = 1 + songs = [] + while True: + if current_page > 1: + params['page'] = current_page + results_page = BeautifulSoup(self.opener.open(SEARCH_URL + '?' + urlencode(params)).read(), 'lxml') + search_results = results_page.find_all('li', 'result pane') + if not search_results: + break + for result in search_results: + song = { + 'title': self.html_parser.unescape(result.find('h3').string), + 'authors': [self.html_parser.unescape(author.string) for author in result.find_all('li')], + 'link': BASE_URL + result.find('a')['href'] + } + if callback: + callback(song) + songs.append(song) + if len(songs) >= max_results: + break + current_page += 1 + return songs + + def get_song(self, song, callback=None): + """ + Get the full song from SongSelect + + :param song: + :param callback: + """ + if callback: + callback() + song_page = BeautifulSoup(self.opener.open(song['link']).read(), 'lxml') + if callback: + callback() + try: + lyrics_page = BeautifulSoup(self.opener.open(song['link'] + '/lyrics').read(), 'lxml') + except HTTPError: + lyrics_page = None + if callback: + callback() + song['copyright'] = '/'.join([li.string for li in song_page.find('ul', 'copyright').find_all('li')]) + song['copyright'] = self.html_parser.unescape(song['copyright']) + song['ccli_number'] = song_page.find('ul', 'info').find('li').string.split(':')[1].strip() + song['verses'] = [] + if lyrics_page: + verses = lyrics_page.find('section', 'lyrics').find_all('p') + verse_labels = lyrics_page.find('section', 'lyrics').find_all('h3') + for counter in range(len(verses)): + verse = {'label': verse_labels[counter].string, 'lyrics': ''} + for v in verses[counter].contents: + if isinstance(v, NavigableString): + verse['lyrics'] = verse['lyrics'] + v.string + else: + verse['lyrics'] += '\n' + verse['lyrics'] = verse['lyrics'].strip(' \n\r\t') + song['verses'].append(self.html_parser.unescape(verse)) + for counter, author in enumerate(song['authors']): + song['authors'][counter] = self.html_parser.unescape(author) + + def save_song(self, song): + """ + Save a song to the database, using the db_manager + + :param song: + :return: + """ + db_song = Song.populate(title=song['title'], copyright=song['copyright'], ccli_number=song['ccli_number']) + song_xml = SongXML() + verse_order = [] + for verse in song['verses']: + verse_type, verse_number = verse['label'].split(' ')[:2] + verse_type = VerseType.from_loose_input(verse_type) + verse_number = int(verse_number) + song_xml.add_verse_to_lyrics(VerseType.tags[verse_type], verse_number, verse['lyrics']) + verse_order.append('%s%s' % (VerseType.tags[verse_type], verse_number)) + db_song.verse_order = ' '.join(verse_order) + db_song.lyrics = song_xml.extract_xml() + clean_song(self.db_manager, db_song) + self.db_manager.save_object(db_song) + db_song.authors = [] + for author_name in song['authors']: + author = self.db_manager.get_object_filtered(Author, Author.display_name == author_name) + if not author: + author = Author.populate(first_name=author_name.rsplit(' ', 1)[0], + last_name=author_name.rsplit(' ', 1)[1], + display_name=author_name) + db_song.authors.append(author) + self.db_manager.save_object(db_song) diff --git a/tests/functional/openlp_plugins/songs/test_songbeamerimport.py b/tests/functional/openlp_plugins/songs/test_songbeamerimport.py index dafe7c796..ba976a179 100644 --- a/tests/functional/openlp_plugins/songs/test_songbeamerimport.py +++ b/tests/functional/openlp_plugins/songs/test_songbeamerimport.py @@ -38,20 +38,20 @@ from openlp.plugins.songs.lib.songbeamerimport import SongBeamerImport TEST_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'resources', 'songbeamersongs')) -SONG_TEST_DATA = {'Lobsinget dem Herrn.sng': - {'title': 'GL 1 - Lobsinget dem Herrn', - 'verses': - [('1. Lobsinget dem Herrn,\no preiset Ihn gern!\n' - 'Anbetung und Lob Ihm gebühret.\n', 'v'), - ('2. Lobsingt Seiner Lieb´,\ndie einzig ihn trieb,\n' - 'zu sterben für unsere Sünden!\n', 'v'), - ('3. Lobsingt Seiner Macht!\nSein Werk ist vollbracht:\n' - 'Er sitzet zur Rechten des Vaters.\n', 'v'), - ('4. Lobsingt seiner Treu´,\ndie immerdar neu,\n' - 'bis Er uns zur Herrlichket führet!\n\n', 'v')], - 'song_book_name': 'Glaubenslieder I', - 'song_number': "1"} - } +SONG_TEST_DATA = { + 'Lobsinget dem Herrn.sng': { + 'title': 'GL 1 - Lobsinget dem Herrn', + 'verses': [ + ('1. Lobsinget dem Herrn,\no preiset Ihn gern!\nAnbetung und Lob Ihm gebühret.\n', 'v'), + ('2. Lobsingt Seiner Lieb´,\ndie einzig ihn trieb,\nzu sterben für unsere Sünden!\n', 'v'), + ('3. Lobsingt Seiner Macht!\nSein Werk ist vollbracht:\nEr sitzet zur Rechten des Vaters.\n', 'v'), + ('4. Lobsingt seiner Treu´,\ndie immerdar neu,\nbis Er uns zur Herrlichket führet!\n\n', 'v') + ], + 'song_book_name': 'Glaubenslieder I', + 'song_number': "1" + } +} + class TestSongBeamerImport(TestCase): """ diff --git a/tests/functional/openlp_plugins/songs/test_songselect.py b/tests/functional/openlp_plugins/songs/test_songselect.py new file mode 100644 index 000000000..c848d1206 --- /dev/null +++ b/tests/functional/openlp_plugins/songs/test_songselect.py @@ -0,0 +1,224 @@ +# -*- 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 # +############################################################################### +""" +This module contains tests for the CCLI SongSelect importer. +""" +from unittest import TestCase + +from openlp.plugins.songs.lib.songselect import SongSelectImport, LOGIN_URL, LOGOUT_URL, BASE_URL + +from tests.functional import MagicMock, patch + + +class TestSongSelect(TestCase): + """ + Test the :class:`~openlp.plugins.songs.lib.songselect.SongSelectImport` class + """ + def constructor_test(self): + """ + Test that constructing a basic SongSelectImport object works correctly + """ + # GIVEN: The SongSelectImporter class and a mocked out build_opener + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener: + # WHEN: An object is instantiated + importer = SongSelectImport(None) + + # THEN: The object should have the correct properties + self.assertIsNone(importer.db_manager, 'The db_manager should be None') + self.assertIsNotNone(importer.html_parser, 'There should be a valid html_parser object') + self.assertIsNotNone(importer.opener, 'There should be a valid opener object') + self.assertEqual(1, mocked_build_opener.call_count, 'The build_opener method should have been called once') + + def login_fails_test(self): + """ + Test that when logging in to SongSelect fails, the login method returns False + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, \ + patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + mocked_login_page = MagicMock() + mocked_login_page.find.return_value = {'value': 'blah'} + MockedBeautifulSoup.return_value = mocked_login_page + mock_callback = MagicMock() + importer = SongSelectImport(None) + + # WHEN: The login method is called after being rigged to fail + result = importer.login('username', 'password', mock_callback) + + # THEN: callback was called 3 times, open was called twice, find was called twice, and False was returned + self.assertEqual(3, mock_callback.call_count, 'callback should have been called 3 times') + self.assertEqual(2, mocked_login_page.find.call_count, 'find should have been called twice') + self.assertEqual(2, mocked_opener.open.call_count, 'opener should have been called twice') + self.assertFalse(result, 'The login method should have returned False') + + def login_succeeds_test(self): + """ + Test that when logging in to SongSelect succeeds, the login method returns True + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, \ + patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + mocked_login_page = MagicMock() + mocked_login_page.find.side_effect = [{'value': 'blah'}, None] + MockedBeautifulSoup.return_value = mocked_login_page + mock_callback = MagicMock() + importer = SongSelectImport(None) + + # WHEN: The login method is called after being rigged to fail + result = importer.login('username', 'password', mock_callback) + + # THEN: callback was called 3 times, open was called twice, find was called twice, and True was returned + self.assertEqual(3, mock_callback.call_count, 'callback should have been called 3 times') + self.assertEqual(2, mocked_login_page.find.call_count, 'find should have been called twice') + self.assertEqual(2, mocked_opener.open.call_count, 'opener should have been called twice') + self.assertTrue(result, 'The login method should have returned True') + + def logout_test(self): + """ + Test that when the logout method is called, it logs the user out of SongSelect + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener: + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + importer = SongSelectImport(None) + + # WHEN: The login method is called after being rigged to fail + importer.logout() + + # THEN: The opener is called once with the logout url + self.assertEqual(1, mocked_opener.open.call_count, 'opener should have been called once') + mocked_opener.open.assert_called_with(LOGOUT_URL) + + def search_returns_no_results_test(self): + """ + Test that when the search finds no results, it simply returns an empty list + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, patch( + 'openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + mocked_results_page = MagicMock() + mocked_results_page.find_all.return_value = [] + MockedBeautifulSoup.return_value = mocked_results_page + mock_callback = MagicMock() + importer = SongSelectImport(None) + + # WHEN: The login method is called after being rigged to fail + results = importer.search('text', 1000, mock_callback) + + # THEN: callback was never called, open was called once, find_all was called once, an empty list returned + self.assertEqual(0, mock_callback.call_count, 'callback should not have been called') + self.assertEqual(1, mocked_opener.open.call_count, 'open should have been called once') + self.assertEqual(1, mocked_results_page.find_all.call_count, 'find_all should have been called once') + mocked_results_page.find_all.assert_called_with('li', 'result pane') + self.assertEqual([], results, 'The search method should have returned an empty list') + + def search_returns_two_results_test(self): + """ + Test that when the search finds 2 results, it simply returns a list with 2 results + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, patch( + 'openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + # first search result + mocked_result1 = MagicMock() + mocked_result1.find.side_effect = [MagicMock(string='Title 1'), {'href': '/url1'}] + mocked_result1.find_all.return_value = [MagicMock(string='Author 1-1'), MagicMock(string='Author 1-2')] + # second search result + mocked_result2 = MagicMock() + mocked_result2.find.side_effect = [MagicMock(string='Title 2'), {'href': '/url2'}] + mocked_result2.find_all.return_value = [MagicMock(string='Author 2-1'), MagicMock(string='Author 2-2')] + # rest of the stuff + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + mocked_results_page = MagicMock() + mocked_results_page.find_all.side_effect = [[mocked_result1, mocked_result2], []] + MockedBeautifulSoup.return_value = mocked_results_page + mock_callback = MagicMock() + importer = SongSelectImport(None) + + # WHEN: The login method is called after being rigged to fail + results = importer.search('text', 1000, mock_callback) + + # THEN: callback was never called, open was called once, find_all was called once, an empty list returned + self.assertEqual(2, mock_callback.call_count, 'callback should have been called twice') + self.assertEqual(2, mocked_opener.open.call_count, 'open should have been called twice') + self.assertEqual(2, mocked_results_page.find_all.call_count, 'find_all should have been called twice') + mocked_results_page.find_all.assert_called_with('li', 'result pane') + expected_list = [ + {'title': 'Title 1', 'authors': ['Author 1-1', 'Author 1-2'], 'link': BASE_URL + '/url1'}, + {'title': 'Title 2', 'authors': ['Author 2-1', 'Author 2-2'], 'link': BASE_URL + '/url2'} + ] + self.assertListEqual(expected_list, results, 'The search method should have returned two songs') + + def search_reaches_max_results_test(self): + """ + Test that when the search finds MAX (2) results, it simply returns a list with those (2) + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, patch( + 'openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + # first search result + mocked_result1 = MagicMock() + mocked_result1.find.side_effect = [MagicMock(string='Title 1'), {'href': '/url1'}] + mocked_result1.find_all.return_value = [MagicMock(string='Author 1-1'), MagicMock(string='Author 1-2')] + # second search result + mocked_result2 = MagicMock() + mocked_result2.find.side_effect = [MagicMock(string='Title 2'), {'href': '/url2'}] + mocked_result2.find_all.return_value = [MagicMock(string='Author 2-1'), MagicMock(string='Author 2-2')] + # third search result + mocked_result3 = MagicMock() + mocked_result3.find.side_effect = [MagicMock(string='Title 3'), {'href': '/url3'}] + mocked_result3.find_all.return_value = [MagicMock(string='Author 3-1'), MagicMock(string='Author 3-2')] + # rest of the stuff + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + mocked_results_page = MagicMock() + mocked_results_page.find_all.side_effect = [[mocked_result1, mocked_result2, mocked_result3], []] + MockedBeautifulSoup.return_value = mocked_results_page + mock_callback = MagicMock() + importer = SongSelectImport(None) + + # WHEN: The login method is called after being rigged to fail + results = importer.search('text', 2, mock_callback) + + # THEN: callback was never called, open was called once, find_all was called once, an empty list returned + self.assertEqual(2, mock_callback.call_count, 'callback should have been called twice') + self.assertEqual(2, mocked_opener.open.call_count, 'open should have been called twice') + self.assertEqual(2, mocked_results_page.find_all.call_count, 'find_all should have been called twice') + mocked_results_page.find_all.assert_called_with('li', 'result pane') + expected_list = [{'title': 'Title 1', 'authors': ['Author 1-1', 'Author 1-2'], 'link': BASE_URL + '/url1'}, + {'title': 'Title 2', 'authors': ['Author 2-1', 'Author 2-2'], 'link': BASE_URL + '/url2'}] + self.assertListEqual(expected_list, results, 'The search method should have returned two songs') diff --git a/tests/functional/openlp_plugins/songs/test_songshowplusimport.py b/tests/functional/openlp_plugins/songs/test_songshowplusimport.py index e105cdbe2..6d909c2a9 100644 --- a/tests/functional/openlp_plugins/songs/test_songshowplusimport.py +++ b/tests/functional/openlp_plugins/songs/test_songshowplusimport.py @@ -41,6 +41,7 @@ from tests.functional import patch, MagicMock TEST_PATH = os.path.abspath( os.path.join(os.path.dirname(__file__), '..', '..', '..', 'resources', 'songshowplussongs')) + class TestSongShowPlusFileImport(SongImportTestHelper): def __init__(self, *args, **kwargs): self.importer_class_name = 'SongShowPlusImport' @@ -48,10 +49,13 @@ class TestSongShowPlusFileImport(SongImportTestHelper): super(TestSongShowPlusFileImport, self).__init__(*args, **kwargs) def test_song_import(self): - test_import = self.file_import(os.path.join(TEST_PATH, 'Amazing Grace.sbsong'), - self.load_external_result_data(os.path.join(TEST_PATH, 'Amazing Grace.json'))) - test_import = self.file_import(os.path.join(TEST_PATH, 'Beautiful Garden Of Prayer.sbsong'), - self.load_external_result_data(os.path.join(TEST_PATH, 'Beautiful Garden Of Prayer.json'))) + """ + Test that loading a SongShow Plus file works correctly on various files + """ + self.file_import(os.path.join(TEST_PATH, 'Amazing Grace.sbsong'), + self.load_external_result_data(os.path.join(TEST_PATH, 'Amazing Grace.json'))) + self.file_import(os.path.join(TEST_PATH, 'Beautiful Garden Of Prayer.sbsong'), + self.load_external_result_data(os.path.join(TEST_PATH, 'Beautiful Garden Of Prayer.json'))) class TestSongShowPlusImport(TestCase): diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 000000000..45908d5ea --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1,31 @@ +# -*- 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 # +############################################################################### +""" +The :mod:`~tests.helpers` module provides helper classes for use in the tests. +""" diff --git a/tests/helpers/songfileimport.py b/tests/helpers/songfileimport.py index bc9feae8a..af99bab6a 100644 --- a/tests/helpers/songfileimport.py +++ b/tests/helpers/songfileimport.py @@ -35,6 +35,7 @@ from unittest import TestCase from tests.functional import patch, MagicMock + class SongImportTestHelper(TestCase): """ This class is designed to be a helper class to reduce repition when testing the import of song files. From b094d4867831336dd67f82d8e631b0e494cc8dcf Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Tue, 4 Mar 2014 23:28:15 +0200 Subject: [PATCH 4/7] Add some error handling around the getting a song. Add some more tests. --- openlp/plugins/songs/forms/songselectform.py | 2 +- openlp/plugins/songs/lib/songselect.py | 40 ++++--- tests/functional/__init__.py | 10 +- .../openlp_plugins/songs/test_songselect.py | 102 ++++++++++++++++-- 4 files changed, 126 insertions(+), 28 deletions(-) diff --git a/openlp/plugins/songs/forms/songselectform.py b/openlp/plugins/songs/forms/songselectform.py index a340978e1..566932d42 100755 --- a/openlp/plugins/songs/forms/songselectform.py +++ b/openlp/plugins/songs/forms/songselectform.py @@ -193,7 +193,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): self.song_progress_bar.setValue(0) self.main_window.application.process_events() # Get the full song - self.song_select_importer.get_song(song, self._update_song_progress) + song = self.song_select_importer.get_song(song, self._update_song_progress) # Update the UI self.title_edit.setText(song['title']) self.copyright_edit.setText(song['copyright']) diff --git a/openlp/plugins/songs/lib/songselect.py b/openlp/plugins/songs/lib/songselect.py index 5d9840bcb..9c78680ca 100644 --- a/openlp/plugins/songs/lib/songselect.py +++ b/openlp/plugins/songs/lib/songselect.py @@ -137,38 +137,44 @@ class SongSelectImport(object): """ Get the full song from SongSelect - :param song: - :param callback: + :param song: The song dictionary to update + :param callback: A callback which can be used to indicate progress + :return: The updated song dictionary """ if callback: callback() - song_page = BeautifulSoup(self.opener.open(song['link']).read(), 'lxml') + try: + song_page = BeautifulSoup(self.opener.open(song['link']).read(), 'lxml') + except (TypeError, HTTPError) as e: + log.exception(u'Could not get song from SongSelect, %s', e) + return None if callback: callback() try: lyrics_page = BeautifulSoup(self.opener.open(song['link'] + '/lyrics').read(), 'lxml') - except HTTPError: - lyrics_page = None + except (TypeError, HTTPError): + log.exception(u'Could not get lyrics from SongSelect') + return None if callback: callback() song['copyright'] = '/'.join([li.string for li in song_page.find('ul', 'copyright').find_all('li')]) song['copyright'] = self.html_parser.unescape(song['copyright']) song['ccli_number'] = song_page.find('ul', 'info').find('li').string.split(':')[1].strip() song['verses'] = [] - if lyrics_page: - verses = lyrics_page.find('section', 'lyrics').find_all('p') - verse_labels = lyrics_page.find('section', 'lyrics').find_all('h3') - for counter in range(len(verses)): - verse = {'label': verse_labels[counter].string, 'lyrics': ''} - for v in verses[counter].contents: - if isinstance(v, NavigableString): - verse['lyrics'] = verse['lyrics'] + v.string - else: - verse['lyrics'] += '\n' - verse['lyrics'] = verse['lyrics'].strip(' \n\r\t') - song['verses'].append(self.html_parser.unescape(verse)) + verses = lyrics_page.find('section', 'lyrics').find_all('p') + verse_labels = lyrics_page.find('section', 'lyrics').find_all('h3') + for counter in range(len(verses)): + verse = {'label': verse_labels[counter].string, 'lyrics': ''} + for v in verses[counter].contents: + if isinstance(v, NavigableString): + verse['lyrics'] = verse['lyrics'] + v.string + else: + verse['lyrics'] += '\n' + verse['lyrics'] = verse['lyrics'].strip(' \n\r\t') + song['verses'].append(self.html_parser.unescape(verse)) for counter, author in enumerate(song['authors']): song['authors'][counter] = self.html_parser.unescape(author) + return song def save_song(self, song): """ diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py index f3b85b9b3..d20aae496 100644 --- a/tests/functional/__init__.py +++ b/tests/functional/__init__.py @@ -1,3 +1,7 @@ +# -*- coding: utf-8 -*- +""" +Base directory for tests +""" import sip sip.setapi('QDate', 2) sip.setapi('QDateTime', 2) @@ -11,9 +15,11 @@ import sys from PyQt4 import QtGui if sys.version_info[1] >= 3: - from unittest.mock import MagicMock, patch, mock_open + from unittest.mock import MagicMock, patch, mock_open, call else: - from mock import MagicMock, patch, mock_open + from mock import MagicMock, patch, mock_open, call # Only one QApplication can be created. Use QtGui.QApplication.instance() when you need to "create" a QApplication. application = QtGui.QApplication([]) + +__all__ = ['MagicMock', 'patch', 'mock_open', 'call', 'application'] diff --git a/tests/functional/openlp_plugins/songs/test_songselect.py b/tests/functional/openlp_plugins/songs/test_songselect.py index c848d1206..a7a488102 100644 --- a/tests/functional/openlp_plugins/songs/test_songselect.py +++ b/tests/functional/openlp_plugins/songs/test_songselect.py @@ -30,10 +30,11 @@ This module contains tests for the CCLI SongSelect importer. """ from unittest import TestCase +from urllib.error import URLError -from openlp.plugins.songs.lib.songselect import SongSelectImport, LOGIN_URL, LOGOUT_URL, BASE_URL +from openlp.plugins.songs.lib.songselect import SongSelectImport, LOGOUT_URL, BASE_URL -from tests.functional import MagicMock, patch +from tests.functional import MagicMock, patch, call class TestSongSelect(TestCase): @@ -125,8 +126,8 @@ class TestSongSelect(TestCase): Test that when the search finds no results, it simply returns an empty list """ # GIVEN: A bunch of mocked out stuff and an importer object - with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, patch( - 'openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, \ + patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: mocked_opener = MagicMock() mocked_build_opener.return_value = mocked_opener mocked_results_page = MagicMock() @@ -150,8 +151,8 @@ class TestSongSelect(TestCase): Test that when the search finds 2 results, it simply returns a list with 2 results """ # GIVEN: A bunch of mocked out stuff and an importer object - with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, patch( - 'openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, \ + patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: # first search result mocked_result1 = MagicMock() mocked_result1.find.side_effect = [MagicMock(string='Title 1'), {'href': '/url1'}] @@ -188,8 +189,8 @@ class TestSongSelect(TestCase): Test that when the search finds MAX (2) results, it simply returns a list with those (2) """ # GIVEN: A bunch of mocked out stuff and an importer object - with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, patch( - 'openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, \ + patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: # first search result mocked_result1 = MagicMock() mocked_result1.find.side_effect = [MagicMock(string='Title 1'), {'href': '/url1'}] @@ -222,3 +223,88 @@ class TestSongSelect(TestCase): expected_list = [{'title': 'Title 1', 'authors': ['Author 1-1', 'Author 1-2'], 'link': BASE_URL + '/url1'}, {'title': 'Title 2', 'authors': ['Author 2-1', 'Author 2-2'], 'link': BASE_URL + '/url2'}] self.assertListEqual(expected_list, results, 'The search method should have returned two songs') + + def get_song_page_raises_exception_test(self): + """ + Test that when BeautifulSoup gets a bad song page the get_song() method returns None + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener: + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + mocked_opener.open.read.side_effect = URLError('[Errno -2] Name or service not known') + mocked_callback = MagicMock() + importer = SongSelectImport(None) + + # WHEN: get_song is called + result = importer.get_song({'link': 'link'}, callback=mocked_callback) + + # THEN: The callback should have been called once and None should be returned + mocked_callback.assert_called_with() + self.assertIsNone(result, 'The get_song() method should have returned None') + + def get_song_lyrics_raise_exception_test(self): + """ + Test that when BeautifulSoup gets a bad lyrics page the get_song() method returns None + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener'), \ + patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + MockedBeautifulSoup.side_effect = [None, TypeError('Test Error')] + mocked_callback = MagicMock() + importer = SongSelectImport(None) + + # WHEN: get_song is called + result = importer.get_song({'link': 'link'}, callback=mocked_callback) + + # THEN: The callback should have been called twice and None should be returned + self.assertEqual(2, mocked_callback.call_count, 'The callback should have been called twice') + self.assertIsNone(result, 'The get_song() method should have returned None') + + def get_song_test(self): + """ + Test that the get_song() method returns the correct song details + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener'), \ + patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + mocked_song_page = MagicMock() + mocked_copyright = MagicMock() + mocked_copyright.find_all.return_value = [MagicMock(string='Copyright 1'), MagicMock(string='Copyright 2')] + mocked_song_page.find.side_effect = [ + mocked_copyright, + MagicMock(find=MagicMock(string='CCLI: 123456')) + ] + mocked_lyrics_page = MagicMock() + mocked_find_all = MagicMock() + mocked_find_all.side_effect = [ + [ + MagicMock(contents='The Lord told Noah: there\'s gonna be a floody, floody'), + MagicMock(contents='So, rise and shine, and give God the glory, glory'), + MagicMock(contents='The Lord told Noah to build him an arky, arky') + ], + [MagicMock(string='Verse 1'), MagicMock(string='Chorus'), MagicMock(string='Verse 2')] + ] + mocked_lyrics_page.find.return_value = MagicMock(find_all=mocked_find_all) + MockedBeautifulSoup.side_effect = [mocked_song_page, mocked_lyrics_page] + mocked_callback = MagicMock() + importer = SongSelectImport(None) + fake_song = {'title': 'Title', 'authors': ['Author 1', 'Author 2'], 'link': 'url'} + + # WHEN: get_song is called + result = importer.get_song(fake_song, callback=mocked_callback) + + # THEN: The callback should have been called three times and the song should be returned + self.assertEqual(3, mocked_callback.call_count, 'The callback should have been called twice') + self.assertIsNotNone(result, 'The get_song() method should have returned a song dictionary') + self.assertEqual(2, mocked_lyrics_page.find.call_count, 'The find() method should have been called twice') + self.assertEqual(2, mocked_find_all.call_count, 'The find_all() method should have been called twice') + self.assertEqual([call('section', 'lyrics'), call('section', 'lyrics')], + mocked_lyrics_page.find.call_args_list, + 'The find() method should have been called with the right arguments') + self.assertEqual([call('p'), call('h3')], mocked_find_all.call_args_list, + 'The find_all() method should have been called with the right arguments') + self.assertIn('copyright', result, 'The returned song should have a copyright') + self.assertIn('ccli_number', result, 'The returned song should have a CCLI number') + self.assertIn('verses', result, 'The returned song should have verses') + self.assertEqual(3, len(result['verses']), 'Three verses should have been returned') From 6747dec2b510615204cc2044671fa003c0e44e5c Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Thu, 6 Mar 2014 23:28:13 +0200 Subject: [PATCH 5/7] Tests! Tests! Tests! --- openlp/plugins/songs/lib/songselect.py | 1 + .../openlp_plugins/songs/test_songselect.py | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/openlp/plugins/songs/lib/songselect.py b/openlp/plugins/songs/lib/songselect.py index 9c78680ca..a18b08c8a 100644 --- a/openlp/plugins/songs/lib/songselect.py +++ b/openlp/plugins/songs/lib/songselect.py @@ -205,3 +205,4 @@ class SongSelectImport(object): display_name=author_name) db_song.authors.append(author) self.db_manager.save_object(db_song) + return db_song diff --git a/tests/functional/openlp_plugins/songs/test_songselect.py b/tests/functional/openlp_plugins/songs/test_songselect.py index a7a488102..0b32cff95 100644 --- a/tests/functional/openlp_plugins/songs/test_songselect.py +++ b/tests/functional/openlp_plugins/songs/test_songselect.py @@ -31,6 +31,7 @@ This module contains tests for the CCLI SongSelect importer. """ from unittest import TestCase from urllib.error import URLError +from openlp.plugins.songs.lib import Author, Song from openlp.plugins.songs.lib.songselect import SongSelectImport, LOGOUT_URL, BASE_URL @@ -308,3 +309,74 @@ class TestSongSelect(TestCase): self.assertIn('ccli_number', result, 'The returned song should have a CCLI number') self.assertIn('verses', result, 'The returned song should have verses') self.assertEqual(3, len(result['verses']), 'Three verses should have been returned') + + def save_song_new_author_test(self): + """ + Test that saving a song with a new author performs the correct actions + """ + # GIVEN: A song to save, and some mocked out objects + with patch('openlp.plugins.songs.lib.songselect.clean_song') as mocked_clean_song, \ + patch('openlp.plugins.songs.lib.songselect.Author') as MockedAuthor: + song_dict = { + 'title': 'Arky Arky', + 'authors': ['Public Domain'], + 'verses': [ + {'label': 'Verse 1', 'lyrics': 'The Lord told Noah: there\'s gonna be a floody, floody'}, + {'label': 'Chorus 1', 'lyrics': 'So, rise and shine, and give God the glory, glory'}, + {'label': 'Verse 2', 'lyrics': 'The Lord told Noah to build him an arky, arky'} + ], + 'copyright': 'Public Domain', + 'ccli_number': '123456' + } + MockedAuthor.display_name.__eq__.return_value = False + mocked_db_manager = MagicMock() + mocked_db_manager.get_object_filtered.return_value = None + importer = SongSelectImport(mocked_db_manager) + + # WHEN: The song is saved to the database + result = importer.save_song(song_dict) + + # THEN: The return value should be a Song class and the mocked_db_manager should have been called + self.assertIsInstance(result, Song, 'The returned value should be a Song object') + mocked_clean_song.assert_called_with(mocked_db_manager, result) + self.assertEqual(2, mocked_db_manager.save_object.call_count, + 'The save_object() method should have been called twice') + mocked_db_manager.get_object_filtered.assert_called_with(MockedAuthor, False) + MockedAuthor.populate.assert_called_with(first_name='Public', last_name='Domain', + display_name='Public Domain') + self.assertEqual(1, len(result.authors), 'There should only be one author') + + def save_song_existing_author_test(self): + """ + Test that saving a song with an existing author performs the correct actions + """ + # GIVEN: A song to save, and some mocked out objects + with patch('openlp.plugins.songs.lib.songselect.clean_song') as mocked_clean_song, \ + patch('openlp.plugins.songs.lib.songselect.Author') as MockedAuthor: + song_dict = { + 'title': 'Arky Arky', + 'authors': ['Public Domain'], + 'verses': [ + {'label': 'Verse 1', 'lyrics': 'The Lord told Noah: there\'s gonna be a floody, floody'}, + {'label': 'Chorus 1', 'lyrics': 'So, rise and shine, and give God the glory, glory'}, + {'label': 'Verse 2', 'lyrics': 'The Lord told Noah to build him an arky, arky'} + ], + 'copyright': 'Public Domain', + 'ccli_number': '123456' + } + MockedAuthor.display_name.__eq__.return_value = False + mocked_db_manager = MagicMock() + mocked_db_manager.get_object_filtered.return_value = MagicMock() + importer = SongSelectImport(mocked_db_manager) + + # WHEN: The song is saved to the database + result = importer.save_song(song_dict) + + # THEN: The return value should be a Song class and the mocked_db_manager should have been called + self.assertIsInstance(result, Song, 'The returned value should be a Song object') + mocked_clean_song.assert_called_with(mocked_db_manager, result) + self.assertEqual(2, mocked_db_manager.save_object.call_count, + 'The save_object() method should have been called twice') + mocked_db_manager.get_object_filtered.assert_called_with(MockedAuthor, False) + self.assertEqual(0, MockedAuthor.populate.call_count, 'A new author should not have been instantiated') + self.assertEqual(1, len(result.authors), 'There should only be one author') From b35de747ab6273ae0048a82e13aef50928b2d830 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Tue, 11 Mar 2014 20:58:49 +0200 Subject: [PATCH 6/7] Move HistoryComboBox to openlp.core.common --- openlp/core/common/__init__.py | 2 +- openlp/core/{lib => common}/historycombobox.py | 0 openlp/core/lib/__init__.py | 1 - openlp/plugins/songs/forms/songselectdialog.py | 3 ++- 4 files changed, 3 insertions(+), 3 deletions(-) rename openlp/core/{lib => common}/historycombobox.py (100%) diff --git a/openlp/core/common/__init__.py b/openlp/core/common/__init__.py index 8963213d3..c6f96a616 100644 --- a/openlp/core/common/__init__.py +++ b/openlp/core/common/__init__.py @@ -134,4 +134,4 @@ from .registrymixin import RegistryMixin from .uistrings import UiStrings from .settings import Settings from .applocation import AppLocation - +from .historycombobox import HistoryComboBox diff --git a/openlp/core/lib/historycombobox.py b/openlp/core/common/historycombobox.py similarity index 100% rename from openlp/core/lib/historycombobox.py rename to openlp/core/common/historycombobox.py diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index 197e74312..e6b7f4fe3 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -331,4 +331,3 @@ from .dockwidget import OpenLPDockWidget from .imagemanager import ImageManager from .renderer import Renderer from .mediamanageritem import MediaManagerItem -from .historycombobox import HistoryComboBox diff --git a/openlp/plugins/songs/forms/songselectdialog.py b/openlp/plugins/songs/forms/songselectdialog.py index 5eda9c497..a26d86c33 100644 --- a/openlp/plugins/songs/forms/songselectdialog.py +++ b/openlp/plugins/songs/forms/songselectdialog.py @@ -32,7 +32,8 @@ The :mod:`~openlp.plugins.songs.forms.songselectdialog` module contains the user from PyQt4 import QtCore, QtGui -from openlp.core.lib import HistoryComboBox, translate, build_icon +from openlp.core.common import HistoryComboBox +from openlp.core.lib import translate, build_icon from openlp.core.ui import SingleColumnTableWidget From 5af971f7239476d09aa4e992b383986de400a03f Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Tue, 11 Mar 2014 21:07:58 +0200 Subject: [PATCH 7/7] Remove main_menu property and add an application property. --- openlp/plugins/songs/forms/songselectform.py | 49 +++++++++++--------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/openlp/plugins/songs/forms/songselectform.py b/openlp/plugins/songs/forms/songselectform.py index 566932d42..d3ff5ab52 100755 --- a/openlp/plugins/songs/forms/songselectform.py +++ b/openlp/plugins/songs/forms/songselectform.py @@ -31,6 +31,7 @@ The :mod:`~openlp.plugins.songs.forms.songselectform` module contains the GUI fo """ import logging +import os from time import sleep from PyQt4 import QtCore, QtGui @@ -144,28 +145,21 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): progress_dialog.setValue(1) progress_dialog.show() progress_dialog.setFocus() - self.main_window.application.process_events() + self.application.process_events() sleep(0.5) - self.main_window.application.process_events() + self.application.process_events() self.song_select_importer.logout() - self.main_window.application.process_events() + self.application.process_events() progress_dialog.setValue(2) return QtGui.QDialog.done(self, r) - def _get_main_window(self): - if not hasattr(self, '_main_window'): - self._main_window = Registry().get('main_window') - return self._main_window - - main_window = property(_get_main_window) - def _update_login_progress(self): self.login_progress_bar.setValue(self.login_progress_bar.value() + 1) - self.main_window.application.process_events() + self.application.process_events() def _update_song_progress(self): self.song_progress_bar.setValue(self.song_progress_bar.value() + 1) - self.main_window.application.process_events() + self.application.process_events() def _view_song(self, current_item): if not current_item: @@ -191,7 +185,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): for key, value in current_item.items(): song[key] = value self.song_progress_bar.setValue(0) - self.main_window.application.process_events() + self.application.process_events() # Get the full song song = self.song_select_importer.get_song(song, self._update_song_progress) # Update the UI @@ -219,7 +213,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): self.song_progress_bar.setVisible(False) self.song_progress_bar.setValue(0) self.song = song - self.main_window.application.process_events() + self.application.process_events() def on_save_password_checkbox_toggled(self, checked): """ @@ -248,7 +242,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): self.login_spacer.setVisible(False) self.login_progress_bar.setValue(0) self.login_progress_bar.setVisible(True) - self.main_window.application.process_events() + self.application.process_events() # Log the user in if not self.song_select_importer.login( self.username_edit.text(), self.password_edit.text(), self._update_login_progress): @@ -271,12 +265,13 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): self.login_spacer.setVisible(True) self.login_button.setEnabled(True) self.search_combobox.setFocus() - self.main_window.application.process_events() + self.application.process_events() def on_search_button_clicked(self): """ Run a search on SongSelect. """ + # Set up UI components self.view_button.setEnabled(False) self.search_button.setEnabled(False) self.search_progress_bar.setMinimum(0) @@ -285,11 +280,10 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): self.search_progress_bar.setVisible(True) self.search_results_widget.clear() self.result_count_label.setText(translate('SongsPlugin.SongSelectForm', 'Found %s song(s)') % self.song_count) - self.main_window.application.process_events() + self.application.process_events() self.song_count = 0 search_history = self.search_combobox.getItems() Settings().setValue(self.plugin.settings_section + '/songselect searches', '|'.join(search_history)) - # Create thread and run search self.thread = QtCore.QThread() self.worker = SearchWorker(self.song_select_importer, self.search_combobox.currentText()) @@ -328,10 +322,10 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): :param songs: """ - self.main_window.application.process_events() + self.application.process_events() self.search_progress_bar.setVisible(False) self.search_button.setEnabled(True) - self.main_window.application.process_events() + self.application.process_events() def on_search_results_widget_selection_changed(self): """ @@ -376,5 +370,18 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): if question_dialog.exec_() == QtGui.QMessageBox.Yes: self.on_back_button_clicked() else: - self.main_window.application.process_events() + self.application.process_events() self.done(QtGui.QDialog.Accepted) + + @property + def application(self): + """ + Adds the openlp to the class dynamically. + Windows needs to access the application in a dynamic manner. + """ + if os.name == 'nt': + return Registry().get('application') + else: + if not hasattr(self, '_application'): + self._application = Registry().get('application') + return self._application