← Back to team overview

openlp-core team mailing list archive

[Merge] lp:~patrick-zakweb/openlp/duplicate-removal-review into lp:openlp

 

mohij has proposed merging lp:~patrick-zakweb/openlp/duplicate-removal-review into lp:openlp.

Requested reviews:
  Raoul Snyman (raoul-snyman)
  Tim Bentley (trb143)
  Andreas Preikschat (googol)

For more details, see:
https://code.launchpad.net/~patrick-zakweb/openlp/duplicate-removal-review/+merge/151121

!!!!!!!!!!!!!!!!!!!!!!!
Just as a reminder:
This branch is broken and is only intended for review. It should *NOT* be merged into trunk. The correct branch to merge is lp:~patrick-zakweb/openlp/duplicate-song-finder. That branch is always identical to this one except that the resource file is not reverted back to trunk and does not contain all those "Merge upstream." commits.
!!!!!!!!!!!!!!!!!!!!!!!

I resubmitted this request in a separate branch, because I removed the resources file to make the diff readable. This basically breaks the branch but makes it reviewable.
---
This is no real merge request. I would just be grateful for some feedback on how to continue.

This branch contains a logic and GUI to find, review and remove duplicate songs from the song database.
The GUI can be reached via "Tools->Find Duplicate Songs". As far as I have tested everything works.

There are several pain points in the code however:
-No tests for the wizard. I have no real idea which parts would be best for testing (I think "everything" is not a good answer for this question :-). Probably some refactoring of the code is necessary to make it testable.
-No good MVC separation for the song review widgets. Some feedback on how to improve this (hopefully without pulling off a full QItemModel subclass) would be appreciated.
-I changed the wizard.py class to allow *not* adding a final progress page. I am not sure whether this is ok.
-------------------------------
Latest changes: 
-Merge master.
-Several cosmetic comment fixes.
-Add use_shortcuts=True parameter to action
-Reformat constants correctly
--------------------
-fix a thinko: Three tests always succeeded by comparing a list with itself.
-- 
https://code.launchpad.net/~patrick-zakweb/openlp/duplicate-removal-review/+merge/151121
Your team OpenLP Core is subscribed to branch lp:openlp.
=== modified file 'openlp/core/lib/settings.py'
--- openlp/core/lib/settings.py	2013-02-21 21:26:24 +0000
+++ openlp/core/lib/settings.py	2013-02-28 22:50:44 +0000
@@ -272,6 +272,7 @@
         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': [],

=== modified file 'openlp/core/ui/wizard.py'
--- openlp/core/ui/wizard.py	2013-02-07 08:42:17 +0000
+++ openlp/core/ui/wizard.py	2013-02-28 22:50:44 +0000
@@ -79,13 +79,30 @@
     """
     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.openIcon = build_icon(u':/general/general_open.png')
         self.deleteIcon = build_icon(u':/general/general_delete.png')
@@ -95,9 +112,10 @@
         self.registerFields()
         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)
+        self.currentIdChanged.connect(self.onCurrentIdChanged)
+        if self.with_progress_page:
+            self.errorCopyToButton.clicked.connect(self.onErrorCopyToButtonClicked)
+            self.errorSaveToButton.clicked.connect(self.onErrorSaveToButtonClicked)
 
     def setupUi(self, image):
         """
@@ -110,7 +128,8 @@
             QtGui.QWizard.NoBackButtonOnLastPage)
         add_welcome_page(self, image)
         self.addCustomPages()
-        self.addProgressPage()
+        if self.with_progress_page:
+            self.addProgressPage()
         self.retranslateUi()
 
     def registerFields(self):
@@ -172,7 +191,7 @@
         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.with_progress_page and self.currentPage() == self.progressPage:
             Registry().execute(u'openlp_stop_wizard')
         self.done(QtGui.QDialog.Rejected)
 
@@ -180,13 +199,19 @@
         """
         Perform necessary functions depending on which wizard page is active.
         """
-        if self.page(pageId) == self.progressPage:
+        if self.with_progress_page and self.page(pageId) == self.progressPage:
             self.preWizard()
             self.performWizard()
             self.postWizard()
         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

