From 2dfb7bec9cb5bfc1e69c8fb24fb3ac387592b187 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sat, 5 Jan 2013 18:08:01 +0100 Subject: [PATCH 01/95] Added basic duplicate song detection, no removal or fancy GUI yet. --- openlp/plugins/songs/lib/doublesfinder.py | 114 ++++++++++++++++++++++ openlp/plugins/songs/songsplugin.py | 32 +++++- 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 openlp/plugins/songs/lib/doublesfinder.py diff --git a/openlp/plugins/songs/lib/doublesfinder.py b/openlp/plugins/songs/lib/doublesfinder.py new file mode 100644 index 000000000..f1098a7c3 --- /dev/null +++ b/openlp/plugins/songs/lib/doublesfinder.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2013 Raoul Snyman # +# Portions copyright (c) 2008-2013 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:`dreambeamimport` module provides the functionality for importing +DreamBeam songs into the OpenLP database. +""" +import logging +import difflib + +from openlp.core.lib import translate +from openlp.plugins.songs.lib.db import Song +from openlp.plugins.songs.lib.ui import SongStrings + +log = logging.getLogger(__name__) + +class DuplicateSongFinder(object): + """ + The :class:`DreamBeamImport` class provides functionality to search for + and remove duplicate songs. + """ + + def __init__(self): + self.minFragmentSize = 5 + self.minBlockSize = 70 + self.maxTypoSize = 3 + + def songsProbablyEqual(self, song1, song2): + if len(song1.search_lyrics) < len(song2.search_lyrics): + small = song1.search_lyrics + large = song2.search_lyrics + else: + small = song2.search_lyrics + large = song1.search_lyrics + differ = difflib.SequenceMatcher(a=small, b=large) + diff_tuples = differ.get_opcodes() + diff_no_typos = self.__removeTypos(diff_tuples) + #print(diff_no_typos) + if self.__lengthOfEqualBlocks(diff_no_typos) >= self.minBlockSize or \ + self.__lengthOfLongestEqualBlock(diff_no_typos) > len(small)*2/3: + return True + else: + return False + + def __opLength(self, opcode): + return max(opcode[2]-opcode[1], opcode[4] - opcode[3]) + + def __removeTypos(self, diff): + #remove typo at beginning of string + if len(diff) >= 2: + if diff[0][0] != "equal" and self.__opLength(diff[0]) <= self.maxTypoSize and \ + self.__opLength(diff[1]) >= self.minFragmentSize: + del diff[0] + #remove typos in the middle of string + if len(diff) >= 3: + for index in range(len(diff)-3, -1, -1): + if self.__opLength(diff[index]) >= self.minFragmentSize and \ + diff[index+1][0] != "equal" and self.__opLength(diff[index+1]) <= self.maxTypoSize and \ + self.__opLength(diff[index+2]) >= self.minFragmentSize: + del diff[index+1] + #remove typo at the end of string + if len(diff) >= 2: + if self.__opLength(diff[-2]) >= self.minFragmentSize and \ + diff[-1][0] != "equal" and self.__opLength(diff[-1]) <= self.maxTypoSize: + del diff[-1] + + #merge fragments + for index in range(len(diff)-2, -1, -1): + if diff[index][0] == "equal" and self.__opLength(diff[index]) >= self.minFragmentSize and \ + diff[index+1][0] == "equal" and self.__opLength(diff[index+1]) >= self.minFragmentSize: + diff[index] = ("equal", diff[index][1], diff[index+1][2], diff[index][3], + diff[index+1][4]) + del diff[index+1] + + return diff + + def __lengthOfEqualBlocks(self, diff): + length = 0 + for element in diff: + if element[0] == "equal" and self.__opLength(element) >= self.minBlockSize: + length += self.__opLength(element) + return length + + def __lengthOfLongestEqualBlock(self, diff): + length = 0 + for element in diff: + if element[0] == "equal" and self.__opLength(element) > length: + length = self.__opLength(element) + return length diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index 83e54512c..9c696489d 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -45,6 +45,7 @@ from openlp.plugins.songs.lib import clean_song, upgrade, SongMediaItem, \ from openlp.plugins.songs.lib.db import init_schema, Song from openlp.plugins.songs.lib.importer import SongFormat from openlp.plugins.songs.lib.olpimport import OpenLPSongImport +from openlp.plugins.songs.lib.doublesfinder import DuplicateSongFinder log = logging.getLogger(__name__) @@ -77,10 +78,12 @@ class SongsPlugin(Plugin): self.songImportItem.setVisible(True) self.songExportItem.setVisible(True) self.toolsReindexItem.setVisible(True) + self.toolsFindDuplicates.setVisible(True) action_list = ActionList.get_instance() action_list.add_action(self.songImportItem, UiStrings().Import) action_list.add_action(self.songExportItem, UiStrings().Export) action_list.add_action(self.toolsReindexItem, UiStrings().Tools) + action_list.add_action(self.toolsFindDuplicates, UiStrings().Tools) QtCore.QObject.connect(Receiver.get_receiver(), QtCore.SIGNAL(u'servicemanager_new_service'), self.clearTemporarySongs) @@ -122,7 +125,7 @@ class SongsPlugin(Plugin): def addToolsMenuItem(self, tools_menu): """ - Give the alerts plugin the opportunity to add items to the + Give the Songs plugin the opportunity to add items to the **Tools** menu. ``tools_menu`` @@ -137,6 +140,12 @@ class SongsPlugin(Plugin): 'Re-index the songs database to improve searching and ordering.'), visible=False, triggers=self.onToolsReindexItemTriggered) tools_menu.addAction(self.toolsReindexItem) + self.toolsFindDuplicates = create_action(tools_menu, u'toolsFindDuplicates', + text=translate('SongsPlugin', 'Find &duplicate songs'), + statustip=translate('SongsPlugin', + 'Find and remove duplicate songs in the song database.'), + visible=False, triggers=self.onToolsFindDuplicatesTriggered) + tools_menu.addAction(self.toolsFindDuplicates) def onToolsReindexItemTriggered(self): """ @@ -157,6 +166,25 @@ class SongsPlugin(Plugin): self.manager.save_objects(songs) self.mediaItem.onSearchTextButtonClicked() + def onToolsFindDuplicatesTriggered(self): + """ + Search for duplicates in the song database. + """ + maxSongs = self.manager.get_object_count(Song) + if maxSongs == 0: + return + QtGui.QMessageBox.information(self.formParent, + "Find duplicates called", "Called...") + songs = self.manager.get_all_objects(Song) + for outerSongCounter in range(maxSongs-1): + for innerSongCounter in range(outerSongCounter+1, maxSongs): + doubleFinder = DuplicateSongFinder() + if doubleFinder.songsProbablyEqual(songs[outerSongCounter], + songs[innerSongCounter]): + QtGui.QMessageBox.information(self.formParent, + "Double found", str(innerSongCounter) + " " + + str(outerSongCounter)) + def onSongImportItemClicked(self): if self.mediaItem: self.mediaItem.onImportClick() @@ -280,10 +308,12 @@ class SongsPlugin(Plugin): self.songImportItem.setVisible(False) self.songExportItem.setVisible(False) self.toolsReindexItem.setVisible(False) + self.toolsFindDuplicates.setVisible(False) action_list = ActionList.get_instance() action_list.remove_action(self.songImportItem, UiStrings().Import) action_list.remove_action(self.songExportItem, UiStrings().Export) action_list.remove_action(self.toolsReindexItem, UiStrings().Tools) + action_list.remove_action(self.toolsFindDuplicates, UiStrings().Tools) Plugin.finalise(self) def clearTemporarySongs(self): From d0e83c1c7c031cfeb70cd810a42bf5fdea41ab36 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sun, 6 Jan 2013 23:28:29 +0100 Subject: [PATCH 02/95] Commit missing file. --- .../songs/lib/{doublesfinder.py => duplicatesongfinder.py} | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) rename openlp/plugins/songs/lib/{doublesfinder.py => duplicatesongfinder.py} (96%) diff --git a/openlp/plugins/songs/lib/doublesfinder.py b/openlp/plugins/songs/lib/duplicatesongfinder.py similarity index 96% rename from openlp/plugins/songs/lib/doublesfinder.py rename to openlp/plugins/songs/lib/duplicatesongfinder.py index f1098a7c3..6ace0a68b 100644 --- a/openlp/plugins/songs/lib/doublesfinder.py +++ b/openlp/plugins/songs/lib/duplicatesongfinder.py @@ -26,10 +26,6 @@ # with this program; if not, write to the Free Software Foundation, Inc., 59 # # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### -""" -The :mod:`dreambeamimport` module provides the functionality for importing -DreamBeam songs into the OpenLP database. -""" import logging import difflib @@ -41,7 +37,7 @@ log = logging.getLogger(__name__) class DuplicateSongFinder(object): """ - The :class:`DreamBeamImport` class provides functionality to search for + The :class:`DuplicateSongFinder` class provides functionality to search for and remove duplicate songs. """ From a4ce06fa9b4ae4fcc777c676777b07878f21ddee Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sun, 6 Jan 2013 23:35:00 +0100 Subject: [PATCH 03/95] Fix last commit, I actually renamed a file and did not adapt the code to reflect this. --- openlp/plugins/songs/songsplugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index 9c696489d..726cbfbf5 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -45,7 +45,7 @@ from openlp.plugins.songs.lib import clean_song, upgrade, SongMediaItem, \ from openlp.plugins.songs.lib.db import init_schema, Song from openlp.plugins.songs.lib.importer import SongFormat from openlp.plugins.songs.lib.olpimport import OpenLPSongImport -from openlp.plugins.songs.lib.doublesfinder import DuplicateSongFinder +from openlp.plugins.songs.lib.duplicatesongfinder import DuplicateSongFinder log = logging.getLogger(__name__) From d85ede45b82bc222dfe9a51f085fc18f34c8f152 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 8 Jan 2013 21:06:57 +0100 Subject: [PATCH 04/95] Added a wizard. Non functional yet. Changed OpenLPWizard to allow for no progressPage to be added. --- openlp/core/ui/wizard.py | 15 +- .../songs/forms/duplicatesongremovalform.py | 139 ++++++++++++++++++ openlp/plugins/songs/songsplugin.py | 6 + 3 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 openlp/plugins/songs/forms/duplicatesongremovalform.py diff --git a/openlp/core/ui/wizard.py b/openlp/core/ui/wizard.py index 3609302b5..be71a0bbe 100644 --- a/openlp/core/ui/wizard.py +++ b/openlp/core/ui/wizard.py @@ -79,9 +79,10 @@ class OpenLPWizard(QtGui.QWizard): Generic OpenLP wizard to provide generic functionality and a unified look and feel. """ - def __init__(self, parent, plugin, name, image): + def __init__(self, parent, plugin, name, image, addProgressPage=True): QtGui.QWizard.__init__(self, parent) self.plugin = plugin + self.withProgressPage = addProgressPage self.setObjectName(name) self.openIcon = build_icon(u':/general/general_open.png') self.deleteIcon = build_icon(u':/general/general_delete.png') @@ -92,8 +93,9 @@ class OpenLPWizard(QtGui.QWizard): self.customInit() self.customSignals() QtCore.QObject.connect(self, QtCore.SIGNAL(u'currentIdChanged(int)'), self.onCurrentIdChanged) - QtCore.QObject.connect(self.errorCopyToButton, QtCore.SIGNAL(u'clicked()'), self.onErrorCopyToButtonClicked) - QtCore.QObject.connect(self.errorSaveToButton, QtCore.SIGNAL(u'clicked()'), self.onErrorSaveToButtonClicked) + if self.withProgressPage: + QtCore.QObject.connect(self.errorCopyToButton, QtCore.SIGNAL(u'clicked()'), self.onErrorCopyToButtonClicked) + QtCore.QObject.connect(self.errorSaveToButton, QtCore.SIGNAL(u'clicked()'), self.onErrorSaveToButtonClicked) def setupUi(self, image): """ @@ -106,7 +108,8 @@ class OpenLPWizard(QtGui.QWizard): QtGui.QWizard.NoBackButtonOnLastPage) add_welcome_page(self, image) self.addCustomPages() - self.addProgressPage() + if self.withProgressPage: + self.addProgressPage() self.retranslateUi() def registerFields(self): @@ -168,7 +171,7 @@ class OpenLPWizard(QtGui.QWizard): Stop the wizard on cancel button, close button or ESC key. """ log.debug(u'Wizard cancelled by user.') - if self.currentPage() == self.progressPage: + if self.withProgressPage and self.currentPage() == self.progressPage: Receiver.send_message(u'openlp_stop_wizard') self.done(QtGui.QDialog.Rejected) @@ -176,7 +179,7 @@ class OpenLPWizard(QtGui.QWizard): """ Perform necessary functions depending on which wizard page is active. """ - if self.page(pageId) == self.progressPage: + if self.withProgressPage and self.page(pageId) == self.progressPage: self.preWizard() self.performWizard() self.postWizard() diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py new file mode 100644 index 000000000..33cd64b4a --- /dev/null +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=80 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2013 Raoul Snyman # +# Portions copyright (c) 2008-2013 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 duplicate song removal logic for OpenLP. +""" +import codecs +import logging +import os + +from PyQt4 import QtCore, QtGui + +from openlp.core.lib import Receiver, Settings, SettingsManager, translate +from openlp.core.lib.ui import UiStrings, critical_error_message_box +from openlp.core.ui.wizard import OpenLPWizard, WizardStrings +from openlp.plugins.songs.lib.importer import SongFormat, SongFormatSelect + +log = logging.getLogger(__name__) + +class DuplicateSongRemovalForm(OpenLPWizard): + """ + This is the Duplicate Song Removal Wizard. It provides functionality to + search for and remove duplicate songs in the database. + """ + log.info(u'DuplicateSongRemovalForm loaded') + + def __init__(self, parent, plugin): + """ + Instantiate the wizard, and run any extra setup we need to. + + ``parent`` + The QWidget-derived parent of the wizard. + + ``plugin`` + The songs plugin. + """ + self.clipboard = plugin.formParent.clipboard + OpenLPWizard.__init__(self, parent, plugin, u'duplicateSongRemovalWizard', + u':/wizards/wizard_importsong.bmp', False) + + def customInit(self): + """ + Song wizard specific initialisation. + """ + pass + + def customSignals(self): + """ + Song wizard specific signals. + """ + #QtCore.QObject.connect(self.addButton, + # QtCore.SIGNAL(u'clicked()'), self.onAddButtonClicked) + #QtCore.QObject.connect(self.removeButton, + # QtCore.SIGNAL(u'clicked()'), self.onRemoveButtonClicked) + + def addCustomPages(self): + """ + Add song wizard specific pages. + """ + self.searchingPage = QtGui.QWizardPage() + self.searchingPage.setObjectName('searchingPage') + self.verticalLayout = QtGui.QVBoxLayout(self.searchingPage) + self.verticalLayout.setObjectName('verticalLayout') + self.duplicateSearchProgressBar = QtGui.QProgressBar(self.searchingPage) + self.duplicateSearchProgressBar.setObjectName(u'duplicateSearchProgressBar') + self.verticalLayout.addWidget(self.duplicateSearchProgressBar) + self.foundDuplicatesEdit = QtGui.QPlainTextEdit(self.searchingPage) + self.foundDuplicatesEdit.setUndoRedoEnabled(False) + self.foundDuplicatesEdit.setReadOnly(True) + self.foundDuplicatesEdit.setObjectName('foundDuplicatesEdit') + self.verticalLayout.addWidget(self.foundDuplicatesEdit) + self.addPage(self.searchingPage) + self.reviewPage = QtGui.QWizardPage() + self.reviewPage.setObjectName('reviewPage') + self.addPage(self.reviewPage) + + def retranslateUi(self): + """ + Song wizard localisation. + """ + self.setWindowTitle(translate('Wizard', 'Wizard')) + self.titleLabel.setText(WizardStrings.HeaderStyle % translate('OpenLP.Ui', + 'Welcome to the Duplicate Song Removal Wizard')) + self.informationLabel.setText(translate("Wizard", + 'This wizard will help you to remove duplicate songs from the song database.')) + self.searchingPage.setTitle(translate('Wizard', 'Searching for doubles')) + self.searchingPage.setSubTitle(translate('Wizard', 'The song database is searched for double songs.')) + + def customPageChanged(self, pageId): + """ + Called when changing to a page other than the progress page. + """ + pass + + def onAddButtonClicked(self): + pass + + def onRemoveButtonClicked(self): + pass + + def setDefaults(self): + """ + Set default form values for the song import wizard. + """ + self.restart() + self.foundDuplicatesEdit.clear() + + def performWizard(self): + """ + Perform the actual import. This method pulls in the correct importer + class, and then runs the ``doImport`` method of the importer to do + the actual importing. + """ + pass diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index 726cbfbf5..b19eee5e7 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -46,6 +46,8 @@ from openlp.plugins.songs.lib.db import init_schema, Song from openlp.plugins.songs.lib.importer import SongFormat from openlp.plugins.songs.lib.olpimport import OpenLPSongImport from openlp.plugins.songs.lib.duplicatesongfinder import DuplicateSongFinder +from openlp.plugins.songs.forms.duplicatesongremovalform import \ + DuplicateSongRemovalForm log = logging.getLogger(__name__) @@ -184,6 +186,10 @@ class SongsPlugin(Plugin): QtGui.QMessageBox.information(self.formParent, "Double found", str(innerSongCounter) + " " + str(outerSongCounter)) + if not hasattr(self, u'duplicate_removal_wizard'): + self.duplicate_removal_wizard = \ + DuplicateSongRemovalForm(self.formParent, self) + self.duplicate_removal_wizard.exec_() def onSongImportItemClicked(self): if self.mediaItem: From 3a9820528ad0b1b26d67261a893b382485579b50 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Wed, 9 Jan 2013 00:17:00 +0100 Subject: [PATCH 05/95] Added detection logic to wizard. --- .../songs/forms/duplicatesongremovalform.py | 19 +++++++++++- openlp/plugins/songs/songsplugin.py | 29 +++++++++---------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 33cd64b4a..3e58a1757 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -38,7 +38,9 @@ from PyQt4 import QtCore, QtGui from openlp.core.lib import Receiver, Settings, SettingsManager, translate from openlp.core.lib.ui import UiStrings, critical_error_message_box from openlp.core.ui.wizard import OpenLPWizard, WizardStrings +from openlp.plugins.songs.lib.db import Song from openlp.plugins.songs.lib.importer import SongFormat, SongFormatSelect +from openlp.plugins.songs.lib.duplicatesongfinder import DuplicateSongFinder log = logging.getLogger(__name__) @@ -88,6 +90,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.verticalLayout.setObjectName('verticalLayout') self.duplicateSearchProgressBar = QtGui.QProgressBar(self.searchingPage) self.duplicateSearchProgressBar.setObjectName(u'duplicateSearchProgressBar') + self.duplicateSearchProgressBar.setFormat(WizardStrings.PercentSymbolFormat) self.verticalLayout.addWidget(self.duplicateSearchProgressBar) self.foundDuplicatesEdit = QtGui.QPlainTextEdit(self.searchingPage) self.foundDuplicatesEdit.setUndoRedoEnabled(False) @@ -115,7 +118,20 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ Called when changing to a page other than the progress page. """ - pass + if self.page(pageId) == self.searchingPage: + maxSongs = self.plugin.manager.get_object_count(Song) + if maxSongs == 0 or maxSongs == 1: + return + # with x songs we have x*(x-1)/2 comparisons + maxProgressCount = maxSongs*(maxSongs-1)/2 + self.duplicateSearchProgressBar.setMaximum(maxProgressCount) + songs = self.plugin.manager.get_all_objects(Song) + for outerSongCounter in range(maxSongs-1): + for innerSongCounter in range(outerSongCounter+1, maxSongs): + doubleFinder = DuplicateSongFinder() + if doubleFinder.songsProbablyEqual(songs[outerSongCounter], songs[innerSongCounter]): + self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + songs[innerSongCounter].title) + self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value()+1) def onAddButtonClicked(self): pass @@ -128,6 +144,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): Set default form values for the song import wizard. """ self.restart() + self.duplicateSearchProgressBar.setValue(0) self.foundDuplicatesEdit.clear() def performWizard(self): diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index b19eee5e7..b006eb19a 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -45,7 +45,6 @@ from openlp.plugins.songs.lib import clean_song, upgrade, SongMediaItem, \ from openlp.plugins.songs.lib.db import init_schema, Song from openlp.plugins.songs.lib.importer import SongFormat from openlp.plugins.songs.lib.olpimport import OpenLPSongImport -from openlp.plugins.songs.lib.duplicatesongfinder import DuplicateSongFinder from openlp.plugins.songs.forms.duplicatesongremovalform import \ DuplicateSongRemovalForm @@ -172,20 +171,20 @@ class SongsPlugin(Plugin): """ Search for duplicates in the song database. """ - maxSongs = self.manager.get_object_count(Song) - if maxSongs == 0: - return - QtGui.QMessageBox.information(self.formParent, - "Find duplicates called", "Called...") - songs = self.manager.get_all_objects(Song) - for outerSongCounter in range(maxSongs-1): - for innerSongCounter in range(outerSongCounter+1, maxSongs): - doubleFinder = DuplicateSongFinder() - if doubleFinder.songsProbablyEqual(songs[outerSongCounter], - songs[innerSongCounter]): - QtGui.QMessageBox.information(self.formParent, - "Double found", str(innerSongCounter) + " " + - str(outerSongCounter)) + #maxSongs = self.manager.get_object_count(Song) + #if maxSongs == 0: + # return + #QtGui.QMessageBox.information(self.formParent, + # "Find duplicates called", "Called...") + #songs = self.manager.get_all_objects(Song) + #for outerSongCounter in range(maxSongs-1): + # for innerSongCounter in range(outerSongCounter+1, maxSongs): + # doubleFinder = DuplicateSongFinder() + # if doubleFinder.songsProbablyEqual(songs[outerSongCounter], + # songs[innerSongCounter]): + # QtGui.QMessageBox.information(self.formParent, + # "Double found", str(innerSongCounter) + " " + + # str(outerSongCounter)) if not hasattr(self, u'duplicate_removal_wizard'): self.duplicate_removal_wizard = \ DuplicateSongRemovalForm(self.formParent, self) From 1640bc653161b203e293eff9a7dd9d852e66b166 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 10 Jan 2013 23:39:32 +0100 Subject: [PATCH 06/95] Add images/song_delete.png to resources. --- resources/images/openlp-2.qrc | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/images/openlp-2.qrc b/resources/images/openlp-2.qrc index beebcff67..c68bbc4f5 100644 --- a/resources/images/openlp-2.qrc +++ b/resources/images/openlp-2.qrc @@ -20,6 +20,7 @@ song_author_edit.png song_topic_edit.png song_book_edit.png + song_delete.png bibles_search_text.png From 709c398205b55bfc527ec3c0a00b09445feba3a1 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 10 Jan 2013 23:49:40 +0100 Subject: [PATCH 07/95] First half of song removal review UI. --- .../songs/forms/duplicatesongremovalform.py | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 3e58a1757..122c0f5b2 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# vim: autoindent shiftwidth=4 expandtab textwidth=80 tabstop=4 softtabstop=4 +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 ############################################################################### # OpenLP - Open Source Lyrics Projection # @@ -35,7 +35,7 @@ import os from PyQt4 import QtCore, QtGui -from openlp.core.lib import Receiver, Settings, SettingsManager, translate +from openlp.core.lib import Receiver, Settings, SettingsManager, translate, build_icon from openlp.core.lib.ui import UiStrings, critical_error_message_box from openlp.core.ui.wizard import OpenLPWizard, WizardStrings from openlp.plugins.songs.lib.db import Song @@ -86,20 +86,30 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ self.searchingPage = QtGui.QWizardPage() self.searchingPage.setObjectName('searchingPage') - self.verticalLayout = QtGui.QVBoxLayout(self.searchingPage) - self.verticalLayout.setObjectName('verticalLayout') + self.searchingVerticalLayout = QtGui.QVBoxLayout(self.searchingPage) + self.searchingVerticalLayout.setObjectName('searchingVerticalLayout') self.duplicateSearchProgressBar = QtGui.QProgressBar(self.searchingPage) self.duplicateSearchProgressBar.setObjectName(u'duplicateSearchProgressBar') self.duplicateSearchProgressBar.setFormat(WizardStrings.PercentSymbolFormat) - self.verticalLayout.addWidget(self.duplicateSearchProgressBar) + self.searchingVerticalLayout.addWidget(self.duplicateSearchProgressBar) self.foundDuplicatesEdit = QtGui.QPlainTextEdit(self.searchingPage) self.foundDuplicatesEdit.setUndoRedoEnabled(False) self.foundDuplicatesEdit.setReadOnly(True) self.foundDuplicatesEdit.setObjectName('foundDuplicatesEdit') - self.verticalLayout.addWidget(self.foundDuplicatesEdit) + self.searchingVerticalLayout.addWidget(self.foundDuplicatesEdit) self.addPage(self.searchingPage) self.reviewPage = QtGui.QWizardPage() self.reviewPage.setObjectName('reviewPage') + self.headerVerticalLayout = QtGui.QVBoxLayout(self.reviewPage) + self.headerVerticalLayout.setObjectName('headerVerticalLayout') + self.reviewCounterLabel = QtGui.QLabel(self.reviewPage) + self.reviewCounterLabel.setObjectName('reviewCounterLabel') + self.headerVerticalLayout.addWidget(self.reviewCounterLabel) + self.songsHorizontalLayout = QtGui.QHBoxLayout() + self.songsHorizontalLayout.setObjectName('songsHorizontalLayout') + self.songReviewWidget = SongReviewWidget(self.reviewPage) + self.songsHorizontalLayout.addWidget(self.songReviewWidget) + self.headerVerticalLayout.addLayout(self.songsHorizontalLayout) self.addPage(self.reviewPage) def retranslateUi(self): @@ -113,6 +123,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): 'This wizard will help you to remove duplicate songs from the song database.')) self.searchingPage.setTitle(translate('Wizard', 'Searching for doubles')) self.searchingPage.setSubTitle(translate('Wizard', 'The song database is searched for double songs.')) + self.reviewPage.setTitle(translate('Wizard', 'Review duplicate songs')) + self.reviewPage.setSubTitle(translate('Wizard', + 'This page shows all duplicate songs to review which ones to remove and which ones to keep.')) def customPageChanged(self, pageId): """ @@ -154,3 +167,23 @@ class DuplicateSongRemovalForm(OpenLPWizard): the actual importing. """ pass + +class SongReviewWidget(QtGui.QWidget): + def __init__(self, parent): + QtGui.QWidget.__init__(self, parent) + self.setupUi() + self.retranslateUi() + + def setupUi(self): + self.songVerticalLayout = QtGui.QVBoxLayout(self) + self.songVerticalLayout.setObjectName('songVerticalLayout') + self.songGroupBox = QtGui.QGroupBox(self) + self.songGroupBox.setObjectName('songGroupBox') + self.songVerticalLayout.addWidget(self.songGroupBox) + self.songRemoveButton = QtGui.QPushButton(self) + self.songRemoveButton.setObjectName('songRemoveButton') + self.songRemoveButton.setIcon(build_icon(u':/songs/song_delete.png')) + self.songVerticalLayout.addWidget(self.songRemoveButton) + + def retranslateUi(self): + self.songRemoveButton.setText(u'Remove') From bb23cd133d0579af532895cf9dba2af532fcbf24 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Fri, 11 Jan 2013 00:08:52 +0100 Subject: [PATCH 08/95] Some more GUI. --- .../songs/forms/duplicatesongremovalform.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 122c0f5b2..a36f951a7 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -179,6 +179,21 @@ class SongReviewWidget(QtGui.QWidget): self.songVerticalLayout.setObjectName('songVerticalLayout') self.songGroupBox = QtGui.QGroupBox(self) self.songGroupBox.setObjectName('songGroupBox') + self.songContentVerticalLayout = QtGui.QVBoxLayout(self.songGroupBox) + self.songContentVerticalLayout.setObjectName('songContentVerticalLayout') + self.songInfoFormLayout = QtGui.QFormLayout() + self.songInfoFormLayout.setObjectName('songInfoFormLayout') + #add ccli number, name, altname, authors, ... here + self.songNameLabel = QtGui.QLabel(self) + self.songNameLabel.setObjectName('songNameLabel') + self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.LabelRole, self.songNameLabel) + self.songNameContent = QtGui.QLabel(self) + self.songNameContent.setObjectName('songNameContent') + self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.FieldRole, self.songNameContent) + self.songContentVerticalLayout.addLayout(self.songInfoFormLayout) + self.songVerseButton = QtGui.QPushButton(self) + self.songVerseButton.setObjectName('songVerseButton') + self.songContentVerticalLayout.addWidget(self.songVerseButton) self.songVerticalLayout.addWidget(self.songGroupBox) self.songRemoveButton = QtGui.QPushButton(self) self.songRemoveButton.setObjectName('songRemoveButton') @@ -187,3 +202,5 @@ class SongReviewWidget(QtGui.QWidget): def retranslateUi(self): self.songRemoveButton.setText(u'Remove') + self.songNameLabel.setText(u'Name:') + From cd7a57028b75a469720e2d912ec037ec007dbd81 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Fri, 11 Jan 2013 18:45:08 +0100 Subject: [PATCH 09/95] Fill song review widgets with useful content. --- .../songs/forms/duplicatesongremovalform.py | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index a36f951a7..0a244ecb9 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -36,6 +36,7 @@ import os from PyQt4 import QtCore, QtGui from openlp.core.lib import Receiver, Settings, SettingsManager, translate, build_icon +from openlp.core.lib.db import Manager from openlp.core.lib.ui import UiStrings, critical_error_message_box from openlp.core.ui.wizard import OpenLPWizard, WizardStrings from openlp.plugins.songs.lib.db import Song @@ -69,7 +70,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ Song wizard specific initialisation. """ - pass + self.duplicateSongList = [] def customSignals(self): """ @@ -107,8 +108,6 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.headerVerticalLayout.addWidget(self.reviewCounterLabel) self.songsHorizontalLayout = QtGui.QHBoxLayout() self.songsHorizontalLayout.setObjectName('songsHorizontalLayout') - self.songReviewWidget = SongReviewWidget(self.reviewPage) - self.songsHorizontalLayout.addWidget(self.songReviewWidget) self.headerVerticalLayout.addLayout(self.songsHorizontalLayout) self.addPage(self.reviewPage) @@ -143,8 +142,36 @@ class DuplicateSongRemovalForm(OpenLPWizard): for innerSongCounter in range(outerSongCounter+1, maxSongs): doubleFinder = DuplicateSongFinder() if doubleFinder.songsProbablyEqual(songs[outerSongCounter], songs[innerSongCounter]): + self.addDuplicatesToSongList(songs[outerSongCounter], songs[innerSongCounter]) self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + songs[innerSongCounter].title) self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value()+1) + elif self.page(pageId) == self.reviewPage: + print "asdf" + print self.duplicateSongList + for duplicates in self.duplicateSongList[0:1]: + print "qwer" + for duplicate in duplicates: + print "zxcv" + songReviewWidget = SongReviewWidget(self.reviewPage, duplicate) + self.songsHorizontalLayout.addWidget(songReviewWidget) + + def addDuplicatesToSongList(self, searchSong, duplicateSong): + duplicateGroupFound = False + for duplicates in self.duplicateSongList: + #skip the first song in the duplicate lists, since the first one has to be an earlier song + for duplicate in duplicates[1:]: + if duplicate == searchSong: + duplicates.append(duplicateSong) + duplicateGroupFound = True + break + elif duplicate == duplicateSong: + duplicates.appen(searchSong) + duplicateGroupFound = True + break + if duplicateGroupFound: + break + if not duplicateGroupFound: + self.duplicateSongList.append([searchSong, duplicateSong]) def onAddButtonClicked(self): pass @@ -169,8 +196,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): pass class SongReviewWidget(QtGui.QWidget): - def __init__(self, parent): + def __init__(self, parent, song): QtGui.QWidget.__init__(self, parent) + self.song = song self.setupUi() self.retranslateUi() @@ -189,6 +217,7 @@ class SongReviewWidget(QtGui.QWidget): self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.LabelRole, self.songNameLabel) self.songNameContent = QtGui.QLabel(self) self.songNameContent.setObjectName('songNameContent') + self.songNameContent.setText(self.song.title) self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.FieldRole, self.songNameContent) self.songContentVerticalLayout.addLayout(self.songInfoFormLayout) self.songVerseButton = QtGui.QPushButton(self) From 1b641da997982a619401eda9d281baca8a8f6791 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sat, 12 Jan 2013 22:22:44 +0100 Subject: [PATCH 10/95] Add authors to review page. --- .../songs/forms/duplicatesongremovalform.py | 73 ++++++++++++++++--- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 0a244ecb9..e32dce30a 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -146,12 +146,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + songs[innerSongCounter].title) self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value()+1) elif self.page(pageId) == self.reviewPage: - print "asdf" - print self.duplicateSongList for duplicates in self.duplicateSongList[0:1]: - print "qwer" for duplicate in duplicates: - print "zxcv" songReviewWidget = SongReviewWidget(self.reviewPage, duplicate) self.songsHorizontalLayout.addWidget(songReviewWidget) @@ -212,13 +208,60 @@ class SongReviewWidget(QtGui.QWidget): self.songInfoFormLayout = QtGui.QFormLayout() self.songInfoFormLayout.setObjectName('songInfoFormLayout') #add ccli number, name, altname, authors, ... here - self.songNameLabel = QtGui.QLabel(self) - self.songNameLabel.setObjectName('songNameLabel') - self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.LabelRole, self.songNameLabel) - self.songNameContent = QtGui.QLabel(self) - self.songNameContent.setObjectName('songNameContent') - self.songNameContent.setText(self.song.title) - self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.FieldRole, self.songNameContent) + self.songTitleLabel = QtGui.QLabel(self) + self.songTitleLabel.setObjectName('songTitleLabel') + self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.LabelRole, self.songTitleLabel) + self.songTitleContent = QtGui.QLabel(self) + self.songTitleContent.setObjectName('songTitleContent') + self.songTitleContent.setText(self.song.title) + self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.FieldRole, self.songTitleContent) + self.songAlternateTitleLabel = QtGui.QLabel(self) + self.songAlternateTitleLabel.setObjectName('songAlternateTitleLabel') + self.songInfoFormLayout.setWidget(1, QtGui.QFormLayout.LabelRole, self.songAlternateTitleLabel) + self.songAlternateTitleContent = QtGui.QLabel(self) + self.songAlternateTitleContent.setObjectName('songAlternateTitleContent') + self.songAlternateTitleContent.setText(self.song.alternate_title) + self.songInfoFormLayout.setWidget(1, QtGui.QFormLayout.FieldRole, self.songAlternateTitleContent) + self.songCCLINumberLabel = QtGui.QLabel(self) + self.songCCLINumberLabel.setObjectName('songCCLINumberLabel') + self.songInfoFormLayout.setWidget(2, QtGui.QFormLayout.LabelRole, self.songCCLINumberLabel) + self.songCCLINumberContent = QtGui.QLabel(self) + self.songCCLINumberContent.setObjectName('songCCLINumberContent') + self.songCCLINumberContent.setText(self.song.ccli_number) + self.songInfoFormLayout.setWidget(2, QtGui.QFormLayout.FieldRole, self.songCCLINumberContent) + self.songVerseOrderLabel = QtGui.QLabel(self) + self.songVerseOrderLabel.setObjectName('songVerseOrderLabel') + self.songInfoFormLayout.setWidget(3, QtGui.QFormLayout.LabelRole, self.songVerseOrderLabel) + self.songVerseOrderContent = QtGui.QLabel(self) + self.songVerseOrderContent.setObjectName('songVerseOrderContent') + self.songVerseOrderContent.setText(self.song.verse_order) + self.songInfoFormLayout.setWidget(3, QtGui.QFormLayout.FieldRole, self.songVerseOrderContent) + self.songCopyrightLabel = QtGui.QLabel(self) + self.songCopyrightLabel.setObjectName('songCopyrightLabel') + self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.LabelRole, self.songCopyrightLabel) + self.songCopyrightContent = QtGui.QLabel(self) + self.songCopyrightContent.setObjectName('songCopyrightContent') + self.songCopyrightContent.setText(self.song.copyright) + self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.FieldRole, self.songCopyrightContent) + self.songCommentsLabel = QtGui.QLabel(self) + self.songCommentsLabel.setObjectName('songCommentsLabel') + self.songInfoFormLayout.setWidget(5, QtGui.QFormLayout.LabelRole, self.songCommentsLabel) + self.songCommentsContent = QtGui.QLabel(self) + self.songCommentsContent.setObjectName('songCommentsContent') + self.songCommentsContent.setText(self.song.comments) + self.songInfoFormLayout.setWidget(5, QtGui.QFormLayout.FieldRole, self.songCommentsContent) + self.songAuthorsLabel = QtGui.QLabel(self) + self.songAuthorsLabel.setObjectName('songAuthorsLabel') + self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.LabelRole, self.songAuthorsLabel) + self.songAuthorsContent = QtGui.QLabel(self) + self.songAuthorsContent.setObjectName('songAuthorsContent') + authorsText = u'' + for author in self.song.authors: + authorsText += author.display_name + ', ' + if authorsText: + authorsText = authorsText[:-2] + self.songAuthorsContent.setText(authorsText) + self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.FieldRole, self.songAuthorsContent) self.songContentVerticalLayout.addLayout(self.songInfoFormLayout) self.songVerseButton = QtGui.QPushButton(self) self.songVerseButton.setObjectName('songVerseButton') @@ -231,5 +274,11 @@ class SongReviewWidget(QtGui.QWidget): def retranslateUi(self): self.songRemoveButton.setText(u'Remove') - self.songNameLabel.setText(u'Name:') + self.songTitleLabel.setText(u'Title:') + self.songAlternateTitleLabel.setText(u'Alternate Title:') + self.songCCLINumberLabel.setText(u'CCLI Number:') + self.songVerseOrderLabel.setText(u'Verse Order:') + self.songCopyrightLabel.setText(u'Copyright:') + self.songCommentsLabel.setText(u'Comments:') + self.songAuthorsLabel.setText(u'Authors:') From 90c83dbbe6abd04fa1d9036a732e5a28440ed099 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sun, 13 Jan 2013 23:59:58 +0100 Subject: [PATCH 11/95] Got layout to sort of work. --- .../songs/forms/duplicatesongremovalform.py | 91 ++++++++++++++----- 1 file changed, 67 insertions(+), 24 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index e32dce30a..99b19bb1b 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -106,9 +106,25 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.reviewCounterLabel = QtGui.QLabel(self.reviewPage) self.reviewCounterLabel.setObjectName('reviewCounterLabel') self.headerVerticalLayout.addWidget(self.reviewCounterLabel) - self.songsHorizontalLayout = QtGui.QHBoxLayout() + + #self.songsHorizontalLayout = QtGui.QHBoxLayout() + #self.songsHorizontalLayout.setObjectName('songsHorizontalLayout') + #self.headerVerticalLayout.addLayout(self.songsHorizontalLayout) + + self.songsHorizontalScrollArea = QtGui.QScrollArea(self.reviewPage) + self.songsHorizontalScrollArea.setObjectName('songsHorizontalScrollArea') + self.songsHorizontalScrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.songsHorizontalScrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.songsHorizontalScrollArea.setFrameStyle(QtGui.QFrame.NoFrame) + self.songsHorizontalScrollArea.setWidgetResizable(True) + self.songsHorizontalSongsWidget = QtGui.QWidget(self.songsHorizontalScrollArea) + self.songsHorizontalSongsWidget.setObjectName('songsHorizontalSongsWidget') + self.songsHorizontalLayout = QtGui.QHBoxLayout(self.songsHorizontalSongsWidget) self.songsHorizontalLayout.setObjectName('songsHorizontalLayout') - self.headerVerticalLayout.addLayout(self.songsHorizontalLayout) + self.songsHorizontalLayout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) + self.songsHorizontalScrollArea.setWidget(self.songsHorizontalSongsWidget) + self.headerVerticalLayout.addWidget(self.songsHorizontalScrollArea) + self.addPage(self.reviewPage) def retranslateUi(self): @@ -146,10 +162,15 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + songs[innerSongCounter].title) self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value()+1) elif self.page(pageId) == self.reviewPage: + #a stretch doesn't seem to stretch endlessly, so I add two to get enough stetch for 1400x1050 + self.songsHorizontalLayout.addStretch() + self.songsHorizontalLayout.addStretch() for duplicates in self.duplicateSongList[0:1]: for duplicate in duplicates: songReviewWidget = SongReviewWidget(self.reviewPage, duplicate) self.songsHorizontalLayout.addWidget(songReviewWidget) + self.songsHorizontalLayout.addStretch() + self.songsHorizontalLayout.addStretch() def addDuplicatesToSongList(self, searchSong, duplicateSong): duplicateGroupFound = False @@ -161,7 +182,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): duplicateGroupFound = True break elif duplicate == duplicateSong: - duplicates.appen(searchSong) + duplicates.append(searchSong) duplicateGroupFound = True break if duplicateGroupFound: @@ -200,61 +221,80 @@ class SongReviewWidget(QtGui.QWidget): def setupUi(self): self.songVerticalLayout = QtGui.QVBoxLayout(self) - self.songVerticalLayout.setObjectName('songVerticalLayout') + self.songVerticalLayout.setObjectName(u'songVerticalLayout') self.songGroupBox = QtGui.QGroupBox(self) - self.songGroupBox.setObjectName('songGroupBox') - self.songContentVerticalLayout = QtGui.QVBoxLayout(self.songGroupBox) - self.songContentVerticalLayout.setObjectName('songContentVerticalLayout') + self.songGroupBox.setObjectName(u'songGroupBox') + self.songGroupBox.setMinimumWidth(300) + self.songGroupBox.setMaximumWidth(300) + #self.songGroupBox.setMinimumHeight(300) + #self.songGroupBox.setMaximumHeight(300) + self.songGroupBoxLayout = QtGui.QVBoxLayout(self.songGroupBox) + self.songGroupBoxLayout.setObjectName(u'songGroupBoxLayout') + self.songScrollArea = QtGui.QScrollArea(self) + self.songScrollArea.setObjectName(u'songScrollArea') + self.songScrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.songScrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.songScrollArea.setFrameStyle(QtGui.QFrame.NoFrame) + self.songScrollArea.setWidgetResizable(True) + self.songContentWidget = QtGui.QWidget(self.songScrollArea) + self.songContentWidget.setObjectName(u'songContentWidget') + #self.songContentWidget.setMinimumWidth(300) + #self.songContentWidget.setMaximumWidth(300) + self.songContentVerticalLayout = QtGui.QVBoxLayout(self.songContentWidget) + self.songContentVerticalLayout.setObjectName(u'songContentVerticalLayout') + self.songContentVerticalLayout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) self.songInfoFormLayout = QtGui.QFormLayout() - self.songInfoFormLayout.setObjectName('songInfoFormLayout') + self.songInfoFormLayout.setObjectName(u'songInfoFormLayout') #add ccli number, name, altname, authors, ... here self.songTitleLabel = QtGui.QLabel(self) - self.songTitleLabel.setObjectName('songTitleLabel') + self.songTitleLabel.setObjectName(u'songTitleLabel') self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.LabelRole, self.songTitleLabel) self.songTitleContent = QtGui.QLabel(self) - self.songTitleContent.setObjectName('songTitleContent') + self.songTitleContent.setObjectName(u'songTitleContent') self.songTitleContent.setText(self.song.title) self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.FieldRole, self.songTitleContent) self.songAlternateTitleLabel = QtGui.QLabel(self) - self.songAlternateTitleLabel.setObjectName('songAlternateTitleLabel') + self.songAlternateTitleLabel.setObjectName(u'songAlternateTitleLabel') self.songInfoFormLayout.setWidget(1, QtGui.QFormLayout.LabelRole, self.songAlternateTitleLabel) self.songAlternateTitleContent = QtGui.QLabel(self) - self.songAlternateTitleContent.setObjectName('songAlternateTitleContent') + self.songAlternateTitleContent.setObjectName(u'songAlternateTitleContent') self.songAlternateTitleContent.setText(self.song.alternate_title) self.songInfoFormLayout.setWidget(1, QtGui.QFormLayout.FieldRole, self.songAlternateTitleContent) self.songCCLINumberLabel = QtGui.QLabel(self) - self.songCCLINumberLabel.setObjectName('songCCLINumberLabel') + self.songCCLINumberLabel.setObjectName(u'songCCLINumberLabel') self.songInfoFormLayout.setWidget(2, QtGui.QFormLayout.LabelRole, self.songCCLINumberLabel) self.songCCLINumberContent = QtGui.QLabel(self) - self.songCCLINumberContent.setObjectName('songCCLINumberContent') + self.songCCLINumberContent.setObjectName(u'songCCLINumberContent') self.songCCLINumberContent.setText(self.song.ccli_number) self.songInfoFormLayout.setWidget(2, QtGui.QFormLayout.FieldRole, self.songCCLINumberContent) self.songVerseOrderLabel = QtGui.QLabel(self) - self.songVerseOrderLabel.setObjectName('songVerseOrderLabel') + self.songVerseOrderLabel.setObjectName(u'songVerseOrderLabel') self.songInfoFormLayout.setWidget(3, QtGui.QFormLayout.LabelRole, self.songVerseOrderLabel) self.songVerseOrderContent = QtGui.QLabel(self) - self.songVerseOrderContent.setObjectName('songVerseOrderContent') + self.songVerseOrderContent.setObjectName(u'songVerseOrderContent') self.songVerseOrderContent.setText(self.song.verse_order) self.songInfoFormLayout.setWidget(3, QtGui.QFormLayout.FieldRole, self.songVerseOrderContent) self.songCopyrightLabel = QtGui.QLabel(self) - self.songCopyrightLabel.setObjectName('songCopyrightLabel') + self.songCopyrightLabel.setObjectName(u'songCopyrightLabel') self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.LabelRole, self.songCopyrightLabel) self.songCopyrightContent = QtGui.QLabel(self) - self.songCopyrightContent.setObjectName('songCopyrightContent') + self.songCopyrightContent.setObjectName(u'songCopyrightContent') + self.songCopyrightContent.setWordWrap(True) self.songCopyrightContent.setText(self.song.copyright) self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.FieldRole, self.songCopyrightContent) self.songCommentsLabel = QtGui.QLabel(self) - self.songCommentsLabel.setObjectName('songCommentsLabel') + self.songCommentsLabel.setObjectName(u'songCommentsLabel') self.songInfoFormLayout.setWidget(5, QtGui.QFormLayout.LabelRole, self.songCommentsLabel) self.songCommentsContent = QtGui.QLabel(self) - self.songCommentsContent.setObjectName('songCommentsContent') + self.songCommentsContent.setObjectName(u'songCommentsContent') self.songCommentsContent.setText(self.song.comments) self.songInfoFormLayout.setWidget(5, QtGui.QFormLayout.FieldRole, self.songCommentsContent) self.songAuthorsLabel = QtGui.QLabel(self) - self.songAuthorsLabel.setObjectName('songAuthorsLabel') + self.songAuthorsLabel.setObjectName(u'songAuthorsLabel') self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.LabelRole, self.songAuthorsLabel) self.songAuthorsContent = QtGui.QLabel(self) - self.songAuthorsContent.setObjectName('songAuthorsContent') + self.songAuthorsContent.setObjectName(u'songAuthorsContent') + self.songAuthorsContent.setWordWrap(True) authorsText = u'' for author in self.song.authors: authorsText += author.display_name + ', ' @@ -264,11 +304,14 @@ class SongReviewWidget(QtGui.QWidget): self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.FieldRole, self.songAuthorsContent) self.songContentVerticalLayout.addLayout(self.songInfoFormLayout) self.songVerseButton = QtGui.QPushButton(self) - self.songVerseButton.setObjectName('songVerseButton') + self.songVerseButton.setObjectName(u'songVerseButton') self.songContentVerticalLayout.addWidget(self.songVerseButton) + self.songScrollArea.setWidget(self.songContentWidget) + self.songGroupBoxLayout.addWidget(self.songScrollArea) + #self.songGroupBoxLayout.addStretch() self.songVerticalLayout.addWidget(self.songGroupBox) self.songRemoveButton = QtGui.QPushButton(self) - self.songRemoveButton.setObjectName('songRemoveButton') + self.songRemoveButton.setObjectName(u'songRemoveButton') self.songRemoveButton.setIcon(build_icon(u':/songs/song_delete.png')) self.songVerticalLayout.addWidget(self.songRemoveButton) From bdac939dcde13daaf2317490c1f4f2357ad33138 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 14 Jan 2013 21:58:09 +0100 Subject: [PATCH 12/95] Lots of polishing. --- .../songs/forms/duplicatesongremovalform.py | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 99b19bb1b..20a1954cb 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -35,12 +35,12 @@ import os from PyQt4 import QtCore, QtGui -from openlp.core.lib import Receiver, Settings, SettingsManager, translate, build_icon +from openlp.core.lib import translate, build_icon from openlp.core.lib.db import Manager from openlp.core.lib.ui import UiStrings, critical_error_message_box from openlp.core.ui.wizard import OpenLPWizard, WizardStrings from openlp.plugins.songs.lib.db import Song -from openlp.plugins.songs.lib.importer import SongFormat, SongFormatSelect +from openlp.plugins.songs.lib.xml import SongXML from openlp.plugins.songs.lib.duplicatesongfinder import DuplicateSongFinder log = logging.getLogger(__name__) @@ -106,25 +106,21 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.reviewCounterLabel = QtGui.QLabel(self.reviewPage) self.reviewCounterLabel.setObjectName('reviewCounterLabel') self.headerVerticalLayout.addWidget(self.reviewCounterLabel) - - #self.songsHorizontalLayout = QtGui.QHBoxLayout() - #self.songsHorizontalLayout.setObjectName('songsHorizontalLayout') - #self.headerVerticalLayout.addLayout(self.songsHorizontalLayout) - self.songsHorizontalScrollArea = QtGui.QScrollArea(self.reviewPage) self.songsHorizontalScrollArea.setObjectName('songsHorizontalScrollArea') self.songsHorizontalScrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) self.songsHorizontalScrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.songsHorizontalScrollArea.setFrameStyle(QtGui.QFrame.NoFrame) self.songsHorizontalScrollArea.setWidgetResizable(True) + self.songsHorizontalScrollArea.setStyleSheet("QScrollArea#songsHorizontalScrollArea {background-color:transparent;}"); self.songsHorizontalSongsWidget = QtGui.QWidget(self.songsHorizontalScrollArea) self.songsHorizontalSongsWidget.setObjectName('songsHorizontalSongsWidget') + self.songsHorizontalSongsWidget.setStyleSheet("QWidget#songsHorizontalSongsWidget {background-color:transparent;}"); self.songsHorizontalLayout = QtGui.QHBoxLayout(self.songsHorizontalSongsWidget) self.songsHorizontalLayout.setObjectName('songsHorizontalLayout') self.songsHorizontalLayout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) self.songsHorizontalScrollArea.setWidget(self.songsHorizontalSongsWidget) self.headerVerticalLayout.addWidget(self.songsHorizontalScrollArea) - self.addPage(self.reviewPage) def retranslateUi(self): @@ -226,8 +222,6 @@ class SongReviewWidget(QtGui.QWidget): self.songGroupBox.setObjectName(u'songGroupBox') self.songGroupBox.setMinimumWidth(300) self.songGroupBox.setMaximumWidth(300) - #self.songGroupBox.setMinimumHeight(300) - #self.songGroupBox.setMaximumHeight(300) self.songGroupBoxLayout = QtGui.QVBoxLayout(self.songGroupBox) self.songGroupBoxLayout.setObjectName(u'songGroupBoxLayout') self.songScrollArea = QtGui.QScrollArea(self) @@ -238,14 +232,11 @@ class SongReviewWidget(QtGui.QWidget): self.songScrollArea.setWidgetResizable(True) self.songContentWidget = QtGui.QWidget(self.songScrollArea) self.songContentWidget.setObjectName(u'songContentWidget') - #self.songContentWidget.setMinimumWidth(300) - #self.songContentWidget.setMaximumWidth(300) self.songContentVerticalLayout = QtGui.QVBoxLayout(self.songContentWidget) self.songContentVerticalLayout.setObjectName(u'songContentVerticalLayout') self.songContentVerticalLayout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) self.songInfoFormLayout = QtGui.QFormLayout() self.songInfoFormLayout.setObjectName(u'songInfoFormLayout') - #add ccli number, name, altname, authors, ... here self.songTitleLabel = QtGui.QLabel(self) self.songTitleLabel.setObjectName(u'songTitleLabel') self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.LabelRole, self.songTitleLabel) @@ -303,17 +294,30 @@ class SongReviewWidget(QtGui.QWidget): self.songAuthorsContent.setText(authorsText) self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.FieldRole, self.songAuthorsContent) self.songContentVerticalLayout.addLayout(self.songInfoFormLayout) + + + + songXml = SongXML() + verses = songXml.get_verses(self.song.lyrics) + print verses + + + self.songVerseButton = QtGui.QPushButton(self) self.songVerseButton.setObjectName(u'songVerseButton') self.songContentVerticalLayout.addWidget(self.songVerseButton) + + + + self.songContentVerticalLayout.addStretch() self.songScrollArea.setWidget(self.songContentWidget) self.songGroupBoxLayout.addWidget(self.songScrollArea) - #self.songGroupBoxLayout.addStretch() self.songVerticalLayout.addWidget(self.songGroupBox) self.songRemoveButton = QtGui.QPushButton(self) self.songRemoveButton.setObjectName(u'songRemoveButton') self.songRemoveButton.setIcon(build_icon(u':/songs/song_delete.png')) - self.songVerticalLayout.addWidget(self.songRemoveButton) + self.songRemoveButton.setSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) + self.songVerticalLayout.addWidget(self.songRemoveButton, alignment = QtCore.Qt.AlignHCenter) def retranslateUi(self): self.songRemoveButton.setText(u'Remove') From abd2d783aa4fc19a9e9605a2a77c6cb7b4cd5cae Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 14 Jan 2013 23:02:32 +0100 Subject: [PATCH 13/95] Add verses to review page. --- .../songs/forms/duplicatesongremovalform.py | 49 +++++++++---------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 20a1954cb..63f018b01 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -258,31 +258,24 @@ class SongReviewWidget(QtGui.QWidget): self.songCCLINumberContent.setObjectName(u'songCCLINumberContent') self.songCCLINumberContent.setText(self.song.ccli_number) self.songInfoFormLayout.setWidget(2, QtGui.QFormLayout.FieldRole, self.songCCLINumberContent) - self.songVerseOrderLabel = QtGui.QLabel(self) - self.songVerseOrderLabel.setObjectName(u'songVerseOrderLabel') - self.songInfoFormLayout.setWidget(3, QtGui.QFormLayout.LabelRole, self.songVerseOrderLabel) - self.songVerseOrderContent = QtGui.QLabel(self) - self.songVerseOrderContent.setObjectName(u'songVerseOrderContent') - self.songVerseOrderContent.setText(self.song.verse_order) - self.songInfoFormLayout.setWidget(3, QtGui.QFormLayout.FieldRole, self.songVerseOrderContent) self.songCopyrightLabel = QtGui.QLabel(self) self.songCopyrightLabel.setObjectName(u'songCopyrightLabel') - self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.LabelRole, self.songCopyrightLabel) + self.songInfoFormLayout.setWidget(3, QtGui.QFormLayout.LabelRole, self.songCopyrightLabel) self.songCopyrightContent = QtGui.QLabel(self) self.songCopyrightContent.setObjectName(u'songCopyrightContent') self.songCopyrightContent.setWordWrap(True) self.songCopyrightContent.setText(self.song.copyright) - self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.FieldRole, self.songCopyrightContent) + self.songInfoFormLayout.setWidget(3, QtGui.QFormLayout.FieldRole, self.songCopyrightContent) self.songCommentsLabel = QtGui.QLabel(self) self.songCommentsLabel.setObjectName(u'songCommentsLabel') - self.songInfoFormLayout.setWidget(5, QtGui.QFormLayout.LabelRole, self.songCommentsLabel) + self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.LabelRole, self.songCommentsLabel) self.songCommentsContent = QtGui.QLabel(self) self.songCommentsContent.setObjectName(u'songCommentsContent') self.songCommentsContent.setText(self.song.comments) - self.songInfoFormLayout.setWidget(5, QtGui.QFormLayout.FieldRole, self.songCommentsContent) + self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.FieldRole, self.songCommentsContent) self.songAuthorsLabel = QtGui.QLabel(self) self.songAuthorsLabel.setObjectName(u'songAuthorsLabel') - self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.LabelRole, self.songAuthorsLabel) + self.songInfoFormLayout.setWidget(5, QtGui.QFormLayout.LabelRole, self.songAuthorsLabel) self.songAuthorsContent = QtGui.QLabel(self) self.songAuthorsContent.setObjectName(u'songAuthorsContent') self.songAuthorsContent.setWordWrap(True) @@ -292,23 +285,26 @@ class SongReviewWidget(QtGui.QWidget): if authorsText: authorsText = authorsText[:-2] self.songAuthorsContent.setText(authorsText) - self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.FieldRole, self.songAuthorsContent) + self.songInfoFormLayout.setWidget(5, QtGui.QFormLayout.FieldRole, self.songAuthorsContent) + self.songVerseOrderLabel = QtGui.QLabel(self) + self.songVerseOrderLabel.setObjectName(u'songVerseOrderLabel') + self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.LabelRole, self.songVerseOrderLabel) + self.songVerseOrderContent = QtGui.QLabel(self) + self.songVerseOrderContent.setObjectName(u'songVerseOrderContent') + self.songVerseOrderContent.setText(self.song.verse_order) + self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.FieldRole, self.songVerseOrderContent) self.songContentVerticalLayout.addLayout(self.songInfoFormLayout) - - - + self.songInfoVerseGroupBox = QtGui.QGroupBox(self.songGroupBox) + self.songInfoVerseGroupBox.setObjectName(u'songInfoVerseGroupBox') + self.songInfoVerseGroupBoxLayout = QtGui.QFormLayout(self.songInfoVerseGroupBox) songXml = SongXML() verses = songXml.get_verses(self.song.lyrics) - print verses - - - - self.songVerseButton = QtGui.QPushButton(self) - self.songVerseButton.setObjectName(u'songVerseButton') - self.songContentVerticalLayout.addWidget(self.songVerseButton) - - - + for verse in verses: + verseMarker = verse[0]['type'] + verse[0]['label'] + verseLabel = QtGui.QLabel(self.songInfoVerseGroupBox) + verseLabel.setText(verse[1]) + self.songInfoVerseGroupBoxLayout.addRow(verseMarker, verseLabel) + self.songContentVerticalLayout.addWidget(self.songInfoVerseGroupBox) self.songContentVerticalLayout.addStretch() self.songScrollArea.setWidget(self.songContentWidget) self.songGroupBoxLayout.addWidget(self.songScrollArea) @@ -328,4 +324,5 @@ class SongReviewWidget(QtGui.QWidget): self.songCopyrightLabel.setText(u'Copyright:') self.songCommentsLabel.setText(u'Comments:') self.songAuthorsLabel.setText(u'Authors:') + self.songInfoVerseGroupBox.setTitle(u'Verses') From 6f50adc8c6ea2257cae53fa60880ccfbc1ec1d67 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 15 Jan 2013 21:48:05 +0100 Subject: [PATCH 14/95] Implement deletion of songs. --- .../songs/forms/duplicatesongremovalform.py | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 63f018b01..1584cde1b 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -39,7 +39,8 @@ from openlp.core.lib import translate, build_icon from openlp.core.lib.db import Manager from openlp.core.lib.ui import UiStrings, critical_error_message_box from openlp.core.ui.wizard import OpenLPWizard, WizardStrings -from openlp.plugins.songs.lib.db import Song +from openlp.core.utils import AppLocation +from openlp.plugins.songs.lib.db import Song, MediaFile from openlp.plugins.songs.lib.xml import SongXML from openlp.plugins.songs.lib.duplicatesongfinder import DuplicateSongFinder @@ -164,6 +165,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): for duplicates in self.duplicateSongList[0:1]: for duplicate in duplicates: songReviewWidget = SongReviewWidget(self.reviewPage, duplicate) + QtCore.QObject.connect(songReviewWidget, + QtCore.SIGNAL(u'songRemoveButtonClicked(PyQt_PyObject)'), + self.removeButtonClicked) self.songsHorizontalLayout.addWidget(songReviewWidget) self.songsHorizontalLayout.addStretch() self.songsHorizontalLayout.addStretch() @@ -208,12 +212,37 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ pass + def removeButtonClicked(self, songReviewWidget): + #TODO: turn this method into a slot + #check if last song + #disable remove button + #remove GUI elements + #remove song + item_id = songReviewWidget.song.id + media_files = self.plugin.manager.get_all_objects(MediaFile, + MediaFile.song_id == item_id) + for media_file in media_files: + try: + os.remove(media_file.file_name) + except: + log.exception('Could not remove file: %s', + media_file.file_name) + try: + save_path = os.path.join(AppLocation.get_section_data_path( + self.plugin.name), 'audio', str(item_id)) + if os.path.exists(save_path): + os.rmdir(save_path) + except OSError: + log.exception(u'Could not remove directory: %s', save_path) + self.plugin.manager.delete_object(Song, item_id) + class SongReviewWidget(QtGui.QWidget): def __init__(self, parent, song): QtGui.QWidget.__init__(self, parent) self.song = song self.setupUi() self.retranslateUi() + QtCore.QObject.connect(self.songRemoveButton, QtCore.SIGNAL(u'clicked()'), self.onRemoveButtonClicked) def setupUi(self): self.songVerticalLayout = QtGui.QVBoxLayout(self) @@ -243,6 +272,7 @@ class SongReviewWidget(QtGui.QWidget): self.songTitleContent = QtGui.QLabel(self) self.songTitleContent.setObjectName(u'songTitleContent') self.songTitleContent.setText(self.song.title) + self.songTitleContent.setWordWrap(True) self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.FieldRole, self.songTitleContent) self.songAlternateTitleLabel = QtGui.QLabel(self) self.songAlternateTitleLabel.setObjectName(u'songAlternateTitleLabel') @@ -250,6 +280,7 @@ class SongReviewWidget(QtGui.QWidget): self.songAlternateTitleContent = QtGui.QLabel(self) self.songAlternateTitleContent.setObjectName(u'songAlternateTitleContent') self.songAlternateTitleContent.setText(self.song.alternate_title) + self.songAlternateTitleContent.setWordWrap(True) self.songInfoFormLayout.setWidget(1, QtGui.QFormLayout.FieldRole, self.songAlternateTitleContent) self.songCCLINumberLabel = QtGui.QLabel(self) self.songCCLINumberLabel.setObjectName(u'songCCLINumberLabel') @@ -257,6 +288,7 @@ class SongReviewWidget(QtGui.QWidget): self.songCCLINumberContent = QtGui.QLabel(self) self.songCCLINumberContent.setObjectName(u'songCCLINumberContent') self.songCCLINumberContent.setText(self.song.ccli_number) + self.songCCLINumberContent.setWordWrap(True) self.songInfoFormLayout.setWidget(2, QtGui.QFormLayout.FieldRole, self.songCCLINumberContent) self.songCopyrightLabel = QtGui.QLabel(self) self.songCopyrightLabel.setObjectName(u'songCopyrightLabel') @@ -272,6 +304,7 @@ class SongReviewWidget(QtGui.QWidget): self.songCommentsContent = QtGui.QLabel(self) self.songCommentsContent.setObjectName(u'songCommentsContent') self.songCommentsContent.setText(self.song.comments) + self.songCommentsContent.setWordWrap(True) self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.FieldRole, self.songCommentsContent) self.songAuthorsLabel = QtGui.QLabel(self) self.songAuthorsLabel.setObjectName(u'songAuthorsLabel') @@ -292,6 +325,7 @@ class SongReviewWidget(QtGui.QWidget): self.songVerseOrderContent = QtGui.QLabel(self) self.songVerseOrderContent.setObjectName(u'songVerseOrderContent') self.songVerseOrderContent.setText(self.song.verse_order) + self.songVerseOrderContent.setWordWrap(True) self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.FieldRole, self.songVerseOrderContent) self.songContentVerticalLayout.addLayout(self.songInfoFormLayout) self.songInfoVerseGroupBox = QtGui.QGroupBox(self.songGroupBox) @@ -326,3 +360,6 @@ class SongReviewWidget(QtGui.QWidget): self.songAuthorsLabel.setText(u'Authors:') self.songInfoVerseGroupBox.setTitle(u'Verses') + def onRemoveButtonClicked(self): + self.emit(QtCore.SIGNAL(u'songRemoveButtonClicked(PyQt_PyObject)'), self) + From 4aeb91eb6662ad431f5fc783baac2ce0a914ce99 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 15 Jan 2013 23:26:46 +0100 Subject: [PATCH 15/95] Remove GUI for removed songs, grey out remove button for last song. --- .../songs/forms/duplicatesongremovalform.py | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 1584cde1b..c3a32b98f 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -77,10 +77,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ Song wizard specific signals. """ - #QtCore.QObject.connect(self.addButton, - # QtCore.SIGNAL(u'clicked()'), self.onAddButtonClicked) - #QtCore.QObject.connect(self.removeButton, - # QtCore.SIGNAL(u'clicked()'), self.onRemoveButtonClicked) + QtCore.QObject.connect(self.finishButton, QtCore.SIGNAL(u'clicked()'), self.onWizardExit) + QtCore.QObject.connect(self.cancelButton, QtCore.SIGNAL(u'clicked()'), self.onWizardExit) def addCustomPages(self): """ @@ -190,10 +188,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): if not duplicateGroupFound: self.duplicateSongList.append([searchSong, duplicateSong]) - def onAddButtonClicked(self): - pass - - def onRemoveButtonClicked(self): + def onWizardExit(self): + #refresh the song list + self.plugin.mediaItem.onSearchTextButtonClicked() pass def setDefaults(self): @@ -213,10 +210,6 @@ class DuplicateSongRemovalForm(OpenLPWizard): pass def removeButtonClicked(self, songReviewWidget): - #TODO: turn this method into a slot - #check if last song - #disable remove button - #remove GUI elements #remove song item_id = songReviewWidget.song.id media_files = self.plugin.manager.get_all_objects(MediaFile, @@ -235,6 +228,14 @@ class DuplicateSongRemovalForm(OpenLPWizard): except OSError: log.exception(u'Could not remove directory: %s', save_path) self.plugin.manager.delete_object(Song, item_id) + #remove GUI elements + self.songsHorizontalLayout.removeWidget(songReviewWidget) + songReviewWidget.setParent(None) + #check if we only have one SongReviewWidget left + # 4 stretches + 1 SongReviewWidget = 5 + # the SongReviewWidget is then at position 3 + if self.songsHorizontalLayout.count() == 5: + self.songsHorizontalLayout.itemAt(2).widget().songRemoveButton.setEnabled(False) class SongReviewWidget(QtGui.QWidget): def __init__(self, parent, song): From f0c58777a2a31d1ff578db45df4a2cde28ea4b9e Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 17 Jan 2013 23:01:05 +0100 Subject: [PATCH 16/95] Implemented reviewing all songs instead of just one. --- .../songs/forms/duplicatesongremovalform.py | 97 +++++++++++++++---- openlp/plugins/songs/songsplugin.py | 19 +--- 2 files changed, 78 insertions(+), 38 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index c3a32b98f..93611f83a 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -63,6 +63,10 @@ class DuplicateSongRemovalForm(OpenLPWizard): ``plugin`` The songs plugin. """ + from PyQt4.QtCore import pyqtRemoveInputHook + pyqtRemoveInputHook() + + self.clipboard = plugin.formParent.clipboard OpenLPWizard.__init__(self, parent, plugin, u'duplicateSongRemovalWizard', u':/wizards/wizard_importsong.bmp', False) @@ -84,7 +88,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ Add song wizard specific pages. """ - self.searchingPage = QtGui.QWizardPage() + #add custom pages + self.searchingPage = SearchWizardPage(self, self.getNextPageForSearchWizardPage) self.searchingPage.setObjectName('searchingPage') self.searchingVerticalLayout = QtGui.QVBoxLayout(self.searchingPage) self.searchingVerticalLayout.setObjectName('searchingVerticalLayout') @@ -97,7 +102,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.foundDuplicatesEdit.setReadOnly(True) self.foundDuplicatesEdit.setObjectName('foundDuplicatesEdit') self.searchingVerticalLayout.addWidget(self.foundDuplicatesEdit) - self.addPage(self.searchingPage) + self.searchingPageId = self.addPage(self.searchingPage) self.reviewPage = QtGui.QWizardPage() self.reviewPage.setObjectName('reviewPage') self.headerVerticalLayout = QtGui.QVBoxLayout(self.reviewPage) @@ -120,7 +125,10 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songsHorizontalLayout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) self.songsHorizontalScrollArea.setWidget(self.songsHorizontalSongsWidget) self.headerVerticalLayout.addWidget(self.songsHorizontalScrollArea) - self.addPage(self.reviewPage) + self.reviewPageId = self.addPage(self.reviewPage) + self.finalPage = QtGui.QWizardPage() + self.finalPage.setObjectName('finalPage') + self.finalPageId = self.addPage(self.finalPage) def retranslateUi(self): """ @@ -141,7 +149,10 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ Called when changing to a page other than the progress page. """ - if self.page(pageId) == self.searchingPage: + #hide back button + self.button(QtGui.QWizard.BackButton).hide() + + if pageId == self.searchingPageId: maxSongs = self.plugin.manager.get_object_count(Song) if maxSongs == 0 or maxSongs == 1: return @@ -156,19 +167,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.addDuplicatesToSongList(songs[outerSongCounter], songs[innerSongCounter]) self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + songs[innerSongCounter].title) self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value()+1) - elif self.page(pageId) == self.reviewPage: - #a stretch doesn't seem to stretch endlessly, so I add two to get enough stetch for 1400x1050 - self.songsHorizontalLayout.addStretch() - self.songsHorizontalLayout.addStretch() - for duplicates in self.duplicateSongList[0:1]: - for duplicate in duplicates: - songReviewWidget = SongReviewWidget(self.reviewPage, duplicate) - QtCore.QObject.connect(songReviewWidget, - QtCore.SIGNAL(u'songRemoveButtonClicked(PyQt_PyObject)'), - self.removeButtonClicked) - self.songsHorizontalLayout.addWidget(songReviewWidget) - self.songsHorizontalLayout.addStretch() - self.songsHorizontalLayout.addStretch() + elif pageId == self.reviewPageId: + self.nextReviewButtonClicked() def addDuplicatesToSongList(self, searchSong, duplicateSong): duplicateGroupFound = False @@ -209,6 +209,23 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ pass + def getNextPageForSearchWizardPage(self): + #if we have not found any duplicates we advance directly to the final page + if len(self.duplicateSongList) == 0: + return self.finalPageId + else: + return self.reviewPageId + + def validateCurrentPage(self): + if self.currentId() == self.reviewPageId: + #as long as the duplicate list is not empty we revisit the review page + if len(self.duplicateSongList) == 0: + return True + else: + self.nextReviewButtonClicked() + return False + return OpenLPWizard.validateCurrentPage(self) + def removeButtonClicked(self, songReviewWidget): #remove song item_id = songReviewWidget.song.id @@ -228,15 +245,54 @@ class DuplicateSongRemovalForm(OpenLPWizard): except OSError: log.exception(u'Could not remove directory: %s', save_path) self.plugin.manager.delete_object(Song, item_id) - #remove GUI elements + # remove GUI elements self.songsHorizontalLayout.removeWidget(songReviewWidget) songReviewWidget.setParent(None) - #check if we only have one SongReviewWidget left + # check if we only have one SongReviewWidget left # 4 stretches + 1 SongReviewWidget = 5 - # the SongReviewWidget is then at position 3 + # the SongReviewWidget is then at position 2 if self.songsHorizontalLayout.count() == 5: self.songsHorizontalLayout.itemAt(2).widget().songRemoveButton.setEnabled(False) + def nextReviewButtonClicked(self): + #show/hide finish/cancel/nextReview buttons + if len(self.duplicateSongList) <= 1: + self.button(QtGui.QWizard.CancelButton).setEnabled(False) + # remove all previous elements + for i in reversed(range(self.songsHorizontalLayout.count())): + item = self.songsHorizontalLayout.itemAt(i) + if isinstance(item, QtGui.QWidgetItem): + # the order is important here, if the .setParent(None) call is done before the .removeItem() call, a + # segfault occurs + widget = item.widget() + self.songsHorizontalLayout.removeItem(item) + widget.setParent(None) + else: + self.songsHorizontalLayout.removeItem(item) + #add next set of duplicates + if len(self.duplicateSongList) > 0: + # a stretch doesn't seem to stretch endlessly, so I add two to get enough stetch for 1400x1050 + self.songsHorizontalLayout.addStretch() + self.songsHorizontalLayout.addStretch() + for duplicate in self.duplicateSongList.pop(0): + songReviewWidget = SongReviewWidget(self.reviewPage, duplicate) + QtCore.QObject.connect(songReviewWidget, + QtCore.SIGNAL(u'songRemoveButtonClicked(PyQt_PyObject)'), + self.removeButtonClicked) + self.songsHorizontalLayout.addWidget(songReviewWidget) + self.songsHorizontalLayout.addStretch() + self.songsHorizontalLayout.addStretch() + # add counter + + +class SearchWizardPage(QtGui.QWizardPage): + def __init__(self, parent, nextPageCallback): + QtGui.QWizardPage.__init__(self, parent) + self.nextPageCallback = nextPageCallback + + def nextId(self): + return self.nextPageCallback() + class SongReviewWidget(QtGui.QWidget): def __init__(self, parent, song): QtGui.QWidget.__init__(self, parent) @@ -338,6 +394,7 @@ class SongReviewWidget(QtGui.QWidget): verseMarker = verse[0]['type'] + verse[0]['label'] verseLabel = QtGui.QLabel(self.songInfoVerseGroupBox) verseLabel.setText(verse[1]) + verseLabel.setWordWrap(True) self.songInfoVerseGroupBoxLayout.addRow(verseMarker, verseLabel) self.songContentVerticalLayout.addWidget(self.songInfoVerseGroupBox) self.songContentVerticalLayout.addStretch() diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index b006eb19a..9f69fa700 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -171,24 +171,7 @@ class SongsPlugin(Plugin): """ Search for duplicates in the song database. """ - #maxSongs = self.manager.get_object_count(Song) - #if maxSongs == 0: - # return - #QtGui.QMessageBox.information(self.formParent, - # "Find duplicates called", "Called...") - #songs = self.manager.get_all_objects(Song) - #for outerSongCounter in range(maxSongs-1): - # for innerSongCounter in range(outerSongCounter+1, maxSongs): - # doubleFinder = DuplicateSongFinder() - # if doubleFinder.songsProbablyEqual(songs[outerSongCounter], - # songs[innerSongCounter]): - # QtGui.QMessageBox.information(self.formParent, - # "Double found", str(innerSongCounter) + " " + - # str(outerSongCounter)) - if not hasattr(self, u'duplicate_removal_wizard'): - self.duplicate_removal_wizard = \ - DuplicateSongRemovalForm(self.formParent, self) - self.duplicate_removal_wizard.exec_() + DuplicateSongRemovalForm(self.formParent, self).exec_() def onSongImportItemClicked(self): if self.mediaItem: From b46fac5c829e3c23bfef13c5a06e006775f053b6 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 17 Jan 2013 23:29:37 +0100 Subject: [PATCH 17/95] Add a counter to the song review page. --- .../songs/forms/duplicatesongremovalform.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 93611f83a..57690f742 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -63,10 +63,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): ``plugin`` The songs plugin. """ - from PyQt4.QtCore import pyqtRemoveInputHook - pyqtRemoveInputHook() - - + self.duplicateSongList = [] + self.reviewCurrentCount = 0 + self.reviewTotalCount = 0 self.clipboard = plugin.formParent.clipboard OpenLPWizard.__init__(self, parent, plugin, u'duplicateSongRemovalWizard', u':/wizards/wizard_importsong.bmp', False) @@ -75,7 +74,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ Song wizard specific initialisation. """ - self.duplicateSongList = [] + pass def customSignals(self): """ @@ -141,10 +140,14 @@ class DuplicateSongRemovalForm(OpenLPWizard): 'This wizard will help you to remove duplicate songs from the song database.')) self.searchingPage.setTitle(translate('Wizard', 'Searching for doubles')) self.searchingPage.setSubTitle(translate('Wizard', 'The song database is searched for double songs.')) - self.reviewPage.setTitle(translate('Wizard', 'Review duplicate songs')) + self.updateReviewCounterText() self.reviewPage.setSubTitle(translate('Wizard', 'This page shows all duplicate songs to review which ones to remove and which ones to keep.')) + def updateReviewCounterText(self): + self.reviewPage.setTitle(translate('Wizard', 'Review duplicate songs (%s/%s)') % \ + (self.reviewCurrentCount, self.reviewTotalCount)) + def customPageChanged(self, pageId): """ Called when changing to a page other than the progress page. @@ -167,6 +170,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.addDuplicatesToSongList(songs[outerSongCounter], songs[innerSongCounter]) self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + songs[innerSongCounter].title) self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value()+1) + self.reviewTotalCount = len(self.duplicateSongList) elif pageId == self.reviewPageId: self.nextReviewButtonClicked() @@ -255,7 +259,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songsHorizontalLayout.itemAt(2).widget().songRemoveButton.setEnabled(False) def nextReviewButtonClicked(self): - #show/hide finish/cancel/nextReview buttons + self.reviewCurrentCount = self.reviewTotalCount - (len(self.duplicateSongList) - 1) + self.updateReviewCounterText() if len(self.duplicateSongList) <= 1: self.button(QtGui.QWizard.CancelButton).setEnabled(False) # remove all previous elements From bc3906fbadee345a8c7af166715b4a8bf6599128 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Fri, 18 Jan 2013 21:08:27 +0100 Subject: [PATCH 18/95] Add a finish page to the wizard. --- .../songs/forms/duplicatesongremovalform.py | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 57690f742..fd440bf3f 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -126,7 +126,19 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.headerVerticalLayout.addWidget(self.songsHorizontalScrollArea) self.reviewPageId = self.addPage(self.reviewPage) self.finalPage = QtGui.QWizardPage() - self.finalPage.setObjectName('finalPage') + self.finalPage.setObjectName(u'finalPage') + self.finalPage.setPixmap(QtGui.QWizard.WatermarkPixmap, QtGui.QPixmap(u':/wizards/wizard_importsong.bmp')) + self.finalLayout = QtGui.QVBoxLayout(self.finalPage) + self.finalLayout.setObjectName(u'finalLayout') + self.finalTitleLabel = QtGui.QLabel(self.finalPage) + self.finalTitleLabel.setObjectName(u'finalTitleLabel') + self.finalLayout.addWidget(self.finalTitleLabel) + self.finalLayout.addSpacing(40) + self.finalInformationLabel = QtGui.QLabel(self.finalPage) + self.finalInformationLabel.setWordWrap(True) + self.finalInformationLabel.setObjectName(u'finalInformationLabel') + self.finalLayout.addWidget(self.finalInformationLabel) + self.finalLayout.addStretch() self.finalPageId = self.addPage(self.finalPage) def retranslateUi(self): @@ -143,6 +155,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.updateReviewCounterText() self.reviewPage.setSubTitle(translate('Wizard', 'This page shows all duplicate songs to review which ones to remove and which ones to keep.')) + self.finalTitleLabel.setText(WizardStrings.HeaderStyle % translate('Wizard', 'Duplicate Song Removal Wizard sucessfully finished')) + self.finalInformationLabel.setText(translate('Wizard', + 'The Duplicate Song Removal Wizard has finished sucessfully.')) def updateReviewCounterText(self): self.reviewPage.setTitle(translate('Wizard', 'Review duplicate songs (%s/%s)') % \ @@ -154,8 +169,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ #hide back button self.button(QtGui.QWizard.BackButton).hide() - if pageId == self.searchingPageId: + #search duplicate songs maxSongs = self.plugin.manager.get_object_count(Song) if maxSongs == 0 or maxSongs == 1: return @@ -171,6 +186,10 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + songs[innerSongCounter].title) self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value()+1) self.reviewTotalCount = len(self.duplicateSongList) + if self.reviewTotalCount == 0: + QtGui.QMessageBox.information(self, translate('Wizard', 'Information'), + translate('Wizard', 'No duplicate songs have been found in the database.'), + QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Ok)) elif pageId == self.reviewPageId: self.nextReviewButtonClicked() @@ -259,10 +278,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songsHorizontalLayout.itemAt(2).widget().songRemoveButton.setEnabled(False) def nextReviewButtonClicked(self): + # update counter self.reviewCurrentCount = self.reviewTotalCount - (len(self.duplicateSongList) - 1) self.updateReviewCounterText() - if len(self.duplicateSongList) <= 1: - self.button(QtGui.QWizard.CancelButton).setEnabled(False) # remove all previous elements for i in reversed(range(self.songsHorizontalLayout.count())): item = self.songsHorizontalLayout.itemAt(i) @@ -287,8 +305,6 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songsHorizontalLayout.addWidget(songReviewWidget) self.songsHorizontalLayout.addStretch() self.songsHorizontalLayout.addStretch() - # add counter - class SearchWizardPage(QtGui.QWizardPage): def __init__(self, parent, nextPageCallback): From 2e7f2392e1b8ebfddd0d8d74df819fe07f5dc0d0 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 21 Jan 2013 21:14:20 +0100 Subject: [PATCH 19/95] Add better image. Remove final page, this simplifies the code in several parts. --- .../songs/forms/duplicatesongremovalform.py | 49 ++++++------------- resources/images/openlp-2.qrc | 1 + 2 files changed, 15 insertions(+), 35 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index fd440bf3f..212ff5eab 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -68,7 +68,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.reviewTotalCount = 0 self.clipboard = plugin.formParent.clipboard OpenLPWizard.__init__(self, parent, plugin, u'duplicateSongRemovalWizard', - u':/wizards/wizard_importsong.bmp', False) + u':/wizards/wizard_duplicateremoval.bmp', False) def customInit(self): """ @@ -88,7 +88,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): Add song wizard specific pages. """ #add custom pages - self.searchingPage = SearchWizardPage(self, self.getNextPageForSearchWizardPage) + self.searchingPage = QtGui.QWizardPage() self.searchingPage.setObjectName('searchingPage') self.searchingVerticalLayout = QtGui.QVBoxLayout(self.searchingPage) self.searchingVerticalLayout.setObjectName('searchingVerticalLayout') @@ -125,21 +125,10 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songsHorizontalScrollArea.setWidget(self.songsHorizontalSongsWidget) self.headerVerticalLayout.addWidget(self.songsHorizontalScrollArea) self.reviewPageId = self.addPage(self.reviewPage) - self.finalPage = QtGui.QWizardPage() - self.finalPage.setObjectName(u'finalPage') - self.finalPage.setPixmap(QtGui.QWizard.WatermarkPixmap, QtGui.QPixmap(u':/wizards/wizard_importsong.bmp')) - self.finalLayout = QtGui.QVBoxLayout(self.finalPage) - self.finalLayout.setObjectName(u'finalLayout') - self.finalTitleLabel = QtGui.QLabel(self.finalPage) - self.finalTitleLabel.setObjectName(u'finalTitleLabel') - self.finalLayout.addWidget(self.finalTitleLabel) - self.finalLayout.addSpacing(40) - self.finalInformationLabel = QtGui.QLabel(self.finalPage) - self.finalInformationLabel.setWordWrap(True) - self.finalInformationLabel.setObjectName(u'finalInformationLabel') - self.finalLayout.addWidget(self.finalInformationLabel) - self.finalLayout.addStretch() - self.finalPageId = self.addPage(self.finalPage) + #add a dummy page to the end, to prevent the finish button to appear and the next button do disappear on the + #review page + self.dummyPage = QtGui.QWizardPage() + self.dummyPageId = self.addPage(self.dummyPage) def retranslateUi(self): """ @@ -155,9 +144,6 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.updateReviewCounterText() self.reviewPage.setSubTitle(translate('Wizard', 'This page shows all duplicate songs to review which ones to remove and which ones to keep.')) - self.finalTitleLabel.setText(WizardStrings.HeaderStyle % translate('Wizard', 'Duplicate Song Removal Wizard sucessfully finished')) - self.finalInformationLabel.setText(translate('Wizard', - 'The Duplicate Song Removal Wizard has finished sucessfully.')) def updateReviewCounterText(self): self.reviewPage.setTitle(translate('Wizard', 'Review duplicate songs (%s/%s)') % \ @@ -187,6 +173,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value()+1) self.reviewTotalCount = len(self.duplicateSongList) if self.reviewTotalCount == 0: + self.button(QtGui.QWizard.FinishButton).show() + self.button(QtGui.QWizard.FinishButton).setEnabled(True) + self.button(QtGui.QWizard.NextButton).hide() QtGui.QMessageBox.information(self, translate('Wizard', 'Information'), translate('Wizard', 'No duplicate songs have been found in the database.'), QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Ok)) @@ -232,13 +221,6 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ pass - def getNextPageForSearchWizardPage(self): - #if we have not found any duplicates we advance directly to the final page - if len(self.duplicateSongList) == 0: - return self.finalPageId - else: - return self.reviewPageId - def validateCurrentPage(self): if self.currentId() == self.reviewPageId: #as long as the duplicate list is not empty we revisit the review page @@ -305,14 +287,11 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songsHorizontalLayout.addWidget(songReviewWidget) self.songsHorizontalLayout.addStretch() self.songsHorizontalLayout.addStretch() - -class SearchWizardPage(QtGui.QWizardPage): - def __init__(self, parent, nextPageCallback): - QtGui.QWizardPage.__init__(self, parent) - self.nextPageCallback = nextPageCallback - - def nextId(self): - return self.nextPageCallback() + #change next button to finish button on last review + if len(self.duplicateSongList) == 0: + self.button(QtGui.QWizard.FinishButton).show() + self.button(QtGui.QWizard.FinishButton).setEnabled(True) + self.button(QtGui.QWizard.NextButton).hide() class SongReviewWidget(QtGui.QWidget): def __init__(self, parent, song): diff --git a/resources/images/openlp-2.qrc b/resources/images/openlp-2.qrc index c68bbc4f5..76e9ca8c9 100644 --- a/resources/images/openlp-2.qrc +++ b/resources/images/openlp-2.qrc @@ -99,6 +99,7 @@ wizard_importbible.bmp wizard_firsttime.bmp wizard_createtheme.bmp + wizard_duplicateremoval.bmp service_collapse_all.png From 5f942248340e94047371d958905c705f365b6659 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 21 Jan 2013 21:18:19 +0100 Subject: [PATCH 20/95] Turn all strings into unicode strings. --- .../songs/forms/duplicatesongremovalform.py | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 212ff5eab..6ba64438f 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -89,9 +89,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ #add custom pages self.searchingPage = QtGui.QWizardPage() - self.searchingPage.setObjectName('searchingPage') + self.searchingPage.setObjectName(u'searchingPage') self.searchingVerticalLayout = QtGui.QVBoxLayout(self.searchingPage) - self.searchingVerticalLayout.setObjectName('searchingVerticalLayout') + self.searchingVerticalLayout.setObjectName(u'searchingVerticalLayout') self.duplicateSearchProgressBar = QtGui.QProgressBar(self.searchingPage) self.duplicateSearchProgressBar.setObjectName(u'duplicateSearchProgressBar') self.duplicateSearchProgressBar.setFormat(WizardStrings.PercentSymbolFormat) @@ -99,28 +99,28 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.foundDuplicatesEdit = QtGui.QPlainTextEdit(self.searchingPage) self.foundDuplicatesEdit.setUndoRedoEnabled(False) self.foundDuplicatesEdit.setReadOnly(True) - self.foundDuplicatesEdit.setObjectName('foundDuplicatesEdit') + self.foundDuplicatesEdit.setObjectName(u'foundDuplicatesEdit') self.searchingVerticalLayout.addWidget(self.foundDuplicatesEdit) self.searchingPageId = self.addPage(self.searchingPage) self.reviewPage = QtGui.QWizardPage() - self.reviewPage.setObjectName('reviewPage') + self.reviewPage.setObjectName(u'reviewPage') self.headerVerticalLayout = QtGui.QVBoxLayout(self.reviewPage) - self.headerVerticalLayout.setObjectName('headerVerticalLayout') + self.headerVerticalLayout.setObjectName(u'headerVerticalLayout') self.reviewCounterLabel = QtGui.QLabel(self.reviewPage) - self.reviewCounterLabel.setObjectName('reviewCounterLabel') + self.reviewCounterLabel.setObjectName(u'reviewCounterLabel') self.headerVerticalLayout.addWidget(self.reviewCounterLabel) self.songsHorizontalScrollArea = QtGui.QScrollArea(self.reviewPage) - self.songsHorizontalScrollArea.setObjectName('songsHorizontalScrollArea') + self.songsHorizontalScrollArea.setObjectName(u'songsHorizontalScrollArea') self.songsHorizontalScrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) self.songsHorizontalScrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.songsHorizontalScrollArea.setFrameStyle(QtGui.QFrame.NoFrame) self.songsHorizontalScrollArea.setWidgetResizable(True) - self.songsHorizontalScrollArea.setStyleSheet("QScrollArea#songsHorizontalScrollArea {background-color:transparent;}"); + self.songsHorizontalScrollArea.setStyleSheet(u'QScrollArea#songsHorizontalScrollArea {background-color:transparent;}') self.songsHorizontalSongsWidget = QtGui.QWidget(self.songsHorizontalScrollArea) - self.songsHorizontalSongsWidget.setObjectName('songsHorizontalSongsWidget') - self.songsHorizontalSongsWidget.setStyleSheet("QWidget#songsHorizontalSongsWidget {background-color:transparent;}"); + self.songsHorizontalSongsWidget.setObjectName(u'songsHorizontalSongsWidget') + self.songsHorizontalSongsWidget.setStyleSheet(u'QWidget#songsHorizontalSongsWidget {background-color:transparent;}') self.songsHorizontalLayout = QtGui.QHBoxLayout(self.songsHorizontalSongsWidget) - self.songsHorizontalLayout.setObjectName('songsHorizontalLayout') + self.songsHorizontalLayout.setObjectName(u'songsHorizontalLayout') self.songsHorizontalLayout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) self.songsHorizontalScrollArea.setWidget(self.songsHorizontalSongsWidget) self.headerVerticalLayout.addWidget(self.songsHorizontalScrollArea) @@ -134,19 +134,19 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ Song wizard localisation. """ - self.setWindowTitle(translate('Wizard', 'Wizard')) - self.titleLabel.setText(WizardStrings.HeaderStyle % translate('OpenLP.Ui', - 'Welcome to the Duplicate Song Removal Wizard')) + self.setWindowTitle(translate(u'Wizard', u'Wizard')) + self.titleLabel.setText(WizardStrings.HeaderStyle % translate(u'OpenLP.Ui', + u'Welcome to the Duplicate Song Removal Wizard')) self.informationLabel.setText(translate("Wizard", - 'This wizard will help you to remove duplicate songs from the song database.')) - self.searchingPage.setTitle(translate('Wizard', 'Searching for doubles')) - self.searchingPage.setSubTitle(translate('Wizard', 'The song database is searched for double songs.')) + u'This wizard will help you to remove duplicate songs from the song database.')) + self.searchingPage.setTitle(translate(u'Wizard', u'Searching for doubles')) + self.searchingPage.setSubTitle(translate(u'Wizard', u'The song database is searched for double songs.')) self.updateReviewCounterText() - self.reviewPage.setSubTitle(translate('Wizard', - 'This page shows all duplicate songs to review which ones to remove and which ones to keep.')) + self.reviewPage.setSubTitle(translate(u'Wizard', + u'This page shows all duplicate songs to review which ones to remove and which ones to keep.')) def updateReviewCounterText(self): - self.reviewPage.setTitle(translate('Wizard', 'Review duplicate songs (%s/%s)') % \ + self.reviewPage.setTitle(translate(u'Wizard', u'Review duplicate songs (%s/%s)') % \ (self.reviewCurrentCount, self.reviewTotalCount)) def customPageChanged(self, pageId): @@ -176,8 +176,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.button(QtGui.QWizard.FinishButton).show() self.button(QtGui.QWizard.FinishButton).setEnabled(True) self.button(QtGui.QWizard.NextButton).hide() - QtGui.QMessageBox.information(self, translate('Wizard', 'Information'), - translate('Wizard', 'No duplicate songs have been found in the database.'), + QtGui.QMessageBox.information(self, translate(u'Wizard', u'Information'), + translate(u'Wizard', u'No duplicate songs have been found in the database.'), QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Ok)) elif pageId == self.reviewPageId: self.nextReviewButtonClicked() @@ -240,11 +240,11 @@ class DuplicateSongRemovalForm(OpenLPWizard): try: os.remove(media_file.file_name) except: - log.exception('Could not remove file: %s', + log.exception(u'Could not remove file: %s', media_file.file_name) try: save_path = os.path.join(AppLocation.get_section_data_path( - self.plugin.name), 'audio', str(item_id)) + self.plugin.name), u'audio', str(item_id)) if os.path.exists(save_path): os.rmdir(save_path) except OSError: From 398b71dda1d51c8ba3ffa8f7fea4290f584ccc46 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 21 Jan 2013 21:27:56 +0100 Subject: [PATCH 21/95] Clarify wizard page descriptions a bit. --- openlp/plugins/songs/forms/duplicatesongremovalform.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 6ba64438f..b6bf29cdf 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -138,12 +138,14 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.titleLabel.setText(WizardStrings.HeaderStyle % translate(u'OpenLP.Ui', u'Welcome to the Duplicate Song Removal Wizard')) self.informationLabel.setText(translate("Wizard", - u'This wizard will help you to remove duplicate songs from the song database.')) - self.searchingPage.setTitle(translate(u'Wizard', u'Searching for doubles')) + u'This wizard will help you to remove duplicate songs from the song database. You will have a chance to ' + u'review every potential duplicate song before it is deleted. So no songs will be deleted without your ' + u'explicit approval.')) + self.searchingPage.setTitle(translate(u'Wizard', u'Searching for duplicate songs.')) self.searchingPage.setSubTitle(translate(u'Wizard', u'The song database is searched for double songs.')) self.updateReviewCounterText() self.reviewPage.setSubTitle(translate(u'Wizard', - u'This page shows all duplicate songs to review which ones to remove and which ones to keep.')) + u'Here you can decide which songs to remove and which ones to keep.')) def updateReviewCounterText(self): self.reviewPage.setTitle(translate(u'Wizard', u'Review duplicate songs (%s/%s)') % \ From 071fc4799d83ad1db02e156aac965532d46037c0 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 21 Jan 2013 21:30:49 +0100 Subject: [PATCH 22/95] Remove an unused function and a dummy pass statement. --- openlp/plugins/songs/forms/duplicatesongremovalform.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index b6bf29cdf..422c5d871 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -205,7 +205,6 @@ class DuplicateSongRemovalForm(OpenLPWizard): def onWizardExit(self): #refresh the song list self.plugin.mediaItem.onSearchTextButtonClicked() - pass def setDefaults(self): """ @@ -215,14 +214,6 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.duplicateSearchProgressBar.setValue(0) self.foundDuplicatesEdit.clear() - def performWizard(self): - """ - Perform the actual import. This method pulls in the correct importer - class, and then runs the ``doImport`` method of the importer to do - the actual importing. - """ - pass - def validateCurrentPage(self): if self.currentId() == self.reviewPageId: #as long as the duplicate list is not empty we revisit the review page From 09d99c689bd9e1e1abcd0e103a6bb58de0633c0e Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 21 Jan 2013 21:59:14 +0100 Subject: [PATCH 23/95] Add missing resource image. --- resources/images/wizard_duplicateremoval.bmp | Bin 0 -> 172254 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 resources/images/wizard_duplicateremoval.bmp diff --git a/resources/images/wizard_duplicateremoval.bmp b/resources/images/wizard_duplicateremoval.bmp new file mode 100644 index 0000000000000000000000000000000000000000..ac937714fb4edea8f5d5583b3baa1cdbfaccb675 GIT binary patch literal 172254 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%0AuXenhq!3n*Xj`*4TR%~`2%8=qgpzSS)%F2)3 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 RjRJLD@|m&sER!qPaW_nY&gi%u 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¨9AoxJBAmV>Av zX1H}m-1t~nJ04JVC$Qo=n56-wC%yCcc<1f(%jtDXYzNjn+H#La$^bH9R0+x#p~DiUD|Qs*|CvtfUh2FJm8#}{2oAm!kX;*7d_TPaN|$^7*Lmvx*C?kX?w&)5EV}ola@*6ewg-|{xN)*;DX`x3Sh;;h z+cSst0g1cc#_fEg>7G+;g@*jIkh>XE<-EDN}S?brdOhx|+S1{C!BWp+BnqNuXo zUQ%Z1lf2A9h9>f2OJ`Is$!QxyK%27=L~bK2H9%5ezu27C2+{@mH~)g8N`O>D7P7XNm4M=w7Y-w-G7V0${KJnx9M5N_VBoOsPd@zOqhA@U>Bj+NK0u#J=AO(@UKOqiSN#@-O%vf*;7cN6-h-jj?m4Gji zy+N17h#6zuK$)=ClMEU55#8EII*6x4!{c<8nj#}ZeHDX>7HlUg2Nz7PVYmsTS)Okg z4&Rrsw)gON@Cb193}n0ev)urC01CzS?miq>PmZe>+u7UR#nZvf%hAn;=i%qz;lp+J zws-TQ`x=uSd*PqwG3x=ESv1`ErfEsbDDG>?hA~{(825k530N^s=g(v91^ZP6}bGcuwDT*OKjk;X|F#r2nS{lcAs74Bg= z@9=nUd0e0>IYg5x(xwKflRPDAzJDk(Yj=q@vAnr{*OmUE=O;%0jxhVe<6ka3`WXk$ zXQnCqCO1C))47MgBJc*jLr-AQe_VX>+r=mN&aa5ok#;p5nq1X#bVt{rk%5aZuHAff zf8@pWt0#MRL`SKyBMI-`A~PsUTY&?!gdu+}!YWltvM@(R1RKDL-2u$41hH{EgRWq+ z&=iAaa0rAxF=jt=%;?tEbIcgN=vM~BM#FbdO zi0>o9c8~uYtbTCt@aMYu+q?MzZjKvZ_2;_!VcVv?s}I-BkLMBK;O+;kx$cB@5+*zL z!i~R%q5|fuwcX%>Ka#ag$XX`DHjF`{C>oZaP z!@$aW=x>Iuy#+nPpz_nfD~^fE4hai(`J`@liES5X>bcStHvY*hcLm!+DiA7z)M=4v z%hkD+n!>fZ(z=B5rli$v>1(#;)OP0Abro*dQ9!4!uyI#jLtk!vZ*F~0UPDh|Q(tLI ze_7kERh##&+O(&C+olS2;R+kVv~ZTn$w_@kyyGR`Za7Q@rsh)HK3)4kjxJV*Ic8q-O#h zc?VtG;M9=`6ML2(aBCAyR8O!(c^tcJ46RUY1I-4zbP;QTAdPz&zMAV3?C2}zdIdXp z2JyXv^;kQ42>ISYFmNDlPbiaV+q?NXxDnid*wH-zP9C2GJ!98sXksdp6p(h!L#9&K zg9gWA*~Up}%Os2%j3a5&gnSd&<1c_UOk2}4tK9yAVzZ{l!S&MtzT0tezM=nK<%R>hx(;02d*s%MQzIu%9@@GsOdO6V*M#M` zjEWUZ7lYQT!NAd@VWB2QMSH62qB;tiI+mdm%KD?nT_2vo)dOn=ScQLR=N(IwKT~xklTG}?H#~Pko z);gtxX>Wg_>z)PHQQKaGw?7d#Jrb@P_pcfWSba-ac|EM=nyBhr$g0yJ<;Ox-9tpx& zUv6Jue!pKoP*QDNSzE>CgPRXu?Y}sA=;q9!JF~~`&YryY`sBSg1EcTGOuRoc z`C)MC8~x|N>%qxyan4Qs{Tv=9zBx7i{=n^7yb|mB&S%tgN9EPz z*Y~#Uzqxbx#qs;!5Y~@=x`;4r`sZ^~KOx2*ocQU?_(ynhU=1tQV@;<0_%}QcPXBWF z&Uf1fXV!LKuiCJ$uVeS={yi7=9X#FJU$e5p!P(oK;|jZ827)U>M022tOb}rt=EM;| z5<`rz#^44DWwieV*8Rz-$(G5z(&(6!gZRTo264}_GT3|ntfXIA+EvnaWXq8o z2d_RoKlRO(=igs@`Qz1@kJn~@x<3EYjaNV4nE%(!SAX1`|KrB|@A$kv|NAwZx!>sE zJFkDg@#?o5ukp=);as2n`NETb9=bcX_2fwT=A+t@4Y~C@+78_68+v~1-Uoza7jb1H zy_)*zEJ88{>%nnYH|}mqu|H$|+c1Omz{F4cZ+yGu^z)UQFH|(_*|BZck)EALyLawt z-Flj(kh56R?i%nFWtD2E&+r z^YO58#*M2wp32*%ab-(3j7l5sBTkdIgL(K!jq>>R7f~HA6dRvOH=s;9C0u(Sb{)9- zTG-laq1BgySDy`DH6W=v6<&Qxg7L03m!fKiBC1bCui0PObD`(_oXrm-u`~? z-5)q3@91Il>%Z>5|C2d?-GBG*d;0VCuaUQZ-Fr*l8U6ph`RDC7cn5wNc<{~6%a3b! zo=>URTGY6=jS8gCj@N!Y}<37bLalf{?4Y>th5{(2TwCwXFx!WX;Y$_;2`k_BVFH; zL*EL|xVWk7fw~aZ^q8U+4XmjWPK{aw=gibRjoIp_KNguTqHVd1GiR=EjDAoP(~^yj z2FhP1wtNeYGx(@1o(Blhr~{*oit`D&pZsa*UtG7RkXn!a}+6AB+?n@iTl$#!Cwn1m( zxvujiD&$HCSGHi6(=^H{!phqL71u)7!nB7&R-X$gACOcJ$k(2ctvMG}e=WNCma2Xz zw*Exr&ddFm9-f_ie|z?)(Ko-3zW;Ol-CyJH{~CYyCqBpC{{_Sebi)1LqjdfUC=<}{ z|7YyoU-)_E2k4a;dH?6lxnIvc`fktl7mfQb=hpR=HtlcSceC%p^CP!E3`~3vtk0l? zLTU9!RLp=i9$?pJC*jq9x%lw6OE{0{0P9m@KkmK!w&l=tS<}ge=7W9P_8#cmvv1q3 zEe$P+u_-LBD^|&apiVMtrX-Q7KWM;xD*K@8N`*c;6s@7OYr(bnhIU9S|0N z5e^jvWZ8{lVf7=D#?gq4lbUUq+kGC}Gpp;KQFlO%_KB?Nv3UJt(3-n}RX0RyZ-{9& z6Ib?NSoL5;?M3;zOAxq=ZX40GT-P-Wl=ofPdFjEKv9~v0{y6sb4?VtP4A7sRf&1sf zSUT|ibjIHQIq~(s$MG`2{23>3`}J>du^uVGhN>ycL2_4((AZ+&3u;omb@ ze;gS5abO(hBkpSeeSZ2^7&WjSe)RkB<3Hfcf%VDz-|rcE+j3yCyyaBerlY+bd-rwi z?eFMsscn?YHI}GIv-yaPp`D0zy@o^$MURzSF6~D2TEiOxp;x6hzmbN~2`O%ry-+PM#ch zHv)Auwvb_wT@#UvY#D5D8tkG;A7%sq#0EQKYgF2?o5>kvZD5V+s5Q?MSX<#sXfxW{ zn}C+X;+R^aw~Iroz!@)%7XXw7J5w91*R;_;&@=0ky1rhp7c%>VH32=ZKN{3YdgoO= zbIQ(_vi8T))@f9+zMTH z1G{tsSDg`84$9VEP}B}ZZn&n~cwf8mUQE-af^9<`gJZ|;&0l%`&wH>=dRt z2All=tpB3(;ji(p{{-}OK707dum3&y;eT)#>!$Z%x4*@~^U0C#PT%|InXwWK*e00}rRL4tM+mn#`$)K8hqMEy5>uzDQYEbzZQNOE`ToH5xBITX*?akQ|Havz zmuAt_?!PdzcW8d!(CY(N-WYoU<;nF|V%^*sq57qQT zm*{Zm2XcuI%#=gFT1d;07cM4y$8}(60Z;2>PWJ_d%Dq@l-oTp0_dMXCgK|#dH8@rQ+I=u)15KHJ1a+&xEW#12+!1 zr8UD*EjOb#ji?)kv)V6gJTZFc_U!PJzu$ZFFSuyLW_l=LzXT$jGh^EL9Q*KZa^3pd zocW}ef%!3f0_(eP{)7)dJTkxW=*`lW{Y4GCYqk%z9=Owa>H)f4+fP5-dSYth;rlHI zMw$=YZQ6ggdEcG3{UcisPjnue>H=|P;NgyeM}23W^qhXQ>F8KZ&*icWN1ECXwYBs% z*KcXtu(_(DCL}bH1wsg64GNzPEu=64GYXLyKAAc;s$P^DqeqFvinV~sq@S=xJhp@; z=xNJ2UIs+q`idDYYj^{S%0L#S$y{tXw}b;nPKRj`7&XfgeJdaIuxy?2iDyJ;d<590 z3}isi3^-DR#<5_tE$wkrJK~PEWELA)a~+}H2@z(JV&?F`;jy%Hz_TTri^E{eIB@J{ z(gTf+52%)O&nmXPkhVW0no7$gu!dbncRYgFU2^{nRIrjjGwz$$Fs2>e_DGDO?7F+5 z>u-x{t_WA36_%e4T{9S3eO6j?F|zSGR%5H1ucfw~Z$5H=-}RS6kH5Y9>K6uU#%2K` zjQ2k{zWY;e>7!pG4EuEGhyU{f%#{tShoAq56ubTSEfiGKS8pv`*H^Licx~^6y1wD6 zj)B#iPnI<8FRJg)Ti2VlrYoo+ykwKdi?FJE5i?jvIHye#cqE!cdxO2lSh^&`VtjFvhWCg8;(QNmeD z!}1ICJV`X=kz#{8#~f5wVAo{YD5N0vreSm3*Ps}YI0nN(A_u<26kRP8MWGtuZo~7j zbMj@o_@Ojv=Ymcpax0oarbvttSSFce=yL*72L~%G3wCs3@dYd%cv;wV1Mz%kJHEgU z4NcH5(GYYLK%p03VvWKlAKo*s>Yb15o{hx1hn~5}-WgyWi7OlQvyIadunn8X6s;4g zEmM#;Ox+EMBy4wj9k&Z4hu7-P83nvORb56x>u-x{uLZ0+D_nJ2Ty-Y2dQeh(LD6tU z)p}3Ud?UH_e8a)Jd#}B`F#YwNxu3?~{Xw@d#bbZb1ER7|ULCOFAgBHxWLuP9zyCLK zF1!HxcL2}#-v2f7=FiJde>i*>_w|jux*kC|$Xtwqecs4K)qx*VYvj ztaNeGp%Yg#~TsTfHTo+e+S9gxH8`@ir9$pS^?hfuAj$Yn;4-XukyF1U_gXiX{ zKMpR$8AfcTfj35YMy5@HnF^}HFzv3{2nZ>(qYr`z4GQoRs?AfeosW`tzDV9P4=rM7 zmL~MSj$tH-(fSnE-;>ni%Dxd?c|K^>fT(gHY|YuQwdW%0FRNNdqML6fHlM56cX!w2 zXJ@D0-<QjGibKn87H+gTc&W!Lkv@>RL z&A9?=z6Zz6pXV7O@D6kHlez|k3VebE-a$^D0Xz>MzNa_O)62=r$H~jr+1pRx>ErC< z@9G!m5g6ni806+J#Diy0u)9#`86@-y3iJ#N^b!g^@TGsSyKkU}UyvSaTEYdy5s)MQ zVvYvT!^V+BZ+aY#0vbaakJ^Or>|sLRb5L2*4!lVvRl~OlJ6~(NX3_VQHjam_zay=? zg@qu&s}Y_JkX^4mAHHrVs`(DmuK4B)6}xZmxb$dX{LQtOKa9TpooxBtpZ~G$|9ELs z$;OyM3b5A0jW5UPAlo8mKKk~LJFkBuyS_EocIakCRY#yQ%_Tr)@9KxPFY+MDWtJE( zT4sttD%`V%mMkPqW-J~Q4$V=iU?V5B2U!ts0+3p2iUttc0Dl-M4)WFmhWp)$D{y4hWx%%*Yg4_baJcDGuA(6geavzb*D=5q} zP~;vE3@AMU13iO+yo1Hw!6M&av9Bo1Pb?8i!-Z0LkSrodCJ&a&gTljuSi$NPgpa~{I9#O{}_HkShpU$ zo>ackR~ql^E#~mujZ9e>g#!f_w9EyD)cOavoFq7`>`hsY7Kn9i`G6b3N>ISV4iRjc zz>SGUhL>+hNuC6bJ8&J?I1! zu$h73&q92)1QrdC{h33EVC)mzj64FcrV#+t&uMWaTA60xkzyi%fVL+73Cqdd!Q0;@ zK;$Ks1xO-=vZ$blNTFO26b=w&!7@M=79^DjC85DGKp8HQM~EVnI1-ggs#eQ18o5?W zM~4%w(9)4H-$fxgA!!i`(np#}x-ZO@E=VOkb5 zRun$jJ9#J~w6QU1Nh!H0$+;>`iWAX(LP3BL_y!5Q199;=I6K?(1vaQw*%8*TYmgqX zxd8tKA|B8n1GS`KyeO&BH{rUGU!!0S)*Qi&A0X-xMtGJiwJZAmGsuJVW332N9IAv^1WL%6gMu!s-ts%Q+ilSu5h^s;M@59ACrv~>7nmLHmwh?JO zVj~9dC!#iv>pG?rdY)wLo5?-$I`7!KoTDEyVA^|dYrn)m5V`v1d!g&Dir1dUWi4Gx zDRxBNP}GL&y0#Hr<8W@r4fn&O{N zSYrqp_chWMq@wWNNK#Ot0zZ%jtVk!2xl+w+dH#VCxm>G=idU(VgT-n`FK~XO&ORc6 zPmrSr*)>-n0M;zD8#pL6qofH2J}scZ8%0=y+)j@A(*fKRcq0c#0A@_nD|qV`Ls(-O z@+@-$7&T}K45jMSL))VR#_*o;&hdQMM?O-+f*NQ+5J(IqG2Xp@ruKOc@;d&fK| zMi>py_ItGVr|njW_4AY#$&ck#rHnW-1{=`=CsnL9{v6H%a6Bb ze!BVM$D7Z8y7~OaThG70{``j$LlYD#zzQNnQZ+S@vMdHPDdg-YL`Xufwi&r2ro2G(K$LSi>BduKN` zXscL8;J~x6zvbc&nSTP=IN--lZ5s5srVfa)Aqi|JNhR7_`Ce$AM%dE zjRWhK5YEE;fPI<~jkiPBUPOM4Wt#GJ7sKm@utr&n4oDNyu8V7S-PwL-^1!vH10(b2 zC*EBI4`KB6!078U_g|eFnL9Buck=%H$$N8nrh{)1li}^z@z>`j-khI&dvW^Rh3U70 z(_bIG_iEShqm75J6*u)s;|qA+B3lO+BXeu`aAr&fGjS&PRl~}tRq@HLskH~7&1@Zk z6mDBIqbWtgFoH3AfeB{hvGB{!&N$!#qzP2Ukyf43RmxvXJmH z9S8Ud^f#GNdNOJ%vkk1bNE&MvA2cCzb-iTA@k7NI-B0l|s*Mv- z%_GuvL$bB!#`^=r*!IA#+W4kWkjP4np*fl)e zeP*ofz_rS)hf~TL0u}LKYuIp|jDhu1%(P-8%81lvmKuYDjbE=JHtky5L-J0KHB%eG zg^Ouf(jCKA~#6C$Afp)tr~F zA*>baf%Ody$|ib{s9;(3@YgC8we#eMMbzQDtgL zRYqBD&Z;_`f;G*>>)K1}w=CbVZDn(Jd23JQroOdXch_|6uiCP|w7xg3tRXZy)h#d# zELm%g!!l#!t%hGLzFDigN=9Uy@7Z+re6y}r^<`x&^6&Dt+D9vY1@v0R-b9r?|X?4Z& zHI+DJ)fIHAD$AJXxLrMUyo|OtA>nx%Z=E^;e-umQW}q?*6!7oZVHYsbCbq9`bb$kAB?I&j?&1=1}1|X z7PGfBupn@E@bq=^33Nq{86@@zl?o#ip@=^dGbGy7K!wg#7z!3NTi|NN=6=Q+Q?9tL zjTSF6(Puga<`~~b*N=nxu%#Ue#e6e@i8gg&>~0a5>c(u$u`aQ8hPt~MFWAOK!u1Mw z4ph1aDZIrIz``R~>=6*;;_ZiM+}_#QhQ|ZenEf||Rw`rHv@;H|0flGK5yk8Vwv3@# zVzLy=r;CXiNuE1;nUaNYSHVEwuHhxmrcL{ZAf_&8l^YC=|4T5e8eeqI** zb5TJKfG*0<2G+#|`K87AC58EACGgKUC97AKR;~iXrE3W5(yG<;Wz}lHT2j5bxDxih zN{@0;1w8<34BBfMxYd+rAv~i)#Wdz%YERoWgOM(XhpV?>z2<|M?T-`to@MNt%RT%W zw|2qtcX`LZ&OG=!Y1fPvS9be^vh|*P{T1n&bEu_A*PNHH8&))2SGL^MZko_;9Md-6 zQm;QBS$Qb5sM|MjJztiM)sV&adTAhwws znp+xMSex6}ShCr+P69_yA7s9cAU}gU=OnPOVJ|gA7YToSDgG?}AXI43kTS%G5RLS* zfi-#uR+tE5JDS^Y4K2{71R;~ zEK77A?OBfQwmeUsN1$7P*iRxuk`)mft4d0WL&OCKo&&@)$zvDhWftUR=92@*!IwG3 z`8mZfbKJ!x%mux?xV*d&zPfxxemPy)u=;|PWrcKcm%-2D$MRMz$DRIPtYOz+r;uHf z57&EgP)6y>?w$n;MYR=+H6J7TLI(s(1k(1*WFLHuTN`la9etN|@D1b*W4iDI6H%?B z5e?VH)n`#ArOsyUFusgzyc>-xV9R98<}q!{HPyO7>59F=^frOKfaM`KvG>HJFtGl= zUo54_ih&!piGv0KG8Fs*!3n`KTvIES1r z-V=<);Wv!mGwc@)T=e=GyJiR;h@vrFf|Uw13lGtLJIL}tUCo3WXvP=Wc|UVHREO(gl?e%&q(mf2@foQ}nJI~xDal!>%x79s zW?FJqdP;UiT5eW)UQQYv+~)c4^N7B(ON-Ns@-uJ>^HXuT7v-nXDM-Uzzy#+RMHHKn zQ6tG>J^?pXQJ7+>3Pd9YYi;*z^tR_%n5)|Qh>r=z=5vp{%_Xc~ zr|+G|G9b<7DJ6XPh8yBF=f&t^qnuJZj4pP>hTED=6LFmnVz*6c+ecIlmn5qW3$r#m zMU>ciM*(Zhh7;DL_l0%I%b*8^Js0TuEnEyuFlf7(f)k?0+RWMp=80YpngZwoAP<6- znOWN!nf(`Qvg@U=Yct%HSQ!8ZYhukoPE9>Nrqc&~hGpgyhohFVh&cR^Bj7D%d6;mq zhB%Dv5y|(D76eAS21U6EWv&5Y+^T#pKYJH<4$sA!xO|A@ITXp001gcZ;QxZjpqX47 z8l)7V>2GZqxEVwXfi>jpmYG_cSYst9=wxmfP;&I}N1?_qEIc?u8K#a_#Ko%;;+63+ z(MfUI6u9(wd`2h7>XIRU8yl0HfD@OR9GjLLlbVDKIzBy($)_^ovod0{(_@ihr=`WF zry$qXpV;)&_>Ap=&Qt>8t_-m5cJa%Mlx{M{gNT=z5UQ z^)P13Xms;c#hMd=Ia^&-tJuC;s9IryM(@MPu9?H!xhB91&7j50%wRIhOf6v7mbPpw zT)P~!^QiS>!*#G`+nZV08iND240cUET<`qh!x^lJA%+wiDUv0|JlT#Gwhn0GQ!RzT z8tMnwa|c<}r8XYM4nlKhXum5Q1JzD}D#T{40a9mQ5kSG{1QISAM*)i~pvs#K;&9BJ zQGlmcIs)7X519HGl4u5QB>3R`FVzp;!pu>hw=lu9DH~l#%oKZJBE#7?$Xg@{kVc3i zRiP@iRIQS0)e48?Nd$jU;tWrSv>b+&&T4enV9^5K_?PqAs`djW*_Zft~fmA|u%K7hf6t zjfx?dN5+8L_H{dm#q#Ozg4Q5bL5$c|nA=m%6r58zL<>Jn(g$DQjbKv5x>zAC<&EtKT5>LOB#=G|bf!YXt#0$O#M)1!AOd z=>}$&h~7a=rTHBvPhc(Z6?z7T`ogs3O4PuE<>4WUaFIfWqG*^>AyGzzM#{yJ_>Lqr zGCWL)8mS^wDHkikB}!CHBf}zT(g)&&742e_)herlQ=%1tT@`#nup-NqtIyO9( z^ur;Hqr-F$^fCG|i8&7jf(}z%y`;1d)2lyFv3m)^FtCmS*6pzCDc!b*Nqx^U_s!;z zX}`)o{3>JrT=K3Lv7Hap?W4-3JJ_2XR(US8;tT?E#kxz%24Hz+aCRhysvIm?$=}|!9RiEC%A>ghzv9txg4Ay`fYn1Jci3V&7r4Aan zqm3}wDdUCprj{zjK1k~wPwBX&YaEoV*y#~lZ5OD6Vk-1o^jLpIff)j6=<$+8 zP&7;B2-9Vzgf&+7FzhKV?Aq4Rkot zCEAfNYQowKRtfDOn%w~N)AlPPHl)BHXo+2T*nY_NQ8@TV@`Xz0;7AuC)l!_iF&pJc zv4MjV3NrBChE{g?`z*uG0^A*pU6XPhYU5~MGLz%_>KTzw5MIKlk!u@SkYWH@Fl?;t z#)z~J*CSBiCw31C_YRHp50CPbEBxgV{&KmWEIdFK9thCG#QvdZUxxULML2u-9H5YBLG3GSuHxy<3i7n_)a`wFZr+4|M?4t6{TTP|*4jw$X< zBm7FqhyU%XB@~-2HiGOY6A@9`Wn#&OEt}g=eyz7_6Nm`WOcv(spg|2E?u2bD*j{So zf}MI1yZ{x?Khh~M!X;3GP+8#Z=jiIr<~yf}4Mkz&AkPBXsr+cJ>vc?BgMd z^pdE3q-sBj#y>(A5D_bkN(@iT(PYt1rP{RpMG~xPTP~wXvCV%^z!IxhmHbYHY zzaoKQ;w?2ZK;6mM($E4v+?H}=hIz&2IdOP=I|oNgTaJkZYF4lyl)?3NGpI=6t|zQ1 z=Q3gLYby@y8V88$eYhTL^5NJ^Yb5~IX8a&4moPhbg}sl0AE0y=Mz{q^U3`NDUJxj7 z!Igo?5Pt`xT8P2uYG$+`7{NL+aJ&He2n|;@$;=`YHz2qHIvhAvM=e3)r}-TWR&%|C z&Os74vD`}*<)?`DS8Dwu)c*3wK!w^bT;(T=3=CKJhmk+WCqVZH))M;CM-=K466O&Y z;uZk^AB<{}i!jVRNbV_8`N9hfP51rt34OEJ?}HuLG239>kD@ys!~tvcviHmYYZ!F~ z{_M^dF~EBBxUvQ1l7h z#AYN7w_q8fI{*!b0IZq%BnbR0V{;q$aG-2%3&Z3h7&e4F6x7~mMVjqGtO>*3Lp#En z)V94M9Rs4Agpn@65tMWJq1A=Vny>~h1vMGe+=yrR32PeV17ks-b|Ky)OzEJbjhBs@ zAao5Gn;Tj{dJ$AlcyDh=VG9C6-9+JDGL^ql7o>^}QpXBaI-EeIR;Y>ziqZ)qG{K4} z91%{0G9)5W6sbfQ78D+d`ng}I4A-=OsKO7vTBZU2DoB|k(q&0>IkMP1obdSk@PqV zv+ZGg*Q2D~r>Xrf2Q03g_j<&w0oLX`A+|q!vBVS+IITrkKq@hMdM$|b$X|dpVrd9RBddiZ zsU@RmhuaejNo(+CZS9Of`NP5mV2$gZQE5gDk7P$r=U!%PjqDrY8A5S%KyVP45!RG# zLHdBR&)*SqCHmWG#y1;=G z;lzaIDLH5ok*Nce zu|b+du{JqWmlPJ8EYc;2b%~+6p-y;lGe3jmr&BEDTR5j7nXuOkJ+dS{a>F9+O+1T)Z~3ydGHR9D0N7IGf-m z^Tfsn$WdY)fF5x`j0bGD2X{2S?*%x+3EfW-Y>4kfIR@X0#1h<2QyzlA$Fu2kgwjp0($J3ytL3mWN+H7u8YJPXCMb7X}Os ziLhRba*ClPutsYe0Ks+ZvBsD)X6J$RViTycVE3QXGKh_{p-wNtdK5b!l|ZC(73&G zhteqcz>val4i5K{ssfeqLD4Cqm~?4;rZgc#mY6P2#%G2+DNB)@BTveVz)47pOw3Tk zr^(}zBNA{@B)a&}=$NqR_)u*UxU!+zbZJ~xcw)XhxhNu~M4qxdDziKq$`v^kx`N8M z!pfx5+Kg2jfHfE*dIrlcJ&>9tkzra%e6PEjG9R0vjCQ!>Jg~WY)~|6MQ(?wIS6xQT@R0*^C4ES=1QO z84-XK&Eh(OdITs@g~nz5)mO;S=|7_P;kd71VvG+bmCOHN4f+{jZHgf~=sG$=XV1XK z8+**nT%=g)#0}87gvPpwW8B3$u$y?^Vh0FWxOn2qKt~3D2L^7!8ete@i%>Vi(bvr= zFoRJ8ZX-zFpvy+@gAtmTEbz))>;yj8N+Uq;Ori-;#s^2IO5$_n$%TsaB4uV#R8~<` zb|HOc6{)j}adf$*x}1{e%py%zp(ZOoI=eudU8qXWl_#asYml6)NGedI6e&`RqcT^h zvR0`B1)rQQfW?)?ah6!_WkGw)1Eb{|6|E_}jT z$e+)bioR3A|^4OyCxT5le;#F~lE92>51x|cX zSzLaZDkDECqfnhyqRv_om9A>t>+yQvzK-vChKww-Wi)cbT^WVq=dp4TjqE6}&gzGw>MtQei)+6XxAA(^ z`ZJ-WyZw?|c;N+B?o#OD!iQ5Cgjk@|^+34{TTx&dOY!S61=d!Eme!aohQt{rSy((L zwg7T%eB9T_!c4&kWw8EY;nM&1xu^j_{}F`EkUa#E4_fXZic`tU5_WBmO|^?))E0uT ztXvFmSqsGG?%^zNH8(I`Aja;PB&^8v2+=tC$heS}a`wo9B%@`Hssxm{)r3A*%wM>&LMJliP~S8D$-?#Cl|;wmMe0~)rD2j z#g)3{)tcf8e8w!Vjw`KBEL)qfygG43b^P+mq~+B~%hx8C)ugOglTcb2onNlWD_7^0 zN9C@J%3T#*P_2d5P)VJ(c%8OnU0m6Qq_U=@m5m828|bsVA+@4`vFkSlXidV6AD+)8 zyPi$oH=DK>tD0tD;OYASJ*8B*)3Xk|0q(Hhj9t$&`k$ra;9<{;EMTp_=;7oUtQF9s zZn!P2xe`_dtWSrpIisw*plTRaH4JH6>B>&nbW_`SQChywFLk4nqS(Ssg7vt6Ta5gg zVl&J-QFWBGM2O>oCKpN;2IiJ%ZDWueqvhnoF}lL%vmB6UBZ0C-e*FpS1xt}%BgdgW z7wRyuYv%XQlu1z+bsz!~xI@rug&r@K^I}V{h07wAr-6N-k(0#IQ)%O`!!9Q$aiY5{ z*-aeh8l(~cYu5m_iw6rLkam11qasbFnh6T^qnfKb;krI#W$P@OJ`&zGf^D6&>Y<*kk`ToY4L9k+aKd>Kv+K9g3|CatJR zUP+$-9bYD|s7+n9KDE3qc|}cpDZs6WE?BK8s8G|X(iE+YE?yU1vR+$SAHSk0VMSA1 zSv^k7^7S#x*Tpb#c;V5v1xMZ#9DbdDXfEf#EJz3$`(6S;I{T1MQTm*H@Kx^NH;Cd8 zZ>Q~^$;AJ(>lsegZd}$c@uzYQzM{QOJ8457s7mstyAchy!q;8J1fOWtDM{ria^n=9 zUDmbTNZNicY3rSsmdg=UhlH7%1yReby&{%aJAQ!$*0AfZ7_2efhRuUWnGwpOFKlXQ zWQp+&+}9Wgv85C^@S9s#A={#e*kL(#PBATsGA`OH)DZ8 z>HuOPHLpot+hMT<#PhLs)7OA2GIf<(dSg*tEcR?Vhov}6l3gY7F2QP^52g(KS$tPZ zJAo;S&lKEHFrmd;3_bY2^ilFc46h9Lj{d-?Q53~Z&BBP5XE0AFah9mPBNK(%tk8r4 zX&9BnOMF)etBKuiuwe6X9ciZ z6J1mpRj^uBP^BuWiYlm57E~(>YodzRt4ixN#p|Ps*Q<-_BJ*n%xmEJ)a%m=UkYLnB zN8S{o6Mb+t_rMHJ_P!T5Is0E`@0-cmHv{zH+Yz~eoPeHn`ktALy)X4}XA;&gviHxR zQi|6A`8C>_>MfHLhu7bN3RWnU#0P-2eC;5xRySVJZX~QzI!59*URSI+Cd%t{(X6%$ z&|)dc->@DLC6tBOOo!^R8MN`g0!hz^Sb4bea9=ay#)P#shi~Hmtf3#`h;dY!RHY^t zVNF0&Io&{CvjSTi76)5HZfk6f5SmZg1vU=QMKrLmH?Tmeg*}Y!i&%aJT(PNZq@_=^ z9aapBlCi4`SPMeqoP*U|FA0KY=(w7)ct#eE2AHFVx)GyDMxj3GDiBYYNe?M1qsAP* zi38BszZyJoPa zzM%zpM66E*QVwdBWZG5^7)ph;+8LQzfvE7mu?C?OJ{+RA+~b#gv6PVg@b_+ z2SOQ`!~GIfZZy_OrVOi`3FyzI0AiCe=#zeq0U~e|;T=6$?m_ncGJ#m-7M|d($_k4A ze^tHpcbr+4E%;kkf}Lg(Gc#jI3Pnf?DL}!@%p8(J3NbU2nVojJl9|DbGNrRBtNL{h z^m{$;t)4gY56r%y?3$Ui&id9TIV3xC-*fJ{=k9a1gQ{>htGqO=zr_$}GXxl>V7n>U zVeW#l?Y(Rpu}(*%)7i&$ML5oWt_u(ka-0#ibAag>VA#TRQ&+R0i>mF0HEBZ>wZBg7 zr>KJU3SX_%T`hK3i9D4;SB21CEwlq`+m*jqul>bx`8V^$U(FX@SuVUZpMPOG`_g*; zwe`Yp){FQfEQ83`4w0r0^t0GP65IELl$EA(D#f1 z`1Qw)f%lq&*Wub7k&k#IUkVASIX1 zAwLJd28*7EOQI4Ln;7k%N{&fQjZaI*ObMd;fp*l)thh9^PbMCQ6Ol&OLwisR-$VFj zIGd9r4^hp48V)=fV<>WCQqi{#CF>l_PCp!<1tE<62}K8zD)uMW0qYZmoP-KtO1(6P zrp#_uWJ5iiBF(B|B^NYchBM;8ehj|Gq_TZ+m<+!M%aSOcq6lyd*O#On?+^kJ)QxB}& z(%Wv0Fl?~CHfxw+?QORYw%dj}?89xg!8Y4ayM2Ua?4#;?nshz&nywmEphgj>miuaC zz6zKjasbVDRSDe{LI<$6BMHCqyZO>z&4Bg7Z8BT4uSBhrfBsD_EYZBT$ z@?%pA&^V9T1$f_;zc~<}b0DGcU~<*LG>AOX<4U;6HInoOMIK#~-K5BZdU%Z>y}TnK zk8&)v82m#t<&pU|`(hA(-rjR;?>>U{9}Tu-{v?@|5xur1fBWuuWQgpX$S6!Gs7Wnv z&Te2A(8XnJU6s&Ak-MlWU>$1GbTw(=o55y%7tPQ^)AzI(ds|JBR!bkO%^GPn_cN@6 zZI*!+%iyljO#{t_eyYB&Q5U9Yy6e?lwaOr{{)g2{ef9DHoU-->-@h`yIe(A4{%fCCX{0&dM6Rfe1?_y0dGy*qrIpWu=&qRxl zK^bL^{JIOW@(Vt#i#_RS!JUPelWTGKw9nL zY+77tXHvB|jUvydYhl?<@{C%3aw#J=n{qg*2y%XJA4MyPop}}pa#}G zR9#Pl+7HB=wB616FwM|MH}p{rVXC3G*%)ay_BZK!cdb$1*P!cb)J7UKy^Y%523@#; z{A^dFx|^czs#ApkJw??;QHB~}sxBBpdY!^wr|gBo3 z+T3PME>)3M%S$e8kIiX1lvwiik#vX}ysDxDFdG0UEeB z>w9VXzGhu7RU3viM6tuOUKIkGwTd7`)l;wRq5xkFzzsHOx@r2}W?h)7?}eu~8Tx3( z2z~>MieG{_Pqv9}g7uZZ*)IR}|HT?v*_oGyGf1#O0{>NyZ2ZD&<3++hFP@n^)gpJ`6NRGoSyTYmJTU*U=Bp(0s(Z6^z*K0nLcIlH9EXLsqJI4rWYqQ@*~J?7HE$n;TH+D7Hg3?66<~}KR2woXTC(d{ zg)QQ8wianpgFMiz>TcHbz^R*b1R$zDOgHw?483$yWcQ_6+Xruj&(cA!8EA+*zW1ZFfE#d)69KTQ$O82&}JE8*oIpzLv5zPHuDe+Sfh#p ztSx{NXDe*GI6LDcg$ha3$VQCq`21h=*vc>d)r!N{`PcXuIb5H50l!vnKGg!~?H!a> z)F*#cZoZJMJQdB|N23R8)CJB^glqzRv*nKejQ)QO{GY3X&Q*0=h9S}6md!gJHCK+B&{6!V$dOa z6Jh*d{9F5C-+Jfxo9_^^>sxzK=p$Ssz>N-iaE-`qHTrr`E=$gh%PL7JuFt4y$#3Qr zGh`KfV~yC+sD?8Jo5?mEZjL%He6YpXPd7xG;f&gFqo$``)m^U$0a>J26+%}@r}ZCJ z(P^vVIV*YgDxtkj=AtM8H_lvr*w$NZ!wkn*n|-X!G2Y>tY6ll%Km+Ea4x?iZaUuaIh(wuswIZF=ONWWo*b2w_f1il2~tsT|I6Xp72#rk9A z){gq*EA`eZ`Ra~%;SoO?#(_1(bEDRFq0WB3!F8e0a|zx}4_$BVevj(CT&3U4;|?ZM z495!F_NA1)9h1E$7Wc?G`x5e@`*|?A1k8L;R*n;}Ao^UNR1BaSQ_Gst${W+m>od!0 z(f9`T7Z}&@YtUCvTzwN4WSGtk<-6F7LrHljGRtFfs)RP8he756MlVBr;DZMyhZQZ0!dnn)Zz z`BJq_OtJY~xdm5!wQF#2_`5N>Q^TX-&M@W16Zz^R+1g_{u!e(Uk!-z?uRWEmJ`v2{ zV^93l*8f$b|NUC~rE2r(THD!r`&o+P9IVcErpCApb*GFLUqY4SNN&?Ri4bec+6!60 zgd8Y-9!M%W1bN)-8t6F3=0ffnU{O*^2+$dov?$h;tnzwH*-gko$43?_W29N{02H!f zppoz2Xw*eSp0YqHMxkIR7{P}uZ@FWXOs^Um?(}}|N zq-sHWy)?I3T|n36($tVkPOT9p6f==dyc1gtso%GbKsqqxO_T!mktB_T@hIYZFu)44 z>0r^TSez4&Bxc2Al_ZxmWYjQno8Z)n%1&dw*x4ZWH|u&(?jU9a(5QYy+RTFlainD0 zZeUj@_f(18Wn4!I+fu|d7BIBAbVY89JewxXqst0gl|`+};towE*NAgET^nTC2Kc^7 zN!PqGvZ@{4(vO`oPo1|;U$D(wbk1G0&zyJ9Uk28O3n-2LMR)F3&DmGVQzX9vK@?p) zvdw3^p#Ska`5Y^O6v|FLhes>c9>JYuYmX%8+*|?c`=JVZH{t*m39i6E0$)G_$j^+y zZ>WI}fOVA-n??tUL25QLZ@U?vjIr|dx z4kLrgs5lPsS!87pEh?--Hk(@7kXF{1QPGf9(TD-s(C5NI`vjULNi79qT`^?{l?|{6 zGTtE^U?@$1eoz;i-;h{DODSzlE^SFJX-+C^fR^;p)Z+aKfII(CV)4PGilf<$F-7gk zm3*k5=Oc!<=<-^1Sxw568ex11`&btBKw{;d69s#Y<-T<+<4=c^-oObFdD=TC-o}*; zIed}9{xFi2A%Npn4o&@FilSCl&njt=RI;^o0!x#^OV=Wm3d33qaOyDNZZU?N^*z|B zu?CuTz}izUbyn~!C2VtXhq16-liMQ8p-Qr;qUAmwI&rcL23laz~Q74Y!-)-%Z{ek z9nYi17I&mn2{RfL`K^W`rn#Wqm_<{k*GUpeImfbT2UF_y#+K|kk@x1Y%r}puy>U4C zPY2@u>7AH2_nmkPLOch_JqVe2hDu#b($VCM6Hs&oQ?#-rkHRi#5m&Lb4HA2!+|#5A z&~#zCu9vPQ&lW?t)fjFy^|YE1#(Qa~uBecDIjcpsa<;LgLtEI2^S(HbCeCgW6wt(2 zHWlsiYNoQDtD|FPmoX^z3~Ix(&hZWZ{F$zmOZ_KrjGTRc{K6-bmp_}j^2PL(FXyg) zHFxbRV2%4M?djJD)Zq3@w*c`|`PNhN+yr%S2UpbuYb=0Ga$&6m5oP0fM3LE`%WfX4V+-Z8+PQ3$c`xa{&o$WdWTdTDiP z3Dis~lXG!o&L%x{@N1%YMTE~V;1^UABzT8X(4SQUYG`~BEuj>bmCWRdj>NLI#A0ep zPR+55%46xs`fB3}u@abR)dHxW7q*&8Ivf=|ZzbPT!nPGLExA;6T8${7m~}XldLX&_ zo%j+6{_Z)J{ninp&i2+ph$_b6FD0{&j(}+fc?f7XW6pCvF1C`3>a(jEg-yKj4n+;m zKmpbY4^pFDa&hS7jmh&L&0hX=_Uac4H@;bX z@7txD-z~rQEeu$L)Qz)}9QpI+b15*|cqZHU18CXC&hDfZ{!D@$ zdGUd8=>dQK9=2xD(tX+bBjwf;&8cUI^J=V(^(R;Wozu5bJ*#)$sIi={(4Q<-E)|Hz z0dz*QE0v;;E#@6gZ$g!l$e_n$fv$i;7WP@l`0j^aC*>VT23@28s_xicfpt7Y#|o>G zFwn2GCLN6!B{k5ZLcJ7(LtKvFN)hvZ@xT4;!^zR6%YwtHrAIS>W?ftXHMWSJSkVsG zMq6k?5j`%y5tdxumQ^pvr>aUBwyI8Ft*EC-KG3WhYSj<7Y6dCNZh)TOYD}+}$CvPq zWVP-~u74+?5^(Q1p8NK(jJF``jKA(6rci=so`!e=mJ1YVPoxzkfurDEReH!!*xXHr35*Y)Fk~K!P$7Ms~`*0c#=G7;1fOS*UuRBsv z+^*k{9#*ta3{u5j)1Et=69VOJr;mPt`1 zmI@DNGWI1m?oFtAC%*jc<9Tl%&)Rc50}Ug4k7h)V-kJMPWF1b(J(f}o{o>?8N@gWJ zr=~Txo>AP)t!$UqarG3TguB#`Q1v?fHivOMXOK5YfnKJgn{5KS|#tuuaIRa*PkiZh$91Nd<=giS$`%X zKk`(t{214lT-e+_7{=z{Ggug!ZIZepAV}7bjo)CT>}yrVbLGm_V)1ltM_&fj zl~|*SFA^NjYCfD?u|KY4Uu@~VxKhYuV*}lvgfmy(!DN6{Ofs~zlGv=u82EKTRZ?kv zYDH68RU^czvdZeyiYqZ03|G>JK+8V{vE6u#?}jcglKBJ41xM1$k7ZWH=G7+@(~`>D zQmR=gl^rRSOdOfBYDHxXL!&6jP!9_n%c|~krh%)rksG#=o93Z+HIefY|0c^i*Q6LI z>2RgjsN#zF5O>~})bLJx_1iI}ZynEj>sZcPN3!-F&DnP%cmIhzuoL&k=c5TRKBp$R zurafekyl4rgbErVF2=OI|jRI4X&`jYOs6t~K2xeLbOY0C?dwUw8!g_)Cw#1r-BR&_ve=qYObCz6|%5 z0^*e&j3nJPFo${b57C&}Ie(u!cOMRp<)Pf%Ar~UZ=eNK(-GSpX$A6}VKdW_JueM&S z(ry(?=LKOatOZO*~y#vJK;N!@Oi}xpj0G59^6;-cd7?j+h z@eQm~%IniA8#Ah#GO8Lg%PEj0O3o=co|KLW71*zF`#{tTkD~aNjZzH!8k=$q{5roe zv4oyd!AP%WrdBdCYB|NN+D2iJsT-GiHw=-ho~ci|m%fRt{}f*S);II1ZS1C^_X69# zOjQk+F@d%0L_TX@O7q@C%HFv0y)h+k9Dz0{EN9>G{C8pyjtY@p9zvUET6KJ0Lt1G| zb`7%t)$b;5K@*$A@ODLQr=B9PQiS#hh++N z7y|9O07L6<)p!_MZ=2HHDs#8UoNY=c*kxQZID~$Yqg(3kHFl3#2IgH8Yk~Pwy=#{T zPu&@e#%>&>~+_e1ER9U#&Kr zE>$cQ3P;_UdyRuS>VxP&ziCST5#d(!L3`PXYWs5cszdQLI1{&-uch;1K0VU&0VaM z%0x%A+uuo{?oF)Q8&|RCM9~{Z^4>U-^XAc}-@GJ_BoCyDrEy_HZol=m=qpfk9;JowVic57fU~(>^|q3{J4Mfm+6a-mfrhy>AlypSDuZYx!=9=y>;Y6 zk@s}7awLyti7nzE&TQM8+_)zZMf>u%Pn5iQEdQSlW&P7(a8L^lfa;K3eI&K^SY~5< zertLKE4z+YKou1<3(H!i<*kxRhOE9*+bA+orM4ze6l6}C!o|?|*oF|0<=G-4*O1uR zFL4iwoc#b%VDIIFe&*-}V~}U>?X>p5z%}C9!(3Y*`LJhL8s&(B9jXscSqA5vV@tvL z?eNO^fz2zUC$CSOy*Y92{izEWsQ&)Mg`1NX{tN@wK$*b31kuc=?8PVKxdd&dC;ZhN z9>V(?kbNdteL)Ohs~#_j{IqF!jIBcW%{*d(&!F$MZP*(`b8=>)uYR-WyxK_e9B`4(I;qNY)!i zv)?+F8^yZzSVmJ!Zc9Q*M^>FMyFr{wl@!vX6%0ikM@{L}Ht`KK%(#|0S{1GqwYyE@ zY1eqU#vq(jLCO(U@CGCfFcM+D9+JR?}*$#rV5O! zg5&DYxV~q?)Hi7voNuG^V>a3r~6mW4Q*Z;+PXZrd1-L#a{t!F$mWH}`Z-|D zS$@tzZ#6u0=@|?o>3|?-`6+khDQ|U$4@X{q&PUMRcrILj!C!mHU3&?oY^W!5S6|}u z%(=&`nFlQNTuPF+HNBV0SV6g`;qtQu|& z3*`B6br3S0f9_lRe*El%*~>c<7ak9vebBx7lV|F4W!GhvX|a^!ORAKd$m1N&Y}uFA zxG$w{AM)^+(mhA=-#C;Bd-E7>Mazz)Q;uad#}+V9JkP8XGjD~k&3-%sKhl~PbM#(sC2+@+d1ZddF2jd`Uk_JjP8mYicfbant z0dUkx&Mg9Y7-CgqTrjwN2gyAnVKE(mY%$8DX@}#ounQoKkIAZv%d10C9o5mK0%~S8 ztAe4UNf0L&O_8hqsm~*8-w$p7(!cprXytqN>=&BIyF%Aii)y^M&7M>)j49+C&+Ryx zNyonvA?QGSDJrFJ9Le~nBbk3Xj4d0RPW`dm7L-y`tN7U{r#Hz8=(2i&l`eI)%U$gX zXS>qX4$7$7#nO3U97BL>3JI;fQpbSGHwtidp$S9xq^^5HOP-U!S``>k1xFSBVR>*w z5dz|)ioiI5J2Xz*-#?=84uD831>@AcOEB(N`N1e2R`>_xfqt1kBJo1kG6EhlNT*zT z53q(u|0mWjpsCln{E`Qh*LG1Rff_(_*Isn4y^Pu`?ivJ}UUF7n!mp!PKV?rp?wEYQ zAldlD{jisM3q$N+nfq>>6J4#+?XIyi{Km{Ww7y#$E7XJAY#L`9E5vj)vvS?K#fgHJ(XCPopa zp&w*F7)OUE8jwI!uNGmy!QISBf#!XpcN--_Hf>G3rLy()TACY-B zjghOai4T3#pSY(#wTykJ?tK?Kb%$ZDPSBG})u&Vl6H3@|d9ATI)Y$BXBPrzvgO z*0C%C_aWT5qpn_kEQcOn$^q8sBgkoxSFn(YIa(xcrqbO3pp_1`*2B_zI0k>G!H-TW z^k@KUu{|Pl4XS)2Fm(V9J`8;2z9G410PQ(aH#TwX;C*GMu`oDHXFuD_Zj?{K_^%v&hHP4vSpN>d+}o_^dj7P98if zaBg$8&!uZkr{h*g; zG{qJ%6G}T%s{}cX@>-s?QD~=&U2SqFL*_vHWUJJS_PP$Wi;0dNowrjT;2MIRh7g+U z1W2uIVTq$h41T!1OK1;?>><7tyLpgj2@pUnXwmX>Og@eY*m;;evk+Q!h>8EQux zF(=vz=n8w(95k7guCUPMHacFU78)@!^XfliIMOqSTm$iENX&pW5XavApN0kSf&fh- zJeI^u$YjH~i2sW_%-P2sGY=8Q856e|Q@2?&_tEA9j}{>7TR_|9L)HW+Nk5~Nj~@Df z3fY&ydsN?5+<6sACo|fE=~O#1wBxyrq@qohA`th)F-3m;>@sOhzfFM+v#L z06Mw2F}0MIUfKezv#OZ|4dU`vbpszeX*WwZ&}kgu7{@yd6DXH8$|5yfUtxQ1fGZIQTiRIja7G(un z-zc=TNSt(utwm~O$ZZ&I0BcuTnJPO=?cl)2wH~(C-Kp~kOaY-KAhP%cW}nE4r&nn9 zb{ah#qlatsbQ-WWoGguam!A`eqaKYf z1;Bk7Gwz-+F=2QbSl?oRK?;@vj@ygatck&|nUi-}X-D{O3yo2T>g^)9Bu z)FM_kvxOLNkzP<5pO%A3N0>v1`G-dmpyB~R;Yy&BR@|6TPRlHB1-|L!^z7>P+-j7w z*qPPs*)<$8)vAG4(W0noS68>|>$&!Nfv1M+DQ7v#nf4;OCJ)z$^_`hj?HT2CaK=)L z8nJ9*GRlvq6d#PsC)e448wIqOlKrU-hqGH^3t5TfoZMzvIZHzk7-=FaO=2av8JaO= zMuybTp)zr_4wSMvXv|dESZX`IwIIR+id|pJl3z z3ntK7g)8ru|lytK*;EmA|P6yO@s4+xa$VqFVNq@$zTSE8qh^}w3962%lg zh>aVkEg-&xcr>Eun}isEV2vZ5=~b$28WlOtnlyEz?-TG*q-{OKI}rMnPU3E2p|G ztrYdQ=1fHOI(A_rPQl*S zO-2kuSzD!=R+$>sCez@(MT}}WK0rQ$zp+J(&Tf21(<&iY3vjSP*1i0c4}1yAJAfOw z^MF2p1}SvqF@G5a@E6#Eg~+(pU+k7vB%9FI#QRG=qCF1ga}Su)kmtM0n!3lEeJEVO zMHmUKMPnv=^n?pCiDW!TZHQBaXZR_0NmZERdI(*VQeIc@TWAxOA zqh~)IJNN0t`Ol^=eKCFUi`mOx&0hL)=F%7QSH4`j_VwbGFK5qvG`4lUZ~ly{e^zA= zcLQ;%m4ej|7ZM#I{hQS&rnWCtU^qU3x zC~WEuZ}z@m0k>d}F$N98&@gF%J$;*0R0!5+`T0W(y~TZo7d)#PzUZF2*0=E?lsi^$ z{(SP2`zJqrc>0USXTE%V_Nyo7zIuA$tEcC`d~)&Yor_;Tz4R@fPtJUDZ|mldiNZ13qa?ekJ*%=UA0*F)&eCQ)3KVN3P{6G%TqKFEFN5+aXo6Wkpm$AOU#ZQ-kG z&q<+qzD*XPa9kC1bwM3Bzm8c@&nl%NGnZ02b+ju379XVC>ltM~ePR;SmDhygVm;$UDPvo^Fmy)pz#WY1VQ-$0E>36%_!cv(L zkm1onvkTyIR7SQM@R@)nFclds60;2^Hd*nMnH>s?OK!GHjaI42CW$_U1~Xr06zBlG z9wyKl_!_+sYeQw^tMvkn0mf75h$(ekr8fF*;3!2|FCcF>!IZz$JTC5ZQYyRzCUy7!StyIlUw)3*KQ9k{1_bn)Ed49 zxOuusy0EL3VJd786i_e&r?a9(T+i0fL>8vn!_@}_(ZpC}b&JeSiPUSEzV6k zx=>x$AuMlZ=hd`kl+qIO8)7mk$1)m^X3^sEJ5tL#b1C8qh8i3pnh@JG!Mamp6BryK zlZ&Uf3G_CR0sFF5Xav4?sl_g{*cCRX!cI)(bgLc2G!Bo-=2BXn3ae9!kD2U%8>>QS zG~-2Vw1{+OvEBl>g<2C#q{Gt)6Jbec^nyPYfRU#)aJ2@WmSC+rgRc4Kaxh7@UqypC zDS(q$4MM{D4vO2Nb;xKw-}PnC4pk?ARi8wc$7@Vf0GC*O8ec|lI&cHN{0KA^U@e$? zAeg(48&C9N!#LA-$q{?(CwlJ}4QTHQT&D%DF}mLE3|}$NeG)nQ-P*^Gul)ELdVF8M z_^&_y_CFx!1cScnt-q1y?Z5rs&`>5{{{8>@T6=xe%-kCdhN>V)eFDQZa?W?{5df8xuNTl z$h^Ri^wqSRi>P97gvw~b>UIUH)(knaVt;g=Opn|ZlDh(OhfnG7DI7kz-6yf)eB?wl zrt(#_OkrVTM}BojdTDDy0V*x9_QWDqRt>%{sc2JBFkVccYb86ifu%%CG^S0ni(TdIC2<7e~K|g(1+JusZ;2!]pQ}$p6!j(Q*nk_z!pEbA zvG9bLgeOA10zhxS1hRxpdgc|#gZ8>`c!T_yVS0L+8I#0gKBq3Xl_ z%?=X$u^7w_k=YhCyU64qp~H%Jf)QuVDAv#QXJ2T}JXfB2CO?T-4I}tUKq6YlR4cF) zz#s)(kT49@Xy7`HX1(W#2517(oqeGtKZ^wbs?t;3UyBy+3+L~O=I;tH6Mg|42IAD* z8F0l3)`Vy1f1l#H*66w161dtPx}=-_xbN%_EAQXG_|2;uxBm9gqks8g=YK;C|Mu7a z_RT8<^M6AmhyCyx&;R=UuOy^@_v^oZ@$7$j|K49NeY11&!@JAZew;k@<-pQs0}Ee` zZQY(Z^LX*%v!zSVSFXH*tzLb-dhPYf)z=FbUrnBR+P`$$H}-|8`>N2q*edNUZ_^jh zq-E_WX{vA>ZdbcIO@7p`u)xd7Vgme_p?S4idU(FOYr z!2wfnz}(eu?uyuY25de3_AqP!X6f#;_Vii1BGyo!F%;4H!y11`8|c#cLK+ViUO?#% zsPJ?Jl+K{s;YaY1IovXbNAC1VZ616nvAOYmyvyufiPbH(k+}-S^DhnOUTDrj0Cz{Z zO?af3S|-_eBHo0I%TsK`yR1TRO2HUZpF}Uuj_%|$u*3~#5y=5M(1+R!)&uDakS;$E zE!+{#-xe&u?(socnZ1v+oHKEYMJ6MCv5PfYerUccZNW>bu@Ad8zn#5yXZzD9m%jb= z`mMjbfA@Fn+7M>?_|e}#c?kRGPaprwCy)R6(@ryl@CLcH@hZ2>R@(nya6p^8ID4y0SJ^1yfbS(>2R%s2ig!DRcK~{DbE1F>}wTIXq+u4_SMM z&Ao$`z7ccpkhTjXfdEP$7&C>MSbe7v=kbzOU175>ze$7gOL@DghGVPa*_x#oaOCA0 zdoRJY{ z|CqHGfA6q0JZ$b6G7o{Rm0FBiK^^S*9#1Eg@a|l zC1Z~!IAjQqS^B3P!*h&@UwKdj|fYg{R+>J+Bc{Q45+&XfU+q(Zi`HNMi+b|^WM=p{}^mG zG&R>Xy|8Pc$px5yV%9f4>m8kexrU}(LzDLYFZk#0`RYUI@_otTJ<P-mN|G4_+U)J9L1!wT(_r9CH^lAUbyMftrmVtF`U|Hc>R(ZCy-4{%K*UbIz z!7Kweog*K*$3OMWeBqh=%sc*xYv|9m-pd;AhS)sGlyx_;t+gGxiZ*2>Q$-P(TGej8 ztw-)1H1ob|eAN z6zc_nTE3qu>t<-eEF&&V2c>T4agXAn+0Z>^>>W2oCUMK`7@YTvEaBI7&8+pzu7zh; z!*j%X=U4g`@LcVkTkD<2AFXsxFNdZU{o`}Kv03-njB{k#F*Icxn6M9wTl+?=kuh^G zY}^zcHFgi_y9e|D9UBc|ehAx5L>nYe!~wNGtn&4d{rdD%hVHt|r_raW!FR z2}@Jcp~OM>A6P2`Bl_Mc+wi<^Y9lbS9RS&5b`#b$w;h_>4$PkPOm5f)7mYpB>fn^n zF~l+TGxZUMrngny+br*tEXJo!jc3#d=urPt5zq=e^_ep0PRS@U(qk z+S)&1>z}an5ug!*KrhsHjp#zd+R%WuYXD(K?H^G2`^nSSuk=L}9t=i{&YnH_7;x)} zAviz9&I=r68&9O@)dIxJ_l3*%1&ep_f-e!J;k=5CCki0D`B;1MiDvtW?j$kI7O;LG zCw_fLG=EDxe_Omj;1r zNpFX~mSd~td8&Bc3XTUHeu^wiRYlqiBP`oA+d3n0&M6$zV#~0=5N65Ut$af>@?^HE zzC%&nE^iW=7;3k`9tPIt{yE3kihpK1ID0BIdn!14GBkH;*TB{E&TQF+mkhmg>aJagE;>e6y_1{% zna!^Gjh=;#p80hG_tIu$aRU(VS|7eYx7jtb9w5PF-a9ex9Gb<3V(Xu>^i5bJlg98E z=qNC47cLP-)LkRW;GjI%C-sMw{(hx@048yTk$VvH^x?J?ey!Sgpxu6?Jqde4u!Ba! z1_{^T_5)nu;$1%W-^Dw^rMsfVy9m)_Q(k)n+P{1Q{dx~ITaPq!VKc{xR53YEf{X&i948?W6*>j=AbB6BRXg1GNG^5qhUx@Y%lUTnFINk_Evhb-I>c6anQ~{l$kNU?wsxvp*vf_uSyhV| z1+F%g6LB(>%lj(9vmfiok@X8*on`fE#+J41Kevfko@kigRMaGri%PUJcAF zhi2EhX4iY>wtDB+dr2fm0ACN!tp#RQy%Wpsu_Z8z>_c<5foT)MbuZ{EKzR)8BbX{U zB==+J*?`Q~FAGEfwAkA%bBATF9*MJCZ12LELSzpMZCyND2w1B(?rS#gYd0UMh&_ZU z0omGp$tvl~6D{2X)_mmE^LGSDu>aG+NjxX5n&hV->!96!qD6zx#skIbz1=V_p8G|* za9g@~2Z#%1e&J63z#jd!WAH0r-Qc-aZ8=kGJyUBt)8sl&b)Km+tyQX~iiLey3|n%o zAf}-4P;$vTvAO$VFi9gHbn&>1YTP6yD;L6Nwp^?eccktBpbU{Tk zrgm#vW~jPa6jo9b;?m$xRmlWfQo-ojtHA@Y6PIBg=;Vc}3Tx$UDY&j&$09 zbw9(<*KQnOn}$2>qg>aR$TuMkPRT>lAP#{Cs0EP>)-$Q^o3jkz{%*-RMrdH5hj}L! zgV>j6mZQ&=!1QupYRNaT;2U3Xjm#rIM3#tMK;JX2=>aVb{)_pdg9_iE*w-ia_DVf{ z7=I^rheaScIJ^1IE|I-QU<1`5fWd|chP&2jBJ)x->krgx_tZcQ9XfwteP51fy!H^C zm;e_L|HJSl(BF;cfLjs$Da|%q`>_^T`qo3`+5_p*T@t+)@5mPKNTM%rZSLfc?6L3I zgJ03RKcslB*4xfi8@Fpr+jZtGifN-pyI3xt$m{G*ryJudS;zD04=#&g2^5F4hH1 za|y==tm(2qr@C993y3ssk;*Pq+PPvAi?724S#^t~j;%&}1qebQ{K$i2y1rQ;VjW(# zkFMHAm+fOKj`3B;*edyQWX0IOs1466f>Xen=N#hL23Y2S4$EMNd5CQt;khP6-bsmn zQqeU7tmT0*nSVqc7*PjDK_Jxj%oro{CIs{TIrG4*br3OR+BrJw8HZ-*oOf&y<{F+u z#Kyi1Up7Z3u_NocN7SKVMF2d~h|~ve5;2h{EO2%S99>*{H_sO2gS%q&ajb;L!ZCU~ zO)wwI1^AC*8$2*ztysRVTDh-U1xyfBx~EvbFJHSSUq!6GFNyj!@ZB}y-N?KCzgVvU z?uW`vqRtbwM^V4NBU!pFTKGjc_mg;$MDnO#|JXV419RxBmY$E`*Y&pZ)w<0p!+NE5 zsY*RpDjv<}hI2Z+N%hLu62{S-8uVZ9jnCefgds)6q@RMc;8n-up@V~xP=rw!kU^|T zDyWFfDvHS{0)hNkS}`PUk7m|^X1Yqk{d!Q zBP^&kgp}G4SBkyc+9I-nEX36Lz~+*J^wl+?@0&%SHVrJ82Nx{Ei{>Hn!~%dBBJ;pn zNpOT~k6;97r+tu(U~V7dyCx*QDXD)-5u8?a&8WMlRo#=yZV=JORo&z29;9lsaBW?9 z+7y{JMrMpab7;ysJmnaf0mL|n;prYByR*H2#u}MM@YjcjwcUg2Xt<7!D(%DUN{KrH zFBUkvF~yV%FD9I&02hXLxU(6PY0)0ykHYN(^e|v8U%abaysKKe4=E-2>Rs8&9Vu-2 zu4IYq&f=we|8VVHtnuZpUt>3i`vYr|oNpo~JW(TITfZ+|zAah0C0zI!2CPL3w~?0f zr+?;8{>T~qwx#bAYT&(E`}uP1dbxV3Ofg$39xLSbrMEhh8*~X3f|z{JHLEd3V=uO`4Tc@Bn7uuR1qYgwn2Ciio-Ju*{7XB#v*M=Y)}n|s3Io{}4fIjSCxrki8vK~4x3 zpMp5xGywer>!Aez4ev(ihNr`j{~IF<+VG63drH|g2~Mxn4@aJn2Il0!8LRmxJB1n@hJ{d1;)SqnURc-}fRXUDM}PCYi~9bfQ{FZ#w8@$`(& z19ZpG49;la0|Rkg&xpEvm?U8SL78_@jtQlnKB>1?=;;+8fV+DzjaKZ&L{q{!6xf11 zaGCJ5pid-#Ck$9i=Wfa7FbwvNdRuPH@rKVmfx4H zJwTEsM?)^K##w9w3jpca?I_m20BG{7=I;onf9AojN4{z8|BM>CS?9b^rd=;p%oT~p z3wZt6?f!JSHMve6SHwD+QICEZGEOkH>_AHS;dBU6qbCbaT^nD}kWz~79(qPqb9!ZC zQc-n6eq{_&xRin;Nd<>eN`WlZrnnwf#v(yuSFQG0Pn_ZoK14vRziD=R7xL6m+z4?7pbVg$M2Gp*X%9c z)KA#5+lIcRhCixvU%{A#Ldi_NU?`W_lS#8B*D2#lxyN#;2hq%xRE*|azztUg;qXX$ zDFnP?va91j5iF*V85(7csU>yE`IYh6CB(1OiU2p#xkH(CM-juzcxiRAOsXoUMN`Bw z!>`LbZEeb++%{zK&-o%-T?1$OhA%}%u7*dhdLw5oT^q*WlD=!f&@*cc&sqi+tkKQd zF|y(sTf@^iw(1yPNAe7yNwzk*iOkJ5x@s9-HV-W!kuwis69;K()jY6j9$K*uuh@pM z(Gzowt|1fvW$Oq~o(IrTxEJjs3(hf6_m_}t`zBWi#4}NwCP^AV2i%^qS=aFH#vS$U wFqR@o&r~58d>nA&_&JC}7UoXF$Hks7F=XIQkctW7fLmbq3++Lko#f&F59s? Date: Tue, 22 Jan 2013 21:07:11 +0100 Subject: [PATCH 24/95] Make scroll behavior of review page clearer and a lot simpler. --- .../songs/forms/duplicatesongremovalform.py | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 422c5d871..8e2b74a75 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -112,7 +112,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songsHorizontalScrollArea = QtGui.QScrollArea(self.reviewPage) self.songsHorizontalScrollArea.setObjectName(u'songsHorizontalScrollArea') self.songsHorizontalScrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self.songsHorizontalScrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.songsHorizontalScrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) self.songsHorizontalScrollArea.setFrameStyle(QtGui.QFrame.NoFrame) self.songsHorizontalScrollArea.setWidgetResizable(True) self.songsHorizontalScrollArea.setStyleSheet(u'QScrollArea#songsHorizontalScrollArea {background-color:transparent;}') @@ -303,17 +303,6 @@ class SongReviewWidget(QtGui.QWidget): self.songGroupBox.setMaximumWidth(300) self.songGroupBoxLayout = QtGui.QVBoxLayout(self.songGroupBox) self.songGroupBoxLayout.setObjectName(u'songGroupBoxLayout') - self.songScrollArea = QtGui.QScrollArea(self) - self.songScrollArea.setObjectName(u'songScrollArea') - self.songScrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - self.songScrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self.songScrollArea.setFrameStyle(QtGui.QFrame.NoFrame) - self.songScrollArea.setWidgetResizable(True) - self.songContentWidget = QtGui.QWidget(self.songScrollArea) - self.songContentWidget.setObjectName(u'songContentWidget') - self.songContentVerticalLayout = QtGui.QVBoxLayout(self.songContentWidget) - self.songContentVerticalLayout.setObjectName(u'songContentVerticalLayout') - self.songContentVerticalLayout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) self.songInfoFormLayout = QtGui.QFormLayout() self.songInfoFormLayout.setObjectName(u'songInfoFormLayout') self.songTitleLabel = QtGui.QLabel(self) @@ -377,7 +366,7 @@ class SongReviewWidget(QtGui.QWidget): self.songVerseOrderContent.setText(self.song.verse_order) self.songVerseOrderContent.setWordWrap(True) self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.FieldRole, self.songVerseOrderContent) - self.songContentVerticalLayout.addLayout(self.songInfoFormLayout) + self.songGroupBoxLayout.addLayout(self.songInfoFormLayout) self.songInfoVerseGroupBox = QtGui.QGroupBox(self.songGroupBox) self.songInfoVerseGroupBox.setObjectName(u'songInfoVerseGroupBox') self.songInfoVerseGroupBoxLayout = QtGui.QFormLayout(self.songInfoVerseGroupBox) @@ -389,10 +378,8 @@ class SongReviewWidget(QtGui.QWidget): verseLabel.setText(verse[1]) verseLabel.setWordWrap(True) self.songInfoVerseGroupBoxLayout.addRow(verseMarker, verseLabel) - self.songContentVerticalLayout.addWidget(self.songInfoVerseGroupBox) - self.songContentVerticalLayout.addStretch() - self.songScrollArea.setWidget(self.songContentWidget) - self.songGroupBoxLayout.addWidget(self.songScrollArea) + self.songGroupBoxLayout.addWidget(self.songInfoVerseGroupBox) + self.songGroupBoxLayout.addStretch() self.songVerticalLayout.addWidget(self.songGroupBox) self.songRemoveButton = QtGui.QPushButton(self) self.songRemoveButton.setObjectName(u'songRemoveButton') From f179e5ca95336c3991a907de7182a6ac1c8afdd0 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 22 Jan 2013 21:43:46 +0100 Subject: [PATCH 25/95] Lots of comments. --- .../songs/forms/duplicatesongremovalform.py | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 8e2b74a75..ba7bd4c03 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -148,12 +148,15 @@ class DuplicateSongRemovalForm(OpenLPWizard): u'Here you can decide which songs to remove and which ones to keep.')) def updateReviewCounterText(self): + """ + Set the wizard review page header text. + """ self.reviewPage.setTitle(translate(u'Wizard', u'Review duplicate songs (%s/%s)') % \ (self.reviewCurrentCount, self.reviewTotalCount)) def customPageChanged(self, pageId): """ - Called when changing to a page other than the progress page. + Called when changing the wizard page. """ #hide back button self.button(QtGui.QWizard.BackButton).hide() @@ -171,7 +174,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): doubleFinder = DuplicateSongFinder() if doubleFinder.songsProbablyEqual(songs[outerSongCounter], songs[innerSongCounter]): self.addDuplicatesToSongList(songs[outerSongCounter], songs[innerSongCounter]) - self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + songs[innerSongCounter].title) + self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + + songs[innerSongCounter].title) self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value()+1) self.reviewTotalCount = len(self.duplicateSongList) if self.reviewTotalCount == 0: @@ -185,6 +189,11 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.nextReviewButtonClicked() def addDuplicatesToSongList(self, searchSong, duplicateSong): + """ + Inserts a song duplicate (two simliar songs) to the duplicate song list. + If one of the two songs is already part of the duplicate song list, + don't add another duplicate group but add the other song to that group. + """ duplicateGroupFound = False for duplicates in self.duplicateSongList: #skip the first song in the duplicate lists, since the first one has to be an earlier song @@ -203,7 +212,10 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.duplicateSongList.append([searchSong, duplicateSong]) def onWizardExit(self): - #refresh the song list + """ + Once the wizard is finished, refresh the song list, + since we potentially removed songs from it. + """ self.plugin.mediaItem.onSearchTextButtonClicked() def setDefaults(self): @@ -215,6 +227,10 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.foundDuplicatesEdit.clear() def validateCurrentPage(self): + """ + Controls whether we should switch to the next wizard page. This method loops + on the review page as long as there are more song duplicates to review. + """ if self.currentId() == self.reviewPageId: #as long as the duplicate list is not empty we revisit the review page if len(self.duplicateSongList) == 0: @@ -225,6 +241,11 @@ class DuplicateSongRemovalForm(OpenLPWizard): return OpenLPWizard.validateCurrentPage(self) def removeButtonClicked(self, songReviewWidget): + """ + Removes a song from the database, removes the GUI element representing the + song on the review page, and disable the remove button if only one duplicate + is left. + """ #remove song item_id = songReviewWidget.song.id media_files = self.plugin.manager.get_all_objects(MediaFile, @@ -253,6 +274,13 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songsHorizontalLayout.itemAt(2).widget().songRemoveButton.setEnabled(False) def nextReviewButtonClicked(self): + """ + Called whenever the "next" button is clicked on the review page. + Update the review counter in the wizard header, remove all previous + song widgets, add song widgets for the current duplicate group to review, + if it's the last duplicate song group, hide the "next" button and show + the "finish" button. + """ # update counter self.reviewCurrentCount = self.reviewTotalCount - (len(self.duplicateSongList) - 1) self.updateReviewCounterText() @@ -287,6 +315,13 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.button(QtGui.QWizard.NextButton).hide() class SongReviewWidget(QtGui.QWidget): + """ + A widget representing a song on the duplicate song review page. + It displays most of the information a song contains and + provides a "remove" button to remove the song from the database. + The remove logic is not implemented here, but a signal is provided + when the remove button is clicked. + """ def __init__(self, parent, song): QtGui.QWidget.__init__(self, parent) self.song = song @@ -305,6 +340,7 @@ class SongReviewWidget(QtGui.QWidget): self.songGroupBoxLayout.setObjectName(u'songGroupBoxLayout') self.songInfoFormLayout = QtGui.QFormLayout() self.songInfoFormLayout.setObjectName(u'songInfoFormLayout') + #title self.songTitleLabel = QtGui.QLabel(self) self.songTitleLabel.setObjectName(u'songTitleLabel') self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.LabelRole, self.songTitleLabel) @@ -313,6 +349,7 @@ class SongReviewWidget(QtGui.QWidget): self.songTitleContent.setText(self.song.title) self.songTitleContent.setWordWrap(True) self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.FieldRole, self.songTitleContent) + #alternate title self.songAlternateTitleLabel = QtGui.QLabel(self) self.songAlternateTitleLabel.setObjectName(u'songAlternateTitleLabel') self.songInfoFormLayout.setWidget(1, QtGui.QFormLayout.LabelRole, self.songAlternateTitleLabel) @@ -321,6 +358,7 @@ class SongReviewWidget(QtGui.QWidget): self.songAlternateTitleContent.setText(self.song.alternate_title) self.songAlternateTitleContent.setWordWrap(True) self.songInfoFormLayout.setWidget(1, QtGui.QFormLayout.FieldRole, self.songAlternateTitleContent) + #CCLI number self.songCCLINumberLabel = QtGui.QLabel(self) self.songCCLINumberLabel.setObjectName(u'songCCLINumberLabel') self.songInfoFormLayout.setWidget(2, QtGui.QFormLayout.LabelRole, self.songCCLINumberLabel) @@ -329,6 +367,7 @@ class SongReviewWidget(QtGui.QWidget): self.songCCLINumberContent.setText(self.song.ccli_number) self.songCCLINumberContent.setWordWrap(True) self.songInfoFormLayout.setWidget(2, QtGui.QFormLayout.FieldRole, self.songCCLINumberContent) + #copyright self.songCopyrightLabel = QtGui.QLabel(self) self.songCopyrightLabel.setObjectName(u'songCopyrightLabel') self.songInfoFormLayout.setWidget(3, QtGui.QFormLayout.LabelRole, self.songCopyrightLabel) @@ -337,6 +376,7 @@ class SongReviewWidget(QtGui.QWidget): self.songCopyrightContent.setWordWrap(True) self.songCopyrightContent.setText(self.song.copyright) self.songInfoFormLayout.setWidget(3, QtGui.QFormLayout.FieldRole, self.songCopyrightContent) + #comments self.songCommentsLabel = QtGui.QLabel(self) self.songCommentsLabel.setObjectName(u'songCommentsLabel') self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.LabelRole, self.songCommentsLabel) @@ -345,6 +385,7 @@ class SongReviewWidget(QtGui.QWidget): self.songCommentsContent.setText(self.song.comments) self.songCommentsContent.setWordWrap(True) self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.FieldRole, self.songCommentsContent) + #authors self.songAuthorsLabel = QtGui.QLabel(self) self.songAuthorsLabel.setObjectName(u'songAuthorsLabel') self.songInfoFormLayout.setWidget(5, QtGui.QFormLayout.LabelRole, self.songAuthorsLabel) @@ -358,6 +399,7 @@ class SongReviewWidget(QtGui.QWidget): authorsText = authorsText[:-2] self.songAuthorsContent.setText(authorsText) self.songInfoFormLayout.setWidget(5, QtGui.QFormLayout.FieldRole, self.songAuthorsContent) + #verse order self.songVerseOrderLabel = QtGui.QLabel(self) self.songVerseOrderLabel.setObjectName(u'songVerseOrderLabel') self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.LabelRole, self.songVerseOrderLabel) @@ -366,6 +408,7 @@ class SongReviewWidget(QtGui.QWidget): self.songVerseOrderContent.setText(self.song.verse_order) self.songVerseOrderContent.setWordWrap(True) self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.FieldRole, self.songVerseOrderContent) + #verses self.songGroupBoxLayout.addLayout(self.songInfoFormLayout) self.songInfoVerseGroupBox = QtGui.QGroupBox(self.songGroupBox) self.songInfoVerseGroupBox.setObjectName(u'songInfoVerseGroupBox') @@ -399,5 +442,8 @@ class SongReviewWidget(QtGui.QWidget): self.songInfoVerseGroupBox.setTitle(u'Verses') def onRemoveButtonClicked(self): + """ + Signal emitted when the "remove" button is clicked. + """ self.emit(QtCore.SIGNAL(u'songRemoveButtonClicked(PyQt_PyObject)'), self) From bbe9293392ceef023671e3c9ee6cbfd98ac6c926 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 22 Jan 2013 21:49:41 +0100 Subject: [PATCH 26/95] Even more comments. --- .../songs/forms/duplicatesongremovalform.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index ba7bd4c03..a6f2538df 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -157,6 +157,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): def customPageChanged(self, pageId): """ Called when changing the wizard page. + + ``pageId`` + ID of the page the wizard changed to. """ #hide back button self.button(QtGui.QWizard.BackButton).hide() @@ -193,6 +196,12 @@ class DuplicateSongRemovalForm(OpenLPWizard): Inserts a song duplicate (two simliar songs) to the duplicate song list. If one of the two songs is already part of the duplicate song list, don't add another duplicate group but add the other song to that group. + + ``searchSong`` + The song we searched the duplicate for. + + ``duplicateSong`` + The duplicate song. """ duplicateGroupFound = False for duplicates in self.duplicateSongList: @@ -245,6 +254,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): Removes a song from the database, removes the GUI element representing the song on the review page, and disable the remove button if only one duplicate is left. + + ``songReviewWidget`` + The SongReviewWidget whose song we should delete. """ #remove song item_id = songReviewWidget.song.id @@ -323,6 +335,13 @@ class SongReviewWidget(QtGui.QWidget): when the remove button is clicked. """ def __init__(self, parent, song): + """ + ``parent`` + The QWidget-derived parent of the wizard. + + ``song`` + The Song which this SongReviewWidget should represent. + """ QtGui.QWidget.__init__(self, parent) self.song = song self.setupUi() From bc3f854921cbbc2e9c0eef2b7dcb3a6b1732e371 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 22 Jan 2013 22:11:01 +0100 Subject: [PATCH 27/95] Lots more comments. --- .../plugins/songs/lib/duplicatesongfinder.py | 104 +++++++++++++----- 1 file changed, 74 insertions(+), 30 deletions(-) diff --git a/openlp/plugins/songs/lib/duplicatesongfinder.py b/openlp/plugins/songs/lib/duplicatesongfinder.py index 6ace0a68b..bf984a5ea 100644 --- a/openlp/plugins/songs/lib/duplicatesongfinder.py +++ b/openlp/plugins/songs/lib/duplicatesongfinder.py @@ -26,19 +26,27 @@ # with this program; if not, write to the Free Software Foundation, Inc., 59 # # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### -import logging import difflib -from openlp.core.lib import translate from openlp.plugins.songs.lib.db import Song -from openlp.plugins.songs.lib.ui import SongStrings - -log = logging.getLogger(__name__) class DuplicateSongFinder(object): """ The :class:`DuplicateSongFinder` class provides functionality to search for - and remove duplicate songs. + duplicate songs. + + The algorithm is based on the diff algorithm. + First a diffset is calculated for two songs. + To compensate for typos all differences that are smaller than a + limit (minFragmentSize) are removed and the surrounding equal parts are merged. + Finally two conditions can qualify a song tuple to be a duplicate: + 1. There is a block of equal content that is at least minBlockSize large. + This condition should hit for all larger songs that have a long enough + equal part. Even if only one verse is equal this condition should still hit. + 2. Two thirds of the smaller song is contained in the larger song. + This condition should hit if one of the two songs (or both) is small (smaller + than the minBlockSize), but most of the song is contained in the other song. """ def __init__(self): @@ -47,6 +55,15 @@ class DuplicateSongFinder(object): self.maxTypoSize = 3 def songsProbablyEqual(self, song1, song2): + """ + Calculate and return whether two songs are probably equal. + + ``song1`` + The first song to compare. + + ``song2`` + The second song to compare. + """ if len(song1.search_lyrics) < len(song2.search_lyrics): small = song1.search_lyrics large = song2.search_lyrics @@ -64,38 +81,58 @@ class DuplicateSongFinder(object): return False def __opLength(self, opcode): + """ + Return the length of a given difference. + + ``opcode`` + The difference. + """ return max(opcode[2]-opcode[1], opcode[4] - opcode[3]) def __removeTypos(self, diff): + """ + Remove typos from a diff set. A typo is a small difference (minFragmentSize). + + ``diff`` + The diff set to remove the typos from. + """ #remove typo at beginning of string - if len(diff) >= 2: - if diff[0][0] != "equal" and self.__opLength(diff[0]) <= self.maxTypoSize and \ - self.__opLength(diff[1]) >= self.minFragmentSize: - del diff[0] - #remove typos in the middle of string - if len(diff) >= 3: - for index in range(len(diff)-3, -1, -1): - if self.__opLength(diff[index]) >= self.minFragmentSize and \ - diff[index+1][0] != "equal" and self.__opLength(diff[index+1]) <= self.maxTypoSize and \ - self.__opLength(diff[index+2]) >= self.minFragmentSize: - del diff[index+1] - #remove typo at the end of string - if len(diff) >= 2: - if self.__opLength(diff[-2]) >= self.minFragmentSize and \ - diff[-1][0] != "equal" and self.__opLength(diff[-1]) <= self.maxTypoSize: - del diff[-1] - - #merge fragments - for index in range(len(diff)-2, -1, -1): - if diff[index][0] == "equal" and self.__opLength(diff[index]) >= self.minFragmentSize and \ - diff[index+1][0] == "equal" and self.__opLength(diff[index+1]) >= self.minFragmentSize: - diff[index] = ("equal", diff[index][1], diff[index+1][2], diff[index][3], - diff[index+1][4]) + if len(diff) >= 2: + if diff[0][0] != "equal" and self.__opLength(diff[0]) <= self.maxTypoSize and \ + self.__opLength(diff[1]) >= self.minFragmentSize: + del diff[0] + #remove typos in the middle of string + if len(diff) >= 3: + for index in range(len(diff)-3, -1, -1): + if self.__opLength(diff[index]) >= self.minFragmentSize and \ + diff[index+1][0] != "equal" and self.__opLength(diff[index+1]) <= self.maxTypoSize and \ + self.__opLength(diff[index+2]) >= self.minFragmentSize: del diff[index+1] + #remove typo at the end of string + if len(diff) >= 2: + if self.__opLength(diff[-2]) >= self.minFragmentSize and \ + diff[-1][0] != "equal" and self.__opLength(diff[-1]) <= self.maxTypoSize: + del diff[-1] - return diff + #merge fragments + for index in range(len(diff)-2, -1, -1): + if diff[index][0] == "equal" and self.__opLength(diff[index]) >= self.minFragmentSize and \ + diff[index+1][0] == "equal" and self.__opLength(diff[index+1]) >= self.minFragmentSize: + diff[index] = ("equal", diff[index][1], diff[index+1][2], diff[index][3], + diff[index+1][4]) + del diff[index+1] + + return diff def __lengthOfEqualBlocks(self, diff): + """ + Return the total length of all equal blocks in a diff set. + Blocks smaller than minBlockSize are not counted. + + ``diff`` + The diff set to return the length for. + """ length = 0 for element in diff: if element[0] == "equal" and self.__opLength(element) >= self.minBlockSize: @@ -103,8 +140,15 @@ class DuplicateSongFinder(object): return length def __lengthOfLongestEqualBlock(self, diff): + """ + Return the length of the largest equal block in a diff set. + + ``diff`` + The diff set to return the length for. + """ length = 0 for element in diff: if element[0] == "equal" and self.__opLength(element) > length: length = self.__opLength(element) return length + From 33c6633dbb51ec1f7df7f25824d578629751c677 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 22 Jan 2013 22:35:53 +0100 Subject: [PATCH 28/95] More documentation. --- openlp/core/ui/wizard.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/openlp/core/ui/wizard.py b/openlp/core/ui/wizard.py index be71a0bbe..e79a5d5e0 100644 --- a/openlp/core/ui/wizard.py +++ b/openlp/core/ui/wizard.py @@ -78,6 +78,22 @@ class OpenLPWizard(QtGui.QWizard): """ Generic OpenLP wizard to provide generic functionality and a unified look and feel. + + ``parent`` + The QWidget-derived parent of the wizard. + + ``plugin`` + Plugin this wizard is part of. The plugin will be saved in the "plugin" variable. + The plugin will also be used as basis for the file dialog methods this class provides. + + ``name`` + The object name this wizard should have. + + ``image`` + The image to display on the "welcome" page of the wizard. Should be 163x350. + + ``addProgressPage`` + Whether to add a progress page with a progressbar at the end of the wizard. """ def __init__(self, parent, plugin, name, image, addProgressPage=True): QtGui.QWizard.__init__(self, parent) From d09c8f20a6cc20f54cb3e7555f3ed0056a199c63 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 28 Jan 2013 22:34:38 +0100 Subject: [PATCH 29/95] Remove an unused widget. --- openlp/plugins/songs/forms/duplicatesongremovalform.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index a6f2538df..f393c3fd7 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -104,11 +104,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.searchingPageId = self.addPage(self.searchingPage) self.reviewPage = QtGui.QWizardPage() self.reviewPage.setObjectName(u'reviewPage') - self.headerVerticalLayout = QtGui.QVBoxLayout(self.reviewPage) - self.headerVerticalLayout.setObjectName(u'headerVerticalLayout') - self.reviewCounterLabel = QtGui.QLabel(self.reviewPage) - self.reviewCounterLabel.setObjectName(u'reviewCounterLabel') - self.headerVerticalLayout.addWidget(self.reviewCounterLabel) + self.reviewLayout = QtGui.QVBoxLayout(self.reviewPage) + self.reviewLayout.setObjectName(u'reviewLayout') self.songsHorizontalScrollArea = QtGui.QScrollArea(self.reviewPage) self.songsHorizontalScrollArea.setObjectName(u'songsHorizontalScrollArea') self.songsHorizontalScrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) @@ -123,7 +120,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songsHorizontalLayout.setObjectName(u'songsHorizontalLayout') self.songsHorizontalLayout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) self.songsHorizontalScrollArea.setWidget(self.songsHorizontalSongsWidget) - self.headerVerticalLayout.addWidget(self.songsHorizontalScrollArea) + self.reviewLayout.addWidget(self.songsHorizontalScrollArea) self.reviewPageId = self.addPage(self.reviewPage) #add a dummy page to the end, to prevent the finish button to appear and the next button do disappear on the #review page From cdd52c363f25525a8bc587d0deae70623115f2cc Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 28 Jan 2013 23:20:50 +0100 Subject: [PATCH 30/95] Bugfix: Duplicates were reported more than once. --- .../songs/forms/duplicatesongremovalform.py | 57 ++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index f393c3fd7..923eaaa5e 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -164,6 +164,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): #search duplicate songs maxSongs = self.plugin.manager.get_object_count(Song) if maxSongs == 0 or maxSongs == 1: + self.duplicateSearchProgressBar.setMaximum(1) + self.duplicateSearchProgressBar.setValue(1) + self.notifyNoDuplicates() return # with x songs we have x*(x-1)/2 comparisons maxProgressCount = maxSongs*(maxSongs-1)/2 @@ -173,26 +176,36 @@ class DuplicateSongRemovalForm(OpenLPWizard): for innerSongCounter in range(outerSongCounter+1, maxSongs): doubleFinder = DuplicateSongFinder() if doubleFinder.songsProbablyEqual(songs[outerSongCounter], songs[innerSongCounter]): - self.addDuplicatesToSongList(songs[outerSongCounter], songs[innerSongCounter]) - self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + + duplicateAdded = self.addDuplicatesToSongList(songs[outerSongCounter], songs[innerSongCounter]) + if duplicateAdded: + self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + songs[innerSongCounter].title) self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value()+1) self.reviewTotalCount = len(self.duplicateSongList) if self.reviewTotalCount == 0: - self.button(QtGui.QWizard.FinishButton).show() - self.button(QtGui.QWizard.FinishButton).setEnabled(True) - self.button(QtGui.QWizard.NextButton).hide() - QtGui.QMessageBox.information(self, translate(u'Wizard', u'Information'), - translate(u'Wizard', u'No duplicate songs have been found in the database.'), - QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Ok)) + self.notifyNoDuplicates() elif pageId == self.reviewPageId: self.nextReviewButtonClicked() + def notifyNoDuplicates(self): + """ + Notifies the user, that there were no duplicates found in the database. + """ + self.button(QtGui.QWizard.FinishButton).show() + self.button(QtGui.QWizard.FinishButton).setEnabled(True) + self.button(QtGui.QWizard.NextButton).hide() + QtGui.QMessageBox.information(self, translate(u'Wizard', u'Information'), + translate(u'Wizard', u'No duplicate songs have been found in the database.'), + QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Ok)) + + def addDuplicatesToSongList(self, searchSong, duplicateSong): """ Inserts a song duplicate (two simliar songs) to the duplicate song list. If one of the two songs is already part of the duplicate song list, don't add another duplicate group but add the other song to that group. + Retruns True if at least one of the songs was added, False if both were already + member of a group. ``searchSong`` The song we searched the duplicate for. @@ -201,21 +214,27 @@ class DuplicateSongRemovalForm(OpenLPWizard): The duplicate song. """ duplicateGroupFound = False - for duplicates in self.duplicateSongList: + duplicateAdded = False + for duplicateGroup in self.duplicateSongList: #skip the first song in the duplicate lists, since the first one has to be an earlier song - for duplicate in duplicates[1:]: - if duplicate == searchSong: - duplicates.append(duplicateSong) - duplicateGroupFound = True - break - elif duplicate == duplicateSong: - duplicates.append(searchSong) - duplicateGroupFound = True - break - if duplicateGroupFound: + if searchSong in duplicateGroup and not duplicateSong in duplicateGroup: + duplicateGroup.append(duplicateSong) + duplicateGroupFound = True + duplicateAdded = True + break + elif not searchSong in duplicateGroup and duplicateSong in duplicateGroup: + duplicateGroup.append(searchSong) + duplicateGroupFound = True + duplicateAdded = True + break + elif searchSong in duplicateGroup and duplicateSong in duplicateGroup: + duplicateGroupFound = True + duplicateAdded = False break if not duplicateGroupFound: self.duplicateSongList.append([searchSong, duplicateSong]) + duplicateGroupFound = True + return duplicateAdded def onWizardExit(self): """ From e807c9cb937ac6dec01dc72f3ca43ffdcb13182d Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 12 Feb 2013 21:38:43 +0100 Subject: [PATCH 31/95] Add basic test logic & tune duplicate identification logic a bit. --- .../plugins/songs/lib/duplicatesongfinder.py | 2 +- .../openlp_plugins/songs/__init__.py | 8 +++ .../openlp_plugins/songs/test_lib.py | 72 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/functional/openlp_plugins/songs/__init__.py create mode 100644 tests/functional/openlp_plugins/songs/test_lib.py diff --git a/openlp/plugins/songs/lib/duplicatesongfinder.py b/openlp/plugins/songs/lib/duplicatesongfinder.py index bf984a5ea..bb8df21fc 100644 --- a/openlp/plugins/songs/lib/duplicatesongfinder.py +++ b/openlp/plugins/songs/lib/duplicatesongfinder.py @@ -70,7 +70,7 @@ class DuplicateSongFinder(object): else: small = song2.search_lyrics large = song1.search_lyrics - differ = difflib.SequenceMatcher(a=small, b=large) + differ = difflib.SequenceMatcher(a=large, b=small) diff_tuples = differ.get_opcodes() diff_no_typos = self.__removeTypos(diff_tuples) #print(diff_no_typos) diff --git a/tests/functional/openlp_plugins/songs/__init__.py b/tests/functional/openlp_plugins/songs/__init__.py new file mode 100644 index 000000000..0157fb2f0 --- /dev/null +++ b/tests/functional/openlp_plugins/songs/__init__.py @@ -0,0 +1,8 @@ +import sip +sip.setapi(u'QDate', 2) +sip.setapi(u'QDateTime', 2) +sip.setapi(u'QString', 2) +sip.setapi(u'QTextStream', 2) +sip.setapi(u'QTime', 2) +sip.setapi(u'QUrl', 2) +sip.setapi(u'QVariant', 2) diff --git a/tests/functional/openlp_plugins/songs/test_lib.py b/tests/functional/openlp_plugins/songs/test_lib.py new file mode 100644 index 000000000..283991345 --- /dev/null +++ b/tests/functional/openlp_plugins/songs/test_lib.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2013 Raoul Snyman # +# Portions copyright (c) 2008-2013 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 # +############################################################################### + +from unittest import TestCase + +from mock import MagicMock + +from openlp.plugins.songs.lib.duplicatesongfinder import DuplicateSongFinder + +class TestLib(TestCase): + def duplicate_song_removal_test(self): + full_lyrics =u'''amazing grace how sweet the sound that saved a wretch like me i once was lost but now am found was + blind but now i see twas grace that taught my heart to fear and grace my fears relieved how precious did that grace + appear the hour i first believed through many dangers toils and snares i have already come tis grace that brought + me safe thus far and grace will lead me home the lord has promised good to me his word my hope secures he will my + shield and portion be as long as life endures yea when this flesh and heart shall fail and mortal life shall cease + i shall possess within the veil a life of joy and peace when weve been here ten thousand years bright shining as + the sun weve no less days to sing gods praise than when weve first begun''' + short_lyrics =u'''twas grace that taught my heart to fear and grace my fears relieved how precious did that grace + appear the hour i first believed''' + error_lyrics =u'''amazing grace how sweet the sound that saved a wretch like me i once was lost but now am found was + blind but now i see twas grace that taught my heart to fear and grace my fears relieved how precious did that grace + appear the hour i first believedxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx snares i have already come tis grace that brought + me safe thus far and grace will lead me home the lord has promised good to me his word my hope secures he will my + shield andwhen this flcsh and heart shall fail and mortal life shall cease + i shall possess within the veila lifeofjoy and peace when weve been here ten thousand years bright shining as + the sun weve no less days to sing gods praise than when weve first begun''' + different_lyrics=u'''on a hill far away stood an old rugged cross the emblem of suffering and shame and i love that + old cross where the dearest and best for a world of lost sinners was slain so ill cherish the old rugged cross till + my trophies at last i lay down i will cling to the old rugged cross and exchange it some day for a crown''' + dsf = DuplicateSongFinder() + song1 = MagicMock() + song2 = MagicMock() + + song1.search_lyrics = full_lyrics + song2.search_lyrics = full_lyrics + assert dsf.songsProbablyEqual(song1, song2) is True, u'The result should be True' + song1.search_lyrics = full_lyrics + song2.search_lyrics = short_lyrics + assert dsf.songsProbablyEqual(song1, song2) is True, u'The result should be True' + song1.search_lyrics = full_lyrics + song2.search_lyrics = error_lyrics + assert dsf.songsProbablyEqual(song1, song2) is True, u'The result should be True' + song1.search_lyrics = full_lyrics + song2.search_lyrics = different_lyrics + assert dsf.songsProbablyEqual(song1, song2) is False, u'The result should be False' From d8cfc4f28a2f8a19ba65279bc966e4fc63a79497 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 12 Feb 2013 22:44:33 +0100 Subject: [PATCH 32/95] Fix wizard after trunk merge and fix a minor display bug. --- openlp/core/lib/settings.py | 1 + .../songs/forms/duplicatesongremovalform.py | 48 +++++++++++-------- openlp/plugins/songs/songsplugin.py | 4 +- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/openlp/core/lib/settings.py b/openlp/core/lib/settings.py index 0591871cc..f02148bc0 100644 --- a/openlp/core/lib/settings.py +++ b/openlp/core/lib/settings.py @@ -200,6 +200,7 @@ class Settings(QtCore.QSettings): u'shortcuts/songImportItem': [], u'shortcuts/themeScreen': [QtGui.QKeySequence(u'T')], u'shortcuts/toolsReindexItem': [], + u'shortcuts/toolsFindDuplicates': [], u'shortcuts/toolsAlertItem': [u'F7'], u'shortcuts/toolsFirstTimeWizard': [], u'shortcuts/toolsOpenDataFolder': [], diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 923eaaa5e..d55966426 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -66,7 +66,6 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.duplicateSongList = [] self.reviewCurrentCount = 0 self.reviewTotalCount = 0 - self.clipboard = plugin.formParent.clipboard OpenLPWizard.__init__(self, parent, plugin, u'duplicateSongRemovalWizard', u':/wizards/wizard_duplicateremoval.bmp', False) @@ -185,7 +184,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): if self.reviewTotalCount == 0: self.notifyNoDuplicates() elif pageId == self.reviewPageId: - self.nextReviewButtonClicked() + self.processCurrentDuplicateEntry() def notifyNoDuplicates(self): """ @@ -201,10 +200,10 @@ class DuplicateSongRemovalForm(OpenLPWizard): def addDuplicatesToSongList(self, searchSong, duplicateSong): """ - Inserts a song duplicate (two simliar songs) to the duplicate song list. + Inserts a song duplicate (two similar songs) to the duplicate song list. If one of the two songs is already part of the duplicate song list, don't add another duplicate group but add the other song to that group. - Retruns True if at least one of the songs was added, False if both were already + Returns True if at least one of the songs was added, False if both were already member of a group. ``searchSong`` @@ -233,7 +232,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): break if not duplicateGroupFound: self.duplicateSongList.append([searchSong, duplicateSong]) - duplicateGroupFound = True + duplicateAdded = True return duplicateAdded def onWizardExit(self): @@ -257,11 +256,11 @@ class DuplicateSongRemovalForm(OpenLPWizard): on the review page as long as there are more song duplicates to review. """ if self.currentId() == self.reviewPageId: - #as long as the duplicate list is not empty we revisit the review page - if len(self.duplicateSongList) == 0: + #as long as it's not the last duplicate list entry we revisit the review page + if len(self.duplicateSongList) == 1: return True else: - self.nextReviewButtonClicked() + self.proceedToNextReview() return False return OpenLPWizard.validateCurrentPage(self) @@ -301,17 +300,12 @@ class DuplicateSongRemovalForm(OpenLPWizard): if self.songsHorizontalLayout.count() == 5: self.songsHorizontalLayout.itemAt(2).widget().songRemoveButton.setEnabled(False) - def nextReviewButtonClicked(self): + def proceedToNextReview(self): """ - Called whenever the "next" button is clicked on the review page. - Update the review counter in the wizard header, remove all previous - song widgets, add song widgets for the current duplicate group to review, - if it's the last duplicate song group, hide the "next" button and show - the "finish" button. + Removes the previous review UI elements and calls processCurrentDuplicateEntry. """ - # update counter - self.reviewCurrentCount = self.reviewTotalCount - (len(self.duplicateSongList) - 1) - self.updateReviewCounterText() + #remove last duplicate group + self.duplicateSongList.pop() # remove all previous elements for i in reversed(range(self.songsHorizontalLayout.count())): item = self.songsHorizontalLayout.itemAt(i) @@ -322,13 +316,25 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songsHorizontalLayout.removeItem(item) widget.setParent(None) else: - self.songsHorizontalLayout.removeItem(item) - #add next set of duplicates + self.songsHorizontalLayout.removeItem(item) + #process next set of duplicates + self.processCurrentDuplicateEntry() + + def processCurrentDuplicateEntry(self): + """ + Update the review counter in the wizard header, add song widgets for + the current duplicate group to review, if it's the last + duplicate song group, hide the "next" button and show the "finish" button. + """ + # update counter + self.reviewCurrentCount = self.reviewTotalCount - (len(self.duplicateSongList) - 1) + self.updateReviewCounterText() + # add song elements to the UI if len(self.duplicateSongList) > 0: # a stretch doesn't seem to stretch endlessly, so I add two to get enough stetch for 1400x1050 self.songsHorizontalLayout.addStretch() self.songsHorizontalLayout.addStretch() - for duplicate in self.duplicateSongList.pop(0): + for duplicate in self.duplicateSongList[-1]: songReviewWidget = SongReviewWidget(self.reviewPage, duplicate) QtCore.QObject.connect(songReviewWidget, QtCore.SIGNAL(u'songRemoveButtonClicked(PyQt_PyObject)'), @@ -337,7 +343,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songsHorizontalLayout.addStretch() self.songsHorizontalLayout.addStretch() #change next button to finish button on last review - if len(self.duplicateSongList) == 0: + if len(self.duplicateSongList) == 1: self.button(QtGui.QWizard.FinishButton).show() self.button(QtGui.QWizard.FinishButton).setEnabled(True) self.button(QtGui.QWizard.NextButton).hide() diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index c4c0aa3e0..f4654c8a2 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -153,7 +153,7 @@ class SongsPlugin(Plugin): visible=False, triggers=self.onToolsReindexItemTriggered) tools_menu.addAction(self.toolsReindexItem) self.toolsFindDuplicates = create_action(tools_menu, u'toolsFindDuplicates', - text=translate('SongsPlugin', 'Find &duplicate songs'), + text=translate('SongsPlugin', 'Find &Duplicate Songs'), statustip=translate('SongsPlugin', 'Find and remove duplicate songs in the song database.'), visible=False, triggers=self.onToolsFindDuplicatesTriggered) @@ -181,7 +181,7 @@ class SongsPlugin(Plugin): """ Search for duplicates in the song database. """ - DuplicateSongRemovalForm(self.formParent, self).exec_() + DuplicateSongRemovalForm(self.main_window, self).exec_() def onSongImportItemClicked(self): if self.mediaItem: From 9597c9f1294b5408e52e168aa4d31b81c585fb9e Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 12 Feb 2013 23:10:13 +0100 Subject: [PATCH 33/95] Minor cosmetic fix. --- openlp/plugins/songs/forms/duplicatesongremovalform.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index d55966426..5fdf56af5 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -273,6 +273,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): ``songReviewWidget`` The SongReviewWidget whose song we should delete. """ + #remove song from duplicate song list + self.duplicateSongList[-1].remove(songReviewWidget.song) #remove song item_id = songReviewWidget.song.id media_files = self.plugin.manager.get_all_objects(MediaFile, @@ -294,10 +296,10 @@ class DuplicateSongRemovalForm(OpenLPWizard): # remove GUI elements self.songsHorizontalLayout.removeWidget(songReviewWidget) songReviewWidget.setParent(None) - # check if we only have one SongReviewWidget left + # check if we only have one duplicate left # 4 stretches + 1 SongReviewWidget = 5 # the SongReviewWidget is then at position 2 - if self.songsHorizontalLayout.count() == 5: + if len(self.duplicateSongList[-1]) == 1: self.songsHorizontalLayout.itemAt(2).widget().songRemoveButton.setEnabled(False) def proceedToNextReview(self): From 6d5935a0b9c793999c69fc910c245415a8a20044 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 14 Feb 2013 00:00:05 +0100 Subject: [PATCH 34/95] Fix many whitespace issues. --- .../songs/forms/duplicatesongremovalform.py | 10 ++++----- .../plugins/songs/lib/duplicatesongfinder.py | 22 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 5fdf56af5..ff6c85192 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -167,19 +167,19 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.duplicateSearchProgressBar.setValue(1) self.notifyNoDuplicates() return - # with x songs we have x*(x-1)/2 comparisons - maxProgressCount = maxSongs*(maxSongs-1)/2 + # with x songs we have x*(x - 1) / 2 comparisons + maxProgressCount = maxSongs * (maxSongs - 1) / 2 self.duplicateSearchProgressBar.setMaximum(maxProgressCount) songs = self.plugin.manager.get_all_objects(Song) - for outerSongCounter in range(maxSongs-1): - for innerSongCounter in range(outerSongCounter+1, maxSongs): + for outerSongCounter in range(maxSongs - 1): + for innerSongCounter in range(outerSongCounter + 1, maxSongs): doubleFinder = DuplicateSongFinder() if doubleFinder.songsProbablyEqual(songs[outerSongCounter], songs[innerSongCounter]): duplicateAdded = self.addDuplicatesToSongList(songs[outerSongCounter], songs[innerSongCounter]) if duplicateAdded: self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + songs[innerSongCounter].title) - self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value()+1) + self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value() + 1) self.reviewTotalCount = len(self.duplicateSongList) if self.reviewTotalCount == 0: self.notifyNoDuplicates() diff --git a/openlp/plugins/songs/lib/duplicatesongfinder.py b/openlp/plugins/songs/lib/duplicatesongfinder.py index bb8df21fc..e497c885f 100644 --- a/openlp/plugins/songs/lib/duplicatesongfinder.py +++ b/openlp/plugins/songs/lib/duplicatesongfinder.py @@ -75,7 +75,7 @@ class DuplicateSongFinder(object): diff_no_typos = self.__removeTypos(diff_tuples) #print(diff_no_typos) if self.__lengthOfEqualBlocks(diff_no_typos) >= self.minBlockSize or \ - self.__lengthOfLongestEqualBlock(diff_no_typos) > len(small)*2/3: + self.__lengthOfLongestEqualBlock(diff_no_typos) > len(small) * 2 / 3: return True else: return False @@ -87,7 +87,7 @@ class DuplicateSongFinder(object): ``opcode`` The difference. """ - return max(opcode[2]-opcode[1], opcode[4] - opcode[3]) + return max(opcode[2] - opcode[1], opcode[4] - opcode[3]) def __removeTypos(self, diff): """ @@ -104,11 +104,11 @@ class DuplicateSongFinder(object): del diff[0] #remove typos in the middle of string if len(diff) >= 3: - for index in range(len(diff)-3, -1, -1): + for index in range(len(diff) - 3, -1, -1): if self.__opLength(diff[index]) >= self.minFragmentSize and \ - diff[index+1][0] != "equal" and self.__opLength(diff[index+1]) <= self.maxTypoSize and \ - self.__opLength(diff[index+2]) >= self.minFragmentSize: - del diff[index+1] + diff[index + 1][0] != "equal" and self.__opLength(diff[index + 1]) <= self.maxTypoSize and \ + self.__opLength(diff[index + 2]) >= self.minFragmentSize: + del diff[index + 1] #remove typo at the end of string if len(diff) >= 2: if self.__opLength(diff[-2]) >= self.minFragmentSize and \ @@ -116,12 +116,12 @@ class DuplicateSongFinder(object): del diff[-1] #merge fragments - for index in range(len(diff)-2, -1, -1): + for index in range(len(diff) - 2, -1, -1): if diff[index][0] == "equal" and self.__opLength(diff[index]) >= self.minFragmentSize and \ - diff[index+1][0] == "equal" and self.__opLength(diff[index+1]) >= self.minFragmentSize: - diff[index] = ("equal", diff[index][1], diff[index+1][2], diff[index][3], - diff[index+1][4]) - del diff[index+1] + diff[index + 1][0] == "equal" and self.__opLength(diff[index + 1]) >= self.minFragmentSize: + diff[index] = ("equal", diff[index][1], diff[index + 1][2], diff[index][3], + diff[index + 1][4]) + del diff[index + 1] return diff From bbd491baa84b6c34a88498f1cf7be61fe221331d Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 14 Feb 2013 00:15:21 +0100 Subject: [PATCH 35/95] Make songsProbablyEqual test more standard like. --- .../openlp_plugins/songs/test_lib.py | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/tests/functional/openlp_plugins/songs/test_lib.py b/tests/functional/openlp_plugins/songs/test_lib.py index 283991345..d98f97773 100644 --- a/tests/functional/openlp_plugins/songs/test_lib.py +++ b/tests/functional/openlp_plugins/songs/test_lib.py @@ -34,7 +34,11 @@ from mock import MagicMock from openlp.plugins.songs.lib.duplicatesongfinder import DuplicateSongFinder class TestLib(TestCase): - def duplicate_song_removal_test(self): + + def songs_probably_equal_test(self): + """ + Test the DuplicateSongFinder.songsProbablyEqual function. + """ full_lyrics =u'''amazing grace how sweet the sound that saved a wretch like me i once was lost but now am found was blind but now i see twas grace that taught my heart to fear and grace my fears relieved how precious did that grace appear the hour i first believed through many dangers toils and snares i have already come tis grace that brought @@ -58,15 +62,42 @@ class TestLib(TestCase): song1 = MagicMock() song2 = MagicMock() + #GIVEN: Two equal songs song1.search_lyrics = full_lyrics song2.search_lyrics = full_lyrics - assert dsf.songsProbablyEqual(song1, song2) is True, u'The result should be True' + + #WHEN: We compare those songs for equality + result = dsf.songsProbablyEqual(song1, song2) + + #THEN: The result should be True + assert result is True, u'The result should be True' + + #GIVEN: A song and a short version of the same song song1.search_lyrics = full_lyrics song2.search_lyrics = short_lyrics - assert dsf.songsProbablyEqual(song1, song2) is True, u'The result should be True' + + #WHEN: We compare those songs for equality + result = dsf.songsProbablyEqual(song1, song2) + + #THEN: The result should be True + assert result is True, u'The result should be True' + + #GIVEN: A song and the same song with lots of errors song1.search_lyrics = full_lyrics song2.search_lyrics = error_lyrics - assert dsf.songsProbablyEqual(song1, song2) is True, u'The result should be True' + + #WHEN: We compare those songs for equality + result = dsf.songsProbablyEqual(song1, song2) + + #THEN: The result should be True + assert result is True, u'The result should be True' + + #GIVEN: Two different songs song1.search_lyrics = full_lyrics song2.search_lyrics = different_lyrics - assert dsf.songsProbablyEqual(song1, song2) is False, u'The result should be False' + + #WHEN: We compare those songs for equality + result = dsf.songsProbablyEqual(song1, song2) + + #THEN: The result should be False + assert result is False, u'The result should be False' From 2a16a6ebcb0cb17bfac0fd3492327e8ce737cdd2 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 14 Feb 2013 21:57:13 +0100 Subject: [PATCH 36/95] Rename lots of variables and methods. Shorten some too long lines. --- .../songs/forms/duplicatesongremovalform.py | 433 +++++++++--------- .../plugins/songs/lib/duplicatesongfinder.py | 62 +-- .../openlp_plugins/songs/test_lib.py | 43 +- 3 files changed, 268 insertions(+), 270 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index ff6c85192..6a0326c84 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -63,9 +63,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): ``plugin`` The songs plugin. """ - self.duplicateSongList = [] - self.reviewCurrentCount = 0 - self.reviewTotalCount = 0 + self.duplicate_song_list = [] + self.review_current_count = 0 + self.review_total_count = 0 OpenLPWizard.__init__(self, parent, plugin, u'duplicateSongRemovalWizard', u':/wizards/wizard_duplicateremoval.bmp', False) @@ -87,44 +87,46 @@ class DuplicateSongRemovalForm(OpenLPWizard): Add song wizard specific pages. """ #add custom pages - self.searchingPage = QtGui.QWizardPage() - self.searchingPage.setObjectName(u'searchingPage') - self.searchingVerticalLayout = QtGui.QVBoxLayout(self.searchingPage) - self.searchingVerticalLayout.setObjectName(u'searchingVerticalLayout') - self.duplicateSearchProgressBar = QtGui.QProgressBar(self.searchingPage) - self.duplicateSearchProgressBar.setObjectName(u'duplicateSearchProgressBar') - self.duplicateSearchProgressBar.setFormat(WizardStrings.PercentSymbolFormat) - self.searchingVerticalLayout.addWidget(self.duplicateSearchProgressBar) - self.foundDuplicatesEdit = QtGui.QPlainTextEdit(self.searchingPage) - self.foundDuplicatesEdit.setUndoRedoEnabled(False) - self.foundDuplicatesEdit.setReadOnly(True) - self.foundDuplicatesEdit.setObjectName(u'foundDuplicatesEdit') - self.searchingVerticalLayout.addWidget(self.foundDuplicatesEdit) - self.searchingPageId = self.addPage(self.searchingPage) - self.reviewPage = QtGui.QWizardPage() - self.reviewPage.setObjectName(u'reviewPage') - self.reviewLayout = QtGui.QVBoxLayout(self.reviewPage) - self.reviewLayout.setObjectName(u'reviewLayout') - self.songsHorizontalScrollArea = QtGui.QScrollArea(self.reviewPage) - self.songsHorizontalScrollArea.setObjectName(u'songsHorizontalScrollArea') - self.songsHorizontalScrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self.songsHorizontalScrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self.songsHorizontalScrollArea.setFrameStyle(QtGui.QFrame.NoFrame) - self.songsHorizontalScrollArea.setWidgetResizable(True) - self.songsHorizontalScrollArea.setStyleSheet(u'QScrollArea#songsHorizontalScrollArea {background-color:transparent;}') - self.songsHorizontalSongsWidget = QtGui.QWidget(self.songsHorizontalScrollArea) - self.songsHorizontalSongsWidget.setObjectName(u'songsHorizontalSongsWidget') - self.songsHorizontalSongsWidget.setStyleSheet(u'QWidget#songsHorizontalSongsWidget {background-color:transparent;}') - self.songsHorizontalLayout = QtGui.QHBoxLayout(self.songsHorizontalSongsWidget) - self.songsHorizontalLayout.setObjectName(u'songsHorizontalLayout') - self.songsHorizontalLayout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) - self.songsHorizontalScrollArea.setWidget(self.songsHorizontalSongsWidget) - self.reviewLayout.addWidget(self.songsHorizontalScrollArea) - self.reviewPageId = self.addPage(self.reviewPage) + self.searching_page = QtGui.QWizardPage() + self.searching_page.setObjectName(u'searching_page') + self.searching_vertical_layout = QtGui.QVBoxLayout(self.searching_page) + self.searching_vertical_layout.setObjectName(u'searching_vertical_layout') + self.duplicate_search_progress_bar = QtGui.QProgressBar(self.searching_page) + self.duplicate_search_progress_bar.setObjectName(u'duplicate_search_progress_bar') + self.duplicate_search_progress_bar.setFormat(WizardStrings.PercentSymbolFormat) + self.searching_vertical_layout.addWidget(self.duplicate_search_progress_bar) + self.found_duplicates_edit = QtGui.QPlainTextEdit(self.searching_page) + self.found_duplicates_edit.setUndoRedoEnabled(False) + self.found_duplicates_edit.setReadOnly(True) + self.found_duplicates_edit.setObjectName(u'found_duplicates_edit') + self.searching_vertical_layout.addWidget(self.found_duplicates_edit) + self.searching_page_id = self.addPage(self.searching_page) + self.review_page = QtGui.QWizardPage() + self.review_page.setObjectName(u'review_page') + self.review_layout = QtGui.QVBoxLayout(self.review_page) + self.review_layout.setObjectName(u'review_layout') + self.songs_horizontal_scroll_area = QtGui.QScrollArea(self.review_page) + self.songs_horizontal_scroll_area.setObjectName(u'songs_horizontal_scroll_area') + self.songs_horizontal_scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.songs_horizontal_scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.songs_horizontal_scroll_area.setFrameStyle(QtGui.QFrame.NoFrame) + self.songs_horizontal_scroll_area.setWidgetResizable(True) + self.songs_horizontal_scroll_area.setStyleSheet( + u'QScrollArea#songs_horizontal_scroll_area {background-color:transparent;}') + self.songs_horizontal_songs_widget = QtGui.QWidget(self.songs_horizontal_scroll_area) + self.songs_horizontal_songs_widget.setObjectName(u'songs_horizontal_songs_widget') + self.songs_horizontal_songs_widget.setStyleSheet( + u'QWidget#songs_horizontal_songs_widget {background-color:transparent;}') + self.songs_horizontal_layout = QtGui.QHBoxLayout(self.songs_horizontal_songs_widget) + self.songs_horizontal_layout.setObjectName(u'songs_horizontal_layout') + self.songs_horizontal_layout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) + self.songs_horizontal_scroll_area.setWidget(self.songs_horizontal_songs_widget) + self.review_layout.addWidget(self.songs_horizontal_scroll_area) + self.review_page_id = self.addPage(self.review_page) #add a dummy page to the end, to prevent the finish button to appear and the next button do disappear on the #review page - self.dummyPage = QtGui.QWizardPage() - self.dummyPageId = self.addPage(self.dummyPage) + self.dummy_page = QtGui.QWizardPage() + self.dummy_page_id = self.addPage(self.dummy_page) def retranslateUi(self): """ @@ -137,53 +139,54 @@ class DuplicateSongRemovalForm(OpenLPWizard): u'This wizard will help you to remove duplicate songs from the song database. You will have a chance to ' u'review every potential duplicate song before it is deleted. So no songs will be deleted without your ' u'explicit approval.')) - self.searchingPage.setTitle(translate(u'Wizard', u'Searching for duplicate songs.')) - self.searchingPage.setSubTitle(translate(u'Wizard', u'The song database is searched for double songs.')) + self.searching_page.setTitle(translate(u'Wizard', u'Searching for duplicate songs.')) + self.searching_page.setSubTitle(translate(u'Wizard', u'The song database is searched for double songs.')) self.updateReviewCounterText() - self.reviewPage.setSubTitle(translate(u'Wizard', + self.review_page.setSubTitle(translate(u'Wizard', u'Here you can decide which songs to remove and which ones to keep.')) def updateReviewCounterText(self): """ Set the wizard review page header text. """ - self.reviewPage.setTitle(translate(u'Wizard', u'Review duplicate songs (%s/%s)') % \ - (self.reviewCurrentCount, self.reviewTotalCount)) + self.review_page.setTitle(translate(u'Wizard', u'Review duplicate songs (%s/%s)') % \ + (self.review_current_count, self.review_total_count)) - def customPageChanged(self, pageId): + def customPageChanged(self, page_id): """ Called when changing the wizard page. - ``pageId`` + ``page_id`` ID of the page the wizard changed to. """ #hide back button self.button(QtGui.QWizard.BackButton).hide() - if pageId == self.searchingPageId: + if page_id == self.searching_page_id: #search duplicate songs - maxSongs = self.plugin.manager.get_object_count(Song) - if maxSongs == 0 or maxSongs == 1: - self.duplicateSearchProgressBar.setMaximum(1) - self.duplicateSearchProgressBar.setValue(1) + max_songs = self.plugin.manager.get_object_count(Song) + if max_songs == 0 or max_songs == 1: + self.duplicate_search_progress_bar.setMaximum(1) + self.duplicate_search_progress_bar.setValue(1) self.notifyNoDuplicates() return # with x songs we have x*(x - 1) / 2 comparisons - maxProgressCount = maxSongs * (maxSongs - 1) / 2 - self.duplicateSearchProgressBar.setMaximum(maxProgressCount) + max_progress_count = max_songs * (max_songs - 1) / 2 + self.duplicate_search_progress_bar.setMaximum(max_progress_count) songs = self.plugin.manager.get_all_objects(Song) - for outerSongCounter in range(maxSongs - 1): - for innerSongCounter in range(outerSongCounter + 1, maxSongs): - doubleFinder = DuplicateSongFinder() - if doubleFinder.songsProbablyEqual(songs[outerSongCounter], songs[innerSongCounter]): - duplicateAdded = self.addDuplicatesToSongList(songs[outerSongCounter], songs[innerSongCounter]) - if duplicateAdded: - self.foundDuplicatesEdit.appendPlainText(songs[outerSongCounter].title + " = " + - songs[innerSongCounter].title) - self.duplicateSearchProgressBar.setValue(self.duplicateSearchProgressBar.value() + 1) - self.reviewTotalCount = len(self.duplicateSongList) - if self.reviewTotalCount == 0: + for outer_song_counter in range(max_songs - 1): + for inner_song_counter in range(outer_song_counter + 1, max_songs): + double_finder = DuplicateSongFinder() + if double_finder.songs_probably_equal(songs[outer_song_counter], songs[inner_song_counter]): + duplicate_added = self.addDuplicatesToSongList(songs[outer_song_counter], + songs[inner_song_counter]) + if duplicate_added: + self.found_duplicates_edit.appendPlainText(songs[outer_song_counter].title + " = " + + songs[inner_song_counter].title) + self.duplicate_search_progress_bar.setValue(self.duplicate_search_progress_bar.value() + 1) + self.review_total_count = len(self.duplicate_song_list) + if self.review_total_count == 0: self.notifyNoDuplicates() - elif pageId == self.reviewPageId: + elif page_id == self.review_page_id: self.processCurrentDuplicateEntry() def notifyNoDuplicates(self): @@ -198,7 +201,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Ok)) - def addDuplicatesToSongList(self, searchSong, duplicateSong): + def addDuplicatesToSongList(self, search_song, duplicate_song): """ Inserts a song duplicate (two similar songs) to the duplicate song list. If one of the two songs is already part of the duplicate song list, @@ -206,34 +209,34 @@ class DuplicateSongRemovalForm(OpenLPWizard): Returns True if at least one of the songs was added, False if both were already member of a group. - ``searchSong`` + ``search_song`` The song we searched the duplicate for. - ``duplicateSong`` + ``duplicate_song`` The duplicate song. """ - duplicateGroupFound = False - duplicateAdded = False - for duplicateGroup in self.duplicateSongList: + duplicate_group_found = False + duplicate_added = False + for duplicate_group in self.duplicate_song_list: #skip the first song in the duplicate lists, since the first one has to be an earlier song - if searchSong in duplicateGroup and not duplicateSong in duplicateGroup: - duplicateGroup.append(duplicateSong) - duplicateGroupFound = True - duplicateAdded = True + if search_song in duplicate_group and not duplicate_song in duplicate_group: + duplicate_group.append(duplicate_song) + duplicate_group_found = True + duplicate_added = True break - elif not searchSong in duplicateGroup and duplicateSong in duplicateGroup: - duplicateGroup.append(searchSong) - duplicateGroupFound = True - duplicateAdded = True + elif not search_song in duplicate_group and duplicate_song in duplicate_group: + duplicate_group.append(search_song) + duplicate_group_found = True + duplicate_added = True break - elif searchSong in duplicateGroup and duplicateSong in duplicateGroup: - duplicateGroupFound = True - duplicateAdded = False + elif search_song in duplicate_group and duplicate_song in duplicate_group: + duplicate_group_found = True + duplicate_added = False break - if not duplicateGroupFound: - self.duplicateSongList.append([searchSong, duplicateSong]) - duplicateAdded = True - return duplicateAdded + if not duplicate_group_found: + self.duplicate_song_list.append([search_song, duplicate_song]) + duplicate_added = True + return duplicate_added def onWizardExit(self): """ @@ -247,36 +250,36 @@ class DuplicateSongRemovalForm(OpenLPWizard): Set default form values for the song import wizard. """ self.restart() - self.duplicateSearchProgressBar.setValue(0) - self.foundDuplicatesEdit.clear() + self.duplicate_search_progress_bar.setValue(0) + self.found_duplicates_edit.clear() def validateCurrentPage(self): """ Controls whether we should switch to the next wizard page. This method loops on the review page as long as there are more song duplicates to review. """ - if self.currentId() == self.reviewPageId: + if self.currentId() == self.review_page_id: #as long as it's not the last duplicate list entry we revisit the review page - if len(self.duplicateSongList) == 1: + if len(self.duplicate_song_list) == 1: return True else: self.proceedToNextReview() return False return OpenLPWizard.validateCurrentPage(self) - def removeButtonClicked(self, songReviewWidget): + def removeButtonClicked(self, song_review_widget): """ Removes a song from the database, removes the GUI element representing the song on the review page, and disable the remove button if only one duplicate is left. - ``songReviewWidget`` + ``song_review_widget`` The SongReviewWidget whose song we should delete. """ #remove song from duplicate song list - self.duplicateSongList[-1].remove(songReviewWidget.song) + self.duplicate_song_list[-1].remove(song_review_widget.song) #remove song - item_id = songReviewWidget.song.id + item_id = song_review_widget.song.id media_files = self.plugin.manager.get_all_objects(MediaFile, MediaFile.song_id == item_id) for media_file in media_files: @@ -294,31 +297,31 @@ class DuplicateSongRemovalForm(OpenLPWizard): log.exception(u'Could not remove directory: %s', save_path) self.plugin.manager.delete_object(Song, item_id) # remove GUI elements - self.songsHorizontalLayout.removeWidget(songReviewWidget) - songReviewWidget.setParent(None) + self.songs_horizontal_layout.removeWidget(song_review_widget) + song_review_widget.setParent(None) # check if we only have one duplicate left # 4 stretches + 1 SongReviewWidget = 5 # the SongReviewWidget is then at position 2 - if len(self.duplicateSongList[-1]) == 1: - self.songsHorizontalLayout.itemAt(2).widget().songRemoveButton.setEnabled(False) + if len(self.duplicate_song_list[-1]) == 1: + self.songs_horizontal_layout.itemAt(2).widget().song_remove_button.setEnabled(False) def proceedToNextReview(self): """ Removes the previous review UI elements and calls processCurrentDuplicateEntry. """ #remove last duplicate group - self.duplicateSongList.pop() + self.duplicate_song_list.pop() # remove all previous elements - for i in reversed(range(self.songsHorizontalLayout.count())): - item = self.songsHorizontalLayout.itemAt(i) + for i in reversed(range(self.songs_horizontal_layout.count())): + item = self.songs_horizontal_layout.itemAt(i) if isinstance(item, QtGui.QWidgetItem): - # the order is important here, if the .setParent(None) call is done before the .removeItem() call, a - # segfault occurs + # The order is important here, if the .setParent(None) call is done + # before the .removeItem() call, a segfault occurs. widget = item.widget() - self.songsHorizontalLayout.removeItem(item) + self.songs_horizontal_layout.removeItem(item) widget.setParent(None) else: - self.songsHorizontalLayout.removeItem(item) + self.songs_horizontal_layout.removeItem(item) #process next set of duplicates self.processCurrentDuplicateEntry() @@ -329,23 +332,23 @@ class DuplicateSongRemovalForm(OpenLPWizard): duplicate song group, hide the "next" button and show the "finish" button. """ # update counter - self.reviewCurrentCount = self.reviewTotalCount - (len(self.duplicateSongList) - 1) + self.review_current_count = self.review_total_count - (len(self.duplicate_song_list) - 1) self.updateReviewCounterText() # add song elements to the UI - if len(self.duplicateSongList) > 0: + if len(self.duplicate_song_list) > 0: # a stretch doesn't seem to stretch endlessly, so I add two to get enough stetch for 1400x1050 - self.songsHorizontalLayout.addStretch() - self.songsHorizontalLayout.addStretch() - for duplicate in self.duplicateSongList[-1]: - songReviewWidget = SongReviewWidget(self.reviewPage, duplicate) - QtCore.QObject.connect(songReviewWidget, + self.songs_horizontal_layout.addStretch() + self.songs_horizontal_layout.addStretch() + for duplicate in self.duplicate_song_list[-1]: + song_review_widget = SongReviewWidget(self.review_page, duplicate) + QtCore.QObject.connect(song_review_widget, QtCore.SIGNAL(u'songRemoveButtonClicked(PyQt_PyObject)'), self.removeButtonClicked) - self.songsHorizontalLayout.addWidget(songReviewWidget) - self.songsHorizontalLayout.addStretch() - self.songsHorizontalLayout.addStretch() + self.songs_horizontal_layout.addWidget(song_review_widget) + self.songs_horizontal_layout.addStretch() + self.songs_horizontal_layout.addStretch() #change next button to finish button on last review - if len(self.duplicateSongList) == 1: + if len(self.duplicate_song_list) == 1: self.button(QtGui.QWizard.FinishButton).show() self.button(QtGui.QWizard.FinishButton).setEnabled(True) self.button(QtGui.QWizard.NextButton).hide() @@ -370,119 +373,119 @@ class SongReviewWidget(QtGui.QWidget): self.song = song self.setupUi() self.retranslateUi() - QtCore.QObject.connect(self.songRemoveButton, QtCore.SIGNAL(u'clicked()'), self.onRemoveButtonClicked) + QtCore.QObject.connect(self.song_remove_button, QtCore.SIGNAL(u'clicked()'), self.onRemoveButtonClicked) def setupUi(self): - self.songVerticalLayout = QtGui.QVBoxLayout(self) - self.songVerticalLayout.setObjectName(u'songVerticalLayout') - self.songGroupBox = QtGui.QGroupBox(self) - self.songGroupBox.setObjectName(u'songGroupBox') - self.songGroupBox.setMinimumWidth(300) - self.songGroupBox.setMaximumWidth(300) - self.songGroupBoxLayout = QtGui.QVBoxLayout(self.songGroupBox) - self.songGroupBoxLayout.setObjectName(u'songGroupBoxLayout') - self.songInfoFormLayout = QtGui.QFormLayout() - self.songInfoFormLayout.setObjectName(u'songInfoFormLayout') + self.song_vertical_layout = QtGui.QVBoxLayout(self) + self.song_vertical_layout.setObjectName(u'song_vertical_layout') + self.song_group_box = QtGui.QGroupBox(self) + self.song_group_box.setObjectName(u'song_group_box') + self.song_group_box.setMinimumWidth(300) + self.song_group_box.setMaximumWidth(300) + self.song_group_box_layout = QtGui.QVBoxLayout(self.song_group_box) + self.song_group_box_layout.setObjectName(u'song_group_box_layout') + self.song_info_form_layout = QtGui.QFormLayout() + self.song_info_form_layout.setObjectName(u'song_info_form_layout') #title - self.songTitleLabel = QtGui.QLabel(self) - self.songTitleLabel.setObjectName(u'songTitleLabel') - self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.LabelRole, self.songTitleLabel) - self.songTitleContent = QtGui.QLabel(self) - self.songTitleContent.setObjectName(u'songTitleContent') - self.songTitleContent.setText(self.song.title) - self.songTitleContent.setWordWrap(True) - self.songInfoFormLayout.setWidget(0, QtGui.QFormLayout.FieldRole, self.songTitleContent) + self.song_title_label = QtGui.QLabel(self) + self.song_title_label.setObjectName(u'song_title_label') + self.song_info_form_layout.setWidget(0, QtGui.QFormLayout.LabelRole, self.song_title_label) + self.song_title_content = QtGui.QLabel(self) + self.song_title_content.setObjectName(u'song_title_content') + self.song_title_content.setText(self.song.title) + self.song_title_content.setWordWrap(True) + self.song_info_form_layout.setWidget(0, QtGui.QFormLayout.FieldRole, self.song_title_content) #alternate title - self.songAlternateTitleLabel = QtGui.QLabel(self) - self.songAlternateTitleLabel.setObjectName(u'songAlternateTitleLabel') - self.songInfoFormLayout.setWidget(1, QtGui.QFormLayout.LabelRole, self.songAlternateTitleLabel) - self.songAlternateTitleContent = QtGui.QLabel(self) - self.songAlternateTitleContent.setObjectName(u'songAlternateTitleContent') - self.songAlternateTitleContent.setText(self.song.alternate_title) - self.songAlternateTitleContent.setWordWrap(True) - self.songInfoFormLayout.setWidget(1, QtGui.QFormLayout.FieldRole, self.songAlternateTitleContent) + self.song_alternate_title_label = QtGui.QLabel(self) + self.song_alternate_title_label.setObjectName(u'song_alternate_title_label') + self.song_info_form_layout.setWidget(1, QtGui.QFormLayout.LabelRole, self.song_alternate_title_label) + self.song_alternate_title_content = QtGui.QLabel(self) + self.song_alternate_title_content.setObjectName(u'song_alternate_title_content') + self.song_alternate_title_content.setText(self.song.alternate_title) + self.song_alternate_title_content.setWordWrap(True) + self.song_info_form_layout.setWidget(1, QtGui.QFormLayout.FieldRole, self.song_alternate_title_content) #CCLI number - self.songCCLINumberLabel = QtGui.QLabel(self) - self.songCCLINumberLabel.setObjectName(u'songCCLINumberLabel') - self.songInfoFormLayout.setWidget(2, QtGui.QFormLayout.LabelRole, self.songCCLINumberLabel) - self.songCCLINumberContent = QtGui.QLabel(self) - self.songCCLINumberContent.setObjectName(u'songCCLINumberContent') - self.songCCLINumberContent.setText(self.song.ccli_number) - self.songCCLINumberContent.setWordWrap(True) - self.songInfoFormLayout.setWidget(2, QtGui.QFormLayout.FieldRole, self.songCCLINumberContent) + self.song_ccli_number_label = QtGui.QLabel(self) + self.song_ccli_number_label.setObjectName(u'song_ccli_number_label') + self.song_info_form_layout.setWidget(2, QtGui.QFormLayout.LabelRole, self.song_ccli_number_label) + self.song_ccli_number_content = QtGui.QLabel(self) + self.song_ccli_number_content.setObjectName(u'song_ccli_number_content') + self.song_ccli_number_content.setText(self.song.ccli_number) + self.song_ccli_number_content.setWordWrap(True) + self.song_info_form_layout.setWidget(2, QtGui.QFormLayout.FieldRole, self.song_ccli_number_content) #copyright - self.songCopyrightLabel = QtGui.QLabel(self) - self.songCopyrightLabel.setObjectName(u'songCopyrightLabel') - self.songInfoFormLayout.setWidget(3, QtGui.QFormLayout.LabelRole, self.songCopyrightLabel) - self.songCopyrightContent = QtGui.QLabel(self) - self.songCopyrightContent.setObjectName(u'songCopyrightContent') - self.songCopyrightContent.setWordWrap(True) - self.songCopyrightContent.setText(self.song.copyright) - self.songInfoFormLayout.setWidget(3, QtGui.QFormLayout.FieldRole, self.songCopyrightContent) + self.song_copyright_label = QtGui.QLabel(self) + self.song_copyright_label.setObjectName(u'song_copyright_label') + self.song_info_form_layout.setWidget(3, QtGui.QFormLayout.LabelRole, self.song_copyright_label) + self.song_copyright_content = QtGui.QLabel(self) + self.song_copyright_content.setObjectName(u'song_copyright_content') + self.song_copyright_content.setWordWrap(True) + self.song_copyright_content.setText(self.song.copyright) + self.song_info_form_layout.setWidget(3, QtGui.QFormLayout.FieldRole, self.song_copyright_content) #comments - self.songCommentsLabel = QtGui.QLabel(self) - self.songCommentsLabel.setObjectName(u'songCommentsLabel') - self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.LabelRole, self.songCommentsLabel) - self.songCommentsContent = QtGui.QLabel(self) - self.songCommentsContent.setObjectName(u'songCommentsContent') - self.songCommentsContent.setText(self.song.comments) - self.songCommentsContent.setWordWrap(True) - self.songInfoFormLayout.setWidget(4, QtGui.QFormLayout.FieldRole, self.songCommentsContent) + self.song_comments_label = QtGui.QLabel(self) + self.song_comments_label.setObjectName(u'song_comments_label') + self.song_info_form_layout.setWidget(4, QtGui.QFormLayout.LabelRole, self.song_comments_label) + self.song_comments_content = QtGui.QLabel(self) + self.song_comments_content.setObjectName(u'song_comments_content') + self.song_comments_content.setText(self.song.comments) + self.song_comments_content.setWordWrap(True) + self.song_info_form_layout.setWidget(4, QtGui.QFormLayout.FieldRole, self.song_comments_content) #authors - self.songAuthorsLabel = QtGui.QLabel(self) - self.songAuthorsLabel.setObjectName(u'songAuthorsLabel') - self.songInfoFormLayout.setWidget(5, QtGui.QFormLayout.LabelRole, self.songAuthorsLabel) - self.songAuthorsContent = QtGui.QLabel(self) - self.songAuthorsContent.setObjectName(u'songAuthorsContent') - self.songAuthorsContent.setWordWrap(True) - authorsText = u'' + self.song_authors_label = QtGui.QLabel(self) + self.song_authors_label.setObjectName(u'song_authors_label') + self.song_info_form_layout.setWidget(5, QtGui.QFormLayout.LabelRole, self.song_authors_label) + self.song_authors_content = QtGui.QLabel(self) + self.song_authors_content.setObjectName(u'song_authors_content') + self.song_authors_content.setWordWrap(True) + authors_text = u'' for author in self.song.authors: - authorsText += author.display_name + ', ' - if authorsText: - authorsText = authorsText[:-2] - self.songAuthorsContent.setText(authorsText) - self.songInfoFormLayout.setWidget(5, QtGui.QFormLayout.FieldRole, self.songAuthorsContent) + authors_text += author.display_name + ', ' + if authors_text: + authors_text = authors_text[:-2] + self.song_authors_content.setText(authors_text) + self.song_info_form_layout.setWidget(5, QtGui.QFormLayout.FieldRole, self.song_authors_content) #verse order - self.songVerseOrderLabel = QtGui.QLabel(self) - self.songVerseOrderLabel.setObjectName(u'songVerseOrderLabel') - self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.LabelRole, self.songVerseOrderLabel) - self.songVerseOrderContent = QtGui.QLabel(self) - self.songVerseOrderContent.setObjectName(u'songVerseOrderContent') - self.songVerseOrderContent.setText(self.song.verse_order) - self.songVerseOrderContent.setWordWrap(True) - self.songInfoFormLayout.setWidget(6, QtGui.QFormLayout.FieldRole, self.songVerseOrderContent) + self.song_verse_order_label = QtGui.QLabel(self) + self.song_verse_order_label.setObjectName(u'song_verse_order_label') + self.song_info_form_layout.setWidget(6, QtGui.QFormLayout.LabelRole, self.song_verse_order_label) + self.song_verse_order_content = QtGui.QLabel(self) + self.song_verse_order_content.setObjectName(u'song_verse_order_content') + self.song_verse_order_content.setText(self.song.verse_order) + self.song_verse_order_content.setWordWrap(True) + self.song_info_form_layout.setWidget(6, QtGui.QFormLayout.FieldRole, self.song_verse_order_content) #verses - self.songGroupBoxLayout.addLayout(self.songInfoFormLayout) - self.songInfoVerseGroupBox = QtGui.QGroupBox(self.songGroupBox) - self.songInfoVerseGroupBox.setObjectName(u'songInfoVerseGroupBox') - self.songInfoVerseGroupBoxLayout = QtGui.QFormLayout(self.songInfoVerseGroupBox) - songXml = SongXML() - verses = songXml.get_verses(self.song.lyrics) + self.song_group_box_layout.addLayout(self.song_info_form_layout) + self.song_info_verse_group_box = QtGui.QGroupBox(self.song_group_box) + self.song_info_verse_group_box.setObjectName(u'song_info_verse_group_box') + self.song_info_verse_group_box_layout = QtGui.QFormLayout(self.song_info_verse_group_box) + song_xml = SongXML() + verses = song_xml.get_verses(self.song.lyrics) for verse in verses: - verseMarker = verse[0]['type'] + verse[0]['label'] - verseLabel = QtGui.QLabel(self.songInfoVerseGroupBox) - verseLabel.setText(verse[1]) - verseLabel.setWordWrap(True) - self.songInfoVerseGroupBoxLayout.addRow(verseMarker, verseLabel) - self.songGroupBoxLayout.addWidget(self.songInfoVerseGroupBox) - self.songGroupBoxLayout.addStretch() - self.songVerticalLayout.addWidget(self.songGroupBox) - self.songRemoveButton = QtGui.QPushButton(self) - self.songRemoveButton.setObjectName(u'songRemoveButton') - self.songRemoveButton.setIcon(build_icon(u':/songs/song_delete.png')) - self.songRemoveButton.setSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) - self.songVerticalLayout.addWidget(self.songRemoveButton, alignment = QtCore.Qt.AlignHCenter) + verse_marker = verse[0]['type'] + verse[0]['label'] + verse_label = QtGui.QLabel(self.song_info_verse_group_box) + verse_label.setText(verse[1]) + verse_label.setWordWrap(True) + self.song_info_verse_group_box_layout.addRow(verse_marker, verse_label) + self.song_group_box_layout.addWidget(self.song_info_verse_group_box) + self.song_group_box_layout.addStretch() + self.song_vertical_layout.addWidget(self.song_group_box) + self.song_remove_button = QtGui.QPushButton(self) + self.song_remove_button.setObjectName(u'song_remove_button') + self.song_remove_button.setIcon(build_icon(u':/songs/song_delete.png')) + self.song_remove_button.setSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) + self.song_vertical_layout.addWidget(self.song_remove_button, alignment = QtCore.Qt.AlignHCenter) def retranslateUi(self): - self.songRemoveButton.setText(u'Remove') - self.songTitleLabel.setText(u'Title:') - self.songAlternateTitleLabel.setText(u'Alternate Title:') - self.songCCLINumberLabel.setText(u'CCLI Number:') - self.songVerseOrderLabel.setText(u'Verse Order:') - self.songCopyrightLabel.setText(u'Copyright:') - self.songCommentsLabel.setText(u'Comments:') - self.songAuthorsLabel.setText(u'Authors:') - self.songInfoVerseGroupBox.setTitle(u'Verses') + self.song_remove_button.setText(u'Remove') + self.song_title_label.setText(u'Title:') + self.song_alternate_title_label.setText(u'Alternate Title:') + self.song_ccli_number_label.setText(u'CCLI Number:') + self.song_verse_order_label.setText(u'Verse Order:') + self.song_copyright_label.setText(u'Copyright:') + self.song_comments_label.setText(u'Comments:') + self.song_authors_label.setText(u'Authors:') + self.song_info_verse_group_box.setTitle(u'Verses') def onRemoveButtonClicked(self): """ diff --git a/openlp/plugins/songs/lib/duplicatesongfinder.py b/openlp/plugins/songs/lib/duplicatesongfinder.py index e497c885f..38062632d 100644 --- a/openlp/plugins/songs/lib/duplicatesongfinder.py +++ b/openlp/plugins/songs/lib/duplicatesongfinder.py @@ -38,23 +38,23 @@ class DuplicateSongFinder(object): The algorithm is based on the diff algorithm. First a diffset is calculated for two songs. To compensate for typos all differences that are smaller than a - limit (minFragmentSize) are removed and the surrounding equal parts are merged. + limit (min_fragment_size) are removed and the surrounding equal parts are merged. Finally two conditions can qualify a song tuple to be a duplicate: - 1. There is a block of equal content that is at least minBlockSize large. + 1. There is a block of equal content that is at least min_block_size large. This condition should hit for all larger songs that have a long enough equal part. Even if only one verse is equal this condition should still hit. 2. Two thirds of the smaller song is contained in the larger song. This condition should hit if one of the two songs (or both) is small (smaller - than the minBlockSize), but most of the song is contained in the other song. + than the min_block_size), but most of the song is contained in the other song. """ def __init__(self): - self.minFragmentSize = 5 - self.minBlockSize = 70 - self.maxTypoSize = 3 + self.min_fragment_size = 5 + self.min_block_size = 70 + self.max_typo_size = 3 - def songsProbablyEqual(self, song1, song2): + def songs_probably_equal(self, song1, song2): """ Calculate and return whether two songs are probably equal. @@ -72,15 +72,15 @@ class DuplicateSongFinder(object): large = song1.search_lyrics differ = difflib.SequenceMatcher(a=large, b=small) diff_tuples = differ.get_opcodes() - diff_no_typos = self.__removeTypos(diff_tuples) + diff_no_typos = self.__remove_typos(diff_tuples) #print(diff_no_typos) - if self.__lengthOfEqualBlocks(diff_no_typos) >= self.minBlockSize or \ - self.__lengthOfLongestEqualBlock(diff_no_typos) > len(small) * 2 / 3: + if self.__length_of_equal_blocks(diff_no_typos) >= self.min_block_size or \ + self.__length_of_longest_equal_block(diff_no_typos) > len(small) * 2 / 3: return True else: return False - def __opLength(self, opcode): + def __op_length(self, opcode): """ Return the length of a given difference. @@ -89,57 +89,57 @@ class DuplicateSongFinder(object): """ return max(opcode[2] - opcode[1], opcode[4] - opcode[3]) - def __removeTypos(self, diff): + def __remove_typos(self, diff): """ - Remove typos from a diff set. A typo is a small difference (minFragmentSize). + Remove typos from a diff set. A typo is a small difference (min_fragment_size). ``diff`` The diff set to remove the typos from. """ #remove typo at beginning of string if len(diff) >= 2: - if diff[0][0] != "equal" and self.__opLength(diff[0]) <= self.maxTypoSize and \ - self.__opLength(diff[1]) >= self.minFragmentSize: + if diff[0][0] != "equal" and self.__op_length(diff[0]) <= self.max_typo_size and \ + self.__op_length(diff[1]) >= self.min_fragment_size: del diff[0] #remove typos in the middle of string if len(diff) >= 3: for index in range(len(diff) - 3, -1, -1): - if self.__opLength(diff[index]) >= self.minFragmentSize and \ - diff[index + 1][0] != "equal" and self.__opLength(diff[index + 1]) <= self.maxTypoSize and \ - self.__opLength(diff[index + 2]) >= self.minFragmentSize: + if self.__op_length(diff[index]) >= self.min_fragment_size and \ + diff[index + 1][0] != "equal" and self.__op_length(diff[index + 1]) <= self.max_typo_size and \ + self.__op_length(diff[index + 2]) >= self.min_fragment_size: del diff[index + 1] #remove typo at the end of string if len(diff) >= 2: - if self.__opLength(diff[-2]) >= self.minFragmentSize and \ - diff[-1][0] != "equal" and self.__opLength(diff[-1]) <= self.maxTypoSize: + if self.__op_length(diff[-2]) >= self.min_fragment_size and \ + diff[-1][0] != "equal" and self.__op_length(diff[-1]) <= self.max_typo_size: del diff[-1] #merge fragments for index in range(len(diff) - 2, -1, -1): - if diff[index][0] == "equal" and self.__opLength(diff[index]) >= self.minFragmentSize and \ - diff[index + 1][0] == "equal" and self.__opLength(diff[index + 1]) >= self.minFragmentSize: + if diff[index][0] == "equal" and self.__op_length(diff[index]) >= self.min_fragment_size and \ + diff[index + 1][0] == "equal" and self.__op_length(diff[index + 1]) >= self.min_fragment_size: diff[index] = ("equal", diff[index][1], diff[index + 1][2], diff[index][3], diff[index + 1][4]) del diff[index + 1] return diff - def __lengthOfEqualBlocks(self, diff): + def __length_of_equal_blocks(self, diff): """ Return the total length of all equal blocks in a diff set. - Blocks smaller than minBlockSize are not counted. + Blocks smaller than min_block_size are not counted. ``diff`` The diff set to return the length for. """ length = 0 for element in diff: - if element[0] == "equal" and self.__opLength(element) >= self.minBlockSize: - length += self.__opLength(element) + if element[0] == "equal" and self.__op_length(element) >= self.min_block_size: + length += self.__op_length(element) return length - def __lengthOfLongestEqualBlock(self, diff): + def __length_of_longest_equal_block(self, diff): """ Return the length of the largest equal block in a diff set. @@ -148,7 +148,7 @@ class DuplicateSongFinder(object): """ length = 0 for element in diff: - if element[0] == "equal" and self.__opLength(element) > length: - length = self.__opLength(element) + if element[0] == "equal" and self.__op_length(element) > length: + length = self.__op_length(element) return length diff --git a/tests/functional/openlp_plugins/songs/test_lib.py b/tests/functional/openlp_plugins/songs/test_lib.py index d98f97773..6de41da37 100644 --- a/tests/functional/openlp_plugins/songs/test_lib.py +++ b/tests/functional/openlp_plugins/songs/test_lib.py @@ -37,27 +37,22 @@ class TestLib(TestCase): def songs_probably_equal_test(self): """ - Test the DuplicateSongFinder.songsProbablyEqual function. + Test the DuplicateSongFinder.songs_probably_equal function. """ - full_lyrics =u'''amazing grace how sweet the sound that saved a wretch like me i once was lost but now am found was - blind but now i see twas grace that taught my heart to fear and grace my fears relieved how precious did that grace - appear the hour i first believed through many dangers toils and snares i have already come tis grace that brought - me safe thus far and grace will lead me home the lord has promised good to me his word my hope secures he will my - shield and portion be as long as life endures yea when this flesh and heart shall fail and mortal life shall cease - i shall possess within the veil a life of joy and peace when weve been here ten thousand years bright shining as - the sun weve no less days to sing gods praise than when weve first begun''' - short_lyrics =u'''twas grace that taught my heart to fear and grace my fears relieved how precious did that grace - appear the hour i first believed''' - error_lyrics =u'''amazing grace how sweet the sound that saved a wretch like me i once was lost but now am found was - blind but now i see twas grace that taught my heart to fear and grace my fears relieved how precious did that grace - appear the hour i first believedxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx snares i have already come tis grace that brought - me safe thus far and grace will lead me home the lord has promised good to me his word my hope secures he will my - shield andwhen this flcsh and heart shall fail and mortal life shall cease - i shall possess within the veila lifeofjoy and peace when weve been here ten thousand years bright shining as - the sun weve no less days to sing gods praise than when weve first begun''' - different_lyrics=u'''on a hill far away stood an old rugged cross the emblem of suffering and shame and i love that - old cross where the dearest and best for a world of lost sinners was slain so ill cherish the old rugged cross till - my trophies at last i lay down i will cling to the old rugged cross and exchange it some day for a crown''' + full_lyrics =u'''amazing grace how sweet the sound that saved a wretch like me i once was lost but now am + found was blind but now i see twas grace that taught my heart to fear and grace my fears relieved how + precious did that grace appear the hour i first believed through many dangers toils and snares i have already + come tis grace that brought me safe thus far and grace will lead me home''' + short_lyrics =u'''twas grace that taught my heart to fear and grace my fears relieved how precious did that + grace appear the hour i first believed''' + error_lyrics =u'''amazing how sweet the trumpet that saved a wrench like me i once was losst but now am + found waf blind but now i see it was grace that taught my heart to fear and grace my fears relieved how + precious did that grace appppppppear the hour i first believedxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx snares i have + already come to this grace that brought me safe so far and grace will lead me home''' + different_lyrics=u'''on a hill far away stood an old rugged cross the emblem of suffering and shame and i love + that old cross where the dearest and best for a world of lost sinners was slain so ill cherish the old rugged + cross till my trophies at last i lay down i will cling to the old rugged cross and exchange it some day for a + crown''' dsf = DuplicateSongFinder() song1 = MagicMock() song2 = MagicMock() @@ -67,7 +62,7 @@ class TestLib(TestCase): song2.search_lyrics = full_lyrics #WHEN: We compare those songs for equality - result = dsf.songsProbablyEqual(song1, song2) + result = dsf.songs_probably_equal(song1, song2) #THEN: The result should be True assert result is True, u'The result should be True' @@ -77,7 +72,7 @@ class TestLib(TestCase): song2.search_lyrics = short_lyrics #WHEN: We compare those songs for equality - result = dsf.songsProbablyEqual(song1, song2) + result = dsf.songs_probably_equal(song1, song2) #THEN: The result should be True assert result is True, u'The result should be True' @@ -87,7 +82,7 @@ class TestLib(TestCase): song2.search_lyrics = error_lyrics #WHEN: We compare those songs for equality - result = dsf.songsProbablyEqual(song1, song2) + result = dsf.songs_probably_equal(song1, song2) #THEN: The result should be True assert result is True, u'The result should be True' @@ -97,7 +92,7 @@ class TestLib(TestCase): song2.search_lyrics = different_lyrics #WHEN: We compare those songs for equality - result = dsf.songsProbablyEqual(song1, song2) + result = dsf.songs_probably_equal(song1, song2) #THEN: The result should be False assert result is False, u'The result should be False' From f7c19f599c77eadeaab85f42db250cd07d55c4e6 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 14 Feb 2013 22:01:10 +0100 Subject: [PATCH 37/95] Move customInit to wizard. So dummy implementations are not required in the deriving classes. --- openlp/core/ui/wizard.py | 6 ++++++ openlp/plugins/songs/forms/duplicatesongremovalform.py | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openlp/core/ui/wizard.py b/openlp/core/ui/wizard.py index 001bfbf88..b06c140b6 100644 --- a/openlp/core/ui/wizard.py +++ b/openlp/core/ui/wizard.py @@ -206,6 +206,12 @@ class OpenLPWizard(QtGui.QWizard): else: self.customPageChanged(pageId) + def customInit(self): + """ + Song wizard specific initialization. + """ + pass + def customPageChanged(self, pageId): """ Called when changing to a page other than the progress page diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 6a0326c84..83336d288 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -69,12 +69,6 @@ class DuplicateSongRemovalForm(OpenLPWizard): OpenLPWizard.__init__(self, parent, plugin, u'duplicateSongRemovalWizard', u':/wizards/wizard_duplicateremoval.bmp', False) - def customInit(self): - """ - Song wizard specific initialisation. - """ - pass - def customSignals(self): """ Song wizard specific signals. From 89b9a1dabc3f14e3e280300f58b91e3bfb1375d5 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 14 Feb 2013 22:17:24 +0100 Subject: [PATCH 38/95] Move SongReviewWidget into separate file. --- .../songs/forms/duplicatesongremovalform.py | 143 +------------- .../plugins/songs/forms/songreviewwidget.py | 175 ++++++++++++++++++ 2 files changed, 176 insertions(+), 142 deletions(-) create mode 100644 openlp/plugins/songs/forms/songreviewwidget.py diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 83336d288..4e43e94d2 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -41,8 +41,8 @@ from openlp.core.lib.ui import UiStrings, critical_error_message_box from openlp.core.ui.wizard import OpenLPWizard, WizardStrings from openlp.core.utils import AppLocation from openlp.plugins.songs.lib.db import Song, MediaFile -from openlp.plugins.songs.lib.xml import SongXML from openlp.plugins.songs.lib.duplicatesongfinder import DuplicateSongFinder +from openlp.plugins.songs.forms.songreviewwidget import SongReviewWidget log = logging.getLogger(__name__) @@ -346,144 +346,3 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.button(QtGui.QWizard.FinishButton).show() self.button(QtGui.QWizard.FinishButton).setEnabled(True) self.button(QtGui.QWizard.NextButton).hide() - -class SongReviewWidget(QtGui.QWidget): - """ - A widget representing a song on the duplicate song review page. - It displays most of the information a song contains and - provides a "remove" button to remove the song from the database. - The remove logic is not implemented here, but a signal is provided - when the remove button is clicked. - """ - def __init__(self, parent, song): - """ - ``parent`` - The QWidget-derived parent of the wizard. - - ``song`` - The Song which this SongReviewWidget should represent. - """ - QtGui.QWidget.__init__(self, parent) - self.song = song - self.setupUi() - self.retranslateUi() - QtCore.QObject.connect(self.song_remove_button, QtCore.SIGNAL(u'clicked()'), self.onRemoveButtonClicked) - - def setupUi(self): - self.song_vertical_layout = QtGui.QVBoxLayout(self) - self.song_vertical_layout.setObjectName(u'song_vertical_layout') - self.song_group_box = QtGui.QGroupBox(self) - self.song_group_box.setObjectName(u'song_group_box') - self.song_group_box.setMinimumWidth(300) - self.song_group_box.setMaximumWidth(300) - self.song_group_box_layout = QtGui.QVBoxLayout(self.song_group_box) - self.song_group_box_layout.setObjectName(u'song_group_box_layout') - self.song_info_form_layout = QtGui.QFormLayout() - self.song_info_form_layout.setObjectName(u'song_info_form_layout') - #title - self.song_title_label = QtGui.QLabel(self) - self.song_title_label.setObjectName(u'song_title_label') - self.song_info_form_layout.setWidget(0, QtGui.QFormLayout.LabelRole, self.song_title_label) - self.song_title_content = QtGui.QLabel(self) - self.song_title_content.setObjectName(u'song_title_content') - self.song_title_content.setText(self.song.title) - self.song_title_content.setWordWrap(True) - self.song_info_form_layout.setWidget(0, QtGui.QFormLayout.FieldRole, self.song_title_content) - #alternate title - self.song_alternate_title_label = QtGui.QLabel(self) - self.song_alternate_title_label.setObjectName(u'song_alternate_title_label') - self.song_info_form_layout.setWidget(1, QtGui.QFormLayout.LabelRole, self.song_alternate_title_label) - self.song_alternate_title_content = QtGui.QLabel(self) - self.song_alternate_title_content.setObjectName(u'song_alternate_title_content') - self.song_alternate_title_content.setText(self.song.alternate_title) - self.song_alternate_title_content.setWordWrap(True) - self.song_info_form_layout.setWidget(1, QtGui.QFormLayout.FieldRole, self.song_alternate_title_content) - #CCLI number - self.song_ccli_number_label = QtGui.QLabel(self) - self.song_ccli_number_label.setObjectName(u'song_ccli_number_label') - self.song_info_form_layout.setWidget(2, QtGui.QFormLayout.LabelRole, self.song_ccli_number_label) - self.song_ccli_number_content = QtGui.QLabel(self) - self.song_ccli_number_content.setObjectName(u'song_ccli_number_content') - self.song_ccli_number_content.setText(self.song.ccli_number) - self.song_ccli_number_content.setWordWrap(True) - self.song_info_form_layout.setWidget(2, QtGui.QFormLayout.FieldRole, self.song_ccli_number_content) - #copyright - self.song_copyright_label = QtGui.QLabel(self) - self.song_copyright_label.setObjectName(u'song_copyright_label') - self.song_info_form_layout.setWidget(3, QtGui.QFormLayout.LabelRole, self.song_copyright_label) - self.song_copyright_content = QtGui.QLabel(self) - self.song_copyright_content.setObjectName(u'song_copyright_content') - self.song_copyright_content.setWordWrap(True) - self.song_copyright_content.setText(self.song.copyright) - self.song_info_form_layout.setWidget(3, QtGui.QFormLayout.FieldRole, self.song_copyright_content) - #comments - self.song_comments_label = QtGui.QLabel(self) - self.song_comments_label.setObjectName(u'song_comments_label') - self.song_info_form_layout.setWidget(4, QtGui.QFormLayout.LabelRole, self.song_comments_label) - self.song_comments_content = QtGui.QLabel(self) - self.song_comments_content.setObjectName(u'song_comments_content') - self.song_comments_content.setText(self.song.comments) - self.song_comments_content.setWordWrap(True) - self.song_info_form_layout.setWidget(4, QtGui.QFormLayout.FieldRole, self.song_comments_content) - #authors - self.song_authors_label = QtGui.QLabel(self) - self.song_authors_label.setObjectName(u'song_authors_label') - self.song_info_form_layout.setWidget(5, QtGui.QFormLayout.LabelRole, self.song_authors_label) - self.song_authors_content = QtGui.QLabel(self) - self.song_authors_content.setObjectName(u'song_authors_content') - self.song_authors_content.setWordWrap(True) - authors_text = u'' - for author in self.song.authors: - authors_text += author.display_name + ', ' - if authors_text: - authors_text = authors_text[:-2] - self.song_authors_content.setText(authors_text) - self.song_info_form_layout.setWidget(5, QtGui.QFormLayout.FieldRole, self.song_authors_content) - #verse order - self.song_verse_order_label = QtGui.QLabel(self) - self.song_verse_order_label.setObjectName(u'song_verse_order_label') - self.song_info_form_layout.setWidget(6, QtGui.QFormLayout.LabelRole, self.song_verse_order_label) - self.song_verse_order_content = QtGui.QLabel(self) - self.song_verse_order_content.setObjectName(u'song_verse_order_content') - self.song_verse_order_content.setText(self.song.verse_order) - self.song_verse_order_content.setWordWrap(True) - self.song_info_form_layout.setWidget(6, QtGui.QFormLayout.FieldRole, self.song_verse_order_content) - #verses - self.song_group_box_layout.addLayout(self.song_info_form_layout) - self.song_info_verse_group_box = QtGui.QGroupBox(self.song_group_box) - self.song_info_verse_group_box.setObjectName(u'song_info_verse_group_box') - self.song_info_verse_group_box_layout = QtGui.QFormLayout(self.song_info_verse_group_box) - song_xml = SongXML() - verses = song_xml.get_verses(self.song.lyrics) - for verse in verses: - verse_marker = verse[0]['type'] + verse[0]['label'] - verse_label = QtGui.QLabel(self.song_info_verse_group_box) - verse_label.setText(verse[1]) - verse_label.setWordWrap(True) - self.song_info_verse_group_box_layout.addRow(verse_marker, verse_label) - self.song_group_box_layout.addWidget(self.song_info_verse_group_box) - self.song_group_box_layout.addStretch() - self.song_vertical_layout.addWidget(self.song_group_box) - self.song_remove_button = QtGui.QPushButton(self) - self.song_remove_button.setObjectName(u'song_remove_button') - self.song_remove_button.setIcon(build_icon(u':/songs/song_delete.png')) - self.song_remove_button.setSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) - self.song_vertical_layout.addWidget(self.song_remove_button, alignment = QtCore.Qt.AlignHCenter) - - def retranslateUi(self): - self.song_remove_button.setText(u'Remove') - self.song_title_label.setText(u'Title:') - self.song_alternate_title_label.setText(u'Alternate Title:') - self.song_ccli_number_label.setText(u'CCLI Number:') - self.song_verse_order_label.setText(u'Verse Order:') - self.song_copyright_label.setText(u'Copyright:') - self.song_comments_label.setText(u'Comments:') - self.song_authors_label.setText(u'Authors:') - self.song_info_verse_group_box.setTitle(u'Verses') - - def onRemoveButtonClicked(self): - """ - Signal emitted when the "remove" button is clicked. - """ - self.emit(QtCore.SIGNAL(u'songRemoveButtonClicked(PyQt_PyObject)'), self) - diff --git a/openlp/plugins/songs/forms/songreviewwidget.py b/openlp/plugins/songs/forms/songreviewwidget.py new file mode 100644 index 000000000..36b23b8da --- /dev/null +++ b/openlp/plugins/songs/forms/songreviewwidget.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2013 Raoul Snyman # +# Portions copyright (c) 2008-2013 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 # +############################################################################### +""" +A widget representing a song in the duplicate song removal wizard review page. +""" +from PyQt4 import QtCore, QtGui + +from openlp.core.lib import build_icon +from openlp.plugins.songs.lib.xml import SongXML + +class SongReviewWidget(QtGui.QWidget): + """ + A widget representing a song on the duplicate song review page. + It displays most of the information a song contains and + provides a "remove" button to remove the song from the database. + The remove logic is not implemented here, but a signal is provided + when the remove button is clicked. + """ + def __init__(self, parent, song): + """ + ``parent`` + The QWidget-derived parent of the wizard. + + ``song`` + The Song which this SongReviewWidget should represent. + """ + QtGui.QWidget.__init__(self, parent) + self.song = song + self.setupUi() + self.retranslateUi() + QtCore.QObject.connect(self.song_remove_button, QtCore.SIGNAL(u'clicked()'), self.onRemoveButtonClicked) + + def setupUi(self): + self.song_vertical_layout = QtGui.QVBoxLayout(self) + self.song_vertical_layout.setObjectName(u'song_vertical_layout') + self.song_group_box = QtGui.QGroupBox(self) + self.song_group_box.setObjectName(u'song_group_box') + self.song_group_box.setMinimumWidth(300) + self.song_group_box.setMaximumWidth(300) + self.song_group_box_layout = QtGui.QVBoxLayout(self.song_group_box) + self.song_group_box_layout.setObjectName(u'song_group_box_layout') + self.song_info_form_layout = QtGui.QFormLayout() + self.song_info_form_layout.setObjectName(u'song_info_form_layout') + #title + self.song_title_label = QtGui.QLabel(self) + self.song_title_label.setObjectName(u'song_title_label') + self.song_info_form_layout.setWidget(0, QtGui.QFormLayout.LabelRole, self.song_title_label) + self.song_title_content = QtGui.QLabel(self) + self.song_title_content.setObjectName(u'song_title_content') + self.song_title_content.setText(self.song.title) + self.song_title_content.setWordWrap(True) + self.song_info_form_layout.setWidget(0, QtGui.QFormLayout.FieldRole, self.song_title_content) + #alternate title + self.song_alternate_title_label = QtGui.QLabel(self) + self.song_alternate_title_label.setObjectName(u'song_alternate_title_label') + self.song_info_form_layout.setWidget(1, QtGui.QFormLayout.LabelRole, self.song_alternate_title_label) + self.song_alternate_title_content = QtGui.QLabel(self) + self.song_alternate_title_content.setObjectName(u'song_alternate_title_content') + self.song_alternate_title_content.setText(self.song.alternate_title) + self.song_alternate_title_content.setWordWrap(True) + self.song_info_form_layout.setWidget(1, QtGui.QFormLayout.FieldRole, self.song_alternate_title_content) + #CCLI number + self.song_ccli_number_label = QtGui.QLabel(self) + self.song_ccli_number_label.setObjectName(u'song_ccli_number_label') + self.song_info_form_layout.setWidget(2, QtGui.QFormLayout.LabelRole, self.song_ccli_number_label) + self.song_ccli_number_content = QtGui.QLabel(self) + self.song_ccli_number_content.setObjectName(u'song_ccli_number_content') + self.song_ccli_number_content.setText(self.song.ccli_number) + self.song_ccli_number_content.setWordWrap(True) + self.song_info_form_layout.setWidget(2, QtGui.QFormLayout.FieldRole, self.song_ccli_number_content) + #copyright + self.song_copyright_label = QtGui.QLabel(self) + self.song_copyright_label.setObjectName(u'song_copyright_label') + self.song_info_form_layout.setWidget(3, QtGui.QFormLayout.LabelRole, self.song_copyright_label) + self.song_copyright_content = QtGui.QLabel(self) + self.song_copyright_content.setObjectName(u'song_copyright_content') + self.song_copyright_content.setWordWrap(True) + self.song_copyright_content.setText(self.song.copyright) + self.song_info_form_layout.setWidget(3, QtGui.QFormLayout.FieldRole, self.song_copyright_content) + #comments + self.song_comments_label = QtGui.QLabel(self) + self.song_comments_label.setObjectName(u'song_comments_label') + self.song_info_form_layout.setWidget(4, QtGui.QFormLayout.LabelRole, self.song_comments_label) + self.song_comments_content = QtGui.QLabel(self) + self.song_comments_content.setObjectName(u'song_comments_content') + self.song_comments_content.setText(self.song.comments) + self.song_comments_content.setWordWrap(True) + self.song_info_form_layout.setWidget(4, QtGui.QFormLayout.FieldRole, self.song_comments_content) + #authors + self.song_authors_label = QtGui.QLabel(self) + self.song_authors_label.setObjectName(u'song_authors_label') + self.song_info_form_layout.setWidget(5, QtGui.QFormLayout.LabelRole, self.song_authors_label) + self.song_authors_content = QtGui.QLabel(self) + self.song_authors_content.setObjectName(u'song_authors_content') + self.song_authors_content.setWordWrap(True) + authors_text = u'' + for author in self.song.authors: + authors_text += author.display_name + ', ' + if authors_text: + authors_text = authors_text[:-2] + self.song_authors_content.setText(authors_text) + self.song_info_form_layout.setWidget(5, QtGui.QFormLayout.FieldRole, self.song_authors_content) + #verse order + self.song_verse_order_label = QtGui.QLabel(self) + self.song_verse_order_label.setObjectName(u'song_verse_order_label') + self.song_info_form_layout.setWidget(6, QtGui.QFormLayout.LabelRole, self.song_verse_order_label) + self.song_verse_order_content = QtGui.QLabel(self) + self.song_verse_order_content.setObjectName(u'song_verse_order_content') + self.song_verse_order_content.setText(self.song.verse_order) + self.song_verse_order_content.setWordWrap(True) + self.song_info_form_layout.setWidget(6, QtGui.QFormLayout.FieldRole, self.song_verse_order_content) + #verses + self.song_group_box_layout.addLayout(self.song_info_form_layout) + self.song_info_verse_group_box = QtGui.QGroupBox(self.song_group_box) + self.song_info_verse_group_box.setObjectName(u'song_info_verse_group_box') + self.song_info_verse_group_box_layout = QtGui.QFormLayout(self.song_info_verse_group_box) + song_xml = SongXML() + verses = song_xml.get_verses(self.song.lyrics) + for verse in verses: + verse_marker = verse[0]['type'] + verse[0]['label'] + verse_label = QtGui.QLabel(self.song_info_verse_group_box) + verse_label.setText(verse[1]) + verse_label.setWordWrap(True) + self.song_info_verse_group_box_layout.addRow(verse_marker, verse_label) + self.song_group_box_layout.addWidget(self.song_info_verse_group_box) + self.song_group_box_layout.addStretch() + self.song_vertical_layout.addWidget(self.song_group_box) + self.song_remove_button = QtGui.QPushButton(self) + self.song_remove_button.setObjectName(u'song_remove_button') + self.song_remove_button.setIcon(build_icon(u':/songs/song_delete.png')) + self.song_remove_button.setSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) + self.song_vertical_layout.addWidget(self.song_remove_button, alignment = QtCore.Qt.AlignHCenter) + + def retranslateUi(self): + self.song_remove_button.setText(u'Remove') + self.song_title_label.setText(u'Title:') + self.song_alternate_title_label.setText(u'Alternate Title:') + self.song_ccli_number_label.setText(u'CCLI Number:') + self.song_verse_order_label.setText(u'Verse Order:') + self.song_copyright_label.setText(u'Copyright:') + self.song_comments_label.setText(u'Comments:') + self.song_authors_label.setText(u'Authors:') + self.song_info_verse_group_box.setTitle(u'Verses') + + def onRemoveButtonClicked(self): + """ + Signal emitted when the "remove" button is clicked. + """ + self.emit(QtCore.SIGNAL(u'songRemoveButtonClicked(PyQt_PyObject)'), self) From 489dc40cba2c20ba7b10704a089a759700691593 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 14 Feb 2013 22:31:43 +0100 Subject: [PATCH 39/95] Make use of the registry for retrieving a widget parent. --- .../songs/forms/duplicatesongremovalform.py | 16 +++++++++++++--- openlp/plugins/songs/songsplugin.py | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 4e43e94d2..58ce0c96c 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -35,7 +35,7 @@ import os from PyQt4 import QtCore, QtGui -from openlp.core.lib import translate, build_icon +from openlp.core.lib import Registry, translate, build_icon from openlp.core.lib.db import Manager from openlp.core.lib.ui import UiStrings, critical_error_message_box from openlp.core.ui.wizard import OpenLPWizard, WizardStrings @@ -53,7 +53,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ log.info(u'DuplicateSongRemovalForm loaded') - def __init__(self, parent, plugin): + def __init__(self, plugin): """ Instantiate the wizard, and run any extra setup we need to. @@ -66,7 +66,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.duplicate_song_list = [] self.review_current_count = 0 self.review_total_count = 0 - OpenLPWizard.__init__(self, parent, plugin, u'duplicateSongRemovalWizard', + OpenLPWizard.__init__(self, self.main_window, plugin, u'duplicateSongRemovalWizard', u':/wizards/wizard_duplicateremoval.bmp', False) def customSignals(self): @@ -346,3 +346,13 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.button(QtGui.QWizard.FinishButton).show() self.button(QtGui.QWizard.FinishButton).setEnabled(True) self.button(QtGui.QWizard.NextButton).hide() + + def _get_main_window(self): + """ + Adds the main window to the class dynamically + """ + if not hasattr(self, u'_main_window'): + self._main_window = Registry().get(u'main_window') + return self._main_window + + main_window = property(_get_main_window) diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index f17423337..a6dff04c9 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -178,7 +178,7 @@ class SongsPlugin(Plugin): """ Search for duplicates in the song database. """ - DuplicateSongRemovalForm(self.main_window, self).exec_() + DuplicateSongRemovalForm(self).exec_() def onSongImportItemClicked(self): if self.mediaItem: From 8a19e75be3a63a7a25c79fd09ab994ce64562aa9 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 14 Feb 2013 23:17:46 +0100 Subject: [PATCH 40/95] Move remaining methods over to new naming scheme. --- .../songs/forms/duplicatesongremovalform.py | 34 +++++++++---------- .../plugins/songs/forms/songreviewwidget.py | 6 ++-- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 58ce0c96c..763e44a65 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -135,11 +135,11 @@ class DuplicateSongRemovalForm(OpenLPWizard): u'explicit approval.')) self.searching_page.setTitle(translate(u'Wizard', u'Searching for duplicate songs.')) self.searching_page.setSubTitle(translate(u'Wizard', u'The song database is searched for double songs.')) - self.updateReviewCounterText() + self.update_review_counter_text() self.review_page.setSubTitle(translate(u'Wizard', u'Here you can decide which songs to remove and which ones to keep.')) - def updateReviewCounterText(self): + def update_review_counter_text(self): """ Set the wizard review page header text. """ @@ -161,7 +161,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): if max_songs == 0 or max_songs == 1: self.duplicate_search_progress_bar.setMaximum(1) self.duplicate_search_progress_bar.setValue(1) - self.notifyNoDuplicates() + self.notify_no_duplicates() return # with x songs we have x*(x - 1) / 2 comparisons max_progress_count = max_songs * (max_songs - 1) / 2 @@ -171,7 +171,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): for inner_song_counter in range(outer_song_counter + 1, max_songs): double_finder = DuplicateSongFinder() if double_finder.songs_probably_equal(songs[outer_song_counter], songs[inner_song_counter]): - duplicate_added = self.addDuplicatesToSongList(songs[outer_song_counter], + duplicate_added = self.add_duplicates_to_song_list(songs[outer_song_counter], songs[inner_song_counter]) if duplicate_added: self.found_duplicates_edit.appendPlainText(songs[outer_song_counter].title + " = " + @@ -179,11 +179,11 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.duplicate_search_progress_bar.setValue(self.duplicate_search_progress_bar.value() + 1) self.review_total_count = len(self.duplicate_song_list) if self.review_total_count == 0: - self.notifyNoDuplicates() + self.notify_no_duplicates() elif page_id == self.review_page_id: - self.processCurrentDuplicateEntry() + self.process_current_duplicate_entry() - def notifyNoDuplicates(self): + def notify_no_duplicates(self): """ Notifies the user, that there were no duplicates found in the database. """ @@ -195,7 +195,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Ok)) - def addDuplicatesToSongList(self, search_song, duplicate_song): + def add_duplicates_to_song_list(self, search_song, duplicate_song): """ Inserts a song duplicate (two similar songs) to the duplicate song list. If one of the two songs is already part of the duplicate song list, @@ -257,11 +257,11 @@ class DuplicateSongRemovalForm(OpenLPWizard): if len(self.duplicate_song_list) == 1: return True else: - self.proceedToNextReview() + self.proceed_to_next_review() return False return OpenLPWizard.validateCurrentPage(self) - def removeButtonClicked(self, song_review_widget): + def remove_button_clicked(self, song_review_widget): """ Removes a song from the database, removes the GUI element representing the song on the review page, and disable the remove button if only one duplicate @@ -299,9 +299,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): if len(self.duplicate_song_list[-1]) == 1: self.songs_horizontal_layout.itemAt(2).widget().song_remove_button.setEnabled(False) - def proceedToNextReview(self): + def proceed_to_next_review(self): """ - Removes the previous review UI elements and calls processCurrentDuplicateEntry. + Removes the previous review UI elements and calls process_current_duplicate_entry. """ #remove last duplicate group self.duplicate_song_list.pop() @@ -317,9 +317,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): else: self.songs_horizontal_layout.removeItem(item) #process next set of duplicates - self.processCurrentDuplicateEntry() + self.process_current_duplicate_entry() - def processCurrentDuplicateEntry(self): + def process_current_duplicate_entry(self): """ Update the review counter in the wizard header, add song widgets for the current duplicate group to review, if it's the last @@ -327,7 +327,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ # update counter self.review_current_count = self.review_total_count - (len(self.duplicate_song_list) - 1) - self.updateReviewCounterText() + self.update_review_counter_text() # add song elements to the UI if len(self.duplicate_song_list) > 0: # a stretch doesn't seem to stretch endlessly, so I add two to get enough stetch for 1400x1050 @@ -336,8 +336,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): for duplicate in self.duplicate_song_list[-1]: song_review_widget = SongReviewWidget(self.review_page, duplicate) QtCore.QObject.connect(song_review_widget, - QtCore.SIGNAL(u'songRemoveButtonClicked(PyQt_PyObject)'), - self.removeButtonClicked) + QtCore.SIGNAL(u'song_remove_button_clicked(PyQt_PyObject)'), + self.remove_button_clicked) self.songs_horizontal_layout.addWidget(song_review_widget) self.songs_horizontal_layout.addStretch() self.songs_horizontal_layout.addStretch() diff --git a/openlp/plugins/songs/forms/songreviewwidget.py b/openlp/plugins/songs/forms/songreviewwidget.py index 36b23b8da..6718013dc 100644 --- a/openlp/plugins/songs/forms/songreviewwidget.py +++ b/openlp/plugins/songs/forms/songreviewwidget.py @@ -54,7 +54,7 @@ class SongReviewWidget(QtGui.QWidget): self.song = song self.setupUi() self.retranslateUi() - QtCore.QObject.connect(self.song_remove_button, QtCore.SIGNAL(u'clicked()'), self.onRemoveButtonClicked) + QtCore.QObject.connect(self.song_remove_button, QtCore.SIGNAL(u'clicked()'), self.on_remove_button_clicked) def setupUi(self): self.song_vertical_layout = QtGui.QVBoxLayout(self) @@ -168,8 +168,8 @@ class SongReviewWidget(QtGui.QWidget): self.song_authors_label.setText(u'Authors:') self.song_info_verse_group_box.setTitle(u'Verses') - def onRemoveButtonClicked(self): + def on_remove_button_clicked(self): """ Signal emitted when the "remove" button is clicked. """ - self.emit(QtCore.SIGNAL(u'songRemoveButtonClicked(PyQt_PyObject)'), self) + self.emit(QtCore.SIGNAL(u'song_remove_button_clicked(PyQt_PyObject)'), self) From 10d9b506a6fdb0dacc3c35d488af273eed3957fb Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 18 Feb 2013 19:46:50 +0100 Subject: [PATCH 41/95] Even more variables adapted to PEP8. Sentencify lots of comments. --- openlp/core/ui/wizard.py | 14 +++---- .../songs/forms/duplicatesongremovalform.py | 42 +++++++++---------- .../plugins/songs/forms/songreviewwidget.py | 16 +++---- .../plugins/songs/lib/duplicatesongfinder.py | 9 ++-- openlp/plugins/songs/songsplugin.py | 16 +++---- .../openlp_plugins/songs/test_lib.py | 34 +++++++-------- 6 files changed, 65 insertions(+), 66 deletions(-) diff --git a/openlp/core/ui/wizard.py b/openlp/core/ui/wizard.py index d269ce9fd..3a2596a77 100644 --- a/openlp/core/ui/wizard.py +++ b/openlp/core/ui/wizard.py @@ -93,16 +93,16 @@ class OpenLPWizard(QtGui.QWizard): ``image`` The image to display on the "welcome" page of the wizard. Should be 163x350. - ``addProgressPage`` + ``add_progress_page`` Whether to add a progress page with a progressbar at the end of the wizard. """ - def __init__(self, parent, plugin, name, image, addProgressPage=True): + def __init__(self, parent, plugin, name, image, add_progress_page=True): """ Constructor """ QtGui.QWizard.__init__(self, parent) self.plugin = plugin - self.withProgressPage = addProgressPage + self.with_progress_page = add_progress_page self.setObjectName(name) self.openIcon = build_icon(u':/general/general_open.png') self.deleteIcon = build_icon(u':/general/general_delete.png') @@ -113,7 +113,7 @@ class OpenLPWizard(QtGui.QWizard): self.customInit() self.customSignals() QtCore.QObject.connect(self, QtCore.SIGNAL(u'currentIdChanged(int)'), self.onCurrentIdChanged) - if self.withProgressPage: + if self.with_progress_page: QtCore.QObject.connect(self.errorCopyToButton, QtCore.SIGNAL(u'clicked()'), self.onErrorCopyToButtonClicked) QtCore.QObject.connect(self.errorSaveToButton, QtCore.SIGNAL(u'clicked()'), self.onErrorSaveToButtonClicked) @@ -128,7 +128,7 @@ class OpenLPWizard(QtGui.QWizard): QtGui.QWizard.NoBackButtonOnLastPage) add_welcome_page(self, image) self.addCustomPages() - if self.withProgressPage: + if self.with_progress_page: self.addProgressPage() self.retranslateUi() @@ -191,7 +191,7 @@ class OpenLPWizard(QtGui.QWizard): Stop the wizard on cancel button, close button or ESC key. """ log.debug(u'Wizard cancelled by user.') - if self.withProgressPage and self.currentPage() == self.progressPage: + if self.with_progress_page and self.currentPage() == self.progressPage: Registry().execute(u'openlp_stop_wizard') self.done(QtGui.QDialog.Rejected) @@ -199,7 +199,7 @@ class OpenLPWizard(QtGui.QWizard): """ Perform necessary functions depending on which wizard page is active. """ - if self.withProgressPage and self.page(pageId) == self.progressPage: + if self.with_progress_page and self.page(pageId) == self.progressPage: self.preWizard() self.performWizard() self.postWizard() diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 763e44a65..a0406160c 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -80,7 +80,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ Add song wizard specific pages. """ - #add custom pages + # Add custom pages. self.searching_page = QtGui.QWizardPage() self.searching_page.setObjectName(u'searching_page') self.searching_vertical_layout = QtGui.QVBoxLayout(self.searching_page) @@ -117,8 +117,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songs_horizontal_scroll_area.setWidget(self.songs_horizontal_songs_widget) self.review_layout.addWidget(self.songs_horizontal_scroll_area) self.review_page_id = self.addPage(self.review_page) - #add a dummy page to the end, to prevent the finish button to appear and the next button do disappear on the - #review page + # Add a dummy page to the end, to prevent the finish button to appear and the next button do disappear on the + #review page. self.dummy_page = QtGui.QWizardPage() self.dummy_page_id = self.addPage(self.dummy_page) @@ -153,17 +153,17 @@ class DuplicateSongRemovalForm(OpenLPWizard): ``page_id`` ID of the page the wizard changed to. """ - #hide back button + # Hide back button. self.button(QtGui.QWizard.BackButton).hide() if page_id == self.searching_page_id: - #search duplicate songs + # Search duplicate songs. max_songs = self.plugin.manager.get_object_count(Song) if max_songs == 0 or max_songs == 1: self.duplicate_search_progress_bar.setMaximum(1) self.duplicate_search_progress_bar.setValue(1) self.notify_no_duplicates() return - # with x songs we have x*(x - 1) / 2 comparisons + # With x songs we have x*(x - 1) / 2 comparisons. max_progress_count = max_songs * (max_songs - 1) / 2 self.duplicate_search_progress_bar.setMaximum(max_progress_count) songs = self.plugin.manager.get_all_objects(Song) @@ -212,7 +212,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): duplicate_group_found = False duplicate_added = False for duplicate_group in self.duplicate_song_list: - #skip the first song in the duplicate lists, since the first one has to be an earlier song + # Skip the first song in the duplicate lists, since the first one has to be an earlier song. if search_song in duplicate_group and not duplicate_song in duplicate_group: duplicate_group.append(duplicate_song) duplicate_group_found = True @@ -253,7 +253,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): on the review page as long as there are more song duplicates to review. """ if self.currentId() == self.review_page_id: - #as long as it's not the last duplicate list entry we revisit the review page + # As long as it's not the last duplicate list entry we revisit the review page. if len(self.duplicate_song_list) == 1: return True else: @@ -270,9 +270,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): ``song_review_widget`` The SongReviewWidget whose song we should delete. """ - #remove song from duplicate song list + # Remove song from duplicate song list. self.duplicate_song_list[-1].remove(song_review_widget.song) - #remove song + # Remove song from the database. item_id = song_review_widget.song.id media_files = self.plugin.manager.get_all_objects(MediaFile, MediaFile.song_id == item_id) @@ -290,12 +290,12 @@ class DuplicateSongRemovalForm(OpenLPWizard): except OSError: log.exception(u'Could not remove directory: %s', save_path) self.plugin.manager.delete_object(Song, item_id) - # remove GUI elements + # Remove GUI elements for the song. self.songs_horizontal_layout.removeWidget(song_review_widget) song_review_widget.setParent(None) - # check if we only have one duplicate left + # Check if we only have one duplicate left: # 4 stretches + 1 SongReviewWidget = 5 - # the SongReviewWidget is then at position 2 + # The SongReviewWidget is then at position 2. if len(self.duplicate_song_list[-1]) == 1: self.songs_horizontal_layout.itemAt(2).widget().song_remove_button.setEnabled(False) @@ -303,9 +303,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ Removes the previous review UI elements and calls process_current_duplicate_entry. """ - #remove last duplicate group + # Remove last duplicate group. self.duplicate_song_list.pop() - # remove all previous elements + # Remove all previous elements. for i in reversed(range(self.songs_horizontal_layout.count())): item = self.songs_horizontal_layout.itemAt(i) if isinstance(item, QtGui.QWidgetItem): @@ -316,7 +316,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): widget.setParent(None) else: self.songs_horizontal_layout.removeItem(item) - #process next set of duplicates + # Process next set of duplicates. self.process_current_duplicate_entry() def process_current_duplicate_entry(self): @@ -325,12 +325,12 @@ class DuplicateSongRemovalForm(OpenLPWizard): the current duplicate group to review, if it's the last duplicate song group, hide the "next" button and show the "finish" button. """ - # update counter + # Update the counter. self.review_current_count = self.review_total_count - (len(self.duplicate_song_list) - 1) self.update_review_counter_text() - # add song elements to the UI + # Add song elements to the UI. if len(self.duplicate_song_list) > 0: - # a stretch doesn't seem to stretch endlessly, so I add two to get enough stetch for 1400x1050 + # A stretch doesn't seem to stretch endlessly, so I add two to get enough stetch for 1400x1050. self.songs_horizontal_layout.addStretch() self.songs_horizontal_layout.addStretch() for duplicate in self.duplicate_song_list[-1]: @@ -341,7 +341,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songs_horizontal_layout.addWidget(song_review_widget) self.songs_horizontal_layout.addStretch() self.songs_horizontal_layout.addStretch() - #change next button to finish button on last review + # Change next button to finish button on last review. if len(self.duplicate_song_list) == 1: self.button(QtGui.QWizard.FinishButton).show() self.button(QtGui.QWizard.FinishButton).setEnabled(True) @@ -349,7 +349,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): def _get_main_window(self): """ - Adds the main window to the class dynamically + Adds the main window to the class dynamically. """ if not hasattr(self, u'_main_window'): self._main_window = Registry().get(u'main_window') diff --git a/openlp/plugins/songs/forms/songreviewwidget.py b/openlp/plugins/songs/forms/songreviewwidget.py index 6718013dc..81154750a 100644 --- a/openlp/plugins/songs/forms/songreviewwidget.py +++ b/openlp/plugins/songs/forms/songreviewwidget.py @@ -67,7 +67,7 @@ class SongReviewWidget(QtGui.QWidget): self.song_group_box_layout.setObjectName(u'song_group_box_layout') self.song_info_form_layout = QtGui.QFormLayout() self.song_info_form_layout.setObjectName(u'song_info_form_layout') - #title + # Add title widget. self.song_title_label = QtGui.QLabel(self) self.song_title_label.setObjectName(u'song_title_label') self.song_info_form_layout.setWidget(0, QtGui.QFormLayout.LabelRole, self.song_title_label) @@ -76,7 +76,7 @@ class SongReviewWidget(QtGui.QWidget): self.song_title_content.setText(self.song.title) self.song_title_content.setWordWrap(True) self.song_info_form_layout.setWidget(0, QtGui.QFormLayout.FieldRole, self.song_title_content) - #alternate title + # Add alternate title widget. self.song_alternate_title_label = QtGui.QLabel(self) self.song_alternate_title_label.setObjectName(u'song_alternate_title_label') self.song_info_form_layout.setWidget(1, QtGui.QFormLayout.LabelRole, self.song_alternate_title_label) @@ -85,7 +85,7 @@ class SongReviewWidget(QtGui.QWidget): self.song_alternate_title_content.setText(self.song.alternate_title) self.song_alternate_title_content.setWordWrap(True) self.song_info_form_layout.setWidget(1, QtGui.QFormLayout.FieldRole, self.song_alternate_title_content) - #CCLI number + # Add CCLI number widget. self.song_ccli_number_label = QtGui.QLabel(self) self.song_ccli_number_label.setObjectName(u'song_ccli_number_label') self.song_info_form_layout.setWidget(2, QtGui.QFormLayout.LabelRole, self.song_ccli_number_label) @@ -94,7 +94,7 @@ class SongReviewWidget(QtGui.QWidget): self.song_ccli_number_content.setText(self.song.ccli_number) self.song_ccli_number_content.setWordWrap(True) self.song_info_form_layout.setWidget(2, QtGui.QFormLayout.FieldRole, self.song_ccli_number_content) - #copyright + # Add copyright widget. self.song_copyright_label = QtGui.QLabel(self) self.song_copyright_label.setObjectName(u'song_copyright_label') self.song_info_form_layout.setWidget(3, QtGui.QFormLayout.LabelRole, self.song_copyright_label) @@ -103,7 +103,7 @@ class SongReviewWidget(QtGui.QWidget): self.song_copyright_content.setWordWrap(True) self.song_copyright_content.setText(self.song.copyright) self.song_info_form_layout.setWidget(3, QtGui.QFormLayout.FieldRole, self.song_copyright_content) - #comments + # Add comments widget. self.song_comments_label = QtGui.QLabel(self) self.song_comments_label.setObjectName(u'song_comments_label') self.song_info_form_layout.setWidget(4, QtGui.QFormLayout.LabelRole, self.song_comments_label) @@ -112,7 +112,7 @@ class SongReviewWidget(QtGui.QWidget): self.song_comments_content.setText(self.song.comments) self.song_comments_content.setWordWrap(True) self.song_info_form_layout.setWidget(4, QtGui.QFormLayout.FieldRole, self.song_comments_content) - #authors + # Add authors widget. self.song_authors_label = QtGui.QLabel(self) self.song_authors_label.setObjectName(u'song_authors_label') self.song_info_form_layout.setWidget(5, QtGui.QFormLayout.LabelRole, self.song_authors_label) @@ -126,7 +126,7 @@ class SongReviewWidget(QtGui.QWidget): authors_text = authors_text[:-2] self.song_authors_content.setText(authors_text) self.song_info_form_layout.setWidget(5, QtGui.QFormLayout.FieldRole, self.song_authors_content) - #verse order + # Add verse order widget. self.song_verse_order_label = QtGui.QLabel(self) self.song_verse_order_label.setObjectName(u'song_verse_order_label') self.song_info_form_layout.setWidget(6, QtGui.QFormLayout.LabelRole, self.song_verse_order_label) @@ -135,7 +135,7 @@ class SongReviewWidget(QtGui.QWidget): self.song_verse_order_content.setText(self.song.verse_order) self.song_verse_order_content.setWordWrap(True) self.song_info_form_layout.setWidget(6, QtGui.QFormLayout.FieldRole, self.song_verse_order_content) - #verses + # Add verses widget. self.song_group_box_layout.addLayout(self.song_info_form_layout) self.song_info_verse_group_box = QtGui.QGroupBox(self.song_group_box) self.song_info_verse_group_box.setObjectName(u'song_info_verse_group_box') diff --git a/openlp/plugins/songs/lib/duplicatesongfinder.py b/openlp/plugins/songs/lib/duplicatesongfinder.py index 38062632d..225789109 100644 --- a/openlp/plugins/songs/lib/duplicatesongfinder.py +++ b/openlp/plugins/songs/lib/duplicatesongfinder.py @@ -73,7 +73,6 @@ class DuplicateSongFinder(object): differ = difflib.SequenceMatcher(a=large, b=small) diff_tuples = differ.get_opcodes() diff_no_typos = self.__remove_typos(diff_tuples) - #print(diff_no_typos) if self.__length_of_equal_blocks(diff_no_typos) >= self.min_block_size or \ self.__length_of_longest_equal_block(diff_no_typos) > len(small) * 2 / 3: return True @@ -97,25 +96,25 @@ class DuplicateSongFinder(object): ``diff`` The diff set to remove the typos from. """ - #remove typo at beginning of string + # Remove typo at beginning of the string. if len(diff) >= 2: if diff[0][0] != "equal" and self.__op_length(diff[0]) <= self.max_typo_size and \ self.__op_length(diff[1]) >= self.min_fragment_size: del diff[0] - #remove typos in the middle of string + # Remove typos in the middle of the string. if len(diff) >= 3: for index in range(len(diff) - 3, -1, -1): if self.__op_length(diff[index]) >= self.min_fragment_size and \ diff[index + 1][0] != "equal" and self.__op_length(diff[index + 1]) <= self.max_typo_size and \ self.__op_length(diff[index + 2]) >= self.min_fragment_size: del diff[index + 1] - #remove typo at the end of string + # Remove typo at the end of the string. if len(diff) >= 2: if self.__op_length(diff[-2]) >= self.min_fragment_size and \ diff[-1][0] != "equal" and self.__op_length(diff[-1]) <= self.max_typo_size: del diff[-1] - #merge fragments + # Merge the bordering equal passages that occured by removing differences. for index in range(len(diff) - 2, -1, -1): if diff[index][0] == "equal" and self.__op_length(diff[index]) >= self.min_fragment_size and \ diff[index + 1][0] == "equal" and self.__op_length(diff[index + 1]) >= self.min_fragment_size: diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index a6dff04c9..237b898ba 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -94,12 +94,12 @@ class SongsPlugin(Plugin): self.songImportItem.setVisible(True) self.songExportItem.setVisible(True) self.toolsReindexItem.setVisible(True) - self.toolsFindDuplicates.setVisible(True) + self.tools_find_duplicates.setVisible(True) action_list = ActionList.get_instance() action_list.add_action(self.songImportItem, UiStrings().Import) action_list.add_action(self.songExportItem, UiStrings().Export) action_list.add_action(self.toolsReindexItem, UiStrings().Tools) - action_list.add_action(self.toolsFindDuplicates, UiStrings().Tools) + action_list.add_action(self.tools_find_duplicates, UiStrings().Tools) def addImportMenuItem(self, import_menu): """ @@ -149,12 +149,12 @@ class SongsPlugin(Plugin): statustip=translate('SongsPlugin', 'Re-index the songs database to improve searching and ordering.'), visible=False, triggers=self.onToolsReindexItemTriggered) tools_menu.addAction(self.toolsReindexItem) - self.toolsFindDuplicates = create_action(tools_menu, u'toolsFindDuplicates', + self.tools_find_duplicates = create_action(tools_menu, u'toolsFindDuplicates', text=translate('SongsPlugin', 'Find &Duplicate Songs'), statustip=translate('SongsPlugin', 'Find and remove duplicate songs in the song database.'), - visible=False, triggers=self.onToolsFindDuplicatesTriggered) - tools_menu.addAction(self.toolsFindDuplicates) + visible=False, triggers=self.on_tools_find_duplicates_triggered) + tools_menu.addAction(self.tools_find_duplicates) def onToolsReindexItemTriggered(self): """ @@ -174,7 +174,7 @@ class SongsPlugin(Plugin): self.manager.save_objects(songs) self.mediaItem.onSearchTextButtonClicked() - def onToolsFindDuplicatesTriggered(self): + def on_tools_find_duplicates_triggered(self): """ Search for duplicates in the song database. """ @@ -300,12 +300,12 @@ class SongsPlugin(Plugin): self.songImportItem.setVisible(False) self.songExportItem.setVisible(False) self.toolsReindexItem.setVisible(False) - self.toolsFindDuplicates.setVisible(False) + self.tools_find_duplicates.setVisible(False) action_list = ActionList.get_instance() action_list.remove_action(self.songImportItem, UiStrings().Import) action_list.remove_action(self.songExportItem, UiStrings().Export) action_list.remove_action(self.toolsReindexItem, UiStrings().Tools) - action_list.remove_action(self.toolsFindDuplicates, UiStrings().Tools) + action_list.remove_action(self.tools_find_duplicates, UiStrings().Tools) Plugin.finalise(self) def new_service_created(self): diff --git a/tests/functional/openlp_plugins/songs/test_lib.py b/tests/functional/openlp_plugins/songs/test_lib.py index 6de41da37..284a14859 100644 --- a/tests/functional/openlp_plugins/songs/test_lib.py +++ b/tests/functional/openlp_plugins/songs/test_lib.py @@ -53,46 +53,46 @@ class TestLib(TestCase): that old cross where the dearest and best for a world of lost sinners was slain so ill cherish the old rugged cross till my trophies at last i lay down i will cling to the old rugged cross and exchange it some day for a crown''' - dsf = DuplicateSongFinder() + duplicate_song_finder = DuplicateSongFinder() song1 = MagicMock() song2 = MagicMock() - #GIVEN: Two equal songs + #GIVEN: Two equal songs. song1.search_lyrics = full_lyrics song2.search_lyrics = full_lyrics - #WHEN: We compare those songs for equality - result = dsf.songs_probably_equal(song1, song2) + #WHEN: We compare those songs for equality. + result = duplicate_song_finder.songs_probably_equal(song1, song2) - #THEN: The result should be True + #THEN: The result should be True. assert result is True, u'The result should be True' - #GIVEN: A song and a short version of the same song + #GIVEN: A song and a short version of the same song. song1.search_lyrics = full_lyrics song2.search_lyrics = short_lyrics - #WHEN: We compare those songs for equality - result = dsf.songs_probably_equal(song1, song2) + #WHEN: We compare those songs for equality. + result = duplicate_song_finder.songs_probably_equal(song1, song2) - #THEN: The result should be True + #THEN: The result should be True. assert result is True, u'The result should be True' - #GIVEN: A song and the same song with lots of errors + #GIVEN: A song and the same song with lots of errors. song1.search_lyrics = full_lyrics song2.search_lyrics = error_lyrics - #WHEN: We compare those songs for equality - result = dsf.songs_probably_equal(song1, song2) + #WHEN: We compare those songs for equality. + result = duplicate_song_finder.songs_probably_equal(song1, song2) - #THEN: The result should be True + #THEN: The result should be True. assert result is True, u'The result should be True' - #GIVEN: Two different songs + #GIVEN: Two different songs. song1.search_lyrics = full_lyrics song2.search_lyrics = different_lyrics - #WHEN: We compare those songs for equality - result = dsf.songs_probably_equal(song1, song2) + #WHEN: We compare those songs for equality. + result = duplicate_song_finder.songs_probably_equal(song1, song2) - #THEN: The result should be False + #THEN: The result should be False. assert result is False, u'The result should be False' From 0c78afc8d6624dbab903859a2a662d92897b18a4 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 18 Feb 2013 19:52:56 +0100 Subject: [PATCH 42/95] Simplify a junk of code with a very elegant join snippet by googol++. --- openlp/plugins/songs/forms/songreviewwidget.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openlp/plugins/songs/forms/songreviewwidget.py b/openlp/plugins/songs/forms/songreviewwidget.py index 81154750a..9799a9ee7 100644 --- a/openlp/plugins/songs/forms/songreviewwidget.py +++ b/openlp/plugins/songs/forms/songreviewwidget.py @@ -119,11 +119,7 @@ class SongReviewWidget(QtGui.QWidget): self.song_authors_content = QtGui.QLabel(self) self.song_authors_content.setObjectName(u'song_authors_content') self.song_authors_content.setWordWrap(True) - authors_text = u'' - for author in self.song.authors: - authors_text += author.display_name + ', ' - if authors_text: - authors_text = authors_text[:-2] + authors_text = u', '.join([author.display_name for author in self.song.authors]) self.song_authors_content.setText(authors_text) self.song_info_form_layout.setWidget(5, QtGui.QFormLayout.FieldRole, self.song_authors_content) # Add verse order widget. From 8c8cd3b8674e313d1853441a5dce9ee6b85ffe78 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 18 Feb 2013 21:27:11 +0100 Subject: [PATCH 43/95] Replace DuplicateSongFinder class with a set of functions. --- .../songs/forms/duplicatesongremovalform.py | 10 +- .../plugins/songs/lib/duplicatesongfinder.py | 153 ------------------ .../openlp_plugins/songs/test_lib.py | 15 +- 3 files changed, 10 insertions(+), 168 deletions(-) delete mode 100644 openlp/plugins/songs/lib/duplicatesongfinder.py diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index a0406160c..3ec93f25f 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -29,20 +29,17 @@ """ The duplicate song removal logic for OpenLP. """ -import codecs import logging import os from PyQt4 import QtCore, QtGui -from openlp.core.lib import Registry, translate, build_icon -from openlp.core.lib.db import Manager -from openlp.core.lib.ui import UiStrings, critical_error_message_box +from openlp.core.lib import Registry, translate from openlp.core.ui.wizard import OpenLPWizard, WizardStrings from openlp.core.utils import AppLocation from openlp.plugins.songs.lib.db import Song, MediaFile -from openlp.plugins.songs.lib.duplicatesongfinder import DuplicateSongFinder from openlp.plugins.songs.forms.songreviewwidget import SongReviewWidget +from openlp.plugins.songs.lib.songcompare import songs_probably_equal log = logging.getLogger(__name__) @@ -169,8 +166,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): songs = self.plugin.manager.get_all_objects(Song) for outer_song_counter in range(max_songs - 1): for inner_song_counter in range(outer_song_counter + 1, max_songs): - double_finder = DuplicateSongFinder() - if double_finder.songs_probably_equal(songs[outer_song_counter], songs[inner_song_counter]): + if songs_probably_equal(songs[outer_song_counter], songs[inner_song_counter]): duplicate_added = self.add_duplicates_to_song_list(songs[outer_song_counter], songs[inner_song_counter]) if duplicate_added: diff --git a/openlp/plugins/songs/lib/duplicatesongfinder.py b/openlp/plugins/songs/lib/duplicatesongfinder.py deleted file mode 100644 index 225789109..000000000 --- a/openlp/plugins/songs/lib/duplicatesongfinder.py +++ /dev/null @@ -1,153 +0,0 @@ -# -*- coding: utf-8 -*- -# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 - -############################################################################### -# OpenLP - Open Source Lyrics Projection # -# --------------------------------------------------------------------------- # -# Copyright (c) 2008-2013 Raoul Snyman # -# Portions copyright (c) 2008-2013 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 # -############################################################################### -import difflib - -from openlp.plugins.songs.lib.db import Song - -class DuplicateSongFinder(object): - """ - The :class:`DuplicateSongFinder` class provides functionality to search for - duplicate songs. - - The algorithm is based on the diff algorithm. - First a diffset is calculated for two songs. - To compensate for typos all differences that are smaller than a - limit (min_fragment_size) are removed and the surrounding equal parts are merged. - Finally two conditions can qualify a song tuple to be a duplicate: - 1. There is a block of equal content that is at least min_block_size large. - This condition should hit for all larger songs that have a long enough - equal part. Even if only one verse is equal this condition should still hit. - 2. Two thirds of the smaller song is contained in the larger song. - This condition should hit if one of the two songs (or both) is small (smaller - than the min_block_size), but most of the song is contained in the other song. - """ - - def __init__(self): - self.min_fragment_size = 5 - self.min_block_size = 70 - self.max_typo_size = 3 - - def songs_probably_equal(self, song1, song2): - """ - Calculate and return whether two songs are probably equal. - - ``song1`` - The first song to compare. - - ``song2`` - The second song to compare. - """ - if len(song1.search_lyrics) < len(song2.search_lyrics): - small = song1.search_lyrics - large = song2.search_lyrics - else: - small = song2.search_lyrics - large = song1.search_lyrics - differ = difflib.SequenceMatcher(a=large, b=small) - diff_tuples = differ.get_opcodes() - diff_no_typos = self.__remove_typos(diff_tuples) - if self.__length_of_equal_blocks(diff_no_typos) >= self.min_block_size or \ - self.__length_of_longest_equal_block(diff_no_typos) > len(small) * 2 / 3: - return True - else: - return False - - def __op_length(self, opcode): - """ - Return the length of a given difference. - - ``opcode`` - The difference. - """ - return max(opcode[2] - opcode[1], opcode[4] - opcode[3]) - - def __remove_typos(self, diff): - """ - Remove typos from a diff set. A typo is a small difference (min_fragment_size). - - ``diff`` - The diff set to remove the typos from. - """ - # Remove typo at beginning of the string. - if len(diff) >= 2: - if diff[0][0] != "equal" and self.__op_length(diff[0]) <= self.max_typo_size and \ - self.__op_length(diff[1]) >= self.min_fragment_size: - del diff[0] - # Remove typos in the middle of the string. - if len(diff) >= 3: - for index in range(len(diff) - 3, -1, -1): - if self.__op_length(diff[index]) >= self.min_fragment_size and \ - diff[index + 1][0] != "equal" and self.__op_length(diff[index + 1]) <= self.max_typo_size and \ - self.__op_length(diff[index + 2]) >= self.min_fragment_size: - del diff[index + 1] - # Remove typo at the end of the string. - if len(diff) >= 2: - if self.__op_length(diff[-2]) >= self.min_fragment_size and \ - diff[-1][0] != "equal" and self.__op_length(diff[-1]) <= self.max_typo_size: - del diff[-1] - - # Merge the bordering equal passages that occured by removing differences. - for index in range(len(diff) - 2, -1, -1): - if diff[index][0] == "equal" and self.__op_length(diff[index]) >= self.min_fragment_size and \ - diff[index + 1][0] == "equal" and self.__op_length(diff[index + 1]) >= self.min_fragment_size: - diff[index] = ("equal", diff[index][1], diff[index + 1][2], diff[index][3], - diff[index + 1][4]) - del diff[index + 1] - - return diff - - def __length_of_equal_blocks(self, diff): - """ - Return the total length of all equal blocks in a diff set. - Blocks smaller than min_block_size are not counted. - - ``diff`` - The diff set to return the length for. - """ - length = 0 - for element in diff: - if element[0] == "equal" and self.__op_length(element) >= self.min_block_size: - length += self.__op_length(element) - return length - - def __length_of_longest_equal_block(self, diff): - """ - Return the length of the largest equal block in a diff set. - - ``diff`` - The diff set to return the length for. - """ - length = 0 - for element in diff: - if element[0] == "equal" and self.__op_length(element) > length: - length = self.__op_length(element) - return length - diff --git a/tests/functional/openlp_plugins/songs/test_lib.py b/tests/functional/openlp_plugins/songs/test_lib.py index 284a14859..9f04e7350 100644 --- a/tests/functional/openlp_plugins/songs/test_lib.py +++ b/tests/functional/openlp_plugins/songs/test_lib.py @@ -31,13 +31,13 @@ from unittest import TestCase from mock import MagicMock -from openlp.plugins.songs.lib.duplicatesongfinder import DuplicateSongFinder +from openlp.plugins.songs.lib.songcompare import songs_probably_equal class TestLib(TestCase): def songs_probably_equal_test(self): """ - Test the DuplicateSongFinder.songs_probably_equal function. + Test the songs_probably_equal function. """ full_lyrics =u'''amazing grace how sweet the sound that saved a wretch like me i once was lost but now am found was blind but now i see twas grace that taught my heart to fear and grace my fears relieved how @@ -53,7 +53,6 @@ class TestLib(TestCase): that old cross where the dearest and best for a world of lost sinners was slain so ill cherish the old rugged cross till my trophies at last i lay down i will cling to the old rugged cross and exchange it some day for a crown''' - duplicate_song_finder = DuplicateSongFinder() song1 = MagicMock() song2 = MagicMock() @@ -62,7 +61,7 @@ class TestLib(TestCase): song2.search_lyrics = full_lyrics #WHEN: We compare those songs for equality. - result = duplicate_song_finder.songs_probably_equal(song1, song2) + result = songs_probably_equal(song1, song2) #THEN: The result should be True. assert result is True, u'The result should be True' @@ -72,17 +71,17 @@ class TestLib(TestCase): song2.search_lyrics = short_lyrics #WHEN: We compare those songs for equality. - result = duplicate_song_finder.songs_probably_equal(song1, song2) + result = songs_probably_equal(song1, song2) #THEN: The result should be True. - assert result is True, u'The result should be True' + assert result is True, u'The result should be True' #GIVEN: A song and the same song with lots of errors. song1.search_lyrics = full_lyrics song2.search_lyrics = error_lyrics #WHEN: We compare those songs for equality. - result = duplicate_song_finder.songs_probably_equal(song1, song2) + result = songs_probably_equal(song1, song2) #THEN: The result should be True. assert result is True, u'The result should be True' @@ -92,7 +91,7 @@ class TestLib(TestCase): song2.search_lyrics = different_lyrics #WHEN: We compare those songs for equality. - result = duplicate_song_finder.songs_probably_equal(song1, song2) + result = songs_probably_equal(song1, song2) #THEN: The result should be False. assert result is False, u'The result should be False' From 904620998f3d286353ebbf068a7c6b1fc02d956f Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 18 Feb 2013 22:42:04 +0100 Subject: [PATCH 44/95] Remove non-needed test __init__ file. Split up testfunctions. --- openlp/plugins/songs/lib/songcompare.py | 153 ++++++++++++++++++ .../openlp_plugins/songs/__init__.py | 8 - .../openlp_plugins/songs/test_lib.py | 86 ++++++---- 3 files changed, 205 insertions(+), 42 deletions(-) create mode 100644 openlp/plugins/songs/lib/songcompare.py delete mode 100644 tests/functional/openlp_plugins/songs/__init__.py diff --git a/openlp/plugins/songs/lib/songcompare.py b/openlp/plugins/songs/lib/songcompare.py new file mode 100644 index 000000000..e230d9f53 --- /dev/null +++ b/openlp/plugins/songs/lib/songcompare.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2013 Raoul Snyman # +# Portions copyright (c) 2008-2013 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:`songcompare` module provides functionality to search for +duplicate songs. It has one single :function:`songs_probably_equal`. + +The algorithm is based on the diff algorithm. +First a diffset is calculated for two songs. +To compensate for typos all differences that are smaller than a +limit (min_fragment_size) are removed and the surrounding equal parts are merged. +Finally two conditions can qualify a song tuple to be a duplicate: +1. There is a block of equal content that is at least min_block_size large. + This condition should hit for all larger songs that have a long enough + equal part. Even if only one verse is equal this condition should still hit. +2. Two thirds of the smaller song is contained in the larger song. + This condition should hit if one of the two songs (or both) is small (smaller + than the min_block_size), but most of the song is contained in the other song. +""" +import difflib + + +min_fragment_size = 5 +min_block_size = 70 +max_typo_size = 3 + + +def songs_probably_equal(song1, song2): + """ + Calculate and return whether two songs are probably equal. + + ``song1`` + The first song to compare. + + ``song2`` + The second song to compare. + """ + if len(song1.search_lyrics) < len(song2.search_lyrics): + small = song1.search_lyrics + large = song2.search_lyrics + else: + small = song2.search_lyrics + large = song1.search_lyrics + differ = difflib.SequenceMatcher(a=large, b=small) + diff_tuples = differ.get_opcodes() + diff_no_typos = __remove_typos(diff_tuples) + if __length_of_equal_blocks(diff_no_typos) >= min_block_size or \ + __length_of_longest_equal_block(diff_no_typos) > len(small) * 2 / 3: + return True + else: + return False + + +def __op_length(opcode): + """ + Return the length of a given difference. + + ``opcode`` + The difference. + """ + return max(opcode[2] - opcode[1], opcode[4] - opcode[3]) + + +def __remove_typos(diff): + """ + Remove typos from a diff set. A typo is a small difference (min_fragment_size). + + ``diff`` + The diff set to remove the typos from. + """ + # Remove typo at beginning of the string. + if len(diff) >= 2: + if diff[0][0] != "equal" and __op_length(diff[0]) <= max_typo_size and \ + __op_length(diff[1]) >= min_fragment_size: + del diff[0] + # Remove typos in the middle of the string. + if len(diff) >= 3: + for index in range(len(diff) - 3, -1, -1): + if __op_length(diff[index]) >= min_fragment_size and \ + diff[index + 1][0] != "equal" and __op_length(diff[index + 1]) <= max_typo_size and \ + __op_length(diff[index + 2]) >= min_fragment_size: + del diff[index + 1] + # Remove typo at the end of the string. + if len(diff) >= 2: + if __op_length(diff[-2]) >= min_fragment_size and \ + diff[-1][0] != "equal" and __op_length(diff[-1]) <= max_typo_size: + del diff[-1] + + # Merge the bordering equal passages that occured by removing differences. + for index in range(len(diff) - 2, -1, -1): + if diff[index][0] == "equal" and __op_length(diff[index]) >= min_fragment_size and \ + diff[index + 1][0] == "equal" and __op_length(diff[index + 1]) >= min_fragment_size: + diff[index] = ("equal", diff[index][1], diff[index + 1][2], diff[index][3], + diff[index + 1][4]) + del diff[index + 1] + + return diff + + +def __length_of_equal_blocks(diff): + """ + Return the total length of all equal blocks in a diff set. + Blocks smaller than min_block_size are not counted. + + ``diff`` + The diff set to return the length for. + """ + length = 0 + for element in diff: + if element[0] == "equal" and __op_length(element) >= min_block_size: + length += __op_length(element) + return length + + +def __length_of_longest_equal_block(diff): + """ + Return the length of the largest equal block in a diff set. + + ``diff`` + The diff set to return the length for. + """ + length = 0 + for element in diff: + if element[0] == "equal" and __op_length(element) > length: + length = __op_length(element) + return length diff --git a/tests/functional/openlp_plugins/songs/__init__.py b/tests/functional/openlp_plugins/songs/__init__.py deleted file mode 100644 index 0157fb2f0..000000000 --- a/tests/functional/openlp_plugins/songs/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -import sip -sip.setapi(u'QDate', 2) -sip.setapi(u'QDateTime', 2) -sip.setapi(u'QString', 2) -sip.setapi(u'QTextStream', 2) -sip.setapi(u'QTime', 2) -sip.setapi(u'QUrl', 2) -sip.setapi(u'QVariant', 2) diff --git a/tests/functional/openlp_plugins/songs/test_lib.py b/tests/functional/openlp_plugins/songs/test_lib.py index 9f04e7350..a02563b22 100644 --- a/tests/functional/openlp_plugins/songs/test_lib.py +++ b/tests/functional/openlp_plugins/songs/test_lib.py @@ -34,64 +34,82 @@ from mock import MagicMock from openlp.plugins.songs.lib.songcompare import songs_probably_equal class TestLib(TestCase): + def setUp(self): + """ + Mock up two songs and provide a set of lyrics for the songs_probably_equal tests. + """ + self.full_lyrics =u'''amazing grace how sweet the sound that saved a wretch like me i once was lost but now am + found was blind but now i see twas grace that taught my heart to fear and grace my fears relieved how + precious did that grace appear the hour i first believed through many dangers toils and snares i have already + come tis grace that brought me safe thus far and grace will lead me home''' + self.short_lyrics =u'''twas grace that taught my heart to fear and grace my fears relieved how precious did that + grace appear the hour i first believed''' + self.error_lyrics =u'''amazing how sweet the trumpet that saved a wrench like me i once was losst but now am + found waf blind but now i see it was grace that taught my heart to fear and grace my fears relieved how + precious did that grace appppppppear the hour i first believedxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx snares i have + already come to this grace that brought me safe so far and grace will lead me home''' + self.different_lyrics=u'''on a hill far away stood an old rugged cross the emblem of suffering and shame and i love + that old cross where the dearest and best for a world of lost sinners was slain so ill cherish the old rugged + cross till my trophies at last i lay down i will cling to the old rugged cross and exchange it some day for a + crown''' + self.song1 = MagicMock() + self.song2 = MagicMock() - def songs_probably_equal_test(self): + def songs_probably_equal_same_song_test(self): """ - Test the songs_probably_equal function. + Test the songs_probably_equal function with twice the same song. """ - full_lyrics =u'''amazing grace how sweet the sound that saved a wretch like me i once was lost but now am - found was blind but now i see twas grace that taught my heart to fear and grace my fears relieved how - precious did that grace appear the hour i first believed through many dangers toils and snares i have already - come tis grace that brought me safe thus far and grace will lead me home''' - short_lyrics =u'''twas grace that taught my heart to fear and grace my fears relieved how precious did that - grace appear the hour i first believed''' - error_lyrics =u'''amazing how sweet the trumpet that saved a wrench like me i once was losst but now am - found waf blind but now i see it was grace that taught my heart to fear and grace my fears relieved how - precious did that grace appppppppear the hour i first believedxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx snares i have - already come to this grace that brought me safe so far and grace will lead me home''' - different_lyrics=u'''on a hill far away stood an old rugged cross the emblem of suffering and shame and i love - that old cross where the dearest and best for a world of lost sinners was slain so ill cherish the old rugged - cross till my trophies at last i lay down i will cling to the old rugged cross and exchange it some day for a - crown''' - song1 = MagicMock() - song2 = MagicMock() - #GIVEN: Two equal songs. - song1.search_lyrics = full_lyrics - song2.search_lyrics = full_lyrics + self.song1.search_lyrics = self.full_lyrics + self.song2.search_lyrics = self.full_lyrics #WHEN: We compare those songs for equality. - result = songs_probably_equal(song1, song2) + result = songs_probably_equal(self.song1, self.song2) #THEN: The result should be True. assert result is True, u'The result should be True' - + + + def songs_probably_equal_short_song_test(self): + """ + Test the songs_probably_equal function with a song and a shorter version of the same song. + """ #GIVEN: A song and a short version of the same song. - song1.search_lyrics = full_lyrics - song2.search_lyrics = short_lyrics + self.song1.search_lyrics = self.full_lyrics + self.song2.search_lyrics = self.short_lyrics #WHEN: We compare those songs for equality. - result = songs_probably_equal(song1, song2) + result = songs_probably_equal(self.song1, self.song2) #THEN: The result should be True. assert result is True, u'The result should be True' - + + + def songs_probably_equal_error_song_test(self): + """ + Test the songs_probably_equal function with a song and a very erroneous version of the same song. + """ #GIVEN: A song and the same song with lots of errors. - song1.search_lyrics = full_lyrics - song2.search_lyrics = error_lyrics + self.song1.search_lyrics = self.full_lyrics + self.song2.search_lyrics = self.error_lyrics #WHEN: We compare those songs for equality. - result = songs_probably_equal(song1, song2) + result = songs_probably_equal(self.song1, self.song2) #THEN: The result should be True. assert result is True, u'The result should be True' - + + + def songs_probably_equal_different_song_test(self): + """ + Test the songs_probably_equal function with two different songs. + """ #GIVEN: Two different songs. - song1.search_lyrics = full_lyrics - song2.search_lyrics = different_lyrics + self.song1.search_lyrics = self.full_lyrics + self.song2.search_lyrics = self.different_lyrics #WHEN: We compare those songs for equality. - result = songs_probably_equal(song1, song2) + result = songs_probably_equal(self.song1, self.song2) #THEN: The result should be False. assert result is False, u'The result should be False' From 0df1f3932af2d710c61495849da47e514ffbce57 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 18 Feb 2013 23:26:27 +0100 Subject: [PATCH 45/95] Replace QObject.connect calls with a smarter more pythonic syntax. Courtesy of TRB143++. --- openlp/core/ui/wizard.py | 6 +++--- openlp/plugins/songs/forms/duplicatesongremovalform.py | 4 ++-- openlp/plugins/songs/forms/songreviewwidget.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openlp/core/ui/wizard.py b/openlp/core/ui/wizard.py index 3a2596a77..de73ec005 100644 --- a/openlp/core/ui/wizard.py +++ b/openlp/core/ui/wizard.py @@ -112,10 +112,10 @@ class OpenLPWizard(QtGui.QWizard): self.registerFields() self.customInit() self.customSignals() - QtCore.QObject.connect(self, QtCore.SIGNAL(u'currentIdChanged(int)'), self.onCurrentIdChanged) + self.currentIdChanged.connect(self.onCurrentIdChanged) if self.with_progress_page: - QtCore.QObject.connect(self.errorCopyToButton, QtCore.SIGNAL(u'clicked()'), self.onErrorCopyToButtonClicked) - QtCore.QObject.connect(self.errorSaveToButton, QtCore.SIGNAL(u'clicked()'), self.onErrorSaveToButtonClicked) + self.errorCopyToButton.clicked.connect(self.onErrorCopyToButtonClicked) + self.errorSaveToButton.clicked.connect(self.onErrorSaveToButtonClicked) def setupUi(self, image): """ diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 3ec93f25f..29eb1b312 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -70,8 +70,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): """ Song wizard specific signals. """ - QtCore.QObject.connect(self.finishButton, QtCore.SIGNAL(u'clicked()'), self.onWizardExit) - QtCore.QObject.connect(self.cancelButton, QtCore.SIGNAL(u'clicked()'), self.onWizardExit) + self.finishButton.clicked.connect(self.onWizardExit) + self.cancelButton.clicked.connect(self.onWizardExit) def addCustomPages(self): """ diff --git a/openlp/plugins/songs/forms/songreviewwidget.py b/openlp/plugins/songs/forms/songreviewwidget.py index 9799a9ee7..af861205e 100644 --- a/openlp/plugins/songs/forms/songreviewwidget.py +++ b/openlp/plugins/songs/forms/songreviewwidget.py @@ -54,7 +54,7 @@ class SongReviewWidget(QtGui.QWidget): self.song = song self.setupUi() self.retranslateUi() - QtCore.QObject.connect(self.song_remove_button, QtCore.SIGNAL(u'clicked()'), self.on_remove_button_clicked) + self.song_remove_button.clicked.connect(self.on_remove_button_clicked) def setupUi(self): self.song_vertical_layout = QtGui.QVBoxLayout(self) From 3c8c136d3d1fbaa2f076c77bb4ae4f6f72909743 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 19 Feb 2013 23:58:29 +0100 Subject: [PATCH 46/95] Change remove button signal emission to new style signals. --- .../songs/forms/duplicatesongremovalform.py | 4 +--- openlp/plugins/songs/forms/songreviewwidget.py | 14 +++++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 29eb1b312..4642d6fdb 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -331,9 +331,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songs_horizontal_layout.addStretch() for duplicate in self.duplicate_song_list[-1]: song_review_widget = SongReviewWidget(self.review_page, duplicate) - QtCore.QObject.connect(song_review_widget, - QtCore.SIGNAL(u'song_remove_button_clicked(PyQt_PyObject)'), - self.remove_button_clicked) + song_review_widget.song_remove_button_clicked.connect(self.remove_button_clicked) self.songs_horizontal_layout.addWidget(song_review_widget) self.songs_horizontal_layout.addStretch() self.songs_horizontal_layout.addStretch() diff --git a/openlp/plugins/songs/forms/songreviewwidget.py b/openlp/plugins/songs/forms/songreviewwidget.py index af861205e..eb3dc0372 100644 --- a/openlp/plugins/songs/forms/songreviewwidget.py +++ b/openlp/plugins/songs/forms/songreviewwidget.py @@ -42,6 +42,18 @@ class SongReviewWidget(QtGui.QWidget): The remove logic is not implemented here, but a signal is provided when the remove button is clicked. """ + + # Signals have to be class variables and not instance variables. Otherwise + # they are not registered by Qt (missing emit and connect methods are artifacts of this). + # To use SongReviewWidget as a signal parameter one would have to assigning the class + # variable after the class is declared. While this is possible, it also messes Qts meta + # object system up. The result is an + # "Object::connect: Use the SIGNAL macro to bind SongReviewWidget::(QWidget*)" error on + # connect calls. + # That's why we cheat a little and use QWidget instead of SongReviewWidget as parameter. + # While not being entirely correct, it does work. + song_remove_button_clicked = QtCore.pyqtSignal(QtGui.QWidget) + def __init__(self, parent, song): """ ``parent`` @@ -168,4 +180,4 @@ class SongReviewWidget(QtGui.QWidget): """ Signal emitted when the "remove" button is clicked. """ - self.emit(QtCore.SIGNAL(u'song_remove_button_clicked(PyQt_PyObject)'), self) + self.song_remove_button_clicked.emit(self) From 2cf15f3b693737c7f79c966485833a77b9f9b373 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 21 Feb 2013 00:29:54 +0100 Subject: [PATCH 47/95] Add a lot more test functions. --- openlp/plugins/songs/lib/songcompare.py | 40 ++--- .../openlp_plugins/songs/test_lib.py | 146 +++++++++++++++++- 2 files changed, 161 insertions(+), 25 deletions(-) diff --git a/openlp/plugins/songs/lib/songcompare.py b/openlp/plugins/songs/lib/songcompare.py index e230d9f53..f402e1d5f 100644 --- a/openlp/plugins/songs/lib/songcompare.py +++ b/openlp/plugins/songs/lib/songcompare.py @@ -69,15 +69,15 @@ def songs_probably_equal(song1, song2): large = song1.search_lyrics differ = difflib.SequenceMatcher(a=large, b=small) diff_tuples = differ.get_opcodes() - diff_no_typos = __remove_typos(diff_tuples) - if __length_of_equal_blocks(diff_no_typos) >= min_block_size or \ - __length_of_longest_equal_block(diff_no_typos) > len(small) * 2 / 3: + diff_no_typos = _remove_typos(diff_tuples) + if _length_of_equal_blocks(diff_no_typos) >= min_block_size or \ + _length_of_longest_equal_block(diff_no_typos) > len(small) * 2 / 3: return True else: return False -def __op_length(opcode): +def _op_length(opcode): """ Return the length of a given difference. @@ -87,7 +87,7 @@ def __op_length(opcode): return max(opcode[2] - opcode[1], opcode[4] - opcode[3]) -def __remove_typos(diff): +def _remove_typos(diff): """ Remove typos from a diff set. A typo is a small difference (min_fragment_size). @@ -97,26 +97,26 @@ def __remove_typos(diff): """ # Remove typo at beginning of the string. if len(diff) >= 2: - if diff[0][0] != "equal" and __op_length(diff[0]) <= max_typo_size and \ - __op_length(diff[1]) >= min_fragment_size: + if diff[0][0] != "equal" and _op_length(diff[0]) <= max_typo_size and \ + _op_length(diff[1]) >= min_fragment_size: del diff[0] # Remove typos in the middle of the string. if len(diff) >= 3: for index in range(len(diff) - 3, -1, -1): - if __op_length(diff[index]) >= min_fragment_size and \ - diff[index + 1][0] != "equal" and __op_length(diff[index + 1]) <= max_typo_size and \ - __op_length(diff[index + 2]) >= min_fragment_size: + if _op_length(diff[index]) >= min_fragment_size and \ + diff[index + 1][0] != "equal" and _op_length(diff[index + 1]) <= max_typo_size and \ + _op_length(diff[index + 2]) >= min_fragment_size: del diff[index + 1] # Remove typo at the end of the string. if len(diff) >= 2: - if __op_length(diff[-2]) >= min_fragment_size and \ - diff[-1][0] != "equal" and __op_length(diff[-1]) <= max_typo_size: + if _op_length(diff[-2]) >= min_fragment_size and \ + diff[-1][0] != "equal" and _op_length(diff[-1]) <= max_typo_size: del diff[-1] # Merge the bordering equal passages that occured by removing differences. for index in range(len(diff) - 2, -1, -1): - if diff[index][0] == "equal" and __op_length(diff[index]) >= min_fragment_size and \ - diff[index + 1][0] == "equal" and __op_length(diff[index + 1]) >= min_fragment_size: + if diff[index][0] == "equal" and _op_length(diff[index]) >= min_fragment_size and \ + diff[index + 1][0] == "equal" and _op_length(diff[index + 1]) >= min_fragment_size: diff[index] = ("equal", diff[index][1], diff[index + 1][2], diff[index][3], diff[index + 1][4]) del diff[index + 1] @@ -124,7 +124,7 @@ def __remove_typos(diff): return diff -def __length_of_equal_blocks(diff): +def _length_of_equal_blocks(diff): """ Return the total length of all equal blocks in a diff set. Blocks smaller than min_block_size are not counted. @@ -134,12 +134,12 @@ def __length_of_equal_blocks(diff): """ length = 0 for element in diff: - if element[0] == "equal" and __op_length(element) >= min_block_size: - length += __op_length(element) + if element[0] == "equal" and _op_length(element) >= min_block_size: + length += _op_length(element) return length -def __length_of_longest_equal_block(diff): +def _length_of_longest_equal_block(diff): """ Return the length of the largest equal block in a diff set. @@ -148,6 +148,6 @@ def __length_of_longest_equal_block(diff): """ length = 0 for element in diff: - if element[0] == "equal" and __op_length(element) > length: - length = __op_length(element) + if element[0] == "equal" and _op_length(element) > length: + length = _op_length(element) return length diff --git a/tests/functional/openlp_plugins/songs/test_lib.py b/tests/functional/openlp_plugins/songs/test_lib.py index a02563b22..f12e8ce01 100644 --- a/tests/functional/openlp_plugins/songs/test_lib.py +++ b/tests/functional/openlp_plugins/songs/test_lib.py @@ -31,7 +31,8 @@ from unittest import TestCase from mock import MagicMock -from openlp.plugins.songs.lib.songcompare import songs_probably_equal +from openlp.plugins.songs.lib.songcompare import songs_probably_equal, _remove_typos, _op_length, \ + _length_of_equal_blocks, _length_of_longest_equal_block class TestLib(TestCase): def setUp(self): @@ -55,6 +56,7 @@ class TestLib(TestCase): self.song1 = MagicMock() self.song2 = MagicMock() + def songs_probably_equal_same_song_test(self): """ Test the songs_probably_equal function with twice the same song. @@ -67,7 +69,7 @@ class TestLib(TestCase): result = songs_probably_equal(self.song1, self.song2) #THEN: The result should be True. - assert result is True, u'The result should be True' + assert result == True, u'The result should be True' def songs_probably_equal_short_song_test(self): @@ -82,7 +84,7 @@ class TestLib(TestCase): result = songs_probably_equal(self.song1, self.song2) #THEN: The result should be True. - assert result is True, u'The result should be True' + assert result == True, u'The result should be True' def songs_probably_equal_error_song_test(self): @@ -97,7 +99,7 @@ class TestLib(TestCase): result = songs_probably_equal(self.song1, self.song2) #THEN: The result should be True. - assert result is True, u'The result should be True' + assert result == True, u'The result should be True' def songs_probably_equal_different_song_test(self): @@ -112,4 +114,138 @@ class TestLib(TestCase): result = songs_probably_equal(self.song1, self.song2) #THEN: The result should be False. - assert result is False, u'The result should be False' + assert result == False, u'The result should be False' + + + def remove_typos_beginning_test(self): + """ + Test the _remove_typos function with a typo at the beginning. + """ + #GIVEN: A diffset with a difference at the beginning. + diff = [('replace', 0, 2, 0, 1), ('equal', 2, 11, 1, 10)] + + #WHEN: We remove the typos in there. + result = _remove_typos(diff) + + #THEN: There should be no typos at the beginning anymore. + assert len(result) == 1, u'The result should contain only one element.' + assert result[0][0] == 'equal', u'The result should contain an equal element.' + + + def remove_typos_beginning_negated_test(self): + """ + Test the _remove_typos function with a large difference at the beginning. + """ + #GIVEN: A diffset with a large difference at the beginning. + diff = [('replace', 0, 20, 0, 1), ('equal', 20, 29, 1, 10)] + + #WHEN: We remove the typos in there. + result = _remove_typos(diff) + + #THEN: There diff should not have changed. + assert result == diff + + + def remove_typos_end_test(self): + """ + Test the _remove_typos function with a typo at the end. + """ + #GIVEN: A diffset with a difference at the end. + diff = [('equal', 0, 10, 0, 10), ('replace', 10, 12, 10, 11)] + + #WHEN: We remove the typos in there. + result = _remove_typos(diff) + + #THEN: There should be no typos at the end anymore. + assert len(result) == 1, u'The result should contain only one element.' + assert result[0][0] == 'equal', u'The result should contain an equal element.' + + + def remove_typos_end_negated_test(self): + """ + Test the _remove_typos function with a large difference at the end. + """ + #GIVEN: A diffset with a large difference at the end. + diff = [('equal', 0, 10, 0, 10), ('replace', 10, 20, 10, 1)] + + #WHEN: We remove the typos in there. + result = _remove_typos(diff) + + #THEN: There diff should not have changed. + assert result == diff + + + def remove_typos_middle_test(self): + """ + Test the _remove_typos function with a typo in the middle. + """ + #GIVEN: A diffset with a difference in the middle. + diff = [('equal', 0, 10, 0, 10), ('replace', 10, 12, 10, 11), ('equal', 12, 22, 11, 21)] + + #WHEN: We remove the typos in there. + result = _remove_typos(diff) + + #THEN: There should be no typos in the middle anymore. The remaining equals should have been merged. + assert len(result) is 1, u'The result should contain only one element.' + assert result[0][0] == 'equal', u'The result should contain an equal element.' + assert result[0][1] == 0, u'The start indices should be kept.' + assert result[0][2] == 22, u'The stop indices should be kept.' + assert result[0][3] == 0, u'The start indices should be kept.' + assert result[0][4] == 21, u'The stop indices should be kept.' + + + def remove_typos_beginning_negated_test(self): + """ + Test the _remove_typos function with a large difference in the middle. + """ + #GIVEN: A diffset with a large difference in the middle. + diff = [('equal', 0, 10, 0, 10), ('replace', 10, 20, 10, 11), ('equal', 20, 30, 11, 21)] + + #WHEN: We remove the typos in there. + result = _remove_typos(diff) + + #THEN: There diff should not have changed. + assert result == diff + + + def op_length_test(self): + """ + Test the _op_length function. + """ + #GIVEN: A diff entry. + diff_entry = ('replace', 0, 2, 4, 14) + + #WHEN: We calculate the length of that diff. + result = _op_length(diff_entry) + + #THEN: The maximum length should be returned. + assert result == 10, u'The length should be 10.' + + + def length_of_equal_blocks_test(self): + """ + Test the _length_of_equal_blocks function. + """ + #GIVEN: A diff. + diff = [('equal', 0, 100, 0, 100), ('replace', 100, 110, 100, 110), ('equal', 110, 120, 110, 120), \ + ('replace', 120, 200, 120, 200), ('equal', 200, 300, 200, 300)] + + #WHEN: We calculate the length of that diffs equal blocks. + result = _length_of_equal_blocks(diff) + + #THEN: The total length should be returned. Note: Equals smaller 70 are ignored. + assert result == 200, u'The length should be 200.' + + + def length_of_longest_equal_block_test(self): + """ + Test the _length_of_longest_equal_block function. + """ + #GIVEN: A diff. + diff = [('equal', 0, 100, 0, 100), ('replace', 100, 110, 100, 110), ('equal', 200, 500, 200, 500)] + + #WHEN: We calculate the length of that diffs longest equal block. + result = _length_of_longest_equal_block(diff) + + #THEN: The total correct length should be returned. + assert result == 300, u'The length should be 300.' \ No newline at end of file From ab565ae3e9e224cc7e0e8271b66ce9d59e9f6a60 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 28 Feb 2013 22:46:36 +0100 Subject: [PATCH 48/95] Cosmetic comment fixes in testing functions. --- .../openlp_plugins/songs/test_lib.py | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/tests/functional/openlp_plugins/songs/test_lib.py b/tests/functional/openlp_plugins/songs/test_lib.py index f12e8ce01..be82d3db0 100644 --- a/tests/functional/openlp_plugins/songs/test_lib.py +++ b/tests/functional/openlp_plugins/songs/test_lib.py @@ -61,14 +61,14 @@ class TestLib(TestCase): """ Test the songs_probably_equal function with twice the same song. """ - #GIVEN: Two equal songs. + # GIVEN: Two equal songs. self.song1.search_lyrics = self.full_lyrics self.song2.search_lyrics = self.full_lyrics - #WHEN: We compare those songs for equality. + # WHEN: We compare those songs for equality. result = songs_probably_equal(self.song1, self.song2) - #THEN: The result should be True. + # THEN: The result should be True. assert result == True, u'The result should be True' @@ -76,14 +76,14 @@ class TestLib(TestCase): """ Test the songs_probably_equal function with a song and a shorter version of the same song. """ - #GIVEN: A song and a short version of the same song. + # GIVEN: A song and a short version of the same song. self.song1.search_lyrics = self.full_lyrics self.song2.search_lyrics = self.short_lyrics - #WHEN: We compare those songs for equality. + # WHEN: We compare those songs for equality. result = songs_probably_equal(self.song1, self.song2) - #THEN: The result should be True. + # THEN: The result should be True. assert result == True, u'The result should be True' @@ -91,14 +91,14 @@ class TestLib(TestCase): """ Test the songs_probably_equal function with a song and a very erroneous version of the same song. """ - #GIVEN: A song and the same song with lots of errors. + # GIVEN: A song and the same song with lots of errors. self.song1.search_lyrics = self.full_lyrics self.song2.search_lyrics = self.error_lyrics - #WHEN: We compare those songs for equality. + # WHEN: We compare those songs for equality. result = songs_probably_equal(self.song1, self.song2) - #THEN: The result should be True. + # THEN: The result should be True. assert result == True, u'The result should be True' @@ -106,14 +106,14 @@ class TestLib(TestCase): """ Test the songs_probably_equal function with two different songs. """ - #GIVEN: Two different songs. + # GIVEN: Two different songs. self.song1.search_lyrics = self.full_lyrics self.song2.search_lyrics = self.different_lyrics - #WHEN: We compare those songs for equality. + # WHEN: We compare those songs for equality. result = songs_probably_equal(self.song1, self.song2) - #THEN: The result should be False. + # THEN: The result should be False. assert result == False, u'The result should be False' @@ -121,13 +121,13 @@ class TestLib(TestCase): """ Test the _remove_typos function with a typo at the beginning. """ - #GIVEN: A diffset with a difference at the beginning. + # GIVEN: A diffset with a difference at the beginning. diff = [('replace', 0, 2, 0, 1), ('equal', 2, 11, 1, 10)] - #WHEN: We remove the typos in there. + # WHEN: We remove the typos in there. result = _remove_typos(diff) - #THEN: There should be no typos at the beginning anymore. + # THEN: There should be no typos at the beginning anymore. assert len(result) == 1, u'The result should contain only one element.' assert result[0][0] == 'equal', u'The result should contain an equal element.' @@ -136,13 +136,13 @@ class TestLib(TestCase): """ Test the _remove_typos function with a large difference at the beginning. """ - #GIVEN: A diffset with a large difference at the beginning. + # GIVEN: A diffset with a large difference at the beginning. diff = [('replace', 0, 20, 0, 1), ('equal', 20, 29, 1, 10)] - #WHEN: We remove the typos in there. + # WHEN: We remove the typos in there. result = _remove_typos(diff) - #THEN: There diff should not have changed. + # THEN: There diff should not have changed. assert result == diff @@ -150,13 +150,13 @@ class TestLib(TestCase): """ Test the _remove_typos function with a typo at the end. """ - #GIVEN: A diffset with a difference at the end. + # GIVEN: A diffset with a difference at the end. diff = [('equal', 0, 10, 0, 10), ('replace', 10, 12, 10, 11)] - #WHEN: We remove the typos in there. + # WHEN: We remove the typos in there. result = _remove_typos(diff) - #THEN: There should be no typos at the end anymore. + # THEN: There should be no typos at the end anymore. assert len(result) == 1, u'The result should contain only one element.' assert result[0][0] == 'equal', u'The result should contain an equal element.' @@ -165,13 +165,13 @@ class TestLib(TestCase): """ Test the _remove_typos function with a large difference at the end. """ - #GIVEN: A diffset with a large difference at the end. + # GIVEN: A diffset with a large difference at the end. diff = [('equal', 0, 10, 0, 10), ('replace', 10, 20, 10, 1)] - #WHEN: We remove the typos in there. + # WHEN: We remove the typos in there. result = _remove_typos(diff) - #THEN: There diff should not have changed. + # THEN: There diff should not have changed. assert result == diff @@ -179,13 +179,13 @@ class TestLib(TestCase): """ Test the _remove_typos function with a typo in the middle. """ - #GIVEN: A diffset with a difference in the middle. + # GIVEN: A diffset with a difference in the middle. diff = [('equal', 0, 10, 0, 10), ('replace', 10, 12, 10, 11), ('equal', 12, 22, 11, 21)] - #WHEN: We remove the typos in there. + # WHEN: We remove the typos in there. result = _remove_typos(diff) - #THEN: There should be no typos in the middle anymore. The remaining equals should have been merged. + # THEN: There should be no typos in the middle anymore. The remaining equals should have been merged. assert len(result) is 1, u'The result should contain only one element.' assert result[0][0] == 'equal', u'The result should contain an equal element.' assert result[0][1] == 0, u'The start indices should be kept.' @@ -198,13 +198,13 @@ class TestLib(TestCase): """ Test the _remove_typos function with a large difference in the middle. """ - #GIVEN: A diffset with a large difference in the middle. + # GIVEN: A diffset with a large difference in the middle. diff = [('equal', 0, 10, 0, 10), ('replace', 10, 20, 10, 11), ('equal', 20, 30, 11, 21)] - #WHEN: We remove the typos in there. + # WHEN: We remove the typos in there. result = _remove_typos(diff) - #THEN: There diff should not have changed. + # THEN: There diff should not have changed. assert result == diff @@ -212,13 +212,13 @@ class TestLib(TestCase): """ Test the _op_length function. """ - #GIVEN: A diff entry. + # GIVEN: A diff entry. diff_entry = ('replace', 0, 2, 4, 14) - #WHEN: We calculate the length of that diff. + # WHEN: We calculate the length of that diff. result = _op_length(diff_entry) - #THEN: The maximum length should be returned. + # THEN: The maximum length should be returned. assert result == 10, u'The length should be 10.' @@ -226,14 +226,14 @@ class TestLib(TestCase): """ Test the _length_of_equal_blocks function. """ - #GIVEN: A diff. + # GIVEN: A diff. diff = [('equal', 0, 100, 0, 100), ('replace', 100, 110, 100, 110), ('equal', 110, 120, 110, 120), \ ('replace', 120, 200, 120, 200), ('equal', 200, 300, 200, 300)] - #WHEN: We calculate the length of that diffs equal blocks. + # WHEN: We calculate the length of that diffs equal blocks. result = _length_of_equal_blocks(diff) - #THEN: The total length should be returned. Note: Equals smaller 70 are ignored. + # THEN: The total length should be returned. Note: Equals smaller 70 are ignored. assert result == 200, u'The length should be 200.' @@ -241,11 +241,11 @@ class TestLib(TestCase): """ Test the _length_of_longest_equal_block function. """ - #GIVEN: A diff. + # GIVEN: A diff. diff = [('equal', 0, 100, 0, 100), ('replace', 100, 110, 100, 110), ('equal', 200, 500, 200, 500)] - #WHEN: We calculate the length of that diffs longest equal block. + # WHEN: We calculate the length of that diffs longest equal block. result = _length_of_longest_equal_block(diff) - #THEN: The total correct length should be returned. + # dTHEN: The total correct length should be returned. assert result == 300, u'The length should be 300.' \ No newline at end of file From 2f21ea2f460276d526f85eb066ce6a005369788d Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 28 Feb 2013 23:01:05 +0100 Subject: [PATCH 49/95] Add can_shortcuts=True to menu entry action. --- openlp/plugins/songs/songsplugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index 237b898ba..713e301de 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -153,7 +153,7 @@ class SongsPlugin(Plugin): text=translate('SongsPlugin', 'Find &Duplicate Songs'), statustip=translate('SongsPlugin', 'Find and remove duplicate songs in the song database.'), - visible=False, triggers=self.on_tools_find_duplicates_triggered) + visible=False, triggers=self.on_tools_find_duplicates_triggered, can_shortcuts=True) tools_menu.addAction(self.tools_find_duplicates) def onToolsReindexItemTriggered(self): From 8f5dd8f649cadb04c78ace1d6eb24b84cf35761c Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 28 Feb 2013 23:04:03 +0100 Subject: [PATCH 50/95] Format some constants correctly. --- openlp/plugins/songs/lib/songcompare.py | 28 ++++++++++++------------- openlp/plugins/songs/songsplugin.py | 3 +-- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/openlp/plugins/songs/lib/songcompare.py b/openlp/plugins/songs/lib/songcompare.py index f402e1d5f..543156314 100644 --- a/openlp/plugins/songs/lib/songcompare.py +++ b/openlp/plugins/songs/lib/songcompare.py @@ -46,9 +46,9 @@ Finally two conditions can qualify a song tuple to be a duplicate: import difflib -min_fragment_size = 5 -min_block_size = 70 -max_typo_size = 3 +MIN_FRAGMENT_SIZE = 5 +MIN_BLOCK_SIZE = 70 +MAX_TYPO_SIZE = 3 def songs_probably_equal(song1, song2): @@ -70,7 +70,7 @@ def songs_probably_equal(song1, song2): differ = difflib.SequenceMatcher(a=large, b=small) diff_tuples = differ.get_opcodes() diff_no_typos = _remove_typos(diff_tuples) - if _length_of_equal_blocks(diff_no_typos) >= min_block_size or \ + if _length_of_equal_blocks(diff_no_typos) >= MIN_BLOCK_SIZE or \ _length_of_longest_equal_block(diff_no_typos) > len(small) * 2 / 3: return True else: @@ -97,26 +97,26 @@ def _remove_typos(diff): """ # Remove typo at beginning of the string. if len(diff) >= 2: - if diff[0][0] != "equal" and _op_length(diff[0]) <= max_typo_size and \ - _op_length(diff[1]) >= min_fragment_size: + if diff[0][0] != "equal" and _op_length(diff[0]) <= MAX_TYPO_SIZE and \ + _op_length(diff[1]) >= MIN_FRAGMENT_SIZE: del diff[0] # Remove typos in the middle of the string. if len(diff) >= 3: for index in range(len(diff) - 3, -1, -1): - if _op_length(diff[index]) >= min_fragment_size and \ - diff[index + 1][0] != "equal" and _op_length(diff[index + 1]) <= max_typo_size and \ - _op_length(diff[index + 2]) >= min_fragment_size: + if _op_length(diff[index]) >= MIN_FRAGMENT_SIZE and \ + diff[index + 1][0] != "equal" and _op_length(diff[index + 1]) <= MAX_TYPO_SIZE and \ + _op_length(diff[index + 2]) >= MIN_FRAGMENT_SIZE: del diff[index + 1] # Remove typo at the end of the string. if len(diff) >= 2: - if _op_length(diff[-2]) >= min_fragment_size and \ - diff[-1][0] != "equal" and _op_length(diff[-1]) <= max_typo_size: + if _op_length(diff[-2]) >= MIN_FRAGMENT_SIZE and \ + diff[-1][0] != "equal" and _op_length(diff[-1]) <= MAX_TYPO_SIZE: del diff[-1] # Merge the bordering equal passages that occured by removing differences. for index in range(len(diff) - 2, -1, -1): - if diff[index][0] == "equal" and _op_length(diff[index]) >= min_fragment_size and \ - diff[index + 1][0] == "equal" and _op_length(diff[index + 1]) >= min_fragment_size: + if diff[index][0] == "equal" and _op_length(diff[index]) >= MIN_FRAGMENT_SIZE and \ + diff[index + 1][0] == "equal" and _op_length(diff[index + 1]) >= MIN_FRAGMENT_SIZE: diff[index] = ("equal", diff[index][1], diff[index + 1][2], diff[index][3], diff[index + 1][4]) del diff[index + 1] @@ -134,7 +134,7 @@ def _length_of_equal_blocks(diff): """ length = 0 for element in diff: - if element[0] == "equal" and _op_length(element) >= min_block_size: + if element[0] == "equal" and _op_length(element) >= MIN_BLOCK_SIZE: length += _op_length(element) return length diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index 713e301de..7acf0bcef 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -48,8 +48,7 @@ from openlp.plugins.songs.lib.db import init_schema, Song from openlp.plugins.songs.lib.mediaitem import SongSearch from openlp.plugins.songs.lib.importer import SongFormat from openlp.plugins.songs.lib.olpimport import OpenLPSongImport -from openlp.plugins.songs.forms.duplicatesongremovalform import \ - DuplicateSongRemovalForm +from openlp.plugins.songs.forms.duplicatesongremovalform import DuplicateSongRemovalForm log = logging.getLogger(__name__) __default_settings__ = { From 8de486f869efb862d3821f17559e1bb7e096d2ca Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 28 Feb 2013 23:20:48 +0100 Subject: [PATCH 51/95] Simplify (and slightly speed up) song comparison logic by inlining two functions. --- openlp/plugins/songs/lib/songcompare.py | 53 +++++++------------ .../openlp_plugins/songs/test_lib.py | 32 +---------- 2 files changed, 20 insertions(+), 65 deletions(-) diff --git a/openlp/plugins/songs/lib/songcompare.py b/openlp/plugins/songs/lib/songcompare.py index 543156314..a98e61380 100644 --- a/openlp/plugins/songs/lib/songcompare.py +++ b/openlp/plugins/songs/lib/songcompare.py @@ -70,11 +70,25 @@ def songs_probably_equal(song1, song2): differ = difflib.SequenceMatcher(a=large, b=small) diff_tuples = differ.get_opcodes() diff_no_typos = _remove_typos(diff_tuples) - if _length_of_equal_blocks(diff_no_typos) >= MIN_BLOCK_SIZE or \ - _length_of_longest_equal_block(diff_no_typos) > len(small) * 2 / 3: - return True - else: - return False + # Check 1: Similarity based on the absolute length of equal parts. + # Calculate the total length of all equal blocks of the set. + # Blocks smaller than min_block_size are not counted. + length_of_equal_blocks = 0 + for element in diff_no_typos: + if element[0] == "equal" and _op_length(element) >= MIN_BLOCK_SIZE: + length_of_equal_blocks += _op_length(element) + if length_of_equal_blocks >= MIN_BLOCK_SIZE: + return True + # Check 2: Similarity based on the relative length of the longest equal block. + # Calculate the length of the largest equal block of the diff set. + length_of_longest_equal_block = 0 + for element in diff_no_typos: + if element[0] == "equal" and _op_length(element) > length_of_longest_equal_block: + length_of_longest_equal_block = _op_length(element) + if length_of_equal_blocks >= MIN_BLOCK_SIZE or length_of_longest_equal_block > len(small) * 2 / 3: + return True + # Both checks failed. We assume the songs are not equal. + return False def _op_length(opcode): @@ -122,32 +136,3 @@ def _remove_typos(diff): del diff[index + 1] return diff - - -def _length_of_equal_blocks(diff): - """ - Return the total length of all equal blocks in a diff set. - Blocks smaller than min_block_size are not counted. - - ``diff`` - The diff set to return the length for. - """ - length = 0 - for element in diff: - if element[0] == "equal" and _op_length(element) >= MIN_BLOCK_SIZE: - length += _op_length(element) - return length - - -def _length_of_longest_equal_block(diff): - """ - Return the length of the largest equal block in a diff set. - - ``diff`` - The diff set to return the length for. - """ - length = 0 - for element in diff: - if element[0] == "equal" and _op_length(element) > length: - length = _op_length(element) - return length diff --git a/tests/functional/openlp_plugins/songs/test_lib.py b/tests/functional/openlp_plugins/songs/test_lib.py index be82d3db0..b79f51132 100644 --- a/tests/functional/openlp_plugins/songs/test_lib.py +++ b/tests/functional/openlp_plugins/songs/test_lib.py @@ -31,8 +31,7 @@ from unittest import TestCase from mock import MagicMock -from openlp.plugins.songs.lib.songcompare import songs_probably_equal, _remove_typos, _op_length, \ - _length_of_equal_blocks, _length_of_longest_equal_block +from openlp.plugins.songs.lib.songcompare import songs_probably_equal, _remove_typos, _op_length class TestLib(TestCase): def setUp(self): @@ -220,32 +219,3 @@ class TestLib(TestCase): # THEN: The maximum length should be returned. assert result == 10, u'The length should be 10.' - - - def length_of_equal_blocks_test(self): - """ - Test the _length_of_equal_blocks function. - """ - # GIVEN: A diff. - diff = [('equal', 0, 100, 0, 100), ('replace', 100, 110, 100, 110), ('equal', 110, 120, 110, 120), \ - ('replace', 120, 200, 120, 200), ('equal', 200, 300, 200, 300)] - - # WHEN: We calculate the length of that diffs equal blocks. - result = _length_of_equal_blocks(diff) - - # THEN: The total length should be returned. Note: Equals smaller 70 are ignored. - assert result == 200, u'The length should be 200.' - - - def length_of_longest_equal_block_test(self): - """ - Test the _length_of_longest_equal_block function. - """ - # GIVEN: A diff. - diff = [('equal', 0, 100, 0, 100), ('replace', 100, 110, 100, 110), ('equal', 200, 500, 200, 500)] - - # WHEN: We calculate the length of that diffs longest equal block. - result = _length_of_longest_equal_block(diff) - - # dTHEN: The total correct length should be returned. - assert result == 300, u'The length should be 300.' \ No newline at end of file From 1de79a0ec20c9b453193ba94a69afc0208119129 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 28 Feb 2013 23:46:55 +0100 Subject: [PATCH 52/95] Fix thinko: Three tests always evaluated to true by comparing a list with itself. --- tests/functional/openlp_plugins/songs/test_lib.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/functional/openlp_plugins/songs/test_lib.py b/tests/functional/openlp_plugins/songs/test_lib.py index b79f51132..a0898479b 100644 --- a/tests/functional/openlp_plugins/songs/test_lib.py +++ b/tests/functional/openlp_plugins/songs/test_lib.py @@ -139,7 +139,7 @@ class TestLib(TestCase): diff = [('replace', 0, 20, 0, 1), ('equal', 20, 29, 1, 10)] # WHEN: We remove the typos in there. - result = _remove_typos(diff) + result = _remove_typos(list(diff)) # THEN: There diff should not have changed. assert result == diff @@ -168,7 +168,7 @@ class TestLib(TestCase): diff = [('equal', 0, 10, 0, 10), ('replace', 10, 20, 10, 1)] # WHEN: We remove the typos in there. - result = _remove_typos(diff) + result = _remove_typos(list(diff)) # THEN: There diff should not have changed. assert result == diff @@ -201,7 +201,7 @@ class TestLib(TestCase): diff = [('equal', 0, 10, 0, 10), ('replace', 10, 20, 10, 11), ('equal', 20, 30, 11, 21)] # WHEN: We remove the typos in there. - result = _remove_typos(diff) + result = _remove_typos(list(diff)) # THEN: There diff should not have changed. assert result == diff From f5774c48ecc82e3fa22ee29a5a23442670559c38 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 4 Mar 2013 21:48:28 +0100 Subject: [PATCH 53/95] Do not show cancel button on finish pages. --- openlp/plugins/songs/forms/duplicatesongremovalform.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 4642d6fdb..b87e55370 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -131,7 +131,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): u'review every potential duplicate song before it is deleted. So no songs will be deleted without your ' u'explicit approval.')) self.searching_page.setTitle(translate(u'Wizard', u'Searching for duplicate songs.')) - self.searching_page.setSubTitle(translate(u'Wizard', u'The song database is searched for double songs.')) + self.searching_page.setSubTitle(translate(u'Wizard', u'Please wait while your songs database is searched.')) self.update_review_counter_text() self.review_page.setSubTitle(translate(u'Wizard', u'Here you can decide which songs to remove and which ones to keep.')) @@ -186,6 +186,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.button(QtGui.QWizard.FinishButton).show() self.button(QtGui.QWizard.FinishButton).setEnabled(True) self.button(QtGui.QWizard.NextButton).hide() + self.button(QtGui.QWizard.CancelButton).hide() QtGui.QMessageBox.information(self, translate(u'Wizard', u'Information'), translate(u'Wizard', u'No duplicate songs have been found in the database.'), QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Ok)) @@ -340,7 +341,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.button(QtGui.QWizard.FinishButton).show() self.button(QtGui.QWizard.FinishButton).setEnabled(True) self.button(QtGui.QWizard.NextButton).hide() - + self.button(QtGui.QWizard.CancelButton).hide() + def _get_main_window(self): """ Adds the main window to the class dynamically. From b740f54352bb154dabe715102099f4dfc943dbbe Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 5 Mar 2013 19:52:10 +0100 Subject: [PATCH 54/95] Change verse widget to a QTableView. And increase songwidget width. --- .../plugins/songs/forms/songreviewwidget.py | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/openlp/plugins/songs/forms/songreviewwidget.py b/openlp/plugins/songs/forms/songreviewwidget.py index eb3dc0372..a853c8b8b 100644 --- a/openlp/plugins/songs/forms/songreviewwidget.py +++ b/openlp/plugins/songs/forms/songreviewwidget.py @@ -73,8 +73,7 @@ class SongReviewWidget(QtGui.QWidget): self.song_vertical_layout.setObjectName(u'song_vertical_layout') self.song_group_box = QtGui.QGroupBox(self) self.song_group_box.setObjectName(u'song_group_box') - self.song_group_box.setMinimumWidth(300) - self.song_group_box.setMaximumWidth(300) + self.song_group_box.setFixedWidth(400) self.song_group_box_layout = QtGui.QVBoxLayout(self.song_group_box) self.song_group_box_layout.setObjectName(u'song_group_box_layout') self.song_info_form_layout = QtGui.QFormLayout() @@ -143,20 +142,34 @@ class SongReviewWidget(QtGui.QWidget): self.song_verse_order_content.setText(self.song.verse_order) self.song_verse_order_content.setWordWrap(True) self.song_info_form_layout.setWidget(6, QtGui.QFormLayout.FieldRole, self.song_verse_order_content) - # Add verses widget. self.song_group_box_layout.addLayout(self.song_info_form_layout) - self.song_info_verse_group_box = QtGui.QGroupBox(self.song_group_box) - self.song_info_verse_group_box.setObjectName(u'song_info_verse_group_box') - self.song_info_verse_group_box_layout = QtGui.QFormLayout(self.song_info_verse_group_box) + # Add verses widget. + self.song_info_verse_list_widget = QtGui.QTableWidget(self.song_group_box) + self.song_info_verse_list_widget.setColumnCount(1) + self.song_info_verse_list_widget.horizontalHeader().setVisible(False) + self.song_info_verse_list_widget.setObjectName(u'song_info_verse_list_widget') + self.song_info_verse_list_widget.setSelectionMode(QtGui.QAbstractItemView.NoSelection) + self.song_info_verse_list_widget.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) + self.song_info_verse_list_widget.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.song_info_verse_list_widget.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.song_info_verse_list_widget.setAlternatingRowColors(True) song_xml = SongXML() verses = song_xml.get_verses(self.song.lyrics) - for verse in verses: - verse_marker = verse[0]['type'] + verse[0]['label'] - verse_label = QtGui.QLabel(self.song_info_verse_group_box) - verse_label.setText(verse[1]) - verse_label.setWordWrap(True) - self.song_info_verse_group_box_layout.addRow(verse_marker, verse_label) - self.song_group_box_layout.addWidget(self.song_info_verse_group_box) + self.song_info_verse_list_widget.setRowCount(len(verses)) + song_tags = [] + for verse_number, verse in enumerate(verses): + item = QtGui.QTableWidgetItem() + item.setText(verse[1]) + self.song_info_verse_list_widget.setItem(verse_number, 0, item) + song_tags.append(unicode(verse[0]['type'] + verse[0]['label'])) + self.song_info_verse_list_widget.setVerticalHeaderLabels(song_tags) + # Resize table fields to content and table to columns + self.song_info_verse_list_widget.setColumnWidth(0, self.song_group_box.width()) + self.song_info_verse_list_widget.resizeRowsToContents() + # The 6 is a trial and error value to just remove the scrollbar. + # TODO: Might be a different value with different skins. + self.song_info_verse_list_widget.setFixedHeight(self.song_info_verse_list_widget.verticalHeader().length() + 6) + self.song_group_box_layout.addWidget(self.song_info_verse_list_widget) self.song_group_box_layout.addStretch() self.song_vertical_layout.addWidget(self.song_group_box) self.song_remove_button = QtGui.QPushButton(self) @@ -174,8 +187,6 @@ class SongReviewWidget(QtGui.QWidget): self.song_copyright_label.setText(u'Copyright:') self.song_comments_label.setText(u'Comments:') self.song_authors_label.setText(u'Authors:') - self.song_info_verse_group_box.setTitle(u'Verses') - def on_remove_button_clicked(self): """ Signal emitted when the "remove" button is clicked. From 955a1a3be47eb3e63273a722866102ea829a4996 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 5 Mar 2013 20:49:11 +0100 Subject: [PATCH 55/95] Document a magic number. --- openlp/plugins/songs/forms/songreviewwidget.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/openlp/plugins/songs/forms/songreviewwidget.py b/openlp/plugins/songs/forms/songreviewwidget.py index a853c8b8b..ec7ff9f75 100644 --- a/openlp/plugins/songs/forms/songreviewwidget.py +++ b/openlp/plugins/songs/forms/songreviewwidget.py @@ -34,6 +34,7 @@ from PyQt4 import QtCore, QtGui from openlp.core.lib import build_icon from openlp.plugins.songs.lib.xml import SongXML + class SongReviewWidget(QtGui.QWidget): """ A widget representing a song on the duplicate song review page. @@ -166,9 +167,13 @@ class SongReviewWidget(QtGui.QWidget): # Resize table fields to content and table to columns self.song_info_verse_list_widget.setColumnWidth(0, self.song_group_box.width()) self.song_info_verse_list_widget.resizeRowsToContents() - # The 6 is a trial and error value to just remove the scrollbar. - # TODO: Might be a different value with different skins. - self.song_info_verse_list_widget.setFixedHeight(self.song_info_verse_list_widget.verticalHeader().length() + 6) + # The 6 is a trial and error value since verticalHeader().length() + offset() is a little bit to small. + # It seems there is no clean way to determine the real height of the table contents. + # The "correct" value slightly fluctuates depending on the theme used, in the worst case + # Some pixels are missing at the bottom of the table, but all themes I tried still allowed + # to read the last verse line, so I'll just leave it at that. + self.song_info_verse_list_widget.setFixedHeight(self.song_info_verse_list_widget.verticalHeader().length() + + self.song_info_verse_list_widget.verticalHeader().offset() + 6) self.song_group_box_layout.addWidget(self.song_info_verse_list_widget) self.song_group_box_layout.addStretch() self.song_vertical_layout.addWidget(self.song_group_box) @@ -187,6 +192,7 @@ class SongReviewWidget(QtGui.QWidget): self.song_copyright_label.setText(u'Copyright:') self.song_comments_label.setText(u'Comments:') self.song_authors_label.setText(u'Authors:') + def on_remove_button_clicked(self): """ Signal emitted when the "remove" button is clicked. From daca6135e955aeea1b8303b58b83f22de46aeaff Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 5 Mar 2013 20:54:03 +0100 Subject: [PATCH 56/95] Readd the scrollarea border on the review page and remove the then unnecessary stylesheets. --- openlp/plugins/songs/forms/duplicatesongremovalform.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index b87e55370..e1888e90a 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -100,14 +100,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songs_horizontal_scroll_area.setObjectName(u'songs_horizontal_scroll_area') self.songs_horizontal_scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) self.songs_horizontal_scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self.songs_horizontal_scroll_area.setFrameStyle(QtGui.QFrame.NoFrame) self.songs_horizontal_scroll_area.setWidgetResizable(True) - self.songs_horizontal_scroll_area.setStyleSheet( - u'QScrollArea#songs_horizontal_scroll_area {background-color:transparent;}') self.songs_horizontal_songs_widget = QtGui.QWidget(self.songs_horizontal_scroll_area) self.songs_horizontal_songs_widget.setObjectName(u'songs_horizontal_songs_widget') - self.songs_horizontal_songs_widget.setStyleSheet( - u'QWidget#songs_horizontal_songs_widget {background-color:transparent;}') self.songs_horizontal_layout = QtGui.QHBoxLayout(self.songs_horizontal_songs_widget) self.songs_horizontal_layout.setObjectName(u'songs_horizontal_layout') self.songs_horizontal_layout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) From aced0516b07090c8568b2be5d5864753aacd539a Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 5 Mar 2013 23:18:35 +0100 Subject: [PATCH 57/95] Remove broken quad-stretch and replace with two normal stretches with factor. --- .../plugins/songs/forms/duplicatesongremovalform.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index e1888e90a..4037451fd 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -286,10 +286,10 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.songs_horizontal_layout.removeWidget(song_review_widget) song_review_widget.setParent(None) # Check if we only have one duplicate left: - # 4 stretches + 1 SongReviewWidget = 5 - # The SongReviewWidget is then at position 2. + # 2 stretches + 1 SongReviewWidget = 3 + # The SongReviewWidget is then at position 1. if len(self.duplicate_song_list[-1]) == 1: - self.songs_horizontal_layout.itemAt(2).widget().song_remove_button.setEnabled(False) + self.songs_horizontal_layout.itemAt(1).widget().song_remove_button.setEnabled(False) def proceed_to_next_review(self): """ @@ -322,15 +322,12 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.update_review_counter_text() # Add song elements to the UI. if len(self.duplicate_song_list) > 0: - # A stretch doesn't seem to stretch endlessly, so I add two to get enough stetch for 1400x1050. - self.songs_horizontal_layout.addStretch() - self.songs_horizontal_layout.addStretch() + self.songs_horizontal_layout.addStretch(1) for duplicate in self.duplicate_song_list[-1]: song_review_widget = SongReviewWidget(self.review_page, duplicate) song_review_widget.song_remove_button_clicked.connect(self.remove_button_clicked) self.songs_horizontal_layout.addWidget(song_review_widget) - self.songs_horizontal_layout.addStretch() - self.songs_horizontal_layout.addStretch() + self.songs_horizontal_layout.addStretch(1) # Change next button to finish button on last review. if len(self.duplicate_song_list) == 1: self.button(QtGui.QWizard.FinishButton).show() From 001dd40c6b4f2b1dbb9892d5879e32604149411e Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 5 Mar 2013 23:20:09 +0100 Subject: [PATCH 58/95] Minor string change. --- openlp/plugins/songs/forms/duplicatesongremovalform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 4037451fd..a4efedbe0 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -126,7 +126,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): u'review every potential duplicate song before it is deleted. So no songs will be deleted without your ' u'explicit approval.')) self.searching_page.setTitle(translate(u'Wizard', u'Searching for duplicate songs.')) - self.searching_page.setSubTitle(translate(u'Wizard', u'Please wait while your songs database is searched.')) + self.searching_page.setSubTitle(translate(u'Wizard', u'Please wait while your songs database is analyzed.')) self.update_review_counter_text() self.review_page.setSubTitle(translate(u'Wizard', u'Here you can decide which songs to remove and which ones to keep.')) From c3d16aa4b945370cc6cf8f658721837904e7c221 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 5 Mar 2013 23:25:25 +0100 Subject: [PATCH 59/95] Hide next button during search. --- openlp/plugins/songs/forms/duplicatesongremovalform.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index a4efedbe0..929d6d98a 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -148,6 +148,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): # Hide back button. self.button(QtGui.QWizard.BackButton).hide() if page_id == self.searching_page_id: + self.button(QtGui.QWizard.NextButton).hide() # Search duplicate songs. max_songs = self.plugin.manager.get_object_count(Song) if max_songs == 0 or max_songs == 1: @@ -171,6 +172,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.review_total_count = len(self.duplicate_song_list) if self.review_total_count == 0: self.notify_no_duplicates() + else: + self.button(QtGui.QWizard.NextButton).show() elif page_id == self.review_page_id: self.process_current_duplicate_entry() From 4831fb29fa13460e250c72e0d905fdff1486a114 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 26 Mar 2013 22:31:46 +0100 Subject: [PATCH 60/95] Translate verse tags. --- openlp/plugins/songs/forms/songreviewwidget.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/openlp/plugins/songs/forms/songreviewwidget.py b/openlp/plugins/songs/forms/songreviewwidget.py index ec7ff9f75..0557a35b5 100644 --- a/openlp/plugins/songs/forms/songreviewwidget.py +++ b/openlp/plugins/songs/forms/songreviewwidget.py @@ -32,6 +32,7 @@ A widget representing a song in the duplicate song removal wizard review page. from PyQt4 import QtCore, QtGui from openlp.core.lib import build_icon +from openlp.plugins.songs.lib import VerseType from openlp.plugins.songs.lib.xml import SongXML @@ -162,7 +163,19 @@ class SongReviewWidget(QtGui.QWidget): item = QtGui.QTableWidgetItem() item.setText(verse[1]) self.song_info_verse_list_widget.setItem(verse_number, 0, item) - song_tags.append(unicode(verse[0]['type'] + verse[0]['label'])) + + # We cannot use from_loose_input() here, because database + # is supposed to contain English lowercase singlechar tags. + verse_tag = verse[0][u'type'] + verse_index = None + if len(verse_tag) > 1: + verse_index = VerseType.from_translated_string(verse_tag) + if verse_index is None: + verse_index = VerseType.from_string(verse_tag, None) + if verse_index is None: + verse_index = VerseType.from_tag(verse_tag) + verse_tag = VerseType.translated_tags[verse_index].upper() + song_tags.append(unicode(verse_tag + verse[0]['label'])) self.song_info_verse_list_widget.setVerticalHeaderLabels(song_tags) # Resize table fields to content and table to columns self.song_info_verse_list_widget.setColumnWidth(0, self.song_group_box.width()) From 97c6899a31a278c42c71fdc964989c62c37f8bc5 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sat, 6 Apr 2013 13:53:00 +0200 Subject: [PATCH 61/95] Post merge fixups: Fix a typo in wizard.py, correct some camelCase -> under_score. --- openlp/core/ui/wizard.py | 4 ++-- .../songs/forms/duplicatesongremovalform.py | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/openlp/core/ui/wizard.py b/openlp/core/ui/wizard.py index 2685c63d6..c38304b8f 100644 --- a/openlp/core/ui/wizard.py +++ b/openlp/core/ui/wizard.py @@ -219,9 +219,9 @@ class OpenLPWizard(QtGui.QWizard): self.performWizard() self.post_wizard() else: - self.custom_cage_changed(pageId) + self.custom_page_changed(pageId) - def custom_cage_changed(self, pageId): + def custom_page_changed(self, pageId): """ Called when changing to a page other than the progress page """ diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 929d6d98a..0e8ef18b7 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -66,14 +66,14 @@ class DuplicateSongRemovalForm(OpenLPWizard): OpenLPWizard.__init__(self, self.main_window, plugin, u'duplicateSongRemovalWizard', u':/wizards/wizard_duplicateremoval.bmp', False) - def customSignals(self): + def custom_signals(self): """ Song wizard specific signals. """ - self.finishButton.clicked.connect(self.onWizardExit) - self.cancelButton.clicked.connect(self.onWizardExit) + self.finish_button.clicked.connect(self.on_wizard_exit) + self.cancel_button.clicked.connect(self.on_wizard_exit) - def addCustomPages(self): + def add_custom_pages(self): """ Add song wizard specific pages. """ @@ -119,9 +119,9 @@ class DuplicateSongRemovalForm(OpenLPWizard): Song wizard localisation. """ self.setWindowTitle(translate(u'Wizard', u'Wizard')) - self.titleLabel.setText(WizardStrings.HeaderStyle % translate(u'OpenLP.Ui', + self.title_label.setText(WizardStrings.HeaderStyle % translate(u'OpenLP.Ui', u'Welcome to the Duplicate Song Removal Wizard')) - self.informationLabel.setText(translate("Wizard", + self.information_label.setText(translate("Wizard", u'This wizard will help you to remove duplicate songs from the song database. You will have a chance to ' u'review every potential duplicate song before it is deleted. So no songs will be deleted without your ' u'explicit approval.')) @@ -138,7 +138,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.review_page.setTitle(translate(u'Wizard', u'Review duplicate songs (%s/%s)') % \ (self.review_current_count, self.review_total_count)) - def customPageChanged(self, page_id): + def custom_page_changed(self, page_id): """ Called when changing the wizard page. @@ -227,12 +227,12 @@ class DuplicateSongRemovalForm(OpenLPWizard): duplicate_added = True return duplicate_added - def onWizardExit(self): + def on_wizard_exit(self): """ Once the wizard is finished, refresh the song list, since we potentially removed songs from it. """ - self.plugin.mediaItem.onSearchTextButtonClicked() + self.plugin.media_item.on_search_text_button_clicked() def setDefaults(self): """ From 789439dd6b1ad7dbe62dc840a6dbe5c821bfcdcd Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 9 Apr 2013 23:32:19 +0200 Subject: [PATCH 62/95] Make wizard a little wider to fit at least two review widgets. --- .../songs/forms/duplicatesongremovalform.py | 45 ++++++++++--------- .../plugins/songs/forms/songreviewwidget.py | 2 +- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 0e8ef18b7..cf0f3922f 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -65,6 +65,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.review_total_count = 0 OpenLPWizard.__init__(self, self.main_window, plugin, u'duplicateSongRemovalWizard', u':/wizards/wizard_duplicateremoval.bmp', False) + self.setMinimumWidth(730) def custom_signals(self): """ @@ -96,18 +97,20 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.review_page.setObjectName(u'review_page') self.review_layout = QtGui.QVBoxLayout(self.review_page) self.review_layout.setObjectName(u'review_layout') - self.songs_horizontal_scroll_area = QtGui.QScrollArea(self.review_page) - self.songs_horizontal_scroll_area.setObjectName(u'songs_horizontal_scroll_area') - self.songs_horizontal_scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self.songs_horizontal_scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self.songs_horizontal_scroll_area.setWidgetResizable(True) - self.songs_horizontal_songs_widget = QtGui.QWidget(self.songs_horizontal_scroll_area) - self.songs_horizontal_songs_widget.setObjectName(u'songs_horizontal_songs_widget') - self.songs_horizontal_layout = QtGui.QHBoxLayout(self.songs_horizontal_songs_widget) - self.songs_horizontal_layout.setObjectName(u'songs_horizontal_layout') - self.songs_horizontal_layout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) - self.songs_horizontal_scroll_area.setWidget(self.songs_horizontal_songs_widget) - self.review_layout.addWidget(self.songs_horizontal_scroll_area) + self.review_scroll_area = QtGui.QScrollArea(self.review_page) + self.review_scroll_area.setObjectName(u'review_scroll_area') + self.review_scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.review_scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.review_scroll_area.setWidgetResizable(True) + self.review_scroll_area_widget = QtGui.QWidget(self.review_scroll_area) + self.review_scroll_area_widget.setObjectName(u'review_scroll_area_widget') + self.review_scroll_area_layout = QtGui.QHBoxLayout(self.review_scroll_area_widget) + self.review_scroll_area_layout.setObjectName(u'review_scroll_area_layout') + self.review_scroll_area_layout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize) + self.review_scroll_area_layout.setMargin(0) + self.review_scroll_area_layout.setSpacing(0) + self.review_scroll_area.setWidget(self.review_scroll_area_widget) + self.review_layout.addWidget(self.review_scroll_area) self.review_page_id = self.addPage(self.review_page) # Add a dummy page to the end, to prevent the finish button to appear and the next button do disappear on the #review page. @@ -286,13 +289,13 @@ class DuplicateSongRemovalForm(OpenLPWizard): log.exception(u'Could not remove directory: %s', save_path) self.plugin.manager.delete_object(Song, item_id) # Remove GUI elements for the song. - self.songs_horizontal_layout.removeWidget(song_review_widget) + self.review_scroll_area_layout.removeWidget(song_review_widget) song_review_widget.setParent(None) # Check if we only have one duplicate left: # 2 stretches + 1 SongReviewWidget = 3 # The SongReviewWidget is then at position 1. if len(self.duplicate_song_list[-1]) == 1: - self.songs_horizontal_layout.itemAt(1).widget().song_remove_button.setEnabled(False) + self.review_scroll_area_layout.itemAt(1).widget().song_remove_button.setEnabled(False) def proceed_to_next_review(self): """ @@ -301,16 +304,16 @@ class DuplicateSongRemovalForm(OpenLPWizard): # Remove last duplicate group. self.duplicate_song_list.pop() # Remove all previous elements. - for i in reversed(range(self.songs_horizontal_layout.count())): - item = self.songs_horizontal_layout.itemAt(i) + for i in reversed(range(self.review_scroll_area_layout.count())): + item = self.review_scroll_area_layout.itemAt(i) if isinstance(item, QtGui.QWidgetItem): # The order is important here, if the .setParent(None) call is done # before the .removeItem() call, a segfault occurs. widget = item.widget() - self.songs_horizontal_layout.removeItem(item) + self.review_scroll_area_layout.removeItem(item) widget.setParent(None) else: - self.songs_horizontal_layout.removeItem(item) + self.review_scroll_area_layout.removeItem(item) # Process next set of duplicates. self.process_current_duplicate_entry() @@ -325,12 +328,12 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.update_review_counter_text() # Add song elements to the UI. if len(self.duplicate_song_list) > 0: - self.songs_horizontal_layout.addStretch(1) + self.review_scroll_area_layout.addStretch(1) for duplicate in self.duplicate_song_list[-1]: song_review_widget = SongReviewWidget(self.review_page, duplicate) song_review_widget.song_remove_button_clicked.connect(self.remove_button_clicked) - self.songs_horizontal_layout.addWidget(song_review_widget) - self.songs_horizontal_layout.addStretch(1) + self.review_scroll_area_layout.addWidget(song_review_widget) + self.review_scroll_area_layout.addStretch(1) # Change next button to finish button on last review. if len(self.duplicate_song_list) == 1: self.button(QtGui.QWizard.FinishButton).show() diff --git a/openlp/plugins/songs/forms/songreviewwidget.py b/openlp/plugins/songs/forms/songreviewwidget.py index 0557a35b5..7b24563f9 100644 --- a/openlp/plugins/songs/forms/songreviewwidget.py +++ b/openlp/plugins/songs/forms/songreviewwidget.py @@ -75,7 +75,7 @@ class SongReviewWidget(QtGui.QWidget): self.song_vertical_layout.setObjectName(u'song_vertical_layout') self.song_group_box = QtGui.QGroupBox(self) self.song_group_box.setObjectName(u'song_group_box') - self.song_group_box.setFixedWidth(400) + self.song_group_box.setFixedWidth(300) self.song_group_box_layout = QtGui.QVBoxLayout(self.song_group_box) self.song_group_box_layout.setObjectName(u'song_group_box_layout') self.song_info_form_layout = QtGui.QFormLayout() From 9220beba786334f13eeaa0336f0806b93fe82ea8 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sun, 14 Apr 2013 23:38:40 +0200 Subject: [PATCH 63/95] Pull preview_list_widget out into separate file. Not yet changed calls to this new class. --- openlp/core/ui/listpreviewwidget.py | 174 ++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 openlp/core/ui/listpreviewwidget.py diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py new file mode 100644 index 000000000..44735c58a --- /dev/null +++ b/openlp/core/ui/listpreviewwidget.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2013 Raoul Snyman # +# Portions copyright (c) 2008-2013 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:`slidecontroller` module contains the most important part of OpenLP - the slide controller +""" + +from PyQt4 import QtCore, QtGui + +from openlp.core.lib import ImageSource, Registry, ServiceItem + +class ListPreviewWidget(object): + def __init__(self, parent, is_live): + # Controller list view + self.is_live = is_live + self.preview_table_widget = QtGui.QTableWidget(parent) + self.preview_table_widget.setColumnCount(1) + self.preview_table_widget.horizontalHeader().setVisible(False) + self.preview_table_widget.setColumnWidth(0, parent.width()) + self.preview_table_widget.setObjectName(u'preview_table_widget') + self.preview_table_widget.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) + self.preview_table_widget.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) + self.preview_table_widget.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) + self.preview_table_widget.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.preview_table_widget.setAlternatingRowColors(True) + self.service_item = ServiceItem() + self.clicked = QtCore.pyqtSignal() + self.double_clicked = QtCore.pyqtSignal() + if not self.is_live: + self.preview_table_widget.doubleClicked.connect(self.double_clicked) + self.preview_table_widget.clicked.connect(self.clicked) + + def clicked(self): + self.clicked.emit() + + def double_clicked(self): + self.double_clicked.emit() + + def get_preview_widget(self): + return self.preview_table_widget + + def set_active(self, active): + if active: + self.preview_table_widget.show() + else: + self.preview_table_widget.hide() + + def replace_service_manager_item(self, service_item, width): + self.service_item = service_item + self._refresh(width) + + def preview_size_changed(self, width, ratio): + """ + Takes care of the SlidePreview's size. Is called when one of the the + splitters is moved or when the screen size is changed. Note, that this + method is (also) called frequently from the mainwindow *paintEvent*. + """ + self.preview_table_widget.setColumnWidth(0, self.preview_table_widget.viewport().size().width()) + if self.service_item: + # Sort out songs, bibles, etc. + if self.service_item.is_text(): + self.preview_table_widget.resizeRowsToContents() + else: + # Sort out image heights. + for framenumber in range(len(self.service_item.get_frames())): + self.preview_table_widget.setRowHeight(framenumber, width / ratio) + + def _refresh(self, width, ratio): + """ + Loads a ServiceItem into the system from ServiceManager + Display the slide number passed + """ + self.preview_table_widget.clear() + self.preview_table_widget.setRowCount(0) + self.preview_table_widget.setColumnWidth(0, width) + row = 0 + text = [] + for framenumber, frame in enumerate(self.service_item.get_frames()): + self.preview_table_widget.setRowCount(self.preview_table_widget.rowCount() + 1) + item = QtGui.QTableWidgetItem() + slideHeight = 0 + if self.service_item.is_text(): + if frame[u'verseTag']: + # These tags are already translated. + verse_def = frame[u'verseTag'] + verse_def = u'%s%s' % (verse_def[0], verse_def[1:]) + two_line_def = u'%s\n%s' % (verse_def[0], verse_def[1:]) + row = two_line_def + else: + row += 1 + item.setText(frame[u'text']) + else: + label = QtGui.QLabel() + label.setMargin(4) + if self.service_item.is_media(): + label.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) + else: + label.setScaledContents(True) + self.preview_table_widget.setCellWidget(framenumber, 0, label) + slideHeight = width / ratio + row += 1 + text.append(unicode(row)) + self.preview_table_widget.setItem(framenumber, 0, item) + if slideHeight: + self.preview_table_widget.setRowHeight(framenumber, slideHeight) + self.preview_table_widget.setVerticalHeaderLabels(text) + if self.service_item.is_text(): + self.preview_table_widget.resizeRowsToContents() + self.preview_table_widget.setColumnWidth(0, self.preview_table_widget.viewport().size().width()) + #stuff happens here, perhaps the setFocus has to happen later... + self.preview_table_widget.setFocus() + + def __updatePreviewSelection(self, slideno): + """ + Utility method to update the selected slide in the list. + """ + if slideno > self.preview_table_widget.rowCount(): + self.preview_table_widget.selectRow( + self.preview_table_widget.rowCount() - 1) + else: + self.__checkUpdateSelectedSlide(slideno) + + def __checkUpdateSelectedSlide(self, row): + """ + Check if this slide has been updated + """ + if row + 1 < self.preview_table_widget.rowCount(): + self.preview_table_widget.scrollToItem(self.preview_table_widget.item(row + 1, 0)) + self.preview_table_widget.selectRow(row) + + def _get_image_manager(self): + """ + Adds the image manager to the class dynamically + """ + if not hasattr(self, u'_image_manager'): + self._image_manager = Registry().get(u'image_manager') + return self._image_manager + + image_manager = property(_get_image_manager) + + def _get_main_window(self): + """ + Adds the main window to the class dynamically + """ + if not hasattr(self, u'_main_window'): + self._main_window = Registry().get(u'main_window') + return self._main_window + + main_window = property(_get_main_window) \ No newline at end of file From 379a27212cb177b245c6d0fcbb45d2bd6481796c Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 15 Apr 2013 18:33:50 +0200 Subject: [PATCH 64/95] Clean up preview widget interface a little. --- openlp/core/ui/listpreviewwidget.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py index 44735c58a..6f26d390b 100644 --- a/openlp/core/ui/listpreviewwidget.py +++ b/openlp/core/ui/listpreviewwidget.py @@ -70,10 +70,6 @@ class ListPreviewWidget(object): else: self.preview_table_widget.hide() - def replace_service_manager_item(self, service_item, width): - self.service_item = service_item - self._refresh(width) - def preview_size_changed(self, width, ratio): """ Takes care of the SlidePreview's size. Is called when one of the the @@ -90,11 +86,12 @@ class ListPreviewWidget(object): for framenumber in range(len(self.service_item.get_frames())): self.preview_table_widget.setRowHeight(framenumber, width / ratio) - def _refresh(self, width, ratio): + def replace_service_manager_item(self, service_item, width, ratio): """ Loads a ServiceItem into the system from ServiceManager Display the slide number passed """ + self.service_item = service_item self.preview_table_widget.clear() self.preview_table_widget.setRowCount(0) self.preview_table_widget.setColumnWidth(0, width) @@ -132,10 +129,10 @@ class ListPreviewWidget(object): if self.service_item.is_text(): self.preview_table_widget.resizeRowsToContents() self.preview_table_widget.setColumnWidth(0, self.preview_table_widget.viewport().size().width()) - #stuff happens here, perhaps the setFocus has to happen later... + #stuff happens here, perhaps the setFocus() has to happen later... self.preview_table_widget.setFocus() - def __updatePreviewSelection(self, slideno): + def update_preview_selection(self, slideno): """ Utility method to update the selected slide in the list. """ @@ -143,9 +140,9 @@ class ListPreviewWidget(object): self.preview_table_widget.selectRow( self.preview_table_widget.rowCount() - 1) else: - self.__checkUpdateSelectedSlide(slideno) + self.check_update_selected_slide(slideno) - def __checkUpdateSelectedSlide(self, row): + def check_update_selected_slide(self, row): """ Check if this slide has been updated """ From 3609d2e0e19b28bb23d4f35f6cca2f2b11d312cc Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 15 Apr 2013 19:12:47 +0200 Subject: [PATCH 65/95] Switch over to PreviewListWidget class. Untested. --- openlp/core/ui/__init__.py | 3 +- openlp/core/ui/listpreviewwidget.py | 9 +- openlp/core/ui/slidecontroller.py | 133 +++++++--------------------- 3 files changed, 43 insertions(+), 102 deletions(-) diff --git a/openlp/core/ui/__init__.py b/openlp/core/ui/__init__.py index 49e59e4c1..77993c443 100644 --- a/openlp/core/ui/__init__.py +++ b/openlp/core/ui/__init__.py @@ -99,9 +99,10 @@ from shortcutlistform import ShortcutListForm from mediadockmanager import MediaDockManager from servicemanager import ServiceManager from thememanager import ThemeManager +from listpreviewwidget import ListPreviewWidget __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'] + 'FormattingTagForm', 'ShortcutListForm', 'ListPreviewWidget'] diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py index 6f26d390b..178f748a7 100644 --- a/openlp/core/ui/listpreviewwidget.py +++ b/openlp/core/ui/listpreviewwidget.py @@ -32,7 +32,8 @@ The :mod:`slidecontroller` module contains the most important part of OpenLP - t from PyQt4 import QtCore, QtGui -from openlp.core.lib import ImageSource, Registry, ServiceItem +from openlp.core.lib import Registry, ServiceItem + class ListPreviewWidget(object): def __init__(self, parent, is_live): @@ -150,6 +151,12 @@ class ListPreviewWidget(object): self.preview_table_widget.scrollToItem(self.preview_table_widget.item(row + 1, 0)) self.preview_table_widget.selectRow(row) + def currentRow(self): + return self.preview_table_widget.currentRow() + + def rowCount(self): + return self.preview_table_widget.rowCount() + def _get_image_manager(self): """ Adds the image manager to the class dynamically diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index eaeebfba8..183e0a745 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -38,7 +38,7 @@ from PyQt4 import QtCore, QtGui from openlp.core.lib import OpenLPToolbar, ItemCapabilities, ServiceItem, ImageSource, SlideLimits, \ ServiceItemAction, Settings, Registry, UiStrings, ScreenList, build_icon, build_html, translate -from openlp.core.ui import HideMode, MainDisplay, Display, DisplayControllerType +from openlp.core.ui import HideMode, MainDisplay, Display, DisplayControllerType, ListPreviewWidget from openlp.core.lib.ui import create_action from openlp.core.utils.actions import ActionList, CategoryOrder @@ -157,18 +157,8 @@ class SlideController(DisplayController): self.controller_layout.setSpacing(0) self.controller_layout.setMargin(0) # Controller list view - self.preview_list_widget = QtGui.QTableWidget(self.controller) - self.preview_list_widget.setColumnCount(1) - self.preview_list_widget.horizontalHeader().setVisible(False) - self.preview_list_widget.setColumnWidth(0, self.controller.width()) - self.preview_list_widget.is_live = self.is_live - self.preview_list_widget.setObjectName(u'preview_list_widget') - self.preview_list_widget.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) - self.preview_list_widget.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) - self.preview_list_widget.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) - self.preview_list_widget.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - self.preview_list_widget.setAlternatingRowColors(True) - self.controller_layout.addWidget(self.preview_list_widget) + self.preview_widget = ListPreviewWidget(self, self.is_live) + self.controller_layout.addWidget(self.preview_widget.get_preview_widget()) # Build the full toolbar self.toolbar = OpenLPToolbar(self) size_toolbar_policy = QtGui.QSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) @@ -350,7 +340,7 @@ class SlideController(DisplayController): {u'key': u'O', u'configurable': True, u'text': translate('OpenLP.SlideController', 'Go to "Other"')} ] shortcuts.extend([{u'key': unicode(number)} for number in range(10)]) - self.preview_list_widget.addActions([create_action(self, + self.controller.addActions([create_action(self, u'shortcutAction_%s' % s[u'key'], text=s.get(u'text'), can_shortcuts=True, context=QtCore.Qt.WidgetWithChildrenShortcut, @@ -358,20 +348,20 @@ class SlideController(DisplayController): triggers=self._slideShortcutActivated) for s in shortcuts]) self.shortcutTimer.timeout.connect(self._slideShortcutActivated) # Signals - self.preview_list_widget.clicked.connect(self.onSlideSelected) + self.preview_widget.clicked.connect(self.onSlideSelected) if self.is_live: Registry().register_function(u'slidecontroller_live_spin_delay', self.receive_spin_delay) Registry().register_function(u'slidecontroller_toggle_display', self.toggle_display) self.toolbar.set_widget_visible(self.loop_list, False) self.toolbar.set_widget_visible(self.wide_menu, False) else: - self.preview_list_widget.doubleClicked.connect(self.onGoLiveClick) + self.preview_widget.double_clicked.connect(self.onGoLiveClick) self.toolbar.set_widget_visible([u'editSong'], False) if self.is_live: self.setLiveHotkeys(self) - self.__addActionsToWidget(self.preview_list_widget) + self.__addActionsToWidget(self.controller) else: - self.preview_list_widget.addActions([self.nextItem, self.previous_item]) + self.controller.addActions([self.nextItem, self.previous_item]) Registry().register_function(u'slidecontroller_%s_stop_loop' % self.type_prefix, self.on_stop_loop) Registry().register_function(u'slidecontroller_%s_next' % self.type_prefix, self.on_slide_selected_next) Registry().register_function(u'slidecontroller_%s_previous' % self.type_prefix, self.on_slide_selected_previous) @@ -427,7 +417,7 @@ class SlideController(DisplayController): if len(matches) == 1: self.shortcutTimer.stop() self.current_shortcut = u'' - self.__checkUpdateSelectedSlide(self.slideList[matches[0]]) + self.preview_widget.check_update_selected_slide(self.slideList[matches[0]]) self.slideSelected() elif sender_name != u'shortcutTimer': # Start the time as we did not have any match. @@ -437,7 +427,7 @@ class SlideController(DisplayController): if self.current_shortcut in keys: # We had more than one match for example "V1" and "V10", but # "V1" was the slide we wanted to go. - self.__checkUpdateSelectedSlide(self.slideList[self.current_shortcut]) + self.preview_widget.check_update_selected_slide(self.slideList[self.current_shortcut]) self.slideSelected() # Reset the shortcut. self.current_shortcut = u'' @@ -571,16 +561,8 @@ class SlideController(DisplayController): self.preview_display.screen = { u'size': self.preview_display.geometry()} # Make sure that the frames have the correct size. - self.preview_list_widget.setColumnWidth(0, self.preview_list_widget.viewport().size().width()) - if self.service_item: - # Sort out songs, bibles, etc. - if self.service_item.is_text(): - self.preview_list_widget.resizeRowsToContents() - else: - # Sort out image heights. - width = self.main_window.controlSplitter.sizes()[self.split] - for framenumber in range(len(self.service_item.get_frames())): - self.preview_list_widget.setRowHeight(framenumber, width / self.ratio) + width = self.main_window.controlSplitter.sizes()[self.split] + self.preview_widget.preview_size_changed(width, self.ratio) self.onControllerSizeChanged(self.controller.width()) def onControllerSizeChanged(self, width): @@ -706,7 +688,7 @@ class SlideController(DisplayController): Replacement item following a remote edit """ if item == self.service_item: - self._processItem(item, self.preview_list_widget.currentRow()) + self._processItem(item, self.preview_widget.currentRow()) def addServiceManagerItem(self, item, slideno): """ @@ -722,7 +704,7 @@ class SlideController(DisplayController): slidenum = 0 # If service item is the same as the current one, only change slide if slideno >= 0 and item == self.service_item: - self.__checkUpdateSelectedSlide(slidenum) + self.preview_widget.check_update_selected_slide(slidenum) self.slideSelected() else: self._processItem(item, slidenum) @@ -749,10 +731,6 @@ class SlideController(DisplayController): self._resetBlank() Registry().execute(u'%s_start' % service_item.name.lower(), [service_item, self.is_live, self.hide_mode(), slideno]) self.slideList = {} - width = self.main_window.controlSplitter.sizes()[self.split] - self.preview_list_widget.clear() - self.preview_list_widget.setRowCount(0) - self.preview_list_widget.setColumnWidth(0, width) if self.is_live: self.song_menu.menu().clear() self.display.audio_player.reset() @@ -777,9 +755,8 @@ class SlideController(DisplayController): self.setAudioItemsVisibility(True) row = 0 text = [] + width = self.main_window.controlSplitter.sizes()[self.split] for framenumber, frame in enumerate(self.service_item.get_frames()): - self.preview_list_widget.setRowCount(self.preview_list_widget.rowCount() + 1) - item = QtGui.QTableWidgetItem() slideHeight = 0 if self.service_item.is_text(): if frame[u'verseTag']: @@ -795,37 +772,12 @@ class SlideController(DisplayController): else: row += 1 self.slideList[unicode(row)] = row - 1 - item.setText(frame[u'text']) else: - label = QtGui.QLabel() - label.setMargin(4) - if service_item.is_media(): - label.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) - else: - label.setScaledContents(True) - if self.service_item.is_command(): - label.setPixmap(QtGui.QPixmap(frame[u'image'])) - else: - # If current slide set background to image - if framenumber == slideno: - self.service_item.bg_image_bytes = self.image_manager.get_image_bytes(frame[u'path'], - ImageSource.ImagePlugin) - image = self.image_manager.get_image(frame[u'path'], ImageSource.ImagePlugin) - label.setPixmap(QtGui.QPixmap.fromImage(image)) - self.preview_list_widget.setCellWidget(framenumber, 0, label) slideHeight = width * (1 / self.ratio) row += 1 self.slideList[unicode(row)] = row - 1 - text.append(unicode(row)) - self.preview_list_widget.setItem(framenumber, 0, item) - if slideHeight: - self.preview_list_widget.setRowHeight(framenumber, slideHeight) - self.preview_list_widget.setVerticalHeaderLabels(text) - if self.service_item.is_text(): - self.preview_list_widget.resizeRowsToContents() - self.preview_list_widget.setColumnWidth(0, - self.preview_list_widget.viewport().size().width()) - self.__updatePreviewSelection(slideno) + self.preview_widget.update_preview_selection(slideno) + self.preview_widget.replace_service_manager_item(self.service_item, width, self.ratio) self.enableToolBar(service_item) # Pass to display for viewing. # Postpone image build, we need to do this later to avoid the theme @@ -835,7 +787,6 @@ class SlideController(DisplayController): if service_item.is_media(): self.onMediaStart(service_item) self.slideSelected(True) - self.preview_list_widget.setFocus() if old_item: # Close the old item after the new one is opened # This avoids the service theme/desktop flashing on screen @@ -847,16 +798,6 @@ class SlideController(DisplayController): self.onMediaClose() Registry().execute(u'slidecontroller_%s_started' % self.type_prefix, [service_item]) - def __updatePreviewSelection(self, slideno): - """ - Utility method to update the selected slide in the list. - """ - if slideno > self.preview_list_widget.rowCount(): - self.preview_list_widget.selectRow( - self.preview_list_widget.rowCount() - 1) - else: - self.__checkUpdateSelectedSlide(slideno) - # Screen event methods def on_slide_selected_index(self, message): """ @@ -869,7 +810,7 @@ class SlideController(DisplayController): Registry().execute(u'%s_slide' % self.service_item.name.lower(), [self.service_item, self.is_live, index]) self.updatePreview() else: - self.__checkUpdateSelectedSlide(index) + self.preview_widget.check_update_selected_slide(index) self.slideSelected() def mainDisplaySetBackground(self): @@ -1012,9 +953,9 @@ class SlideController(DisplayController): Generate the preview when you click on a slide. if this is the Live Controller also display on the screen """ - row = self.preview_list_widget.currentRow() + row = self.preview_widget.currentRow() self.selected_row = 0 - if -1 < row < self.preview_list_widget.rowCount(): + if -1 < row < self.preview_widget.rowCount(): if self.service_item.is_command(): if self.is_live and not start: Registry().execute(u'%s_slide' % self.service_item.name.lower(), @@ -1032,7 +973,7 @@ class SlideController(DisplayController): self.service_item.bg_image_bytes = None self.updatePreview() self.selected_row = row - self.__checkUpdateSelectedSlide(row) + self.preview_widget.check_update_selected_slide(row) Registry().execute(u'slidecontroller_%s_changed' % self.type_prefix, row) self.display.setFocus() @@ -1040,7 +981,7 @@ class SlideController(DisplayController): """ The slide has been changed. Update the slidecontroller accordingly """ - self.__checkUpdateSelectedSlide(row) + self.preview_widget.check_update_selected_slide(row) self.updatePreview() Registry().execute(u'slidecontroller_%s_changed' % self.type_prefix, row) @@ -1085,8 +1026,8 @@ class SlideController(DisplayController): if self.service_item.is_command() and self.is_live: self.updatePreview() else: - row = self.preview_list_widget.currentRow() + 1 - if row == self.preview_list_widget.rowCount(): + row = self.preview_widget.currentRow() + 1 + if row == self.preview_widget.rowCount(): if wrap is None: if self.slide_limits == SlideLimits.Wrap: row = 0 @@ -1094,12 +1035,12 @@ class SlideController(DisplayController): self.serviceNext() return else: - row = self.preview_list_widget.rowCount() - 1 + row = self.preview_widget.rowCount() - 1 elif wrap: row = 0 else: - row = self.preview_list_widget.rowCount() - 1 - self.__checkUpdateSelectedSlide(row) + row = self.preview_widget.rowCount() - 1 + self.preview_widget.check_update_selected_slide(row) self.slideSelected() def on_slide_selected_previous(self): @@ -1112,27 +1053,19 @@ class SlideController(DisplayController): if self.service_item.is_command() and self.is_live: self.updatePreview() else: - row = self.preview_list_widget.currentRow() - 1 + row = self.preview_widget.currentRow() - 1 if row == -1: if self.slide_limits == SlideLimits.Wrap: - row = self.preview_list_widget.rowCount() - 1 + row = self.preview_widget.rowCount() - 1 elif self.is_live and self.slide_limits == SlideLimits.Next: self.keypress_queue.append(ServiceItemAction.PreviousLastSlide) self._process_queue() return else: row = 0 - self.__checkUpdateSelectedSlide(row) + self.preview_widget.check_update_selected_slide(row) self.slideSelected() - def __checkUpdateSelectedSlide(self, row): - """ - Check if this slide has been updated - """ - if row + 1 < self.preview_list_widget.rowCount(): - self.preview_list_widget.scrollToItem(self.preview_list_widget.item(row + 1, 0)) - self.preview_list_widget.selectRow(row) - def onToggleLoop(self): """ Toggles the loop state. @@ -1147,7 +1080,7 @@ class SlideController(DisplayController): """ Start the timer loop running and store the timer id """ - if self.preview_list_widget.rowCount() > 1: + if self.preview_widget.rowCount() > 1: self.timer_id = self.startTimer(int(self.delay_spin_box.value()) * 1000) def on_stop_loop(self): @@ -1257,8 +1190,8 @@ class SlideController(DisplayController): """ If preview copy slide item to live controller from Preview Controller """ - row = self.preview_list_widget.currentRow() - if -1 < row < self.preview_list_widget.rowCount(): + row = self.preview_widget.currentRow() + if -1 < row < self.preview_widget.rowCount(): if self.service_item.from_service: self.service_manager.preview_live(self.service_item.unique_identifier, row) else: From 0dbda9ee21514ecf7c0c9f60f7641a8c5357d54a Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 15 Apr 2013 19:26:18 +0200 Subject: [PATCH 66/95] Accidentially removed image load logic. Readded. --- openlp/core/ui/listpreviewwidget.py | 16 +++++++++++++--- openlp/core/ui/slidecontroller.py | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py index 178f748a7..d455a7477 100644 --- a/openlp/core/ui/listpreviewwidget.py +++ b/openlp/core/ui/listpreviewwidget.py @@ -32,7 +32,7 @@ The :mod:`slidecontroller` module contains the most important part of OpenLP - t from PyQt4 import QtCore, QtGui -from openlp.core.lib import Registry, ServiceItem +from openlp.core.lib import ImageSource, Registry, ServiceItem class ListPreviewWidget(object): @@ -87,7 +87,7 @@ class ListPreviewWidget(object): for framenumber in range(len(self.service_item.get_frames())): self.preview_table_widget.setRowHeight(framenumber, width / ratio) - def replace_service_manager_item(self, service_item, width, ratio): + def replace_service_manager_item(self, service_item, width, ratio, slideno): """ Loads a ServiceItem into the system from ServiceManager Display the slide number passed @@ -119,6 +119,15 @@ class ListPreviewWidget(object): label.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) else: label.setScaledContents(True) + if self.service_item.is_command(): + label.setPixmap(QtGui.QPixmap(frame[u'image'])) + else: + # If current slide set background to image + if framenumber == slideno: + self.service_item.bg_image_bytes = self.image_manager.get_image_bytes(frame[u'path'], + ImageSource.ImagePlugin) + image = self.image_manager.get_image(frame[u'path'], ImageSource.ImagePlugin) + label.setPixmap(QtGui.QPixmap.fromImage(image)) self.preview_table_widget.setCellWidget(framenumber, 0, label) slideHeight = width / ratio row += 1 @@ -175,4 +184,5 @@ class ListPreviewWidget(object): self._main_window = Registry().get(u'main_window') return self._main_window - main_window = property(_get_main_window) \ No newline at end of file + main_window = property(_get_main_window) + diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index 183e0a745..15ba53b2f 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -36,7 +36,7 @@ from collections import deque from PyQt4 import QtCore, QtGui -from openlp.core.lib import OpenLPToolbar, ItemCapabilities, ServiceItem, ImageSource, SlideLimits, \ +from openlp.core.lib import OpenLPToolbar, ItemCapabilities, ServiceItem, SlideLimits, \ ServiceItemAction, Settings, Registry, UiStrings, ScreenList, build_icon, build_html, translate from openlp.core.ui import HideMode, MainDisplay, Display, DisplayControllerType, ListPreviewWidget from openlp.core.lib.ui import create_action From fd22d17d371069e2a690131d787220c1b97f9dbc Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 15 Apr 2013 20:01:59 +0200 Subject: [PATCH 67/95] Fix an import problem and use signals correctly. Worksbzr diff --- openlp/core/ui/__init__.py | 3 +-- openlp/core/ui/listpreviewwidget.py | 17 +++++++++-------- openlp/core/ui/slidecontroller.py | 5 +++-- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/openlp/core/ui/__init__.py b/openlp/core/ui/__init__.py index 77993c443..49e59e4c1 100644 --- a/openlp/core/ui/__init__.py +++ b/openlp/core/ui/__init__.py @@ -99,10 +99,9 @@ from shortcutlistform import ShortcutListForm from mediadockmanager import MediaDockManager from servicemanager import ServiceManager from thememanager import ThemeManager -from listpreviewwidget import ListPreviewWidget __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', 'ListPreviewWidget'] + 'FormattingTagForm', 'ShortcutListForm'] diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py index d455a7477..d1639b742 100644 --- a/openlp/core/ui/listpreviewwidget.py +++ b/openlp/core/ui/listpreviewwidget.py @@ -35,9 +35,12 @@ from PyQt4 import QtCore, QtGui from openlp.core.lib import ImageSource, Registry, ServiceItem -class ListPreviewWidget(object): +class ListPreviewWidget(QtCore.QObject): + clicked = QtCore.pyqtSignal() + double_clicked = QtCore.pyqtSignal() + def __init__(self, parent, is_live): - # Controller list view + super(QtCore.QObject, self).__init__() self.is_live = is_live self.preview_table_widget = QtGui.QTableWidget(parent) self.preview_table_widget.setColumnCount(1) @@ -50,16 +53,14 @@ class ListPreviewWidget(object): self.preview_table_widget.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.preview_table_widget.setAlternatingRowColors(True) self.service_item = ServiceItem() - self.clicked = QtCore.pyqtSignal() - self.double_clicked = QtCore.pyqtSignal() if not self.is_live: - self.preview_table_widget.doubleClicked.connect(self.double_clicked) - self.preview_table_widget.clicked.connect(self.clicked) + self.preview_table_widget.doubleClicked.connect(self._double_clicked) + self.preview_table_widget.clicked.connect(self._clicked) - def clicked(self): + def _clicked(self): self.clicked.emit() - def double_clicked(self): + def _double_clicked(self): self.double_clicked.emit() def get_preview_widget(self): diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index 15ba53b2f..8492b69bb 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -38,9 +38,10 @@ from PyQt4 import QtCore, QtGui from openlp.core.lib import OpenLPToolbar, ItemCapabilities, ServiceItem, SlideLimits, \ ServiceItemAction, Settings, Registry, UiStrings, ScreenList, build_icon, build_html, translate -from openlp.core.ui import HideMode, MainDisplay, Display, DisplayControllerType, ListPreviewWidget +from openlp.core.ui import HideMode, MainDisplay, Display, DisplayControllerType from openlp.core.lib.ui import create_action from openlp.core.utils.actions import ActionList, CategoryOrder +from openlp.core.ui.listpreviewwidget import ListPreviewWidget log = logging.getLogger(__name__) @@ -777,7 +778,7 @@ class SlideController(DisplayController): row += 1 self.slideList[unicode(row)] = row - 1 self.preview_widget.update_preview_selection(slideno) - self.preview_widget.replace_service_manager_item(self.service_item, width, self.ratio) + self.preview_widget.replace_service_manager_item(self.service_item, width, self.ratio, slideno) self.enableToolBar(service_item) # Pass to display for viewing. # Postpone image build, we need to do this later to avoid the theme From e4d3d8d6d2d9444dd85d49c5a2df713f1f773ec9 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 16 Apr 2013 19:33:36 +0200 Subject: [PATCH 68/95] Rename a slideno -> row for more consistency. --- openlp/core/ui/listpreviewwidget.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py index d1639b742..3e9bc065c 100644 --- a/openlp/core/ui/listpreviewwidget.py +++ b/openlp/core/ui/listpreviewwidget.py @@ -143,15 +143,14 @@ class ListPreviewWidget(QtCore.QObject): #stuff happens here, perhaps the setFocus() has to happen later... self.preview_table_widget.setFocus() - def update_preview_selection(self, slideno): + def update_preview_selection(self, row): """ Utility method to update the selected slide in the list. """ - if slideno > self.preview_table_widget.rowCount(): - self.preview_table_widget.selectRow( - self.preview_table_widget.rowCount() - 1) + if row > self.preview_table_widget.rowCount(): + self.preview_table_widget.selectRow(self.preview_table_widget.rowCount() - 1) else: - self.check_update_selected_slide(slideno) + self.check_update_selected_slide(row) def check_update_selected_slide(self, row): """ From 1bbf52c7bfe9b537feb27ae6d7e055c2d3e0b225 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 16 Apr 2013 19:34:07 +0200 Subject: [PATCH 69/95] Fix a logic error, an array index border was wrong. --- openlp/core/ui/listpreviewwidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py index 3e9bc065c..fb94555fd 100644 --- a/openlp/core/ui/listpreviewwidget.py +++ b/openlp/core/ui/listpreviewwidget.py @@ -147,7 +147,7 @@ class ListPreviewWidget(QtCore.QObject): """ Utility method to update the selected slide in the list. """ - if row > self.preview_table_widget.rowCount(): + if row >= self.preview_table_widget.rowCount(): self.preview_table_widget.selectRow(self.preview_table_widget.rowCount() - 1) else: self.check_update_selected_slide(row) From 01a0ad3618916401aa3d581492a12e4aaebac0a1 Mon Sep 17 00:00:00 2001 From: Andreas Preikschat Date: Tue, 16 Apr 2013 19:48:42 +0200 Subject: [PATCH 70/95] added whitespace to avoid conflict --- openlp/core/utils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlp/core/utils/__init__.py b/openlp/core/utils/__init__.py index f1c150989..20163ae64 100644 --- a/openlp/core/utils/__init__.py +++ b/openlp/core/utils/__init__.py @@ -386,7 +386,7 @@ def get_natural_key(string): # Python 3 does not support comparision of different types anymore. So make sure, that we do not compare str and int. #if string[0].isdigit(): # return [''] + key - return key + return key from applocation import AppLocation From 1b447c45904ad5e731e500e6b81c3d63d21581c6 Mon Sep 17 00:00:00 2001 From: Andreas Preikschat Date: Tue, 16 Apr 2013 19:54:40 +0200 Subject: [PATCH 71/95] reoved whitespace --- openlp/core/utils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlp/core/utils/__init__.py b/openlp/core/utils/__init__.py index 20163ae64..f1c150989 100644 --- a/openlp/core/utils/__init__.py +++ b/openlp/core/utils/__init__.py @@ -386,7 +386,7 @@ def get_natural_key(string): # Python 3 does not support comparision of different types anymore. So make sure, that we do not compare str and int. #if string[0].isdigit(): # return [''] + key - return key + return key from applocation import AppLocation From 1e8ad4ab7e9f628be8e58610d43fc11c77b6da41 Mon Sep 17 00:00:00 2001 From: Andreas Preikschat Date: Tue, 16 Apr 2013 19:56:06 +0200 Subject: [PATCH 72/95] fixed setup.py --- openlp/.version | 2 +- setup.py | 51 +++++++++++++++++++++++++++---------------------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/openlp/.version b/openlp/.version index d41002840..b6e0556c1 100644 --- a/openlp/.version +++ b/openlp/.version @@ -1 +1 @@ -2.1.0-bzr2141 +2.0.1-bzr2233 \ No newline at end of file diff --git a/setup.py b/setup.py index a96259380..3bd3007b7 100755 --- a/setup.py +++ b/setup.py @@ -27,12 +27,15 @@ # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### -from setuptools import setup, find_packages import re +from setuptools import setup, find_packages +from subprocess import Popen, PIPE + VERSION_FILE = 'openlp/.version' SPLIT_ALPHA_DIGITS = re.compile(r'(\d+|\D+)') + def try_int(s): """ Convert string s to an integer if possible. Fail silently and return @@ -46,6 +49,7 @@ def try_int(s): except Exception: return s + def natural_sort_key(s): """ Return a tuple by which s is sorted. @@ -55,6 +59,7 @@ def natural_sort_key(s): """ return map(try_int, SPLIT_ALPHA_DIGITS.findall(s)) + def natural_compare(a, b): """ Compare two strings naturally and return the result. @@ -67,6 +72,7 @@ def natural_compare(a, b): """ return cmp(natural_sort_key(a), natural_sort_key(b)) + def natural_sort(seq, compare=natural_compare): """ Returns a copy of seq, sorted by natural string sort. @@ -77,28 +83,27 @@ def natural_sort(seq, compare=natural_compare): return temp try: - # Try to import Bazaar - from bzrlib.branch import Branch - b = Branch.open_containing('.')[0] - b.lock_read() - try: - # Get the branch's latest revision number. - revno = b.revno() - # Convert said revision number into a bzr revision id. - revision_id = b.dotted_revno_to_revision_id((revno,)) - # Get a dict of tags, with the revision id as the key. - tags = b.tags.get_reverse_tag_dict() - # Check if the latest - if revision_id in tags: - version = u'%s' % tags[revision_id][0] - else: - version = '%s-bzr%s' % \ - (natural_sort(b.tags.get_tag_dict().keys())[-1], revno) - ver_file = open(VERSION_FILE, u'w') - ver_file.write(version) - ver_file.close() - finally: - b.unlock() + bzr = Popen((u'bzr', u'tags', u'--sort', u'time'), stdout=PIPE) + output, error = bzr.communicate() + code = bzr.wait() + if code != 0: + raise Exception(u'Error running bzr tags') + lines = output.splitlines() + if not lines: + tag = u'0.0.0' + revision = u'0' + else: + tag, revision = lines[-1].split() + bzr = Popen((u'bzr', u'log', u'--line', u'-r', u'-1'), stdout=PIPE) + output, error = bzr.communicate() + code = bzr.wait() + if code != 0: + raise Exception(u'Error running bzr log') + latest = output.split(u':')[0] + version = latest == revision and tag or u'%s-bzr%s' % (tag, latest) + ver_file = open(VERSION_FILE, u'w') + ver_file.write(version) + ver_file.close() except: ver_file = open(VERSION_FILE, u'r') version = ver_file.read().strip() From d68728833601b2a7b98759330aae37f3338c1723 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 16 Apr 2013 23:42:23 +0200 Subject: [PATCH 73/95] Correct a preview_list_widget -> preview_widget call and fix update of preview when switching the service item. --- openlp/core/ui/servicemanager.py | 2 +- openlp/core/ui/slidecontroller.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openlp/core/ui/servicemanager.py b/openlp/core/ui/servicemanager.py index 6e0f4ad95..4aa7c5307 100644 --- a/openlp/core/ui/servicemanager.py +++ b/openlp/core/ui/servicemanager.py @@ -1379,7 +1379,7 @@ class ServiceManager(QtGui.QWidget, ServiceManagerDialog): self.preview_controller.addServiceManagerItem(self.service_items[item][u'service_item'], 0) next_item = self.service_manager_list.topLevelItem(item) self.service_manager_list.setCurrentItem(next_item) - self.live_controller.preview_list_widget.setFocus() + self.live_controller.preview_widget.get_preview_widget().setFocus() else: critical_error_message_box(translate('OpenLP.ServiceManager', 'Missing Display Handler'), translate('OpenLP.ServiceManager', diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index 8492b69bb..d1694ca56 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -777,8 +777,8 @@ class SlideController(DisplayController): slideHeight = width * (1 / self.ratio) row += 1 self.slideList[unicode(row)] = row - 1 - self.preview_widget.update_preview_selection(slideno) self.preview_widget.replace_service_manager_item(self.service_item, width, self.ratio, slideno) + self.preview_widget.update_preview_selection(slideno) self.enableToolBar(service_item) # Pass to display for viewing. # Postpone image build, we need to do this later to avoid the theme From ae5869e3669bc6c7c488a30c38bd6bb062577758 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 18 Apr 2013 20:19:43 +0200 Subject: [PATCH 74/95] Add waiting cursor while searching. --- openlp/plugins/songs/forms/duplicatesongremovalform.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index cf0f3922f..ee27d77a6 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -151,6 +151,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): # Hide back button. self.button(QtGui.QWizard.BackButton).hide() if page_id == self.searching_page_id: + self.application.set_busy_cursor() self.button(QtGui.QWizard.NextButton).hide() # Search duplicate songs. max_songs = self.plugin.manager.get_object_count(Song) @@ -177,6 +178,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.notify_no_duplicates() else: self.button(QtGui.QWizard.NextButton).show() + self.application.set_normal_cursor() elif page_id == self.review_page_id: self.process_current_duplicate_entry() From 25db18d34cb9ad6c1067297b1fc4e30610b647de Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 18 Apr 2013 22:15:44 +0200 Subject: [PATCH 75/95] Use inheritance instead of composition. Some interface cleanup. --- openlp/core/ui/listpreviewwidget.py | 113 +++++++++++++--------------- openlp/core/ui/servicemanager.py | 2 +- openlp/core/ui/slidecontroller.py | 11 ++- 3 files changed, 57 insertions(+), 69 deletions(-) diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py index fb94555fd..e9025a255 100644 --- a/openlp/core/ui/listpreviewwidget.py +++ b/openlp/core/ui/listpreviewwidget.py @@ -35,72 +35,61 @@ from PyQt4 import QtCore, QtGui from openlp.core.lib import ImageSource, Registry, ServiceItem -class ListPreviewWidget(QtCore.QObject): - clicked = QtCore.pyqtSignal() - double_clicked = QtCore.pyqtSignal() - - def __init__(self, parent, is_live): - super(QtCore.QObject, self).__init__() - self.is_live = is_live - self.preview_table_widget = QtGui.QTableWidget(parent) - self.preview_table_widget.setColumnCount(1) - self.preview_table_widget.horizontalHeader().setVisible(False) - self.preview_table_widget.setColumnWidth(0, parent.width()) - self.preview_table_widget.setObjectName(u'preview_table_widget') - self.preview_table_widget.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) - self.preview_table_widget.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) - self.preview_table_widget.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) - self.preview_table_widget.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - self.preview_table_widget.setAlternatingRowColors(True) +class ListPreviewWidget(QtGui.QTableWidget): + def __init__(self, parent, screen_ratio): + super(QtGui.QTableWidget, self).__init__(parent) self.service_item = ServiceItem() - if not self.is_live: - self.preview_table_widget.doubleClicked.connect(self._double_clicked) - self.preview_table_widget.clicked.connect(self._clicked) + self.screen_ratio = screen_ratio - def _clicked(self): - self.clicked.emit() + self.setColumnCount(1) + self.horizontalHeader().setVisible(False) + self.setColumnWidth(0, parent.width()) + self.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) + self.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) + self.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setAlternatingRowColors(True) - def _double_clicked(self): - self.double_clicked.emit() + def resizeEvent(self, QResizeEvent): + self.__recalculate_layout() - def get_preview_widget(self): - return self.preview_table_widget - - def set_active(self, active): - if active: - self.preview_table_widget.show() - else: - self.preview_table_widget.hide() - - def preview_size_changed(self, width, ratio): - """ - Takes care of the SlidePreview's size. Is called when one of the the - splitters is moved or when the screen size is changed. Note, that this - method is (also) called frequently from the mainwindow *paintEvent*. - """ - self.preview_table_widget.setColumnWidth(0, self.preview_table_widget.viewport().size().width()) + def __recalculate_layout(self): + self.setColumnWidth(0, self.viewport().width()) if self.service_item: # Sort out songs, bibles, etc. if self.service_item.is_text(): - self.preview_table_widget.resizeRowsToContents() + self.resizeRowsToContents() else: # Sort out image heights. for framenumber in range(len(self.service_item.get_frames())): - self.preview_table_widget.setRowHeight(framenumber, width / ratio) + #self.setRowHeight(framenumber, width / ratio) + height = self.viewport().width() / self.screen_ratio + self.setRowHeight(framenumber, height) - def replace_service_manager_item(self, service_item, width, ratio, slideno): + #width = self.main_window.controlSplitter.sizes()[self.split] + def screen_size_changed(self, screen_ratio): + self.screen_ratio = screen_ratio + self.__recalculate_layout() + + def set_active(self, active): + if active: + self.show() + else: + self.hide() + + def replace_service_manager_item(self, service_item, width, slideno): """ Loads a ServiceItem into the system from ServiceManager Display the slide number passed """ self.service_item = service_item - self.preview_table_widget.clear() - self.preview_table_widget.setRowCount(0) - self.preview_table_widget.setColumnWidth(0, width) + self.clear() + self.setRowCount(0) + self.setColumnWidth(0, width) row = 0 text = [] for framenumber, frame in enumerate(self.service_item.get_frames()): - self.preview_table_widget.setRowCount(self.preview_table_widget.rowCount() + 1) + self.setRowCount(self.rowCount() + 1) item = QtGui.QTableWidgetItem() slideHeight = 0 if self.service_item.is_text(): @@ -129,26 +118,26 @@ class ListPreviewWidget(QtCore.QObject): ImageSource.ImagePlugin) image = self.image_manager.get_image(frame[u'path'], ImageSource.ImagePlugin) label.setPixmap(QtGui.QPixmap.fromImage(image)) - self.preview_table_widget.setCellWidget(framenumber, 0, label) - slideHeight = width / ratio + self.setCellWidget(framenumber, 0, label) + slideHeight = width / self.screen_ratio row += 1 text.append(unicode(row)) - self.preview_table_widget.setItem(framenumber, 0, item) + self.setItem(framenumber, 0, item) if slideHeight: - self.preview_table_widget.setRowHeight(framenumber, slideHeight) - self.preview_table_widget.setVerticalHeaderLabels(text) + self.setRowHeight(framenumber, slideHeight) + self.setVerticalHeaderLabels(text) if self.service_item.is_text(): - self.preview_table_widget.resizeRowsToContents() - self.preview_table_widget.setColumnWidth(0, self.preview_table_widget.viewport().size().width()) + self.resizeRowsToContents() + self.setColumnWidth(0, self.viewport().width()) #stuff happens here, perhaps the setFocus() has to happen later... - self.preview_table_widget.setFocus() + self.setFocus() def update_preview_selection(self, row): """ Utility method to update the selected slide in the list. """ - if row >= self.preview_table_widget.rowCount(): - self.preview_table_widget.selectRow(self.preview_table_widget.rowCount() - 1) + if row >= self.rowCount(): + self.selectRow(self.rowCount() - 1) else: self.check_update_selected_slide(row) @@ -156,15 +145,15 @@ class ListPreviewWidget(QtCore.QObject): """ Check if this slide has been updated """ - if row + 1 < self.preview_table_widget.rowCount(): - self.preview_table_widget.scrollToItem(self.preview_table_widget.item(row + 1, 0)) - self.preview_table_widget.selectRow(row) + if row + 1 < self.rowCount(): + self.scrollToItem(self.item(row + 1, 0)) + self.selectRow(row) def currentRow(self): - return self.preview_table_widget.currentRow() + return super(ListPreviewWidget, self).currentRow() def rowCount(self): - return self.preview_table_widget.rowCount() + return super(ListPreviewWidget, self).rowCount() def _get_image_manager(self): """ diff --git a/openlp/core/ui/servicemanager.py b/openlp/core/ui/servicemanager.py index 4aa7c5307..51e3f790d 100644 --- a/openlp/core/ui/servicemanager.py +++ b/openlp/core/ui/servicemanager.py @@ -1379,7 +1379,7 @@ class ServiceManager(QtGui.QWidget, ServiceManagerDialog): self.preview_controller.addServiceManagerItem(self.service_items[item][u'service_item'], 0) next_item = self.service_manager_list.topLevelItem(item) self.service_manager_list.setCurrentItem(next_item) - self.live_controller.preview_widget.get_preview_widget().setFocus() + self.live_controller.preview_widget.setFocus() else: critical_error_message_box(translate('OpenLP.ServiceManager', 'Missing Display Handler'), translate('OpenLP.ServiceManager', diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index d1694ca56..6e5e5c8b7 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -158,8 +158,8 @@ class SlideController(DisplayController): self.controller_layout.setSpacing(0) self.controller_layout.setMargin(0) # Controller list view - self.preview_widget = ListPreviewWidget(self, self.is_live) - self.controller_layout.addWidget(self.preview_widget.get_preview_widget()) + self.preview_widget = ListPreviewWidget(self, self.ratio) + self.controller_layout.addWidget(self.preview_widget) # Build the full toolbar self.toolbar = OpenLPToolbar(self) size_toolbar_policy = QtGui.QSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) @@ -356,7 +356,7 @@ class SlideController(DisplayController): self.toolbar.set_widget_visible(self.loop_list, False) self.toolbar.set_widget_visible(self.wide_menu, False) else: - self.preview_widget.double_clicked.connect(self.onGoLiveClick) + self.preview_widget.doubleClicked.connect(self.onGoLiveClick) self.toolbar.set_widget_visible([u'editSong'], False) if self.is_live: self.setLiveHotkeys(self) @@ -524,6 +524,7 @@ class SlideController(DisplayController): self.ratio = 1 self.media_controller.setup_display(self.display, False) self.previewSizeChanged() + self.preview_widget.screen_size_changed(self.ratio) self.preview_display.setup() service_item = ServiceItem() self.preview_display.web_view.setHtml(build_html(service_item, self.preview_display.screen, None, self.is_live, @@ -562,8 +563,6 @@ class SlideController(DisplayController): self.preview_display.screen = { u'size': self.preview_display.geometry()} # Make sure that the frames have the correct size. - width = self.main_window.controlSplitter.sizes()[self.split] - self.preview_widget.preview_size_changed(width, self.ratio) self.onControllerSizeChanged(self.controller.width()) def onControllerSizeChanged(self, width): @@ -777,7 +776,7 @@ class SlideController(DisplayController): slideHeight = width * (1 / self.ratio) row += 1 self.slideList[unicode(row)] = row - 1 - self.preview_widget.replace_service_manager_item(self.service_item, width, self.ratio, slideno) + self.preview_widget.replace_service_manager_item(self.service_item, width, slideno) self.preview_widget.update_preview_selection(slideno) self.enableToolBar(service_item) # Pass to display for viewing. From 2b9a13ff97fdd84ad63ade409e2333d7801f1d0f Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Thu, 18 Apr 2013 22:50:02 +0200 Subject: [PATCH 76/95] Clean up PreviewWidget interface a little more. --- openlp/core/ui/listpreviewwidget.py | 52 +++++++++++++---------------- openlp/core/ui/slidecontroller.py | 23 +++++++------ 2 files changed, 36 insertions(+), 39 deletions(-) diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py index e9025a255..78b9d26e4 100644 --- a/openlp/core/ui/listpreviewwidget.py +++ b/openlp/core/ui/listpreviewwidget.py @@ -51,9 +51,16 @@ class ListPreviewWidget(QtGui.QTableWidget): self.setAlternatingRowColors(True) def resizeEvent(self, QResizeEvent): + """ + Overloaded method from QTableWidget. Will recalculate the layout. + """ self.__recalculate_layout() def __recalculate_layout(self): + """ + Recalculates the layout of the table widget. It will set height and width + of the table cells. QTableWidget does not adapt the cells to the widget size at all. + """ self.setColumnWidth(0, self.viewport().width()) if self.service_item: # Sort out songs, bibles, etc. @@ -66,21 +73,18 @@ class ListPreviewWidget(QtGui.QTableWidget): height = self.viewport().width() / self.screen_ratio self.setRowHeight(framenumber, height) - #width = self.main_window.controlSplitter.sizes()[self.split] def screen_size_changed(self, screen_ratio): + """ + To be called whenever the live screen size changes. + Because this makes a layout recalculation necessary. + """ self.screen_ratio = screen_ratio self.__recalculate_layout() - def set_active(self, active): - if active: - self.show() - else: - self.hide() - - def replace_service_manager_item(self, service_item, width, slideno): + def replace_service_manager_item(self, service_item, width, slide): """ - Loads a ServiceItem into the system from ServiceManager - Display the slide number passed + Replaces the current preview items with the ones in service_item. + Displays the given slide. """ self.service_item = service_item self.clear() @@ -112,10 +116,6 @@ class ListPreviewWidget(QtGui.QTableWidget): if self.service_item.is_command(): label.setPixmap(QtGui.QPixmap(frame[u'image'])) else: - # If current slide set background to image - if framenumber == slideno: - self.service_item.bg_image_bytes = self.image_manager.get_image_bytes(frame[u'path'], - ImageSource.ImagePlugin) image = self.image_manager.get_image(frame[u'path'], ImageSource.ImagePlugin) label.setPixmap(QtGui.QPixmap.fromImage(image)) self.setCellWidget(framenumber, 0, label) @@ -129,25 +129,19 @@ class ListPreviewWidget(QtGui.QTableWidget): if self.service_item.is_text(): self.resizeRowsToContents() self.setColumnWidth(0, self.viewport().width()) - #stuff happens here, perhaps the setFocus() has to happen later... self.setFocus() + self.change_slide(slide) - def update_preview_selection(self, row): + def change_slide(self, slide): """ - Utility method to update the selected slide in the list. + Switches to the given row. """ - if row >= self.rowCount(): - self.selectRow(self.rowCount() - 1) - else: - self.check_update_selected_slide(row) - - def check_update_selected_slide(self, row): - """ - Check if this slide has been updated - """ - if row + 1 < self.rowCount(): - self.scrollToItem(self.item(row + 1, 0)) - self.selectRow(row) + if slide >= self.rowCount(): + slide = self.rowCount() - 1 + #Scroll to next item if possible. + if slide + 1 < self.rowCount(): + self.scrollToItem(self.item(slide + 1, 0)) + self.selectRow(slide) def currentRow(self): return super(ListPreviewWidget, self).currentRow() diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index 6e5e5c8b7..3ae696277 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -36,7 +36,7 @@ from collections import deque from PyQt4 import QtCore, QtGui -from openlp.core.lib import OpenLPToolbar, ItemCapabilities, ServiceItem, SlideLimits, \ +from openlp.core.lib import OpenLPToolbar, ImageSource, ItemCapabilities, ServiceItem, SlideLimits, \ ServiceItemAction, Settings, Registry, UiStrings, ScreenList, build_icon, build_html, translate from openlp.core.ui import HideMode, MainDisplay, Display, DisplayControllerType from openlp.core.lib.ui import create_action @@ -418,7 +418,7 @@ class SlideController(DisplayController): if len(matches) == 1: self.shortcutTimer.stop() self.current_shortcut = u'' - self.preview_widget.check_update_selected_slide(self.slideList[matches[0]]) + self.preview_widget.change_slide(self.slideList[matches[0]]) self.slideSelected() elif sender_name != u'shortcutTimer': # Start the time as we did not have any match. @@ -428,7 +428,7 @@ class SlideController(DisplayController): if self.current_shortcut in keys: # We had more than one match for example "V1" and "V10", but # "V1" was the slide we wanted to go. - self.preview_widget.check_update_selected_slide(self.slideList[self.current_shortcut]) + self.preview_widget.change_slide(self.slideList[self.current_shortcut]) self.slideSelected() # Reset the shortcut. self.current_shortcut = u'' @@ -704,7 +704,7 @@ class SlideController(DisplayController): slidenum = 0 # If service item is the same as the current one, only change slide if slideno >= 0 and item == self.service_item: - self.preview_widget.check_update_selected_slide(slidenum) + self.preview_widget.change_slide(slidenum) self.slideSelected() else: self._processItem(item, slidenum) @@ -776,8 +776,11 @@ class SlideController(DisplayController): slideHeight = width * (1 / self.ratio) row += 1 self.slideList[unicode(row)] = row - 1 + # If current slide set background to image + if not self.service_item.is_command() and framenumber == slideno: + self.service_item.bg_image_bytes = self.image_manager.get_image_bytes(frame[u'path'], + ImageSource.ImagePlugin) self.preview_widget.replace_service_manager_item(self.service_item, width, slideno) - self.preview_widget.update_preview_selection(slideno) self.enableToolBar(service_item) # Pass to display for viewing. # Postpone image build, we need to do this later to avoid the theme @@ -810,7 +813,7 @@ class SlideController(DisplayController): Registry().execute(u'%s_slide' % self.service_item.name.lower(), [self.service_item, self.is_live, index]) self.updatePreview() else: - self.preview_widget.check_update_selected_slide(index) + self.preview_widget.change_slide(index) self.slideSelected() def mainDisplaySetBackground(self): @@ -973,7 +976,7 @@ class SlideController(DisplayController): self.service_item.bg_image_bytes = None self.updatePreview() self.selected_row = row - self.preview_widget.check_update_selected_slide(row) + self.preview_widget.change_slide(row) Registry().execute(u'slidecontroller_%s_changed' % self.type_prefix, row) self.display.setFocus() @@ -981,7 +984,7 @@ class SlideController(DisplayController): """ The slide has been changed. Update the slidecontroller accordingly """ - self.preview_widget.check_update_selected_slide(row) + self.preview_widget.change_slide(row) self.updatePreview() Registry().execute(u'slidecontroller_%s_changed' % self.type_prefix, row) @@ -1040,7 +1043,7 @@ class SlideController(DisplayController): row = 0 else: row = self.preview_widget.rowCount() - 1 - self.preview_widget.check_update_selected_slide(row) + self.preview_widget.change_slide(row) self.slideSelected() def on_slide_selected_previous(self): @@ -1063,7 +1066,7 @@ class SlideController(DisplayController): return else: row = 0 - self.preview_widget.check_update_selected_slide(row) + self.preview_widget.change_slide(row) self.slideSelected() def onToggleLoop(self): From 823e7b1ec0163385e7b2ba6cdf094bdfaa624a21 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Mon, 22 Apr 2013 22:51:33 +0200 Subject: [PATCH 77/95] Change a line back to trunk state. --- openlp/core/ui/slidecontroller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index 18d2eed09..36a47ae87 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -36,7 +36,7 @@ from collections import deque from PyQt4 import QtCore, QtGui -from openlp.core.lib import OpenLPToolbar, ImageSource, ItemCapabilities, ServiceItem, SlideLimits, \ +from openlp.core.lib import OpenLPToolbar, ItemCapabilities, ServiceItem, ImageSource, SlideLimits, \ ServiceItemAction, Settings, Registry, UiStrings, ScreenList, build_icon, build_html, translate from openlp.core.ui import HideMode, MainDisplay, Display, DisplayControllerType from openlp.core.lib.ui import create_action From 145a1aedcb6f9bb6022428e66636f5677985243a Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 23 Apr 2013 19:12:08 +0200 Subject: [PATCH 78/95] Add some docstrings and rename some methods for clarity. --- openlp/core/ui/listpreviewwidget.py | 36 +++++++++++++++++++---------- openlp/core/ui/slidecontroller.py | 24 +++++++++---------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py index 78b9d26e4..636d58468 100644 --- a/openlp/core/ui/listpreviewwidget.py +++ b/openlp/core/ui/listpreviewwidget.py @@ -27,7 +27,8 @@ # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### """ -The :mod:`slidecontroller` module contains the most important part of OpenLP - the slide controller +The :mod:`listpreviewwidget` is a widget that lists the slides in the slide controller. +It is based on a QTableWidget but represents its contents in list form. """ from PyQt4 import QtCore, QtGui @@ -37,6 +38,11 @@ from openlp.core.lib import ImageSource, Registry, ServiceItem class ListPreviewWidget(QtGui.QTableWidget): def __init__(self, parent, screen_ratio): + """ + Initializes the widget to default state. + An empty ServiceItem is used per default. + One needs to call replace_service_manager_item() to make this widget display something. + """ super(QtGui.QTableWidget, self).__init__(parent) self.service_item = ServiceItem() self.screen_ratio = screen_ratio @@ -59,7 +65,7 @@ class ListPreviewWidget(QtGui.QTableWidget): def __recalculate_layout(self): """ Recalculates the layout of the table widget. It will set height and width - of the table cells. QTableWidget does not adapt the cells to the widget size at all. + of the table cells. QTableWidget does not adapt the cells to the widget size on its own. """ self.setColumnWidth(0, self.viewport().width()) if self.service_item: @@ -81,7 +87,7 @@ class ListPreviewWidget(QtGui.QTableWidget): self.screen_ratio = screen_ratio self.__recalculate_layout() - def replace_service_manager_item(self, service_item, width, slide): + def replace_service_manager_item(self, service_item, width, slideNumber): """ Replaces the current preview items with the ones in service_item. Displays the given slide. @@ -93,7 +99,7 @@ class ListPreviewWidget(QtGui.QTableWidget): row = 0 text = [] for framenumber, frame in enumerate(self.service_item.get_frames()): - self.setRowCount(self.rowCount() + 1) + self.setRowCount(self.slide_count() + 1) item = QtGui.QTableWidgetItem() slideHeight = 0 if self.service_item.is_text(): @@ -130,28 +136,34 @@ class ListPreviewWidget(QtGui.QTableWidget): self.resizeRowsToContents() self.setColumnWidth(0, self.viewport().width()) self.setFocus() - self.change_slide(slide) + self.change_slide(slideNumber) def change_slide(self, slide): """ Switches to the given row. """ - if slide >= self.rowCount(): - slide = self.rowCount() - 1 + if slide >= self.slide_count(): + slide = self.slide_count() - 1 #Scroll to next item if possible. - if slide + 1 < self.rowCount(): + if slide + 1 < self.slide_count(): self.scrollToItem(self.item(slide + 1, 0)) self.selectRow(slide) - def currentRow(self): + def current_slide_number(self): + """ + Returns the position of the currently active item. Will return -1 if the widget is empty. + """ return super(ListPreviewWidget, self).currentRow() - def rowCount(self): + def slide_count(self): + """ + Returns the number of slides this widget holds. + """ return super(ListPreviewWidget, self).rowCount() def _get_image_manager(self): """ - Adds the image manager to the class dynamically + Adds the image manager to the class dynamically. """ if not hasattr(self, u'_image_manager'): self._image_manager = Registry().get(u'image_manager') @@ -161,7 +173,7 @@ class ListPreviewWidget(QtGui.QTableWidget): def _get_main_window(self): """ - Adds the main window to the class dynamically + Adds the main window to the class dynamically. """ if not hasattr(self, u'_main_window'): self._main_window = Registry().get(u'main_window') diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index 76616d152..2c24faca0 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -692,7 +692,7 @@ class SlideController(DisplayController): Replacement item following a remote edit """ if item == self.service_item: - self._processItem(item, self.preview_widget.currentRow()) + self._processItem(item, self.preview_widget.current_slide_number()) def addServiceManagerItem(self, item, slideno): """ @@ -960,9 +960,9 @@ class SlideController(DisplayController): Generate the preview when you click on a slide. if this is the Live Controller also display on the screen """ - row = self.preview_widget.currentRow() + row = self.preview_widget.current_slide_number() self.selected_row = 0 - if -1 < row < self.preview_widget.rowCount(): + if -1 < row < self.preview_widget.slide_count(): if self.service_item.is_command(): if self.is_live and not start: Registry().execute(u'%s_slide' % self.service_item.name.lower(), @@ -1033,8 +1033,8 @@ class SlideController(DisplayController): if self.service_item.is_command() and self.is_live: self.updatePreview() else: - row = self.preview_widget.currentRow() + 1 - if row == self.preview_widget.rowCount(): + row = self.preview_widget.current_slide_number() + 1 + if row == self.preview_widget.slide_count(): if wrap is None: if self.slide_limits == SlideLimits.Wrap: row = 0 @@ -1042,11 +1042,11 @@ class SlideController(DisplayController): self.serviceNext() return else: - row = self.preview_widget.rowCount() - 1 + row = self.preview_widget.slide_count() - 1 elif wrap: row = 0 else: - row = self.preview_widget.rowCount() - 1 + row = self.preview_widget.slide_count() - 1 self.preview_widget.change_slide(row) self.slideSelected() @@ -1060,10 +1060,10 @@ class SlideController(DisplayController): if self.service_item.is_command() and self.is_live: self.updatePreview() else: - row = self.preview_widget.currentRow() - 1 + row = self.preview_widget.current_slide_number() - 1 if row == -1: if self.slide_limits == SlideLimits.Wrap: - row = self.preview_widget.rowCount() - 1 + row = self.preview_widget.slide_count() - 1 elif self.is_live and self.slide_limits == SlideLimits.Next: self.keypress_queue.append(ServiceItemAction.PreviousLastSlide) self._process_queue() @@ -1087,7 +1087,7 @@ class SlideController(DisplayController): """ Start the timer loop running and store the timer id """ - if self.preview_widget.rowCount() > 1: + if self.preview_widget.slide_count() > 1: self.timer_id = self.startTimer(int(self.delay_spin_box.value()) * 1000) def on_stop_loop(self): @@ -1197,8 +1197,8 @@ class SlideController(DisplayController): """ If preview copy slide item to live controller from Preview Controller """ - row = self.preview_widget.currentRow() - if -1 < row < self.preview_widget.rowCount(): + row = self.preview_widget.current_slide_number() + if -1 < row < self.preview_widget.slide_count(): if self.service_item.from_service: self.service_manager.preview_live(self.service_item.unique_identifier, row) else: From edb0eab31d9aa0e64bdf5357b0c943d52b92aa28 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 23 Apr 2013 19:14:35 +0200 Subject: [PATCH 79/95] Remove a dev comment. Pretify a comment. --- openlp/core/ui/listpreviewwidget.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py index 636d58468..d259d43e7 100644 --- a/openlp/core/ui/listpreviewwidget.py +++ b/openlp/core/ui/listpreviewwidget.py @@ -75,7 +75,6 @@ class ListPreviewWidget(QtGui.QTableWidget): else: # Sort out image heights. for framenumber in range(len(self.service_item.get_frames())): - #self.setRowHeight(framenumber, width / ratio) height = self.viewport().width() / self.screen_ratio self.setRowHeight(framenumber, height) @@ -144,7 +143,7 @@ class ListPreviewWidget(QtGui.QTableWidget): """ if slide >= self.slide_count(): slide = self.slide_count() - 1 - #Scroll to next item if possible. + # Scroll to next item if possible. if slide + 1 < self.slide_count(): self.scrollToItem(self.item(slide + 1, 0)) self.selectRow(slide) From 84e9f6f1b1dbec12b6884f1ddbd78c93d3c25036 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sat, 27 Apr 2013 11:54:57 +0200 Subject: [PATCH 80/95] Add test utils and move a function to there. --- tests/__init__.py | 0 .../openlp_core_lib/test_serviceitem.py | 49 ++++++------------- tests/utils/__init__.py | 0 tests/utils/constants.py | 5 ++ tests/utils/osdinteraction.py | 49 +++++++++++++++++++ 5 files changed, 70 insertions(+), 33 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/constants.py create mode 100644 tests/utils/osdinteraction.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/functional/openlp_core_lib/test_serviceitem.py b/tests/functional/openlp_core_lib/test_serviceitem.py index 26e9e7d44..cf3560d07 100644 --- a/tests/functional/openlp_core_lib/test_serviceitem.py +++ b/tests/functional/openlp_core_lib/test_serviceitem.py @@ -2,11 +2,12 @@ Package to test the openlp.core.lib package. """ import os -import cPickle from unittest import TestCase from mock import MagicMock, patch from openlp.core.lib import ItemCapabilities, ServiceItem, Registry +from tests.utils.osdinteraction import read_service_from_file +from tests.utils.constants import TEST_RESOURCES_PATH VERSE = u'The Lord said to {r}Noah{/r}: \n'\ @@ -18,8 +19,6 @@ VERSE = u'The Lord said to {r}Noah{/r}: \n'\ 'r{/pk}{o}e{/o}{pp}n{/pp} of the Lord\n' FOOTER = [u'Arky Arky (Unknown)', u'Public Domain', u'CCLI 123456'] -TEST_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), u'..', u'..', u'resources')) - class TestServiceItem(TestCase): @@ -78,7 +77,7 @@ class TestServiceItem(TestCase): service_item.name = u'test' # WHEN: adding image to a service item - test_image = os.path.join(TEST_PATH, u'church.jpg') + test_image = os.path.join(TEST_RESOURCES_PATH, u'church.jpg') service_item.add_from_image(test_image, u'Image Title') # THEN: We should get back a valid service item @@ -133,8 +132,8 @@ class TestServiceItem(TestCase): service_item.name = u'test' # WHEN: adding image to a service item - test_file = os.path.join(TEST_PATH, u'church.jpg') - service_item.add_from_command(TEST_PATH, u'church.jpg', test_file) + test_file = os.path.join(TEST_RESOURCES_PATH, u'church.jpg') + service_item.add_from_command(TEST_RESOURCES_PATH, u'church.jpg', test_file) # THEN: We should get back a valid service item assert service_item.is_valid is True, u'The new service item should be valid' @@ -151,7 +150,7 @@ class TestServiceItem(TestCase): assert len(service) == 2, u'The saved service should have two parts' assert service[u'header'][u'name'] == u'test', u'A test plugin should be returned' assert service[u'data'][0][u'title'] == u'church.jpg', u'The first title name should be "church,jpg"' - assert service[u'data'][0][u'path'] == TEST_PATH, u'The path should match the input path' + assert service[u'data'][0][u'path'] == TEST_RESOURCES_PATH, u'The path should match the input path' assert service[u'data'][0][u'image'] == test_file, u'The image should match the full path to image' # WHEN validating a service item @@ -170,13 +169,12 @@ class TestServiceItem(TestCase): """ Test the Service Item - adding a custom slide from a saved service """ - # GIVEN: A new service item and a mocked add icon function + # GIVEN: A new service item service_item = ServiceItem(None) - service_item.add_icon = MagicMock() # WHEN: adding a custom from a saved Service - line = self.convert_file_service_item(u'serviceitem_custom_1.osd') - service_item.set_from_service(line) + service = read_service_from_file(u'serviceitem_custom_1.osd') + service_item.set_from_service(service[0]) # THEN: We should get back a valid service item assert service_item.is_valid is True, u'The new service item should be valid' @@ -195,18 +193,17 @@ class TestServiceItem(TestCase): """ Test the Service Item - adding an image from a saved service """ - # GIVEN: A new service item and a mocked add icon function + # GIVEN: A new service item image_name = u'image_1.jpg' - test_file = os.path.join(TEST_PATH, image_name) + test_file = os.path.join(TEST_RESOURCES_PATH, image_name) frame_array = {u'path': test_file, u'title': image_name} service_item = ServiceItem(None) - service_item.add_icon = MagicMock() # WHEN: adding an image from a saved Service and mocked exists - line = self.convert_file_service_item(u'serviceitem_image_1.osd') + service = read_service_from_file(u'serviceitem_image_1.osd') with patch('os.path.exists'): - service_item.set_from_service(line, TEST_PATH) + service_item.set_from_service(service[0], TEST_RESOURCES_PATH) # THEN: We should get back a valid service item assert service_item.is_valid is True, u'The new service item should be valid' @@ -230,7 +227,7 @@ class TestServiceItem(TestCase): """ Test the Service Item - adding an image from a saved local service """ - # GIVEN: A new service item and a mocked add icon function + # GIVEN: A new service item image_name1 = u'image_1.jpg' image_name2 = u'image_2.jpg' test_file1 = os.path.join(u'/home/openlp', image_name1) @@ -239,12 +236,11 @@ class TestServiceItem(TestCase): frame_array2 = {u'path': test_file2, u'title': image_name2} service_item = ServiceItem(None) - service_item.add_icon = MagicMock() # WHEN: adding an image from a saved Service and mocked exists - line = self.convert_file_service_item(u'serviceitem_image_2.osd') + service = read_service_from_file(u'serviceitem_image_2.osd') with patch('os.path.exists'): - service_item.set_from_service(line) + service_item.set_from_service(service[0]) # THEN: We should get back a valid service item assert service_item.is_valid is True, u'The new service item should be valid' @@ -267,16 +263,3 @@ class TestServiceItem(TestCase): u'This service item should be able to be run in a can be made to Loop' assert service_item.is_capable(ItemCapabilities.CanAppend) is True, \ u'This service item should be able to have new items added to it' - - def convert_file_service_item(self, name): - service_file = os.path.join(TEST_PATH, name) - try: - open_file = open(service_file, u'r') - items = cPickle.load(open_file) - first_line = items[0] - except IOError: - first_line = u'' - finally: - open_file.close() - return first_line - diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/utils/constants.py b/tests/utils/constants.py new file mode 100644 index 000000000..56a7a78ca --- /dev/null +++ b/tests/utils/constants.py @@ -0,0 +1,5 @@ + +import os + +OPENLP_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), u'..', u'..')) +TEST_RESOURCES_PATH = os.path.join(OPENLP_PATH, u'tests', u'resources') \ No newline at end of file diff --git a/tests/utils/osdinteraction.py b/tests/utils/osdinteraction.py new file mode 100644 index 000000000..f275d18c2 --- /dev/null +++ b/tests/utils/osdinteraction.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2013 Raoul Snyman # +# Portions copyright (c) 2008-2013 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:`osdinteraction` provides miscellaneous functions for interacting with +OSD files. +""" + +import os +import cPickle + +from tests.utils.constants import TEST_RESOURCES_PATH + + +def read_service_from_file(file_name): + """ + Reads an OSD file and returns the first service item found therein. + @param file_name: File name of an OSD file residing in the tests/resources folder. + @return: The service contained in the file. + """ + service_file = os.path.join(TEST_RESOURCES_PATH, file_name) + with open(service_file, u'r') as open_file: + service = cPickle.load(open_file) + return service From 154c82f5ea623d7c83731efcf1694f04712d62f4 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sat, 27 Apr 2013 12:00:27 +0200 Subject: [PATCH 81/95] Rename a method and remove a non needed registry var. --- openlp/core/ui/listpreviewwidget.py | 12 +----------- openlp/core/ui/slidecontroller.py | 2 +- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py index d259d43e7..94385dd91 100644 --- a/openlp/core/ui/listpreviewwidget.py +++ b/openlp/core/ui/listpreviewwidget.py @@ -86,7 +86,7 @@ class ListPreviewWidget(QtGui.QTableWidget): self.screen_ratio = screen_ratio self.__recalculate_layout() - def replace_service_manager_item(self, service_item, width, slideNumber): + def replace_service_item(self, service_item, width, slideNumber): """ Replaces the current preview items with the ones in service_item. Displays the given slide. @@ -170,13 +170,3 @@ class ListPreviewWidget(QtGui.QTableWidget): image_manager = property(_get_image_manager) - def _get_main_window(self): - """ - Adds the main window to the class dynamically. - """ - if not hasattr(self, u'_main_window'): - self._main_window = Registry().get(u'main_window') - return self._main_window - - main_window = property(_get_main_window) - diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index 2c24faca0..e1b8b8183 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -784,7 +784,7 @@ class SlideController(DisplayController): if not self.service_item.is_command() and framenumber == slideno: self.service_item.bg_image_bytes = self.image_manager.get_image_bytes(frame[u'path'], ImageSource.ImagePlugin) - self.preview_widget.replace_service_manager_item(self.service_item, width, slideno) + self.preview_widget.replace_service_item(self.service_item, width, slideno) self.enableToolBar(service_item) # Pass to display for viewing. # Postpone image build, we need to do this later to avoid the theme From 46882beefeb3f76884fba7841e6eb085aa70e9ad Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sat, 27 Apr 2013 12:00:46 +0200 Subject: [PATCH 82/95] Add tests for the listpreviewwidget. --- .../openlp_core_ui/test_listpreviewwidget.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/interfaces/openlp_core_ui/test_listpreviewwidget.py diff --git a/tests/interfaces/openlp_core_ui/test_listpreviewwidget.py b/tests/interfaces/openlp_core_ui/test_listpreviewwidget.py new file mode 100644 index 000000000..afad91ab7 --- /dev/null +++ b/tests/interfaces/openlp_core_ui/test_listpreviewwidget.py @@ -0,0 +1,88 @@ +""" + Package to test the openlp.core.ui.listpreviewwidget. +""" + +from unittest import TestCase +from mock import MagicMock, patch + +from PyQt4 import QtGui + +from openlp.core.lib import Registry, ServiceItem +from openlp.core.ui import listpreviewwidget +from tests.utils.osdinteraction import read_service_from_file + +class TestServiceManager(TestCase): + + def setUp(self): + """ + Create the UI. + """ + Registry.create() + self.app = QtGui.QApplication([]) + self.main_window = QtGui.QMainWindow() + self.image = QtGui.QImage(1, 1, QtGui.QImage.Format_RGB32) + self.image_manager = MagicMock() + self.image_manager.get_image.return_value = self.image + Registry().register(u'image_manager', self.image_manager) + self.preview_widget = listpreviewwidget.ListPreviewWidget(self.main_window, 2) + + def tearDown(self): + """ + Delete all the C++ objects at the end so that we don't have a segfault. + """ + del self.preview_widget + del self.main_window + del self.app + + def initial_slide_count_test(self): + """ + Test the inital slide count. + """ + # GIVEN: A new ListPreviewWidget instance. + # WHEN: No SlideItem has been added yet. + # THEN: The count of items should be zero. + self.assertEqual(self.preview_widget.slide_count(), 0, + u'The slide list should be empty.') + + def initial_slide_number_test(self): + """ + Test the inital slide number. + """ + # GIVEN: A new ListPreviewWidget instance. + # WHEN: No SlideItem has been added yet. + # THEN: The number of the current item should be -1. + self.assertEqual(self.preview_widget.current_slide_number(), -1, + u'The slide number should be -1.') + + def replace_service_item_test(self): + """ + Test item counts and current number with a service item. + """ + # GIVEN: A ServiceItem with two frames. + service_item = ServiceItem(None) + service = read_service_from_file(u'serviceitem_image_2.osd') + with patch('os.path.exists'): + service_item.set_from_service(service[0]) + # WHEN: Added to the preview widget. + self.preview_widget.replace_service_item(service_item, 1, 1) + # THEN: The slide count and number should fit. + self.assertEqual(self.preview_widget.slide_count(), 2, + u'The slide count should be 2.') + self.assertEqual(self.preview_widget.current_slide_number(), 1, + u'The current slide number should be 1.') + + def change_slide_test(self): + """ + Test the change_slide method. + """ + # GIVEN: A ServiceItem with two frames content. + service_item = ServiceItem(None) + service = read_service_from_file(u'serviceitem_image_2.osd') + with patch('os.path.exists'): + service_item.set_from_service(service[0]) + # WHEN: Added to the preview widget and switched to the second frame. + self.preview_widget.replace_service_item(service_item, 1, 0) + self.preview_widget.change_slide(1) + # THEN: The current_slide_number should reflect the change. + self.assertEqual(self.preview_widget.current_slide_number(), 1, + u'The current slide number should be 1.') From c0d722e63ac8bf2b545e7def0ab6147f7574a65e Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sat, 27 Apr 2013 12:06:08 +0200 Subject: [PATCH 83/95] Name listpreviewwidget test class correctly. --- tests/interfaces/openlp_core_ui/test_listpreviewwidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/interfaces/openlp_core_ui/test_listpreviewwidget.py b/tests/interfaces/openlp_core_ui/test_listpreviewwidget.py index afad91ab7..8a25c0b4f 100644 --- a/tests/interfaces/openlp_core_ui/test_listpreviewwidget.py +++ b/tests/interfaces/openlp_core_ui/test_listpreviewwidget.py @@ -11,7 +11,7 @@ from openlp.core.lib import Registry, ServiceItem from openlp.core.ui import listpreviewwidget from tests.utils.osdinteraction import read_service_from_file -class TestServiceManager(TestCase): +class TestListPreviewWidget(TestCase): def setUp(self): """ From 4c2f078bf185574537564b60fac407ab90523d3c Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sat, 27 Apr 2013 22:59:56 +0200 Subject: [PATCH 84/95] Add a missing \n at EOF. Add a comment. --- openlp/core/ui/listpreviewwidget.py | 7 ++++--- tests/utils/constants.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py index 94385dd91..ca04c9688 100644 --- a/openlp/core/ui/listpreviewwidget.py +++ b/openlp/core/ui/listpreviewwidget.py @@ -44,9 +44,7 @@ class ListPreviewWidget(QtGui.QTableWidget): One needs to call replace_service_manager_item() to make this widget display something. """ super(QtGui.QTableWidget, self).__init__(parent) - self.service_item = ServiceItem() - self.screen_ratio = screen_ratio - + # Set up the widget. self.setColumnCount(1) self.horizontalHeader().setVisible(False) self.setColumnWidth(0, parent.width()) @@ -55,6 +53,9 @@ class ListPreviewWidget(QtGui.QTableWidget): self.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setAlternatingRowColors(True) + # Initialize variables. + self.service_item = ServiceItem() + self.screen_ratio = screen_ratio def resizeEvent(self, QResizeEvent): """ diff --git a/tests/utils/constants.py b/tests/utils/constants.py index 56a7a78ca..4b28fcc83 100644 --- a/tests/utils/constants.py +++ b/tests/utils/constants.py @@ -2,4 +2,4 @@ import os OPENLP_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), u'..', u'..')) -TEST_RESOURCES_PATH = os.path.join(OPENLP_PATH, u'tests', u'resources') \ No newline at end of file +TEST_RESOURCES_PATH = os.path.join(OPENLP_PATH, u'tests', u'resources') From 111304495502b57752de708b6c9767678c81d287 Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Sun, 19 May 2013 20:56:48 +0100 Subject: [PATCH 85/95] Add basic Live display to remote --- openlp/core/ui/slidecontroller.py | 25 ++++--- openlp/plugins/remotes/html/live.html | 42 +++++++++++ openlp/plugins/remotes/html/live.js | 92 ++++++++++++++++++++++++ openlp/plugins/remotes/lib/httpserver.py | 16 +++++ 4 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 openlp/plugins/remotes/html/live.html create mode 100644 openlp/plugins/remotes/html/live.js diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index 86b114e1e..e2d318549 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -136,6 +136,8 @@ class SlideController(DisplayController): self.keypress_loop = False self.category = UiStrings().LiveToolbar ActionList.get_instance().add_category(unicode(self.category), CategoryOrder.standard_toolbar) + self.slide_count = 0 + self.slide_image = None else: Registry().register(u'preview_controller', self) self.type_label.setText(UiStrings().Preview) @@ -1050,27 +1052,28 @@ class SlideController(DisplayController): def updatePreview(self): """ - This updates the preview frame, for example after changing a slide or - using *Blank to Theme*. + This updates the preview frame, for example after changing a slide or using *Blank to Theme*. """ log.debug(u'updatePreview %s ' % self.screens.current[u'primary']) if not self.screens.current[u'primary'] and self.service_item and \ self.service_item.is_capable(ItemCapabilities.ProvidesOwnDisplay): - # Grab now, but try again in a couple of seconds if slide change - # is slow - QtCore.QTimer.singleShot(0.5, self.grabMainDisplay) - QtCore.QTimer.singleShot(2.5, self.grabMainDisplay) + # Grab now, but try again in a couple of seconds if slide change is slow + QtCore.QTimer.singleShot(0.5, self.grab_maindisplay) + QtCore.QTimer.singleShot(2.5, self.grab_maindisplay) else: - self.slidePreview.setPixmap(self.display.preview()) + self.slide_image = self.display.preview() + self.slidePreview.setPixmap(self.slide_image) + self.slide_count += 1 - def grabMainDisplay(self): + def grab_maindisplay(self): """ Creates an image of the current screen and updates the preview frame. """ - winid = QtGui.QApplication.desktop().winId() + win_id = QtGui.QApplication.desktop().winId() rect = self.screens.current[u'size'] - winimg = QtGui.QPixmap.grabWindow(winid, rect.x(), rect.y(), rect.width(), rect.height()) - self.slidePreview.setPixmap(winimg) + win_image = QtGui.QPixmap.grabWindow(win_id, rect.x(), rect.y(), rect.width(), rect.height()) + self.slidePreview.setPixmap(win_image) + self.slide_image = win_image def on_slide_selected_next_action(self, checked): """ diff --git a/openlp/plugins/remotes/html/live.html b/openlp/plugins/remotes/html/live.html new file mode 100644 index 000000000..2f505f98e --- /dev/null +++ b/openlp/plugins/remotes/html/live.html @@ -0,0 +1,42 @@ + + + + + + ${live_title} + + + + + + + +
A
+ + \ No newline at end of file diff --git a/openlp/plugins/remotes/html/live.js b/openlp/plugins/remotes/html/live.js new file mode 100644 index 000000000..a44ce02ca --- /dev/null +++ b/openlp/plugins/remotes/html/live.js @@ -0,0 +1,92 @@ +/****************************************************************************** + * OpenLP - Open Source Lyrics Projection * + * --------------------------------------------------------------------------- * + * Copyright (c) 2008-2013 Raoul Snyman * + * Portions copyright (c) 2008-2013 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 * + * --------------------------------------------------------------------------- * + * 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 * + ******************************************************************************/ +window.OpenLP = { + loadSlide: function (event) { + $.getJSON( + "/live/image", + function (data, status) { + OpenLP.currentSlides = data.results.slides; + OpenLP.currentSlide = 0; + OpenLP.currentTags = Array(); + var div = $("#verseorder"); + div.html(""); + var tag = ""; + var tags = 0; + var lastChange = 0; + $.each(data.results.slides, function(idx, slide) { + var prevtag = tag; + tag = slide["tag"]; + if (tag != prevtag) { + // If the tag has changed, add new one to the list + lastChange = idx; + tags = tags + 1; + div.append(" "); + $("#verseorder span").last().attr("id", "tag" + tags).text(tag); + } + else { + if ((slide["text"] == data.results.slides[lastChange]["text"]) && + (data.results.slides.length > idx + (idx - lastChange))) { + // If the tag hasn't changed, check to see if the same verse + // has been repeated consecutively. Note the verse may have been + // split over several slides, so search through. If so, repeat the tag. + var match = true; + for (var idx2 = 0; idx2 < idx - lastChange; idx2++) { + if(data.results.slides[lastChange + idx2]["text"] != data.results.slides[idx + idx2]["text"]) { + match = false; + break; + } + } + if (match) { + lastChange = idx; + tags = tags + 1; + div.append(" "); + $("#verseorder span").last().attr("id", "tag" + tags).text(tag); + } + } + } + OpenLP.currentTags[idx] = tags; + if (slide["selected"]) + OpenLP.currentSlide = idx; + }) + } + ); + }, + pollServer: function () { + $.getJSON( + "/live/poll", + function (data, status) { + if (OpenLP.slideCount != data.results.slide_count) { + OpenLP.slideCount = data.results.slide_count; + OpenLP.loadSlide(); + } + } + ); + } +} +$.ajaxSetup({ cache: false }); +setInterval("OpenLP.pollServer();", 500); +OpenLP.pollServer(); + diff --git a/openlp/plugins/remotes/lib/httpserver.py b/openlp/plugins/remotes/lib/httpserver.py index eedc30102..7a7acab2b 100644 --- a/openlp/plugins/remotes/lib/httpserver.py +++ b/openlp/plugins/remotes/lib/httpserver.py @@ -265,9 +265,11 @@ class HttpRouter(object): self.routes = [ (u'^/$', self.serve_file), (u'^/(stage)$', self.serve_file), + (u'^/(live)$', self.serve_file), (r'^/files/(.*)$', self.serve_file), (r'^/api/poll$', self.poll), (r'^/stage/poll$', self.poll), + (r'^/live/poll$', self.live_poll), (r'^/api/controller/(live|preview)/(.*)$', self.controller), (r'^/stage/controller/(live|preview)/(.*)$', self.controller), (r'^/api/service/(.*)$', self.service), @@ -305,6 +307,7 @@ class HttpRouter(object): if response: return response else: + log.debug('Path not found %s', url_path) return self._http_not_found() def _get_service_items(self): @@ -334,6 +337,7 @@ class HttpRouter(object): self.template_vars = { 'app_title': translate('RemotePlugin.Mobile', 'OpenLP 2.1 Remote'), 'stage_title': translate('RemotePlugin.Mobile', 'OpenLP 2.1 Stage View'), + 'live_title': translate('RemotePlugin.Mobile', 'OpenLP 2.1 Live View'), 'service_manager': translate('RemotePlugin.Mobile', 'Service Manager'), 'slide_controller': translate('RemotePlugin.Mobile', 'Slide Controller'), 'alerts': translate('RemotePlugin.Mobile', 'Alerts'), @@ -371,6 +375,8 @@ class HttpRouter(object): filename = u'index.html' elif filename == u'stage': filename = u'stage.html' + elif filename == u'live': + filename = u'live.html' path = os.path.normpath(os.path.join(self.html_dir, filename)) if not path.startswith(self.html_dir): return self._http_not_found() @@ -425,6 +431,16 @@ class HttpRouter(object): cherrypy.response.headers['Content-Type'] = u'application/json' return json.dumps({u'results': result}) + def live_poll(self): + """ + Poll OpenLP to determine the current display value. + """ + result = { + u'slide_count': self.live_controller.slide_count + } + cherrypy.response.headers['Content-Type'] = u'application/json' + return json.dumps({u'results': result}) + def display(self, action): """ Hide or show the display screen. From e1f3024ff1e2965e770a7cd68f6aea942e0efab1 Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Tue, 21 May 2013 18:45:33 +0100 Subject: [PATCH 86/95] Latest version --- .../presentations/lib/messagelistener.py | 1 + openlp/plugins/remotes/html/live.html | 14 ++++-- openlp/plugins/remotes/html/live.js | 46 ++----------------- openlp/plugins/remotes/lib/httpserver.py | 38 ++++++++++++--- 4 files changed, 46 insertions(+), 53 deletions(-) diff --git a/openlp/plugins/presentations/lib/messagelistener.py b/openlp/plugins/presentations/lib/messagelistener.py index 330c36f5c..219cae9a6 100644 --- a/openlp/plugins/presentations/lib/messagelistener.py +++ b/openlp/plugins/presentations/lib/messagelistener.py @@ -36,6 +36,7 @@ from openlp.core.ui import HideMode log = logging.getLogger(__name__) + class Controller(object): """ This is the Presentation listener who acts on events from the slide controller and passes the messages on the the diff --git a/openlp/plugins/remotes/html/live.html b/openlp/plugins/remotes/html/live.html index 2f505f98e..33a14fd52 100644 --- a/openlp/plugins/remotes/html/live.html +++ b/openlp/plugins/remotes/html/live.html @@ -29,14 +29,22 @@ --> - ${live_title} + ${live_title}A + - -
A
+ \ No newline at end of file diff --git a/openlp/plugins/remotes/html/live.js b/openlp/plugins/remotes/html/live.js index a44ce02ca..d55072c16 100644 --- a/openlp/plugins/remotes/html/live.js +++ b/openlp/plugins/remotes/html/live.js @@ -28,49 +28,9 @@ window.OpenLP = { $.getJSON( "/live/image", function (data, status) { - OpenLP.currentSlides = data.results.slides; - OpenLP.currentSlide = 0; - OpenLP.currentTags = Array(); - var div = $("#verseorder"); - div.html(""); - var tag = ""; - var tags = 0; - var lastChange = 0; - $.each(data.results.slides, function(idx, slide) { - var prevtag = tag; - tag = slide["tag"]; - if (tag != prevtag) { - // If the tag has changed, add new one to the list - lastChange = idx; - tags = tags + 1; - div.append(" "); - $("#verseorder span").last().attr("id", "tag" + tags).text(tag); - } - else { - if ((slide["text"] == data.results.slides[lastChange]["text"]) && - (data.results.slides.length > idx + (idx - lastChange))) { - // If the tag hasn't changed, check to see if the same verse - // has been repeated consecutively. Note the verse may have been - // split over several slides, so search through. If so, repeat the tag. - var match = true; - for (var idx2 = 0; idx2 < idx - lastChange; idx2++) { - if(data.results.slides[lastChange + idx2]["text"] != data.results.slides[idx + idx2]["text"]) { - match = false; - break; - } - } - if (match) { - lastChange = idx; - tags = tags + 1; - div.append(" "); - $("#verseorder span").last().attr("id", "tag" + tags).text(tag); - } - } - } - OpenLP.currentTags[idx] = tags; - if (slide["selected"]) - OpenLP.currentSlide = idx; - }) + var img = document.getElementById('image'); + img.src = data.results.slide_image; + img.style.display = 'block'; } ); }, diff --git a/openlp/plugins/remotes/lib/httpserver.py b/openlp/plugins/remotes/lib/httpserver.py index 7a7acab2b..8d4872cfc 100644 --- a/openlp/plugins/remotes/lib/httpserver.py +++ b/openlp/plugins/remotes/lib/httpserver.py @@ -124,7 +124,7 @@ import cherrypy from mako.template import Template from PyQt4 import QtCore -from openlp.core.lib import Registry, Settings, PluginStatus, StringContent +from openlp.core.lib import Registry, Settings, PluginStatus, StringContent, image_to_byte from openlp.core.utils import AppLocation, translate from cherrypy._cpcompat import sha, ntob @@ -175,9 +175,11 @@ class HttpServer(object): self.root = self.Public() self.root.files = self.Files() self.root.stage = self.Stage() + self.root.live = self.Live() self.root.router = self.router self.root.files.router = self.router self.root.stage.router = self.router + self.root.live.router = self.router cherrypy.tree.mount(self.root, '/', config=self.define_config()) # Turn off the flood of access messages cause by poll cherrypy.log.access_log.propagate = False @@ -212,6 +214,9 @@ class HttpServer(object): u'tools.staticdir.dir': self.router.html_dir, u'tools.basic_auth.on': False}, u'/stage': {u'tools.staticdir.on': True, + u'tools.staticdir.dir': self.router.html_dir, + u'tools.basic_auth.on': False}, + u'/live': {u'tools.staticdir.on': True, u'tools.staticdir.dir': self.router.html_dir, u'tools.basic_auth.on': False}} return directory_config @@ -239,7 +244,16 @@ class HttpServer(object): class Stage(object): """ - Stageview is read only so security is not relevant and would reduce it's usability + Stage view is read only so security is not relevant and would reduce it's usability + """ + @cherrypy.expose + def default(self, *args, **kwargs): + url = urlparse.urlparse(cherrypy.url()) + return self.router.process_http_request(url.path, *args) + + class Live(object): + """ + Live view is read only so security is not relevant and would reduce it's usability """ @cherrypy.expose def default(self, *args, **kwargs): @@ -270,6 +284,7 @@ class HttpRouter(object): (r'^/api/poll$', self.poll), (r'^/stage/poll$', self.poll), (r'^/live/poll$', self.live_poll), + (r'^/live/image$', self.live_image), (r'^/api/controller/(live|preview)/(.*)$', self.controller), (r'^/stage/controller/(live|preview)/(.*)$', self.controller), (r'^/api/service/(.*)$', self.service), @@ -363,12 +378,11 @@ class HttpRouter(object): def serve_file(self, filename=None): """ - Send a file to the socket. For now, just a subset of file types - and must be top level inside the html folder. + Send a file to the socket. For now, just a subset of file types and must be top level inside the html folder. If subfolders requested return 404, easier for security for the present. - Ultimately for i18n, this could first look for xx/file.html before - falling back to file.html... where xx is the language, e.g. 'en' + Ultimately for i18n, this could first look for xx/file.html before falling back to file.html. + where xx is the language, e.g. 'en' """ log.debug(u'serve file request %s' % filename) if not filename: @@ -433,7 +447,7 @@ class HttpRouter(object): def live_poll(self): """ - Poll OpenLP to determine the current display value. + Poll OpenLP to determine the current slide count. """ result = { u'slide_count': self.live_controller.slide_count @@ -441,6 +455,16 @@ class HttpRouter(object): cherrypy.response.headers['Content-Type'] = u'application/json' return json.dumps({u'results': result}) + def live_image(self): + """ + Return the latest display image as a byte stream. + """ + result = { + u'slide_image': str(image_to_byte(self.live_controller.slide_image)) + } + cherrypy.response.headers['Content-Type'] = u'application/json' + return json.dumps({u'results': result}) + def display(self, action): """ Hide or show the display screen. From 20bd86fbce5d60f03b0b7d300b93f29c4adc174f Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Sat, 25 May 2013 18:05:44 +0100 Subject: [PATCH 87/95] Live works! --- openlp/core/lib/__init__.py | 3 ++- openlp/plugins/remotes/html/live.html | 2 ++ openlp/plugins/remotes/lib/httpserver.py | 4 +++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index d6c338271..6bd20f2fd 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -183,7 +183,8 @@ def image_to_byte(image): # use buffer to store pixmap into byteArray buffie = QtCore.QBuffer(byte_array) buffie.open(QtCore.QIODevice.WriteOnly) - image.save(buffie, "PNG") + if isinstance(image, QtGui.QImage): + image.save(buffie, "PNG") log.debug(u'image_to_byte - end') # convert to base64 encoding so does not get missed! return byte_array.toBase64() diff --git a/openlp/plugins/remotes/html/live.html b/openlp/plugins/remotes/html/live.html index 33a14fd52..fed8f6c98 100644 --- a/openlp/plugins/remotes/html/live.html +++ b/openlp/plugins/remotes/html/live.html @@ -42,6 +42,8 @@ top: 0px; width: 100%%; height: 100%%; + background-size: cover; + background-repeat: no-repeat; } diff --git a/openlp/plugins/remotes/lib/httpserver.py b/openlp/plugins/remotes/lib/httpserver.py index 8d4872cfc..a2abbb41e 100644 --- a/openlp/plugins/remotes/lib/httpserver.py +++ b/openlp/plugins/remotes/lib/httpserver.py @@ -136,6 +136,7 @@ def make_sha_hash(password): """ Create an encrypted password for the given password. """ + log.debug("make_sha_hash") return sha(ntob(password)).hexdigest() @@ -143,6 +144,7 @@ def fetch_password(username): """ Fetch the password for a provided user. """ + log.debug("Fetch Password") if username != Settings().value(u'remotes/user id'): return None return make_sha_hash(Settings().value(u'remotes/password')) @@ -460,7 +462,7 @@ class HttpRouter(object): Return the latest display image as a byte stream. """ result = { - u'slide_image': str(image_to_byte(self.live_controller.slide_image)) + u'slide_image': u'data:image/png;base64,' + str(image_to_byte(self.live_controller.slide_image)) } cherrypy.response.headers['Content-Type'] = u'application/json' return json.dumps({u'results': result}) From 512155ff68b54052691df9e8cf704f25ce3844b7 Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Mon, 3 Jun 2013 18:19:42 +0100 Subject: [PATCH 88/95] Add css file --- openlp/core/lib/__init__.py | 3 +-- openlp/plugins/remotes/html/live.css | 39 +++++++++++++++++++++++++++ openlp/plugins/remotes/html/live.html | 15 ++--------- 3 files changed, 42 insertions(+), 15 deletions(-) create mode 100644 openlp/plugins/remotes/html/live.css diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index 6bd20f2fd..d6c338271 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -183,8 +183,7 @@ def image_to_byte(image): # use buffer to store pixmap into byteArray buffie = QtCore.QBuffer(byte_array) buffie.open(QtCore.QIODevice.WriteOnly) - if isinstance(image, QtGui.QImage): - image.save(buffie, "PNG") + image.save(buffie, "PNG") log.debug(u'image_to_byte - end') # convert to base64 encoding so does not get missed! return byte_array.toBase64() diff --git a/openlp/plugins/remotes/html/live.css b/openlp/plugins/remotes/html/live.css new file mode 100644 index 000000000..7181129d9 --- /dev/null +++ b/openlp/plugins/remotes/html/live.css @@ -0,0 +1,39 @@ +/****************************************************************************** +* OpenLP - Open Source Lyrics Projection * +* --------------------------------------------------------------------------- * +* Copyright (c) 2008-2013 Raoul Snyman * +* Portions copyright (c) 2008-2013 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 * +* --------------------------------------------------------------------------- * +* 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 * +******************************************************************************/ +body { + background-color: black; + font-family: sans-serif; + overflow: hidden; +} + +.size { + position: absolute; + top: 0px; + vertical-align: middle; + height: 100%; + background-size: cover; + background-repeat: no-repeat; +} \ No newline at end of file diff --git a/openlp/plugins/remotes/html/live.html b/openlp/plugins/remotes/html/live.html index fed8f6c98..f9a2c874c 100644 --- a/openlp/plugins/remotes/html/live.html +++ b/openlp/plugins/remotes/html/live.html @@ -29,23 +29,12 @@ --> - ${live_title}A - + ${live_title} + - From 9160832e0a1e3b4e292e8caafd0f65d938c28fc5 Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Mon, 3 Jun 2013 19:03:45 +0100 Subject: [PATCH 89/95] fix error --- openlp/core/ui/slidecontroller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index e2d318549..51ac4f525 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -121,6 +121,8 @@ class SlideController(DisplayController): self.update_slide_limits() self.panel = QtGui.QWidget(parent.controlSplitter) self.slideList = {} + self.slide_count = 0 + self.slide_image = None # Layout for holding panel self.panel_layout = QtGui.QVBoxLayout(self.panel) self.panel_layout.setSpacing(0) @@ -136,8 +138,6 @@ class SlideController(DisplayController): self.keypress_loop = False self.category = UiStrings().LiveToolbar ActionList.get_instance().add_category(unicode(self.category), CategoryOrder.standard_toolbar) - self.slide_count = 0 - self.slide_image = None else: Registry().register(u'preview_controller', self) self.type_label.setText(UiStrings().Preview) From 6e5588b7cfc5d2787d4a05ff88715b1b2c5f683a Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Mon, 3 Jun 2013 19:47:25 +0100 Subject: [PATCH 90/95] Update settings and minor cleanups --- openlp/core/ui/mainwindow.py | 8 +-- openlp/core/ui/slidecontroller.py | 81 ++++++++++++------------- openlp/plugins/remotes/lib/remotetab.py | 26 ++++++-- 3 files changed, 65 insertions(+), 50 deletions(-) diff --git a/openlp/core/ui/mainwindow.py b/openlp/core/ui/mainwindow.py index 2afbb4eb0..6b86bfe7a 100644 --- a/openlp/core/ui/mainwindow.py +++ b/openlp/core/ui/mainwindow.py @@ -779,8 +779,8 @@ class MainWindow(QtGui.QMainWindow, Ui_MainWindow): """ We need to make sure, that the SlidePreview's size is correct. """ - self.preview_controller.previewSizeChanged() - self.live_controller.previewSizeChanged() + self.preview_controller.preview_size_changed() + self.live_controller.preview_size_changed() def on_settings_shortcuts_item_clicked(self): """ @@ -989,8 +989,8 @@ class MainWindow(QtGui.QMainWindow, Ui_MainWindow): self.application.set_busy_cursor() self.image_manager.update_display() self.renderer.update_display() - self.preview_controller.screenSizeChanged() - self.live_controller.screenSizeChanged() + self.preview_controller.screen_size_changed() + self.live_controller.screen_size_changed() self.setFocus() self.activateWindow() self.application.set_normal_cursor() diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index 51ac4f525..febef0850 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -89,7 +89,7 @@ class SlideController(DisplayController): Set up the Slide Controller. """ DisplayController.__init__(self, parent, is_live) - Registry().register_function(u'bootstrap_post_set_up', self.screenSizeChanged) + Registry().register_function(u'bootstrap_post_set_up', self.screen_size_changed) self.screens = ScreenList() try: self.ratio = float(self.screens.current[u'size'].width()) / float(self.screens.current[u'size'].height()) @@ -323,18 +323,18 @@ class SlideController(DisplayController): self.slide_layout.insertWidget(0, self.preview_display) self.preview_display.hide() # Actual preview screen - self.slidePreview = QtGui.QLabel(self) - sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.slidePreview.sizePolicy().hasHeightForWidth()) - self.slidePreview.setSizePolicy(sizePolicy) - self.slidePreview.setFrameShape(QtGui.QFrame.Box) - self.slidePreview.setFrameShadow(QtGui.QFrame.Plain) - self.slidePreview.setLineWidth(1) - self.slidePreview.setScaledContents(True) - self.slidePreview.setObjectName(u'slidePreview') - self.slide_layout.insertWidget(0, self.slidePreview) + self.slide_preview = QtGui.QLabel(self) + size_policy = QtGui.QSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) + size_policy.setHorizontalStretch(0) + size_policy.setVerticalStretch(0) + size_policy.setHeightForWidth(self.slide_preview.sizePolicy().hasHeightForWidth()) + self.slide_preview.setSizePolicy(size_policy) + self.slide_preview.setFrameShape(QtGui.QFrame.Box) + self.slide_preview.setFrameShadow(QtGui.QFrame.Plain) + self.slide_preview.setLineWidth(1) + self.slide_preview.setScaledContents(True) + self.slide_preview.setObjectName(u'slide_preview') + self.slide_layout.insertWidget(0, self.slide_preview) self.grid.addLayout(self.slide_layout, 0, 0, 1, 1) if self.is_live: self.current_shortcut = u'' @@ -519,10 +519,9 @@ class SlideController(DisplayController): self.service_manager.next_item() self.keypress_loop = False - def screenSizeChanged(self): + def screen_size_changed(self): """ - Settings dialog has changed the screen size of adjust output and - screen previews. + Settings dialog has changed the screen size of adjust output and screen previews. """ # rebuild display as screen size changed if self.display: @@ -538,14 +537,14 @@ class SlideController(DisplayController): except ZeroDivisionError: self.ratio = 1 self.media_controller.setup_display(self.display, False) - self.previewSizeChanged() + self.preview_size_changed() self.preview_display.setup() service_item = ServiceItem() self.preview_display.web_view.setHtml(build_html(service_item, self.preview_display.screen, None, self.is_live, plugins=self.plugin_manager.plugins)) self.media_controller.setup_display(self.preview_display, True) if self.service_item: - self.refreshServiceItem() + self.refresh_service_item() def __addActionsToWidget(self, widget): """ @@ -556,7 +555,7 @@ class SlideController(DisplayController): self.previousService, self.nextService, self.escapeItem]) - def previewSizeChanged(self): + def preview_size_changed(self): """ Takes care of the SlidePreview's size. Is called when one of the the splitters is moved or when the screen size is changed. Note, that this @@ -565,14 +564,14 @@ class SlideController(DisplayController): if self.ratio < float(self.preview_frame.width()) / float(self.preview_frame.height()): # We have to take the height as limit. max_height = self.preview_frame.height() - self.grid.margin() * 2 - self.slidePreview.setFixedSize(QtCore.QSize(max_height * self.ratio, max_height)) + self.slide_preview.setFixedSize(QtCore.QSize(max_height * self.ratio, max_height)) self.preview_display.setFixedSize(QtCore.QSize(max_height * self.ratio, max_height)) self.preview_display.screen = { u'size': self.preview_display.geometry()} else: # We have to take the width as limit. max_width = self.preview_frame.width() - self.grid.margin() * 2 - self.slidePreview.setFixedSize(QtCore.QSize(max_width, max_width / self.ratio)) + self.slide_preview.setFixedSize(QtCore.QSize(max_width, max_width / self.ratio)) self.preview_display.setFixedSize(QtCore.QSize(max_width, max_width / self.ratio)) self.preview_display.screen = { u'size': self.preview_display.geometry()} @@ -626,17 +625,16 @@ class SlideController(DisplayController): """ self.slide_limits = Settings().value(self.main_window.advanced_settings_section + u'/slide limits') - def enableToolBar(self, item): + def enable_tool_bar(self, item): """ - Allows the toolbars to be reconfigured based on Controller Type - and ServiceItem Type + Allows the toolbars to be reconfigured based on Controller Type and ServiceItem Type """ if self.is_live: - self.enableLiveToolBar(item) + self.enable_live_tool_bar(item) else: - self.enablePreviewToolBar(item) + self.enable_preview_tool_bar(item) - def enableLiveToolBar(self, item): + def enable_live_tool_bar(self, item): """ Allows the live toolbar to be customised """ @@ -665,7 +663,7 @@ class SlideController(DisplayController): # See bug #791050 self.toolbar.show() - def enablePreviewToolBar(self, item): + def enable_preview_tool_bar(self, item): """ Allows the Preview toolbar to be customised """ @@ -684,15 +682,15 @@ class SlideController(DisplayController): # See bug #791050 self.toolbar.show() - def refreshServiceItem(self): + def refresh_service_item(self): """ Method to update the service item if the screen has changed """ - log.debug(u'refreshServiceItem live = %s' % self.is_live) + log.debug(u'refresh_service_item live = %s' % self.is_live) if self.service_item.is_text() or self.service_item.is_image(): item = self.service_item item.render() - self._processItem(item, self.selected_row) + self._process_item(item, self.selected_row) def add_service_item(self, item): """ @@ -705,14 +703,14 @@ class SlideController(DisplayController): if self.song_edit: slideno = self.selected_row self.song_edit = False - self._processItem(item, slideno) + self._process_item(item, slideno) def replaceServiceManagerItem(self, item): """ Replacement item following a remote edit """ if item == self.service_item: - self._processItem(item, self.preview_list_widget.currentRow()) + self._process_item(item, self.preview_list_widget.currentRow()) def addServiceManagerItem(self, item, slideno): """ @@ -731,7 +729,7 @@ class SlideController(DisplayController): self.__checkUpdateSelectedSlide(slidenum) self.slideSelected() else: - self._processItem(item, slidenum) + self._process_item(item, slidenum) if self.is_live and item.auto_play_slides_loop and item.timed_slide_interval > 0: self.play_slides_loop.setChecked(item.auto_play_slides_loop) self.delay_spin_box.setValue(int(item.timed_slide_interval)) @@ -741,7 +739,7 @@ class SlideController(DisplayController): self.delay_spin_box.setValue(int(item.timed_slide_interval)) self.onPlaySlidesOnce() - def _processItem(self, service_item, slideno): + def _process_item(self, service_item, slideno): """ Loads a ServiceItem into the system from ServiceManager Display the slide number passed @@ -829,10 +827,9 @@ class SlideController(DisplayController): self.preview_list_widget.setVerticalHeaderLabels(text) if self.service_item.is_text(): self.preview_list_widget.resizeRowsToContents() - self.preview_list_widget.setColumnWidth(0, - self.preview_list_widget.viewport().size().width()) + self.preview_list_widget.setColumnWidth(0, self.preview_list_widget.viewport().size().width()) self.__updatePreviewSelection(slideno) - self.enableToolBar(service_item) + self.enable_tool_bar(service_item) # Pass to display for viewing. # Postpone image build, we need to do this later to avoid the theme # flashing on the screen @@ -1062,7 +1059,7 @@ class SlideController(DisplayController): QtCore.QTimer.singleShot(2.5, self.grab_maindisplay) else: self.slide_image = self.display.preview() - self.slidePreview.setPixmap(self.slide_image) + self.slide_preview.setPixmap(self.slide_image) self.slide_count += 1 def grab_maindisplay(self): @@ -1072,7 +1069,7 @@ class SlideController(DisplayController): win_id = QtGui.QApplication.desktop().winId() rect = self.screens.current[u'size'] win_image = QtGui.QPixmap.grabWindow(win_id, rect.x(), rect.y(), rect.width(), rect.height()) - self.slidePreview.setPixmap(win_image) + self.slide_preview.setPixmap(win_image) self.slide_image = win_image def on_slide_selected_next_action(self, checked): @@ -1279,7 +1276,7 @@ class SlideController(DisplayController): self.media_controller.video(self.controller_type, item, self.hide_mode()) if not self.is_live: self.preview_display.show() - self.slidePreview.hide() + self.slide_preview.hide() def onMediaClose(self): """ @@ -1288,7 +1285,7 @@ class SlideController(DisplayController): log.debug(u'SlideController onMediaClose') self.media_controller.media_reset(self) self.preview_display.hide() - self.slidePreview.show() + self.slide_preview.show() def _resetBlank(self): """ diff --git a/openlp/plugins/remotes/lib/remotetab.py b/openlp/plugins/remotes/lib/remotetab.py index 09934b58c..c8ed9303e 100644 --- a/openlp/plugins/remotes/lib/remotetab.py +++ b/openlp/plugins/remotes/lib/remotetab.py @@ -86,6 +86,12 @@ class RemoteTab(SettingsTab): self.stage_url.setObjectName(u'stage_url') self.stage_url.setOpenExternalLinks(True) self.http_setting_layout.addRow(self.stage_url_label, self.stage_url) + self.live_url_label = QtGui.QLabel(self.http_settings_group_box) + self.live_url_label.setObjectName(u'live_url_label') + self.live_url = QtGui.QLabel(self.http_settings_group_box) + self.live_url.setObjectName(u'live_url') + self.live_url.setOpenExternalLinks(True) + self.http_setting_layout.addRow(self.live_url_label, self.live_url) self.left_layout.addWidget(self.http_settings_group_box) self.https_settings_group_box = QtGui.QGroupBox(self.left_column) self.https_settings_group_box.setCheckable(True) @@ -116,6 +122,12 @@ class RemoteTab(SettingsTab): self.stage_https_url.setObjectName(u'stage_https_url') self.stage_https_url.setOpenExternalLinks(True) self.https_settings_layout.addRow(self.stage_https_url_label, self.stage_https_url) + self.live_https_url_label = QtGui.QLabel(self.https_settings_group_box) + self.live_https_url_label.setObjectName(u'live_url_label') + self.live_https_url = QtGui.QLabel(self.https_settings_group_box) + self.live_https_url.setObjectName(u'live_https_url') + self.live_https_url.setOpenExternalLinks(True) + self.https_settings_layout.addRow(self.live_https_url_label, self.live_https_url) self.left_layout.addWidget(self.https_settings_group_box) self.user_login_group_box = QtGui.QGroupBox(self.left_column) self.user_login_group_box.setCheckable(True) @@ -163,6 +175,7 @@ class RemoteTab(SettingsTab): self.port_label.setText(translate('RemotePlugin.RemoteTab', 'Port number:')) self.remote_url_label.setText(translate('RemotePlugin.RemoteTab', 'Remote URL:')) self.stage_url_label.setText(translate('RemotePlugin.RemoteTab', 'Stage view URL:')) + self.live_url_label.setText(translate('RemotePlugin.RemoteTab', 'Live view URL:')) self.twelve_hour_check_box.setText(translate('RemotePlugin.RemoteTab', 'Display stage time in 12h format')) self.android_app_group_box.setTitle(translate('RemotePlugin.RemoteTab', 'Android App')) self.qr_description_label.setText(translate('RemotePlugin.RemoteTab', @@ -176,6 +189,7 @@ class RemoteTab(SettingsTab): self.https_port_label.setText(self.port_label.text()) self.remote_https_url_label.setText(self.remote_url_label.text()) self.stage_https_url_label.setText(self.stage_url_label.text()) + self.live_https_url_label.setText(self.live_url_label.text()) self.user_login_group_box.setTitle(translate('RemotePlugin.RemoteTab', 'User Authentication')) self.user_id_label.setText(translate('RemotePlugin.RemoteTab', 'User id:')) self.password_label.setText(translate('RemotePlugin.RemoteTab', 'Password:')) @@ -203,10 +217,14 @@ class RemoteTab(SettingsTab): https_url = u'https://%s:%s/' % (ip_address, self.https_port_spin_box.value()) self.remote_url.setText(u'
%s' % (http_url, http_url)) self.remote_https_url.setText(u'%s' % (https_url, https_url)) - http_url += u'stage' - https_url += u'stage' - self.stage_url.setText(u'%s' % (http_url, http_url)) - self.stage_https_url.setText(u'%s' % (https_url, https_url)) + http_url_temp = http_url + u'stage' + https_url_temp = https_url + u'stage' + self.stage_url.setText(u'%s' % (http_url_temp, http_url_temp)) + self.stage_https_url.setText(u'%s' % (https_url_temp, https_url_temp)) + http_url_temp = http_url + u'live' + https_url_temp = https_url + u'live' + self.live_url.setText(u'%s' % (http_url_temp, http_url_temp)) + self.live_https_url.setText(u'%s' % (https_url_temp, https_url_temp)) def load(self): """ From 43326c5781d2f829286961d37d3c37219e806006 Mon Sep 17 00:00:00 2001 From: Andreas Preikschat Date: Thu, 6 Jun 2013 08:40:10 +0200 Subject: [PATCH 91/95] fixed code --- openlp/.version | 2 +- openlp/core/utils/__init__.py | 41 +++++++++++++++++++----------- setup.py | 47 ++++++++++++++++++++++------------- 3 files changed, 58 insertions(+), 32 deletions(-) diff --git a/openlp/.version b/openlp/.version index b6e0556c1..3245d0c77 100644 --- a/openlp/.version +++ b/openlp/.version @@ -1 +1 @@ -2.0.1-bzr2233 \ No newline at end of file +2.1.0-bzr2234 diff --git a/openlp/core/utils/__init__.py b/openlp/core/utils/__init__.py index f1c150989..5c4a4466c 100644 --- a/openlp/core/utils/__init__.py +++ b/openlp/core/utils/__init__.py @@ -101,25 +101,38 @@ def get_application_version(): if APPLICATION_VERSION: return APPLICATION_VERSION if u'--dev-version' in sys.argv or u'-d' in sys.argv: - # If we're running the dev version, let's use bzr to get the version. - bzr = Popen((u'bzr', u'tags', u'--sort', u'time'), stdout=PIPE) + # NOTE: The following code is a duplicate of the code in setup.py. Any fix applied here should also be applied + # there. + + # Get the revision of this tree. + bzr = Popen((u'bzr', u'revno'), stdout=PIPE) + tree_revision, error = bzr.communicate() + code = bzr.wait() + if code != 0: + raise Exception(u'Error running bzr log') + + # Get all tags. + bzr = Popen((u'bzr', u'tags'), stdout=PIPE) output, error = bzr.communicate() code = bzr.wait() if code != 0: raise Exception(u'Error running bzr tags') - lines = output.splitlines() - if not lines: - tag = u'0.0.0' - revision = u'0' + tags = output.splitlines() + if not tags: + tag_version = u'0.0.0' + tag_revision = u'0' else: - tag, revision = lines[-1].split() - bzr = Popen((u'bzr', u'log', u'--line', u'-r', u'-1'), stdout=PIPE) - output, error = bzr.communicate() - code = bzr.wait() - if code != 0: - raise Exception(u'Error running bzr log') - latest = output.split(u':')[0] - full_version = latest == revision and tag or u'%s-bzr%s' % (tag, latest) + # Remove any tag that has "?" as revision number. A "?" as revision number indicates, that this tag is from + # another series. + tags = [tag for tag in tags if tag.split()[-1].strip() != u'?'] + # Get the last tag and split it in a revision and tag name. + tag_version, tag_revision = tags[-1].split() + # If they are equal, then this tree is tarball with the source for the release. We do not want the revision + # number in the full version. + if tree_revision == tag_revision: + full_version = tag_version + else: + full_version = u'%s-bzr%s' % (tag_version, tree_revision) else: # We're not running the development version, let's use the file. filepath = AppLocation.get_directory(AppLocation.VersionDir) diff --git a/setup.py b/setup.py index 3bd3007b7..c9a192591 100755 --- a/setup.py +++ b/setup.py @@ -82,37 +82,50 @@ def natural_sort(seq, compare=natural_compare): temp.sort(compare) return temp +# NOTE: The following code is a duplicate of the code in openlp/core/utils/__init__.py. Any fix applied here should also +# be applied there. try: - bzr = Popen((u'bzr', u'tags', u'--sort', u'time'), stdout=PIPE) + # Get the revision of this tree. + bzr = Popen((u'bzr', u'revno'), stdout=PIPE) + tree_revision, error = bzr.communicate() + code = bzr.wait() + if code != 0: + raise Exception(u'Error running bzr log') + + # Get all tags. + bzr = Popen((u'bzr', u'tags'), stdout=PIPE) output, error = bzr.communicate() code = bzr.wait() if code != 0: raise Exception(u'Error running bzr tags') - lines = output.splitlines() - if not lines: - tag = u'0.0.0' - revision = u'0' + tags = output.splitlines() + if not tags: + tag_version = u'0.0.0' + tag_revision = u'0' else: - tag, revision = lines[-1].split() - bzr = Popen((u'bzr', u'log', u'--line', u'-r', u'-1'), stdout=PIPE) - output, error = bzr.communicate() - code = bzr.wait() - if code != 0: - raise Exception(u'Error running bzr log') - latest = output.split(u':')[0] - version = latest == revision and tag or u'%s-bzr%s' % (tag, latest) + # Remove any tag that has "?" as revision number. A "?" as revision number indicates, that this tag is from + # another series. + tags = [tag for tag in tags if tag.split()[-1].strip() != u'?'] + # Get the last tag and split it in a revision and tag name. + tag_version, tag_revision = tags[-1].split() + # If they are equal, then this tree is tarball with the source for the release. We do not want the revision number + # in the version string. + if tree_revision == tag_revision: + version_string = tag_version + else: + version_string = u'%s-bzr%s' % (tag_version, tree_revision) ver_file = open(VERSION_FILE, u'w') - ver_file.write(version) - ver_file.close() + ver_file.write(version_string) except: ver_file = open(VERSION_FILE, u'r') - version = ver_file.read().strip() + version_string = ver_file.read().strip() +finally: ver_file.close() setup( name='OpenLP', - version=version, + version=version_string, description="Open source Church presentation and lyrics projection application.", long_description="""\ OpenLP (previously openlp.org) is free church presentation software, or lyrics projection software, used to display slides of songs, Bible verses, videos, images, and even presentations (if PowerPoint is installed) for church worship using a computer and a data projector.""", From b172153b15f0a6681c393f3fb45cdce07f5ce786 Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Tue, 11 Jun 2013 22:27:30 +0200 Subject: [PATCH 92/95] Use future division. --- openlp/plugins/songs/forms/duplicatesongremovalform.py | 4 ++-- openlp/plugins/songs/lib/songcompare.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index ee27d77a6..5a01ede47 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -29,6 +29,7 @@ """ The duplicate song removal logic for OpenLP. """ +from __future__ import division import logging import os @@ -161,7 +162,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.notify_no_duplicates() return # With x songs we have x*(x - 1) / 2 comparisons. - max_progress_count = max_songs * (max_songs - 1) / 2 + max_progress_count = max_songs * (max_songs - 1) // 2 self.duplicate_search_progress_bar.setMaximum(max_progress_count) songs = self.plugin.manager.get_all_objects(Song) for outer_song_counter in range(max_songs - 1): @@ -194,7 +195,6 @@ class DuplicateSongRemovalForm(OpenLPWizard): translate(u'Wizard', u'No duplicate songs have been found in the database.'), QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Ok)) - def add_duplicates_to_song_list(self, search_song, duplicate_song): """ Inserts a song duplicate (two similar songs) to the duplicate song list. diff --git a/openlp/plugins/songs/lib/songcompare.py b/openlp/plugins/songs/lib/songcompare.py index a98e61380..f69c5c827 100644 --- a/openlp/plugins/songs/lib/songcompare.py +++ b/openlp/plugins/songs/lib/songcompare.py @@ -43,6 +43,7 @@ Finally two conditions can qualify a song tuple to be a duplicate: This condition should hit if one of the two songs (or both) is small (smaller than the min_block_size), but most of the song is contained in the other song. """ +from __future__ import division import difflib @@ -85,7 +86,7 @@ def songs_probably_equal(song1, song2): for element in diff_no_typos: if element[0] == "equal" and _op_length(element) > length_of_longest_equal_block: length_of_longest_equal_block = _op_length(element) - if length_of_equal_blocks >= MIN_BLOCK_SIZE or length_of_longest_equal_block > len(small) * 2 / 3: + if length_of_equal_blocks >= MIN_BLOCK_SIZE or length_of_longest_equal_block > len(small) * 2 // 3: return True # Both checks failed. We assume the songs are not equal. return False From 2f55624977b3969c83ace5d67e6259a70eab974b Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Fri, 14 Jun 2013 22:20:26 +0200 Subject: [PATCH 93/95] Extract method to delete a song. --- .../songs/forms/duplicatesongremovalform.py | 19 ++--------- openlp/plugins/songs/lib/__init__.py | 34 ++++++++++++++++++- openlp/plugins/songs/lib/mediaitem.py | 16 ++------- 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 5a01ede47..640403adc 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -38,6 +38,7 @@ from PyQt4 import QtCore, QtGui from openlp.core.lib import Registry, translate from openlp.core.ui.wizard import OpenLPWizard, WizardStrings from openlp.core.utils import AppLocation +from openlp.plugins.songs.lib import delete_song from openlp.plugins.songs.lib.db import Song, MediaFile from openlp.plugins.songs.forms.songreviewwidget import SongReviewWidget from openlp.plugins.songs.lib.songcompare import songs_probably_equal @@ -273,23 +274,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): # Remove song from duplicate song list. self.duplicate_song_list[-1].remove(song_review_widget.song) # Remove song from the database. - item_id = song_review_widget.song.id - media_files = self.plugin.manager.get_all_objects(MediaFile, - MediaFile.song_id == item_id) - for media_file in media_files: - try: - os.remove(media_file.file_name) - except: - log.exception(u'Could not remove file: %s', - media_file.file_name) - try: - save_path = os.path.join(AppLocation.get_section_data_path( - self.plugin.name), u'audio', str(item_id)) - if os.path.exists(save_path): - os.rmdir(save_path) - except OSError: - log.exception(u'Could not remove directory: %s', save_path) - self.plugin.manager.delete_object(Song, item_id) + delete_song(song_review_widget.song.id, self.plugin) # Remove GUI elements for the song. self.review_scroll_area_layout.removeWidget(song_review_widget) song_review_widget.setParent(None) diff --git a/openlp/plugins/songs/lib/__init__.py b/openlp/plugins/songs/lib/__init__.py index d3005c9b2..407fede8d 100644 --- a/openlp/plugins/songs/lib/__init__.py +++ b/openlp/plugins/songs/lib/__init__.py @@ -29,15 +29,21 @@ """ The :mod:`~openlp.plugins.songs.lib` module contains a number of library functions and classes used in the Songs plugin. """ + +import logging +import os import re from PyQt4 import QtGui from openlp.core.lib import translate -from openlp.core.utils import CONTROL_CHARS +from openlp.core.utils import AppLocation, CONTROL_CHARS +from openlp.plugins.songs.lib.db import MediaFile, Song from db import Author from ui import SongStrings +log = logging.getLogger(__name__) + WHITESPACE = re.compile(r'[\W_]+', re.UNICODE) APOSTROPHE = re.compile(u'[\'`’ʻ′]', re.UNICODE) PATTERN = re.compile(r"\\([a-z]{1,32})(-?\d{1,10})?[ ]?|\\'([0-9a-f]{2})|\\([^a-z])|([{}])|[\r\n]+|(.)", re.I) @@ -593,3 +599,29 @@ def strip_rtf(text, default_encoding=None): text = u''.join(out) return text, default_encoding + +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. + + ``song_id`` + The ID of the song to delete. + + ``song_plugin`` + The song plugin instance. + """ + 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: + 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)) + if os.path.exists(save_path): + os.rmdir(save_path) + except OSError: + log.exception(u'Could not remove directory: %s', save_path) + song_plugin.manager.delete_object(Song, song_id) + diff --git a/openlp/plugins/songs/lib/mediaitem.py b/openlp/plugins/songs/lib/mediaitem.py index b60c0b162..7c50eb6b8 100644 --- a/openlp/plugins/songs/lib/mediaitem.py +++ b/openlp/plugins/songs/lib/mediaitem.py @@ -43,7 +43,7 @@ from openlp.plugins.songs.forms.editsongform import EditSongForm from openlp.plugins.songs.forms.songmaintenanceform import SongMaintenanceForm from openlp.plugins.songs.forms.songimportform import SongImportForm from openlp.plugins.songs.forms.songexportform import SongExportForm -from openlp.plugins.songs.lib import VerseType, clean_string +from openlp.plugins.songs.lib import VerseType, clean_string, delete_song from openlp.plugins.songs.lib.db import Author, Song, Book, MediaFile from openlp.plugins.songs.lib.ui import SongStrings from openlp.plugins.songs.lib.xml import OpenLyrics, SongXML @@ -368,19 +368,7 @@ class SongMediaItem(MediaManagerItem): self.main_window.display_progress_bar(len(items)) for item in items: item_id = item.data(QtCore.Qt.UserRole) - media_files = self.plugin.manager.get_all_objects(MediaFile, MediaFile.song_id == item_id) - for media_file in media_files: - try: - os.remove(media_file.file_name) - except: - log.exception('Could not remove file: %s', media_file.file_name) - try: - save_path = os.path.join(AppLocation.get_section_data_path(self.plugin.name), 'audio', str(item_id)) - if os.path.exists(save_path): - os.rmdir(save_path) - except OSError: - log.exception(u'Could not remove directory: %s', save_path) - self.plugin.manager.delete_object(Song, item_id) + delete_song(item_id, self.plugin) self.main_window.increment_progress_bar() self.main_window.finished_progress_bar() self.application.set_normal_cursor() From b18d96b3c9ce85ba0c6c4846e41284b84b7cacfa Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sat, 15 Jun 2013 12:46:00 +0200 Subject: [PATCH 94/95] Keep GUI responsive during search. Allow aborting the search. --- .../songs/forms/duplicatesongremovalform.py | 73 ++++++++++++------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/openlp/plugins/songs/forms/duplicatesongremovalform.py b/openlp/plugins/songs/forms/duplicatesongremovalform.py index 640403adc..d612a5627 100644 --- a/openlp/plugins/songs/forms/duplicatesongremovalform.py +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -65,6 +65,8 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.duplicate_song_list = [] self.review_current_count = 0 self.review_total_count = 0 + # Used to interrupt ongoing searches when cancel is clicked. + self.break_search = False OpenLPWizard.__init__(self, self.main_window, plugin, u'duplicateSongRemovalWizard', u':/wizards/wizard_duplicateremoval.bmp', False) self.setMinimumWidth(730) @@ -154,33 +156,39 @@ class DuplicateSongRemovalForm(OpenLPWizard): self.button(QtGui.QWizard.BackButton).hide() if page_id == self.searching_page_id: self.application.set_busy_cursor() - self.button(QtGui.QWizard.NextButton).hide() - # Search duplicate songs. - max_songs = self.plugin.manager.get_object_count(Song) - if max_songs == 0 or max_songs == 1: - self.duplicate_search_progress_bar.setMaximum(1) - self.duplicate_search_progress_bar.setValue(1) - self.notify_no_duplicates() - return - # With x songs we have x*(x - 1) / 2 comparisons. - max_progress_count = max_songs * (max_songs - 1) // 2 - self.duplicate_search_progress_bar.setMaximum(max_progress_count) - songs = self.plugin.manager.get_all_objects(Song) - for outer_song_counter in range(max_songs - 1): - for inner_song_counter in range(outer_song_counter + 1, max_songs): - if songs_probably_equal(songs[outer_song_counter], songs[inner_song_counter]): - duplicate_added = self.add_duplicates_to_song_list(songs[outer_song_counter], - songs[inner_song_counter]) - if duplicate_added: - self.found_duplicates_edit.appendPlainText(songs[outer_song_counter].title + " = " + - songs[inner_song_counter].title) - self.duplicate_search_progress_bar.setValue(self.duplicate_search_progress_bar.value() + 1) - self.review_total_count = len(self.duplicate_song_list) - if self.review_total_count == 0: - self.notify_no_duplicates() - else: - self.button(QtGui.QWizard.NextButton).show() - self.application.set_normal_cursor() + try: + self.button(QtGui.QWizard.NextButton).hide() + # Search duplicate songs. + max_songs = self.plugin.manager.get_object_count(Song) + if max_songs == 0 or max_songs == 1: + self.duplicate_search_progress_bar.setMaximum(1) + self.duplicate_search_progress_bar.setValue(1) + self.notify_no_duplicates() + return + # With x songs we have x*(x - 1) / 2 comparisons. + max_progress_count = max_songs * (max_songs - 1) // 2 + self.duplicate_search_progress_bar.setMaximum(max_progress_count) + songs = self.plugin.manager.get_all_objects(Song) + for outer_song_counter in range(max_songs - 1): + for inner_song_counter in range(outer_song_counter + 1, max_songs): + if songs_probably_equal(songs[outer_song_counter], songs[inner_song_counter]): + duplicate_added = self.add_duplicates_to_song_list(songs[outer_song_counter], + songs[inner_song_counter]) + if duplicate_added: + self.found_duplicates_edit.appendPlainText(songs[outer_song_counter].title + " = " + + songs[inner_song_counter].title) + self.duplicate_search_progress_bar.setValue(self.duplicate_search_progress_bar.value() + 1) + # The call to process_events() will keep the GUI responsive. + self.application.process_events() + if self.break_search: + return + self.review_total_count = len(self.duplicate_song_list) + if self.review_total_count == 0: + self.notify_no_duplicates() + else: + self.button(QtGui.QWizard.NextButton).show() + finally: + self.application.set_normal_cursor() elif page_id == self.review_page_id: self.process_current_duplicate_entry() @@ -238,6 +246,7 @@ class DuplicateSongRemovalForm(OpenLPWizard): Once the wizard is finished, refresh the song list, since we potentially removed songs from it. """ + self.break_search = True self.plugin.media_item.on_search_text_button_clicked() def setDefaults(self): @@ -337,3 +346,13 @@ class DuplicateSongRemovalForm(OpenLPWizard): return self._main_window main_window = property(_get_main_window) + + def _get_application(self): + """ + Adds the openlp to the class dynamically + """ + if not hasattr(self, u'_application'): + self._application = Registry().get(u'application') + return self._application + + application = property(_get_application) \ No newline at end of file From 4106ecd71e8dfa0b6dc838bc21d2bd3edf7c86ac Mon Sep 17 00:00:00 2001 From: Patrick Zimmermann Date: Sat, 15 Jun 2013 12:56:09 +0200 Subject: [PATCH 95/95] Remove superfluous comment. --- openlp/core/ui/slidecontroller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index 24840e74d..5cec2659c 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -567,7 +567,6 @@ class SlideController(DisplayController): self.preview_display.setFixedSize(QtCore.QSize(max_width, max_width / self.ratio)) self.preview_display.screen = { u'size': self.preview_display.geometry()} - # Make sure that the frames have the correct size. self.onControllerSizeChanged(self.controller.width()) def onControllerSizeChanged(self, width):