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/common/historycombobox.py b/openlp/core/common/historycombobox.py new file mode 100644 index 000000000..b6d06ff9f --- /dev/null +++ b/openlp/core/common/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 dee413ef0..664074a87 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 @@ -101,8 +125,8 @@ from .mediadockmanager import MediaDockManager from .servicemanager import ServiceManager 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'] +__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', 'SingleColumnTableWidget'] diff --git a/openlp/core/ui/mainwindow.py b/openlp/core/ui/mainwindow.py index 296809128..67f75f953 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, @@ -651,7 +653,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 4cf6fc435..88576ca95 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..a26d86c33 --- /dev/null +++ b/openlp/plugins/songs/forms/songselectdialog.py @@ -0,0 +1,252 @@ +# -*- 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.common import HistoryComboBox +from openlp.core.lib import 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..d3ff5ab52 --- /dev/null +++ b/openlp/plugins/songs/forms/songselectform.py @@ -0,0 +1,387 @@ +# -*- 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 +import os +from time import sleep + +from PyQt4 import QtCore, QtGui + +from openlp.core import Settings +from openlp.core.common import Registry +from openlp.core.lib import translate +from openlp.plugins.songs.forms.songselectdialog import Ui_SongSelectDialog +from openlp.plugins.songs.lib.songselect import SongSelectImport + +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() + quit = QtCore.pyqtSignal() + + def __init__(self, importer, search_text): + super().__init__() + 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.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): + """ + 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.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) + 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.application.process_events() + sleep(0.5) + self.application.process_events() + self.song_select_importer.logout() + self.application.process_events() + progress_dialog.setValue(2) + return QtGui.QDialog.done(self, r) + + def _update_login_progress(self): + self.login_progress_bar.setValue(self.login_progress_bar.value() + 1) + self.application.process_events() + + def _update_song_progress(self): + self.song_progress_bar.setValue(self.song_progress_bar.value() + 1) + self.application.process_events() + + 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(0) + self.application.process_events() + # Get the full song + 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(author, self.author_list_widget) + for counter, verse in enumerate(song['verses']): + 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.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.setValue(0) + self.login_progress_bar.setVisible(True) + 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): + 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.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) + 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.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()) + 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: + """ + 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): + """ + Slot which is called when the search is completed. + + :param songs: + """ + self.application.process_events() + self.search_progress_bar.setVisible(False) + self.search_button.setEnabled(True) + self.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. + """ + 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 ' + '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.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 diff --git a/openlp/plugins/songs/lib/__init__.py b/openlp/plugins/songs/lib/__init__.py index 1a2ea1dd9..1c9141b16 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 = [ @@ -199,6 +171,7 @@ class VerseType(object): :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): @@ -216,6 +189,7 @@ class VerseType(object): :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): @@ -233,6 +207,7 @@ class VerseType(object): :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): @@ -250,6 +225,7 @@ class VerseType(object): :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): @@ -266,7 +242,8 @@ class VerseType(object): Return the VerseType for a given string :param verse_name: The string to return a VerseType for - :param default: Default return value if no matching tag is found + :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): @@ -280,6 +257,7 @@ class VerseType(object): Return the VerseType for a given string :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): @@ -293,6 +271,7 @@ class VerseType(object): :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) @@ -313,16 +292,14 @@ def retrieve_windows_encoding(recommendation=None): passed to this method as a recommendation, and user confirmation to return an encoding. :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] @@ -341,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)): @@ -350,15 +328,18 @@ def retrieve_windows_encoding(recommendation=None): break if recommended_index > -1: choice = QtGui.QInputDialog.getItem( - None, translate('SongsPlugin', 'Character Encoding'), - translate('SongsPlugin', 'The codepage setting is responsible\nfor the correct character ' - 'representation.\nUsually you are fine with the preselected choice.'), + None, + translate('SongsPlugin', 'Character Encoding'), + translate('SongsPlugin', 'The codepage setting is responsible\n' + '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, translate('SongsPlugin', 'Character Encoding'), - translate('SongsPlugin', 'Please choose the character encoding.\nThe encoding is responsible for the ' - 'correct character representation.'), + 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) if not choice[1]: return None @@ -368,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() @@ -375,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() @@ -384,7 +371,7 @@ 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. - :param manager: The song's manager. + :param manager: The song database manager object. :param song: The song object. """ from .xml import SongXML @@ -398,54 +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: name = SongStrings.AuthorUnknown @@ -482,12 +425,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 :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 = '' @@ -588,17 +530,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. :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)) diff --git a/openlp/plugins/songs/lib/songselect.py b/openlp/plugins/songs/lib/songselect.py new file mode 100644 index 000000000..a18b08c8a --- /dev/null +++ b/openlp/plugins/songs/lib/songselect.py @@ -0,0 +1,208 @@ +# -*- 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: The song dictionary to update + :param callback: A callback which can be used to indicate progress + :return: The updated song dictionary + """ + if callback: + callback() + 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 (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'] = [] + 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): + """ + 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) + return db_song diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index afa6e4d12..32d3ddbe6 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,27 +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': '' - } +__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/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') @@ -83,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): """ @@ -92,10 +97,11 @@ class SongsPlugin(Plugin): def initialise(self): """ - Lets Initialise the plugin + 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) @@ -119,12 +125,18 @@ class SongsPlugin(Plugin): 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. - :param 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( @@ -179,29 +191,42 @@ 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): """ - The song import option has been selected + Run the song import wizard. """ if self.media_item: self.media_item.on_import_click() def on_song_export_item_clicked(self): """ - The song export option has been selected + 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 diff --git a/resources/images/general_back.png b/resources/images/general_back.png new file mode 100644 index 000000000..8230340bb Binary files /dev/null and b/resources/images/general_back.png differ diff --git a/resources/images/general_find.png b/resources/images/general_find.png new file mode 100644 index 000000000..1b7a25280 Binary files /dev/null and b/resources/images/general_find.png differ 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 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_songbeamerimport.py b/tests/functional/openlp_plugins/songs/test_songbeamerimport.py index de33a06fc..72901eb47 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..0b32cff95 --- /dev/null +++ b/tests/functional/openlp_plugins/songs/test_songselect.py @@ -0,0 +1,382 @@ +# -*- 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 urllib.error import URLError +from openlp.plugins.songs.lib import Author, Song + +from openlp.plugins.songs.lib.songselect import SongSelectImport, LOGOUT_URL, BASE_URL + +from tests.functional import MagicMock, patch, call + + +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') + + 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') + + 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') diff --git a/tests/functional/openlp_plugins/songs/test_songshowplusimport.py b/tests/functional/openlp_plugins/songs/test_songshowplusimport.py index a7fca1b8e..5c945efb3 100644 --- a/tests/functional/openlp_plugins/songs/test_songshowplusimport.py +++ b/tests/functional/openlp_plugins/songs/test_songshowplusimport.py @@ -50,10 +50,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. +"""