=== added file 'openlp/plugins/songs/forms/duplicatesongremovalform.py'
--- openlp/plugins/songs/forms/duplicatesongremovalform.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/songs/forms/duplicatesongremovalform.py	2013-02-28 22:50:44 +0000
@@ -0,0 +1,352 @@
+# -*- 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.
+"""
+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.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
+        OpenLPWizard.__init__(self, self.main_window, plugin, u'duplicateSongRemovalWizard',
+            u':/wizards/wizard_duplicateremoval.bmp', False)
+
+    def customSignals(self):
+        """
+        Song wizard specific signals.
+        """
+        self.finishButton.clicked.connect(self.onWizardExit)
+        self.cancelButton.clicked.connect(self.onWizardExit)
+
+    def addCustomPages(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.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.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.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. 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'The song database is searched for double songs.'))
+        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 customPageChanged(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:
+            # 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()
+        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()
+        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 onWizardExit(self):
+        """
+        Once the wizard is finished, refresh the song list,
+        since we potentially removed songs from it.
+        """
+        self.plugin.mediaItem.onSearchTextButtonClicked()
+
+    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.
+        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)
+        # 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:
+        # 4 stretches + 1 SongReviewWidget = 5
+        # 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)
+
+    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.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.
+                widget = item.widget()
+                self.songs_horizontal_layout.removeItem(item) 
+                widget.setParent(None)
+            else:
+                self.songs_horizontal_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:
+            # 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]:
+                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()
+        # 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()
+    
+    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)

=== added file 'openlp/plugins/songs/forms/songreviewwidget.py'
--- openlp/plugins/songs/forms/songreviewwidget.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/songs/forms/songreviewwidget.py	2013-02-28 22:50:44 +0000
@@ -0,0 +1,183 @@
+# -*- 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.
+    """
+
+    # 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.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')
+        # 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)
+        # 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)
+        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 on_remove_button_clicked(self):
+        """
+        Signal emitted when the "remove" button is clicked.
+        """
+        self.song_remove_button_clicked.emit(self)

=== added file 'openlp/plugins/songs/lib/songcompare.py'
--- openlp/plugins/songs/lib/songcompare.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/songs/lib/songcompare.py	2013-02-28 22:50:44 +0000
@@ -0,0 +1,138 @@
+# -*- 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 (<max_typo_size) and are surrounded by larger equal blocks
+(>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)
+    # 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 (<max_typo_size)
+    surrounded by larger equal passages (>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

=== modified file 'openlp/plugins/songs/songsplugin.py'
--- openlp/plugins/songs/songsplugin.py	2013-02-05 08:05:28 +0000
+++ openlp/plugins/songs/songsplugin.py	2013-02-28 22:50:44 +0000
@@ -48,6 +48,7 @@
 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
 
 log = logging.getLogger(__name__)
 __default_settings__ = {
@@ -92,10 +93,12 @@
         self.songImportItem.setVisible(True)
         self.songExportItem.setVisible(True)
         self.toolsReindexItem.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.tools_find_duplicates, UiStrings().Tools)
 
     def addImportMenuItem(self, import_menu):
         """
@@ -131,7 +134,7 @@
 
     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``
@@ -145,6 +148,12 @@
             statustip=translate('SongsPlugin', 'Re-index the songs database to improve searching and ordering.'),
             visible=False, triggers=self.onToolsReindexItemTriggered)
         tools_menu.addAction(self.toolsReindexItem)
+        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 onToolsReindexItemTriggered(self):
         """
@@ -164,6 +173,12 @@
         self.manager.save_objects(songs)
         self.mediaItem.onSearchTextButtonClicked()
 
+    def on_tools_find_duplicates_triggered(self):
+        """
+        Search for duplicates in the song database.
+        """
+        DuplicateSongRemovalForm(self).exec_()
+
     def onSongImportItemClicked(self):
         if self.mediaItem:
             self.mediaItem.onImportClick()
@@ -284,10 +299,12 @@
         self.songImportItem.setVisible(False)
         self.songExportItem.setVisible(False)
         self.toolsReindexItem.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.tools_find_duplicates, UiStrings().Tools)
         Plugin.finalise(self)
 
     def new_service_created(self):

=== modified file 'resources/images/openlp-2.qrc'
--- resources/images/openlp-2.qrc	2012-12-06 19:26:50 +0000
+++ resources/images/openlp-2.qrc	2013-02-28 22:50:44 +0000
@@ -20,6 +20,7 @@
     <file>song_author_edit.png</file>
     <file>song_topic_edit.png</file>
     <file>song_book_edit.png</file>
+    <file>song_delete.png</file>
   </qresource>
   <qresource prefix="bibles">
     <file>bibles_search_text.png</file>
@@ -98,6 +99,7 @@
     <file>wizard_importbible.bmp</file>
     <file>wizard_firsttime.bmp</file>
     <file>wizard_createtheme.bmp</file>
+    <file>wizard_duplicateremoval.bmp</file>
   </qresource>
   <qresource prefix="services">
     <file>service_collapse_all.png</file>

=== added file 'resources/images/wizard_duplicateremoval.bmp'
Binary files resources/images/wizard_duplicateremoval.bmp	1970-01-01 00:00:00 +0000 and resources/images/wizard_duplicateremoval.bmp	2013-02-28 22:50:44 +0000 differ
=== added directory 'tests/functional/openlp_plugins'
=== added directory 'tests/functional/openlp_plugins/songs'
=== added file 'tests/functional/openlp_plugins/songs/test_lib.py'
--- tests/functional/openlp_plugins/songs/test_lib.py	1970-01-01 00:00:00 +0000
+++ tests/functional/openlp_plugins/songs/test_lib.py	2013-02-28 22:50:44 +0000
@@ -0,0 +1,221 @@
+# -*- 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.songcompare import songs_probably_equal, _remove_typos, _op_length
+
+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_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.'


Follow ups