diff --git a/openlp/core/lib/settings.py b/openlp/core/lib/settings.py index 49cd8f6d5..5e73ffc1d 100644 --- a/openlp/core/lib/settings.py +++ b/openlp/core/lib/settings.py @@ -271,6 +271,7 @@ class Settings(QtCore.QSettings): u'shortcuts/songImportItem': [], u'shortcuts/themeScreen': [QtGui.QKeySequence(u'T')], u'shortcuts/toolsReindexItem': [], + u'shortcuts/toolsFindDuplicates': [], u'shortcuts/toolsAlertItem': [QtGui.QKeySequence(u'F7')], u'shortcuts/toolsFirstTimeWizard': [], u'shortcuts/toolsOpenDataFolder': [], diff --git a/openlp/core/ui/wizard.py b/openlp/core/ui/wizard.py index 783b310b3..0b142b459 100644 --- a/openlp/core/ui/wizard.py +++ b/openlp/core/ui/wizard.py @@ -75,13 +75,30 @@ 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. + + ``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): + def __init__(self, parent, plugin, name, image, add_progress_page=True): """ Constructor """ QtGui.QWizard.__init__(self, parent) self.plugin = plugin + self.with_progress_page = add_progress_page self.setObjectName(name) self.open_icon = build_icon(u':/general/general_open.png') self.delete_icon = build_icon(u':/general/general_delete.png') @@ -92,8 +109,9 @@ class OpenLPWizard(QtGui.QWizard): self.custom_init() self.custom_signals() self.currentIdChanged.connect(self.on_current_id_changed) - self.error_copy_to_button.clicked.connect(self.on_error_copy_to_button_clicked) - self.error_save_to_button.clicked.connect(self.on_error_save_to_button_clicked) + if self.with_progress_page: + self.error_copy_to_button.clicked.connect(self.on_error_copy_to_button_clicked) + self.error_save_to_button.clicked.connect(self.on_error_save_to_button_clicked) def setupUi(self, image): """ @@ -105,7 +123,8 @@ class OpenLPWizard(QtGui.QWizard): QtGui.QWizard.NoBackButtonOnStartPage | QtGui.QWizard.NoBackButtonOnLastPage) add_welcome_page(self, image) self.add_custom_pages() - self.add_progress_page() + if self.with_progress_page: + self.add_progress_page() self.retranslateUi() def register_fields(self): @@ -185,7 +204,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.progress_page: + if self.with_progress_page and self.currentPage() == self.progress_page: Registry().execute(u'openlp_stop_wizard') self.done(QtGui.QDialog.Rejected) @@ -193,14 +212,14 @@ class OpenLPWizard(QtGui.QWizard): """ Perform necessary functions depending on which wizard page is active. """ - if self.page(pageId) == self.progress_page: + if self.with_progress_page and self.page(pageId) == self.progress_page: self.pre_wizard() 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 new file mode 100644 index 000000000..d612a5627 --- /dev/null +++ b/openlp/plugins/songs/forms/duplicatesongremovalform.py @@ -0,0 +1,358 @@ +# -*- 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 duplicate song removal logic for OpenLP. +""" +from __future__ import division +import logging +import os + +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 + +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, 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.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) + + def custom_signals(self): + """ + Song wizard specific signals. + """ + self.finish_button.clicked.connect(self.on_wizard_exit) + self.cancel_button.clicked.connect(self.on_wizard_exit) + + def add_custom_pages(self): + """ + Add song wizard specific 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) + 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.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. + self.dummy_page = QtGui.QWizardPage() + self.dummy_page_id = self.addPage(self.dummy_page) + + def retranslateUi(self): + """ + Song wizard localisation. + """ + self.setWindowTitle(translate(u'Wizard', u'Wizard')) + self.title_label.setText(WizardStrings.HeaderStyle % translate(u'OpenLP.Ui', + u'Welcome to the Duplicate Song Removal 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.')) + 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 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.')) + + def update_review_counter_text(self): + """ + Set the wizard review page header text. + """ + self.review_page.setTitle(translate(u'Wizard', u'Review duplicate songs (%s/%s)') % \ + (self.review_current_count, self.review_total_count)) + + def custom_page_changed(self, page_id): + """ + Called when changing the wizard page. + + ``page_id`` + ID of the page the wizard changed to. + """ + # Hide back button. + self.button(QtGui.QWizard.BackButton).hide() + if page_id == self.searching_page_id: + self.application.set_busy_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() + + def notify_no_duplicates(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() + 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)) + + 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, + don't add another duplicate group but add the other song to that group. + Returns True if at least one of the songs was added, False if both were already + member of a group. + + ``search_song`` + The song we searched the duplicate for. + + ``duplicate_song`` + The duplicate song. + """ + 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 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 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 search_song in duplicate_group and duplicate_song in duplicate_group: + duplicate_group_found = True + duplicate_added = False + break + if not duplicate_group_found: + self.duplicate_song_list.append([search_song, duplicate_song]) + duplicate_added = True + return duplicate_added + + def on_wizard_exit(self): + """ + 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): + """ + Set default form values for the song import wizard. + """ + self.restart() + 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.review_page_id: + # 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: + self.proceed_to_next_review() + return False + return OpenLPWizard.validateCurrentPage(self) + + 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 + is left. + + ``song_review_widget`` + The SongReviewWidget whose song we should delete. + """ + # Remove song from duplicate song list. + self.duplicate_song_list[-1].remove(song_review_widget.song) + # Remove song from the database. + 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) + # 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.review_scroll_area_layout.itemAt(1).widget().song_remove_button.setEnabled(False) + + def proceed_to_next_review(self): + """ + Removes the previous review UI elements and calls process_current_duplicate_entry. + """ + # Remove last duplicate group. + self.duplicate_song_list.pop() + # Remove all previous elements. + 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.review_scroll_area_layout.removeItem(item) + widget.setParent(None) + else: + self.review_scroll_area_layout.removeItem(item) + # Process next set of duplicates. + self.process_current_duplicate_entry() + + 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 + duplicate song group, hide the "next" button and show the "finish" button. + """ + # 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. + if len(self.duplicate_song_list) > 0: + 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.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() + 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. + """ + 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) + + 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 diff --git a/openlp/plugins/songs/forms/songreviewwidget.py b/openlp/plugins/songs/forms/songreviewwidget.py new file mode 100644 index 000000000..7b24563f9 --- /dev/null +++ b/openlp/plugins/songs/forms/songreviewwidget.py @@ -0,0 +1,213 @@ +# -*- 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 import VerseType +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. + """ + + # 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`` + 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() + self.song_remove_button.clicked.connect(self.on_remove_button_clicked) + + 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.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() + self.song_info_form_layout.setObjectName(u'song_info_form_layout') + # 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) + 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) + # 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) + 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) + # 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) + 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) + # 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) + 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) + # 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) + 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) + # 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) + 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', '.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. + 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) + self.song_group_box_layout.addLayout(self.song_info_form_layout) + # 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) + 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) + + # 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()) + self.song_info_verse_list_widget.resizeRowsToContents() + # 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) + 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:') + + def on_remove_button_clicked(self): + """ + Signal emitted when the "remove" button is clicked. + """ + self.song_remove_button_clicked.emit(self) 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() diff --git a/openlp/plugins/songs/lib/songcompare.py b/openlp/plugins/songs/lib/songcompare.py new file mode 100644 index 000000000..f69c5c827 --- /dev/null +++ b/openlp/plugins/songs/lib/songcompare.py @@ -0,0 +1,139 @@ +# -*- 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. +""" +from __future__ import division +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) + # 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): + """ + 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 diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index 4355bc7e5..1c895d327 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -50,6 +50,8 @@ from openlp.plugins.songs.lib.importer import SongFormat from openlp.plugins.songs.lib.olpimport import OpenLPSongImport from openlp.plugins.songs.lib.mediaitem import SongMediaItem from openlp.plugins.songs.lib.songstab import SongsTab +from openlp.plugins.songs.forms.duplicatesongremovalform import DuplicateSongRemovalForm + log = logging.getLogger(__name__) __default_settings__ = { @@ -97,10 +99,12 @@ class SongsPlugin(Plugin): self.song_import_item.setVisible(True) self.song_export_item.setVisible(True) self.tools_reindex_item.setVisible(True) + self.tools_find_duplicates.setVisible(True) action_list = ActionList.get_instance() action_list.add_action(self.song_import_item, UiStrings().Import) action_list.add_action(self.song_export_item, UiStrings().Export) action_list.add_action(self.tools_reindex_item, UiStrings().Tools) + action_list.add_action(self.tools_find_duplicates, UiStrings().Tools) def add_import_menu_item(self, import_menu): """ @@ -136,7 +140,7 @@ class SongsPlugin(Plugin): def add_tools_menu_item(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`` @@ -150,6 +154,12 @@ class SongsPlugin(Plugin): statustip=translate('SongsPlugin', 'Re-index the songs database to improve searching and ordering.'), visible=False, triggers=self.on_tools_reindex_item_triggered) tools_menu.addAction(self.tools_reindex_item) + 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.on_tools_find_duplicates_triggered, can_shortcuts=True) + tools_menu.addAction(self.tools_find_duplicates) def on_tools_reindex_item_triggered(self): """ @@ -169,6 +179,12 @@ class SongsPlugin(Plugin): self.manager.save_objects(songs) self.media_item.on_search_text_button_clicked() + def on_tools_find_duplicates_triggered(self): + """ + Search for duplicates in the song database. + """ + DuplicateSongRemovalForm(self).exec_() + def on_song_import_item_clicked(self): if self.media_item: self.media_item.on_import_click() @@ -287,10 +303,12 @@ class SongsPlugin(Plugin): self.song_import_item.setVisible(False) self.song_export_item.setVisible(False) self.tools_reindex_item.setVisible(False) + self.tools_find_duplicates.setVisible(False) action_list = ActionList.get_instance() action_list.remove_action(self.song_import_item, UiStrings().Import) action_list.remove_action(self.song_export_item, UiStrings().Export) action_list.remove_action(self.tools_reindex_item, UiStrings().Tools) + action_list.remove_action(self.tools_find_duplicates, UiStrings().Tools) Plugin.finalise(self) def new_service_created(self): diff --git a/resources/images/openlp-2.qrc b/resources/images/openlp-2.qrc index 82c763c6f..42af83f79 100644 --- a/resources/images/openlp-2.qrc +++ b/resources/images/openlp-2.qrc @@ -19,6 +19,7 @@ topic_maintenance.png song_author_edit.png song_book_edit.png + song_delete.png image_group.png @@ -101,6 +102,7 @@ wizard_importbible.bmp wizard_firsttime.bmp wizard_createtheme.bmp + wizard_duplicateremoval.bmp service_collapse_all.png diff --git a/resources/images/wizard_duplicateremoval.bmp b/resources/images/wizard_duplicateremoval.bmp new file mode 100644 index 000000000..ac937714f Binary files /dev/null and b/resources/images/wizard_duplicateremoval.bmp differ diff --git a/tests/functional/openlp_plugins/songs/test_lib.py b/tests/functional/openlp_plugins/songs/test_lib.py index 221b54ec2..c5232e348 100644 --- a/tests/functional/openlp_plugins/songs/test_lib.py +++ b/tests/functional/openlp_plugins/songs/test_lib.py @@ -4,15 +4,37 @@ This module contains tests for the lib submodule of the Songs plugin. from unittest import TestCase -from mock import patch +from mock import patch, MagicMock from openlp.plugins.songs.lib import VerseType, clean_string, clean_title +from openlp.plugins.songs.lib.songcompare import songs_probably_equal, _remove_typos, _op_length class TestLib(TestCase): """ Test the functions in the :mod:`lib` module. """ + 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 clean_string_test(self): """ Test the clean_string() function @@ -39,6 +61,160 @@ class TestLib(TestCase): # THEN: The string should be cleaned up self.assertEqual(result, u'This is a dirty string', u'The title should be cleaned up properly: "%s"' % result) + def songs_probably_equal_same_song_test(self): + """ + Test the songs_probably_equal function with twice the same song. + """ + # 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. + result = songs_probably_equal(self.song1, self.song2) + + # THEN: The result should be True. + assert result == 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. + 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(self.song1, self.song2) + + # THEN: The result should be True. + assert result == 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. + 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(self.song1, self.song2) + + # THEN: The result should be True. + assert result == 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. + 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(self.song1, self.song2) + + # THEN: 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(list(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(list(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(list(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.' + class TestVerseType(TestCase): """