forked from openlp/openlp
Duplicate Song finder merge - UI and Services.
bzr-revno: 2252
This commit is contained in:
commit
1aeb9bef2f
@ -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': [],
|
||||
|
@ -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
|
||||
"""
|
||||
|
358
openlp/plugins/songs/forms/duplicatesongremovalform.py
Normal file
358
openlp/plugins/songs/forms/duplicatesongremovalform.py
Normal file
@ -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)
|
213
openlp/plugins/songs/forms/songreviewwidget.py
Normal file
213
openlp/plugins/songs/forms/songreviewwidget.py
Normal file
@ -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)
|
@ -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)
|
||||
|
||||
|
@ -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()
|
||||
|
139
openlp/plugins/songs/lib/songcompare.py
Normal file
139
openlp/plugins/songs/lib/songcompare.py
Normal file
@ -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 (<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.
|
||||
"""
|
||||
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 (<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
|
@ -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):
|
||||
|
@ -19,6 +19,7 @@
|
||||
<file>topic_maintenance.png</file>
|
||||
<file>song_author_edit.png</file>
|
||||
<file>song_book_edit.png</file>
|
||||
<file>song_delete.png</file>
|
||||
</qresource>
|
||||
<qresource prefix="images">
|
||||
<file>image_group.png</file>
|
||||
@ -101,6 +102,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>
|
||||
|
BIN
resources/images/wizard_duplicateremoval.bmp
Normal file
BIN
resources/images/wizard_duplicateremoval.bmp
Normal file
Binary file not shown.
After Width: | Height: | Size: 168 KiB |
@ -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):
|
||||
"""
|
||||
|
Loading…
Reference in New Issue
Block a user