openlp/openlp/plugins/songs/forms/editsongform.py

1124 lines
52 KiB
Python

# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2023 OpenLP Developers #
# ---------------------------------------------------------------------- #
# 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, either version 3 of the License, or #
# (at your option) any later version. #
# #
# 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, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
The :mod:`~openlp.plugins.songs.forms.editsongform` module contains the form
used to edit songs.
"""
import logging
import re
from shutil import copyfile
from PyQt5 import QtCore, QtWidgets, QtGui
from openlp.core.common import sha256_file_hash
from openlp.core.common.applocation import AppLocation
from openlp.core.common.i18n import UiStrings, get_natural_key, translate
from openlp.core.common.mixins import RegistryProperties
from openlp.core.common.path import create_paths
from openlp.core.common.registry import Registry
from openlp.core.lib import MediaType, create_separated_list
from openlp.core.lib.formattingtags import FormattingTags
from openlp.core.lib.plugin import PluginStatus
from openlp.core.lib.ui import critical_error_message_box, find_and_set_in_combo_box, set_case_insensitive_completer
from openlp.core.state import State
from openlp.core.widgets.dialogs import FileDialog
from openlp.plugins.songs.forms.editsongdialog import Ui_EditSongDialog
from openlp.plugins.songs.forms.editverseform import EditVerseForm
from openlp.plugins.songs.forms.mediafilesform import MediaFilesForm
from openlp.plugins.songs.lib import VerseType, clean_song
from openlp.plugins.songs.lib.db import Author, AuthorType, SongBook, MediaFile, Song, SongBookEntry, Topic
from openlp.plugins.songs.lib.openlyricsxml import SongXML
from openlp.plugins.songs.lib.ui import SongStrings, show_key_warning
log = logging.getLogger(__name__)
class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties):
"""
Class to manage the editing of a song
"""
log.info('{name} EditSongForm loaded'.format(name=__name__))
def __init__(self, media_item, parent, manager):
"""
Constructor
"""
super(EditSongForm, self).__init__(parent, QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowTitleHint |
QtCore.Qt.WindowCloseButtonHint)
self.media_item = media_item
self.song = None
# can this be automated?
self.width = 400
self.setup_ui(self)
# Connecting signals and slots
self.author_add_button.clicked.connect(self.on_author_add_button_clicked)
self.author_edit_button.clicked.connect(self.on_author_edit_button_clicked)
self.author_remove_button.clicked.connect(self.on_author_remove_button_clicked)
self.authors_list_view.itemClicked.connect(self.on_authors_list_view_clicked)
self.topic_add_button.clicked.connect(self.on_topic_add_button_clicked)
self.topic_remove_button.clicked.connect(self.on_topic_remove_button_clicked)
self.topics_list_view.itemClicked.connect(self.on_topic_list_view_clicked)
self.songbook_add_button.clicked.connect(self.on_songbook_add_button_clicked)
self.songbook_remove_button.clicked.connect(self.on_songbook_remove_button_clicked)
self.songbooks_list_view.itemClicked.connect(self.on_songbook_list_view_clicked)
self.copyright_insert_button.clicked.connect(self.on_copyright_insert_button_triggered)
self.verse_add_button.clicked.connect(self.on_verse_add_button_clicked)
self.verse_list_widget.doubleClicked.connect(self.on_verse_edit_button_clicked)
self.verse_edit_button.clicked.connect(self.on_verse_edit_button_clicked)
self.verse_edit_all_button.clicked.connect(self.on_verse_edit_all_button_clicked)
self.verse_delete_button.clicked.connect(self.on_verse_delete_button_clicked)
self.verse_list_widget.itemClicked.connect(self.on_verse_list_view_clicked)
self.verse_order_edit.textEdited.connect(self.on_verse_order_text_changed)
self.theme_add_button.clicked.connect(self.theme_manager.on_add_theme)
self.maintenance_button.clicked.connect(self.on_maintenance_button_clicked)
self.from_file_button.clicked.connect(self.on_audio_add_from_file_button_clicked)
self.from_media_button.clicked.connect(self.on_audio_add_from_media_button_clicked)
self.audio_remove_button.clicked.connect(self.on_audio_remove_button_clicked)
self.audio_remove_all_button.clicked.connect(self.on_audio_remove_all_button_clicked)
Registry().register_function('theme_update_list', self.load_themes)
self.preview_button = QtWidgets.QPushButton()
self.preview_button.setObjectName('preview_button')
self.preview_button.setText(UiStrings().SaveAndPreview)
self.button_box.addButton(self.preview_button, QtWidgets.QDialogButtonBox.ActionRole)
self.button_box.clicked.connect(self.on_preview)
# Create other objects and forms
self.manager = manager
self.verse_form = EditVerseForm(self)
self.media_form = MediaFilesForm(self)
self.initialise()
self.authors_list_view.setSortingEnabled(False)
self.authors_list_view.setAlternatingRowColors(True)
self.topics_list_view.setSortingEnabled(False)
self.topics_list_view.setAlternatingRowColors(True)
self.audio_list_widget.setAlternatingRowColors(True)
self.find_verse_split = re.compile(r'---\[\]---\n')
self.whitespace = re.compile(r'\W+')
self.find_tags = re.compile(r'\{/?\w+\}')
def _load_objects(self, cls, combo, cache):
"""
Generically load a set of objects into a cache and a combobox.
"""
def get_key(obj):
"""Get the key to sort by"""
return get_natural_key(obj.name)
objects = self.manager.get_all_objects(cls)
objects.sort(key=get_key)
combo.clear()
for obj in objects:
row = combo.count()
combo.addItem(obj.name)
cache.append(obj.name)
combo.setItemData(row, obj.id)
set_case_insensitive_completer(cache, combo)
combo.setCurrentIndex(-1)
combo.setCurrentText('')
def _add_author_to_list(self, author, author_type):
"""
Add an author to the author list.
"""
author_item = QtWidgets.QListWidgetItem(author.get_display_name(author_type))
author_item.setData(QtCore.Qt.UserRole, (author.id, author_type))
self.authors_list_view.addItem(author_item)
def add_songbook_entry_to_list(self, songbook_id, songbook_name, entry):
songbook_entry_item = QtWidgets.QListWidgetItem(SongBookEntry.get_display_name(songbook_name, entry))
songbook_entry_item.setData(QtCore.Qt.UserRole, (songbook_id, entry))
self.songbooks_list_view.addItem(songbook_entry_item)
def _extract_verse_order(self, verse_order):
"""
Split out the verse order
:param verse_order: The starting verse order
:return: revised order
"""
order = []
order_names = str(verse_order).split()
for item in order_names:
if len(item) == 1:
verse_index = VerseType.from_translated_tag(item, None)
if verse_index is not None:
order.append(VerseType.tags[verse_index] + '1')
else:
# it matches no verses anyway
order.append('')
else:
verse_index = VerseType.from_translated_tag(item[0], None)
if verse_index is None:
# it matches no verses anyway
order.append('')
else:
verse_tag = VerseType.tags[verse_index]
verse_num = item[1:].lower()
order.append(verse_tag + verse_num)
return order
def _validate_verse_list(self, verse_order, verse_count):
"""
Check the verse order list has valid verses
:param verse_order: Verse order
:param verse_count: number of verses
:return: Count of invalid verses
"""
verses = []
invalid_verses = []
verse_names = []
order_names = str(verse_order).split()
order = self._extract_verse_order(verse_order)
for index in range(verse_count):
verse = self.verse_list_widget.item(index, 0)
verse = verse.data(QtCore.Qt.UserRole)
if verse not in verse_names:
verses.append(verse)
verse_names.append('{verse1}{verse2}'.format(verse1=VerseType.translated_tag(verse[0]),
verse2=verse[1:]))
for count, item in enumerate(order):
if item not in verses:
invalid_verses.append(order_names[count])
if invalid_verses:
valid = create_separated_list(verse_names)
if len(invalid_verses) > 1:
msg = translate('SongsPlugin.EditSongForm',
'There are no verses corresponding to "{invalid}". Valid entries are {valid}.\n'
'Please enter the verses separated by spaces.'
).format(invalid=', '.join(invalid_verses), valid=valid)
else:
msg = translate('SongsPlugin.EditSongForm',
'There is no verse corresponding to "{invalid}". Valid entries are {valid}.\n'
'Please enter the verses separated by spaces.').format(invalid=invalid_verses[0],
valid=valid)
critical_error_message_box(title=translate('SongsPlugin.EditSongForm', 'Invalid Verse Order'), message=msg)
return len(invalid_verses) == 0
def _validate_song(self):
"""
Check the validity of the song.
"""
# This checks data in the form *not* self.song. self.song is still
# None at this point.
log.debug('Validate Song')
# Lets be nice and assume the data is correct.
if not self.title_edit.text():
self.song_tab_widget.setCurrentIndex(0)
self.title_edit.setFocus()
critical_error_message_box(
message=translate('SongsPlugin.EditSongForm', 'You need to type in a song title.'))
return False
if self.verse_list_widget.rowCount() == 0:
self.song_tab_widget.setCurrentIndex(0)
self.verse_list_widget.setFocus()
critical_error_message_box(
message=translate('SongsPlugin.EditSongForm', 'You need to type in at least one verse.'))
return False
if self.authors_list_view.count() == 0:
self.song_tab_widget.setCurrentIndex(1)
self.authors_list_view.setFocus()
critical_error_message_box(message=translate('SongsPlugin.EditSongForm',
'You need to have an author for this song.'))
return False
if self.verse_order_edit.text():
result = self._validate_verse_list(self.verse_order_edit.text(), self.verse_list_widget.rowCount())
if not result:
return False
# Validate tags (lp#1199639)
misplaced_tags = []
verse_tags = []
chords = []
for i in range(self.verse_list_widget.rowCount()):
item = self.verse_list_widget.item(i, 0)
tags = self.find_tags.findall(item.text())
stripped_text = re.sub(r'\[---\]', "\n", re.sub(r'\[--}{--\]', "\n", item.text()))
r = re.compile(r'\[(.*?)\]')
for match in r.finditer(stripped_text):
chords += match[1]
field = item.data(QtCore.Qt.UserRole)
verse_tags.append(field)
if not self._validate_tags(tags):
misplaced_tags.append('{field1} {field2}'.format(field1=VerseType.translated_name(field[0]),
field2=field[1:]))
if chords and not chords[0].startswith("="):
show_key_warning(self)
if misplaced_tags:
critical_error_message_box(
message=translate('SongsPlugin.EditSongForm',
'There are misplaced formatting tags in the following verses:\n\n{tag}\n\n'
'Please correct these tags before continuing.').format(tag=', '.join(misplaced_tags)))
return False
for tag in verse_tags:
if verse_tags.count(tag) > 26:
# lp#1310523: OpenLyrics allows only a-z variants of one verse:
# http://openlyrics.info/dataformat.html#verse-name
critical_error_message_box(message=translate(
'SongsPlugin.EditSongForm',
'You have {count} verses named {name} {number}. You can have at most '
'26 verses with the same name').format(count=verse_tags.count(tag),
name=VerseType.translated_name(tag[0]),
number=tag[1:]))
return False
return True
def _validate_tags(self, tags, first_time=True):
"""
Validates a list of tags
Deletes the first affiliated tag pair which is located side by side in the list
and call itself recursively with the shortened tag list.
If there is any misplaced tag in the list, either the length of the tag list is not even,
or the function won't find any tag pairs side by side.
If there is no misplaced tag, the length of the list will be zero on any recursive run.
:param tags: A list of tags
:return: True if the function can't find any mismatched tags. Else False.
"""
if first_time:
fixed_tags = []
endless_tags = []
for formatting_tag in FormattingTags.get_html_tags():
if not formatting_tag['end html']:
endless_tags.append(formatting_tag['start tag'])
for i in range(len(tags)):
if tags[i] not in endless_tags:
fixed_tags.append(tags[i])
tags = fixed_tags
if len(tags) == 0:
return True
if len(tags) % 2 != 0:
return False
for i in range(len(tags) - 1):
if tags[i + 1] == "{/" + tags[i][1:]:
del tags[i:i + 2]
return self._validate_tags(tags, False)
return False
def _process_lyrics(self):
"""
Process the lyric data entered by the user into the OpenLP XML format.
"""
# This method must only be run after the self.song = Song() assignment.
log.debug('_processLyrics')
sxml = None
try:
sxml = SongXML()
multiple = []
for i in range(self.verse_list_widget.rowCount()):
item = self.verse_list_widget.item(i, 0)
verse_id = item.data(QtCore.Qt.UserRole)
verse_tag = verse_id[0]
verse_num = verse_id[1:]
sxml.add_verse_to_lyrics(verse_tag, verse_num, item.text())
if verse_num > '1' and verse_tag not in multiple:
multiple.append(verse_tag)
self.song.lyrics = str(sxml.extract_xml(), 'utf-8')
for verse in multiple:
self.song.verse_order = re.sub(r'([' + verse.upper() + verse.lower() + r'])(\W|$)',
r'\g<1>1\2', self.song.verse_order)
except Exception:
log.exception('Problem processing song Lyrics \n{xml}'.format(xml=sxml.dump_xml()))
raise
def keyPressEvent(self, event):
"""
Re-implement the keyPressEvent to react on Return/Enter keys. When some combo boxes have focus we do not want
dialog's default action be triggered but instead our own.
:param event: A QtWidgets.QKeyEvent event.
"""
if event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return):
if self.authors_combo_box.hasFocus() and self.authors_combo_box.currentText():
self.on_author_add_button_clicked()
return
if self.topics_combo_box.hasFocus() and self.topics_combo_box.currentText():
self.on_topic_add_button_clicked()
return
if self.songbooks_combo_box.hasFocus() or self.songbook_entry_edit.hasFocus():
self.on_songbook_add_button_clicked()
return
QtWidgets.QDialog.keyPressEvent(self, event)
def initialise(self):
"""
Set up the form for when it is displayed.
"""
self.verse_edit_button.setEnabled(False)
self.verse_delete_button.setEnabled(False)
self.author_edit_button.setEnabled(False)
self.author_remove_button.setEnabled(False)
self.topic_remove_button.setEnabled(False)
def load_authors(self):
"""
Load the authors from the database into the combobox.
"""
def get_author_key(author):
"""Get the key to sort by"""
return get_natural_key(author.display_name)
authors = self.manager.get_all_objects(Author)
authors.sort(key=get_author_key)
self.authors_combo_box.clear()
self.authors = []
for author in authors:
row = self.authors_combo_box.count()
self.authors_combo_box.addItem(author.display_name)
self.authors_combo_box.setItemData(row, author.id)
self.authors.append(author.display_name)
set_case_insensitive_completer(self.authors, self.authors_combo_box)
self.authors_combo_box.setCurrentIndex(-1)
self.authors_combo_box.setCurrentText('')
# Types
self.author_types_combo_box.clear()
# Don't iterate over the dictionary to give them this specific order
for author_type in AuthorType.SortedTypes:
self.author_types_combo_box.addItem(AuthorType.Types[author_type], author_type)
def load_topics(self):
"""
Load the topics into the combobox.
"""
self.topics = []
self._load_objects(Topic, self.topics_combo_box, self.topics)
def load_songbooks(self):
"""
Load the Songbooks into the combobox
"""
self.songbooks = []
self._load_objects(SongBook, self.songbooks_combo_box, self.songbooks)
def load_themes(self, theme_list: list):
"""
Load the themes into a combobox.
"""
def get_theme_key(theme):
"""Get the key to sort by"""
return get_natural_key(theme)
self.theme_combo_box.clear()
theme_list.insert(0, f"<{UiStrings().Default}>")
self.themes = theme_list
self.themes.sort(key=get_theme_key)
self.theme_combo_box.addItems(theme_list)
set_case_insensitive_completer(self.themes, self.theme_combo_box)
self.theme_combo_box.setCurrentIndex(-1)
self.theme_combo_box.setCurrentText('')
def load_media_files(self):
"""
Load the media files into a combobox.
"""
self.from_media_button.setVisible(False)
for plugin in State().list_plugins():
if plugin.name == 'media' and plugin.status == PluginStatus.Active:
self.from_media_button.setVisible(True)
self.media_form.populate_files(plugin.media_item.get_list(MediaType.Audio))
break
def new_song(self):
"""
Blank the edit form out in preparation for a new song.
"""
log.debug('New Song')
self.song = None
self.initialise()
self.song_tab_widget.setCurrentIndex(0)
self.title_edit.clear()
self.alternative_edit.clear()
self.copyright_edit.clear()
self.verse_order_edit.clear()
self.comments_edit.clear()
self.ccli_number_edit.clear()
self.verse_list_widget.clear()
self.verse_list_widget.setRowCount(0)
self.authors_list_view.clear()
self.topics_list_view.clear()
self.songbooks_list_view.clear()
self.songbook_entry_edit.clear()
self.audio_list_widget.clear()
self.title_edit.setFocus()
self.load_authors()
self.load_topics()
self.load_songbooks()
self.load_media_files()
self.theme_combo_box.setCurrentIndex(-1)
self.theme_combo_box.setCurrentText('')
# it's a new song to preview is not possible
self.preview_button.setVisible(False)
def load_song(self, song_id, preview=False):
"""
Loads a song.
:param song_id: The song id (int).
:param preview: Should be ``True`` if the song is also previewed (boolean).
"""
log.debug('Load Song')
self.initialise()
self.song_tab_widget.setCurrentIndex(0)
self.load_authors()
self.load_topics()
self.load_songbooks()
self.load_media_files()
self.song = self.manager.get_object(Song, song_id)
self.title_edit.setText(self.song.title)
self.alternative_edit.setText(self.song.alternate_title if self.song.alternate_title else '')
if self.song.theme_name:
find_and_set_in_combo_box(self.theme_combo_box, str(self.song.theme_name))
else:
# Clear the theme combo box in case it was previously set (bug #1212801)
self.theme_combo_box.setCurrentIndex(-1)
self.theme_combo_box.setCurrentText('')
self.copyright_edit.setText(self.song.copyright if self.song.copyright else '')
self.comments_edit.setPlainText(self.song.comments if self.song.comments else '')
self.ccli_number_edit.setText(self.song.ccli_number if self.song.ccli_number else '')
# lazy xml migration for now
self.verse_list_widget.clear()
self.verse_list_widget.setRowCount(0)
verse_tags_translated = False
if self.song.lyrics.startswith('<?xml version='):
song_xml = SongXML()
verse_list = song_xml.get_verses(self.song.lyrics)
for count, verse in enumerate(verse_list):
self.verse_list_widget.setRowCount(self.verse_list_widget.rowCount() + 1)
# This silently migrates from localized verse type markup.
# If we trusted the database, this would be unnecessary.
verse_tag = verse[0]['type']
index = None
if len(verse_tag) > 1:
index = VerseType.from_translated_string(verse_tag)
if index is None:
index = VerseType.from_string(verse_tag, None)
else:
verse_tags_translated = True
if index is None:
index = VerseType.from_tag(verse_tag)
verse[0]['type'] = VerseType.tags[index]
if verse[0]['label'] == '':
verse[0]['label'] = '1'
verse_def = '{verse}{label}'.format(verse=verse[0]['type'], label=verse[0]['label'])
item = QtWidgets.QTableWidgetItem(verse[1])
item.setData(QtCore.Qt.UserRole, verse_def)
self.verse_list_widget.setItem(count, 0, item)
else:
verses = self.song.lyrics.split('\n\n')
for count, verse in enumerate(verses):
self.verse_list_widget.setRowCount(self.verse_list_widget.rowCount() + 1)
item = QtWidgets.QTableWidgetItem(verse)
verse_def = '{verse}{count:d}'.format(verse=VerseType.tags[VerseType.Verse], count=(count + 1))
item.setData(QtCore.Qt.UserRole, verse_def)
self.verse_list_widget.setItem(count, 0, item)
if self.song.verse_order:
# we translate verse order
translated = []
for verse_def in self.song.verse_order.split():
verse_index = None
if verse_tags_translated:
verse_index = VerseType.from_translated_tag(verse_def[0], None)
if verse_index is None:
verse_index = VerseType.from_tag(verse_def[0])
verse_tag = VerseType.translated_tags[verse_index].upper()
translated.append('{tag}{verse}'.format(tag=verse_tag, verse=verse_def[1:]))
self.verse_order_edit.setText(' '.join(translated))
else:
self.verse_order_edit.setText('')
self.tag_rows()
# clear the results
self.authors_list_view.clear()
for author_song in self.song.authors_songs:
self._add_author_to_list(author_song.author, author_song.author_type)
# clear the results
self.topics_list_view.clear()
for topic in self.song.topics:
topic_name = QtWidgets.QListWidgetItem(str(topic.name))
topic_name.setData(QtCore.Qt.UserRole, topic.id)
self.topics_list_view.addItem(topic_name)
self.songbooks_list_view.clear()
self.songbook_entry_edit.clear()
for songbook_entry in self.song.songbook_entries:
self.add_songbook_entry_to_list(songbook_entry.songbook.id, songbook_entry.songbook.name,
songbook_entry.entry)
self.audio_list_widget.clear()
for media in self.song.media_files:
item = QtWidgets.QListWidgetItem(media.file_path.name)
item.setData(QtCore.Qt.UserRole, media.file_path)
self.audio_list_widget.addItem(item)
self.title_edit.setFocus()
# Hide or show the preview button.
self.preview_button.setVisible(preview)
# Check if all verse tags are used.
self.on_verse_order_text_changed(self.verse_order_edit.text())
def tag_rows(self):
"""
Tag the Song List rows based on the verse list
"""
row_label = []
for row in range(self.verse_list_widget.rowCount()):
item = self.verse_list_widget.item(row, 0)
verse_def = item.data(QtCore.Qt.UserRole)
verse_tag = VerseType.translated_tag(verse_def[0])
row_def = '{tag}{verse}'.format(tag=verse_tag, verse=verse_def[1:])
row_label.append(row_def)
self.verse_list_widget.setVerticalHeaderLabels(row_label)
self.verse_list_widget.resizeRowsToContents()
self.verse_list_widget.repaint()
def on_author_add_button_clicked(self):
"""
Add the author to the list of authors associated with this song when the button is clicked.
"""
item = int(self.authors_combo_box.currentIndex())
text = self.authors_combo_box.currentText().strip(' \r\n\t')
author_type = self.author_types_combo_box.itemData(self.author_types_combo_box.currentIndex())
if item == -1 and text:
if QtWidgets.QMessageBox.question(
self,
translate('SongsPlugin.EditSongForm', 'Add Author'),
translate('SongsPlugin.EditSongForm', 'This author does not exist, do you want to add them?'),
defaultButton=QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes:
if text.find(' ') == -1:
author = Author(first_name='', last_name='', display_name=text)
else:
author = Author(first_name=text.rsplit(' ', 1)[0], last_name=text.rsplit(' ', 1)[1],
display_name=text)
self.manager.save_object(author)
self._add_author_to_list(author, author_type)
self.load_authors()
self.authors_combo_box.setCurrentIndex(-1)
self.authors_combo_box.setCurrentText('')
else:
return
elif item >= 0:
item_id = (self.authors_combo_box.itemData(item))
author = self.manager.get_object(Author, item_id)
if self.authors_list_view.findItems(author.get_display_name(author_type), QtCore.Qt.MatchExactly):
critical_error_message_box(
message=translate('SongsPlugin.EditSongForm', 'This author is already in the list.'))
else:
self._add_author_to_list(author, author_type)
self.authors_combo_box.setCurrentIndex(-1)
self.authors_combo_box.setCurrentText('')
else:
QtWidgets.QMessageBox.warning(
self, UiStrings().NISs,
translate('SongsPlugin.EditSongForm', 'You have not selected a valid author. Either select an author '
'from the list, or type in a new author and click the "Add Author to Song" button to add '
'the new author.'))
def on_authors_list_view_clicked(self):
"""
Run a set of actions when an author in the list is selected (mainly enable the delete button).
"""
count = self.authors_list_view.count()
if count > 0:
self.author_edit_button.setEnabled(True)
if count > 1:
# There must be at least one author
self.author_remove_button.setEnabled(True)
def on_author_edit_button_clicked(self):
"""
Show a dialog to change the type of an author when the edit button is clicked
"""
self.author_edit_button.setEnabled(False)
item = self.authors_list_view.currentItem()
author_id, author_type = item.data(QtCore.Qt.UserRole)
choice, ok = QtWidgets.QInputDialog.getItem(self, translate('SongsPlugin.EditSongForm', 'Edit Author Type'),
translate('SongsPlugin.EditSongForm',
'Choose type for this author'),
AuthorType.TranslatedTypes,
current=AuthorType.SortedTypes.index(author_type),
editable=False)
if not ok:
return
author = self.manager.get_object(Author, author_id)
author_type = AuthorType.from_translated_text(choice)
item.setData(QtCore.Qt.UserRole, (author_id, author_type))
item.setText(author.get_display_name(author_type))
def on_author_remove_button_clicked(self):
"""
Remove the author from the list when the delete button is clicked.
"""
if self.authors_list_view.count() <= 2:
self.author_remove_button.setEnabled(False)
item = self.authors_list_view.currentItem()
row = self.authors_list_view.row(item)
self.authors_list_view.takeItem(row)
def on_topic_add_button_clicked(self):
item = int(self.topics_combo_box.currentIndex())
text = self.topics_combo_box.currentText()
if item == -1 and text:
if QtWidgets.QMessageBox.question(
self, translate('SongsPlugin.EditSongForm', 'Add Topic'),
translate('SongsPlugin.EditSongForm', 'This topic does not exist, do you want to add it?'),
defaultButton=QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes:
topic = Topic(name=text)
self.manager.save_object(topic)
topic_item = QtWidgets.QListWidgetItem(str(topic.name))
topic_item.setData(QtCore.Qt.UserRole, topic.id)
self.topics_list_view.addItem(topic_item)
self.load_topics()
self.topics_combo_box.setCurrentIndex(-1)
self.topics_combo_box.setCurrentText('')
else:
return
elif item >= 0:
item_id = (self.topics_combo_box.itemData(item))
topic = self.manager.get_object(Topic, item_id)
if self.topics_list_view.findItems(str(topic.name), QtCore.Qt.MatchExactly):
critical_error_message_box(
message=translate('SongsPlugin.EditSongForm', 'This topic is already in the list.'))
else:
topic_item = QtWidgets.QListWidgetItem(str(topic.name))
topic_item.setData(QtCore.Qt.UserRole, topic.id)
self.topics_list_view.addItem(topic_item)
self.topics_combo_box.setCurrentIndex(-1)
self.topics_combo_box.setCurrentText('')
else:
QtWidgets.QMessageBox.warning(
self, UiStrings().NISs,
translate('SongsPlugin.EditSongForm', 'You have not selected a valid topic. Either select a topic '
'from the list, or type in a new topic and click the "Add Topic to Song" button to add the '
'new topic.'))
def on_topic_list_view_clicked(self):
self.topic_remove_button.setEnabled(True)
def on_topic_remove_button_clicked(self):
self.topic_remove_button.setEnabled(False)
item = self.topics_list_view.currentItem()
row = self.topics_list_view.row(item)
self.topics_list_view.takeItem(row)
def on_songbook_add_button_clicked(self):
item = int(self.songbooks_combo_box.currentIndex())
text = self.songbooks_combo_box.currentText()
if item == -1 and text:
if QtWidgets.QMessageBox.question(
self, translate('SongsPlugin.EditSongForm', 'Add Songbook'),
translate('SongsPlugin.EditSongForm', 'This Songbook does not exist, do you want to add it?'),
defaultButton=QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes:
songbook = SongBook(name=text)
self.manager.save_object(songbook)
self.add_songbook_entry_to_list(songbook.id, songbook.name, self.songbook_entry_edit.text())
self.load_songbooks()
self.songbooks_combo_box.setCurrentIndex(-1)
self.songbooks_combo_box.setCurrentText('')
self.songbook_entry_edit.clear()
else:
return
elif item >= 0:
item_id = (self.songbooks_combo_box.itemData(item))
songbook = self.manager.get_object(SongBook, item_id)
if self.songbooks_list_view.findItems(str(songbook.name), QtCore.Qt.MatchExactly):
critical_error_message_box(
message=translate('SongsPlugin.EditSongForm', 'This Songbook is already in the list.'))
else:
self.add_songbook_entry_to_list(songbook.id, songbook.name, self.songbook_entry_edit.text())
self.songbooks_combo_box.setCurrentIndex(-1)
self.songbooks_combo_box.setCurrentText('')
self.songbook_entry_edit.clear()
else:
QtWidgets.QMessageBox.warning(
self, UiStrings().NISs,
translate('SongsPlugin.EditSongForm', 'You have not selected a valid Songbook. Either select a '
'Songbook from the list, or type in a new Songbook and click the "Add to Song" '
'button to add the new Songbook.'))
def on_songbook_list_view_clicked(self):
self.songbook_remove_button.setEnabled(True)
def on_songbook_remove_button_clicked(self):
self.songbook_remove_button.setEnabled(False)
row = self.songbooks_list_view.row(self.songbooks_list_view.currentItem())
self.songbooks_list_view.takeItem(row)
def on_verse_list_view_clicked(self):
self.verse_edit_button.setEnabled(True)
self.verse_delete_button.setEnabled(True)
def on_verse_add_button_clicked(self):
self.verse_form.set_verse('', True)
if self.verse_form.exec():
after_text, verse_tag, verse_num = self.verse_form.get_verse()
verse_def = '{tag}{number}'.format(tag=verse_tag, number=verse_num)
item = QtWidgets.QTableWidgetItem(after_text)
item.setData(QtCore.Qt.UserRole, verse_def)
item.setText(after_text)
self.verse_list_widget.setRowCount(self.verse_list_widget.rowCount() + 1)
self.verse_list_widget.setItem(self.verse_list_widget.rowCount() - 1, 0, item)
self.tag_rows()
# Check if all verse tags are used.
self.on_verse_order_text_changed(self.verse_order_edit.text())
def on_verse_edit_button_clicked(self):
item = self.verse_list_widget.currentItem()
if item:
temp_text = item.text()
verse_id = item.data(QtCore.Qt.UserRole)
self.verse_form.set_verse(temp_text, True, verse_id)
if self.verse_form.exec():
after_text, verse_tag, verse_num = self.verse_form.get_verse()
verse_def = '{tag}{number}'.format(tag=verse_tag, number=verse_num)
item.setData(QtCore.Qt.UserRole, verse_def)
item.setText(after_text)
# number of lines has changed, repaint the list moving the data
if len(temp_text.split('\n')) != len(after_text.split('\n')):
temp_list = []
temp_ids = []
for row in range(self.verse_list_widget.rowCount()):
item = self.verse_list_widget.item(row, 0)
temp_list.append(item.text())
temp_ids.append(item.data(QtCore.Qt.UserRole))
self.verse_list_widget.clear()
for row, entry in enumerate(temp_list):
item = QtWidgets.QTableWidgetItem(entry, 0)
item.setData(QtCore.Qt.UserRole, temp_ids[row])
self.verse_list_widget.setItem(row, 0, item)
self.tag_rows()
# Check if all verse tags are used.
self.on_verse_order_text_changed(self.verse_order_edit.text())
def on_verse_edit_all_button_clicked(self):
"""
Verse edit all button (save) pressed
:return:
"""
verse_list = ''
if self.verse_list_widget.rowCount() > 0:
for row in range(self.verse_list_widget.rowCount()):
item = self.verse_list_widget.item(row, 0)
field = item.data(QtCore.Qt.UserRole)
verse_tag = VerseType.translated_name(field[0])
verse_num = field[1:]
verse_list += '---[{tag}:{number}]---\n'.format(tag=verse_tag, number=verse_num)
verse_list += item.text()
verse_list += '\n'
self.verse_form.set_verse(verse_list)
else:
self.verse_form.set_verse('')
if not self.verse_form.exec():
return
verse_list = self.verse_form.get_all_verses()
verse_list = str(verse_list.replace('\r\n', '\n'))
self.verse_list_widget.clear()
self.verse_list_widget.setRowCount(0)
for row in self.find_verse_split.split(verse_list):
for match in row.split('---['):
for count, parts in enumerate(match.split(']---\n')):
if count == 0:
if len(parts) == 0:
continue
# handling carefully user inputted versetags
separator = parts.find(':')
if separator >= 0:
verse_name = parts[0:separator].strip()
verse_num = parts[separator + 1:].strip()
else:
verse_name = parts
verse_num = '1'
verse_index = VerseType.from_loose_input(verse_name)
verse_tag = VerseType.tags[verse_index]
# Later we need to handle v1a as well.
regex = re.compile(r'\D*(\d+)\D*')
match = regex.match(verse_num)
if match:
verse_num = match.group(1)
else:
verse_num = '1'
verse_def = '{tag}{number}'.format(tag=verse_tag, number=verse_num)
else:
if parts.endswith('\n'):
parts = parts.rstrip('\n')
item = QtWidgets.QTableWidgetItem(parts)
item.setData(QtCore.Qt.UserRole, verse_def)
self.verse_list_widget.setRowCount(self.verse_list_widget.rowCount() + 1)
self.verse_list_widget.setItem(self.verse_list_widget.rowCount() - 1, 0, item)
self.tag_rows()
self.verse_edit_button.setEnabled(False)
self.verse_delete_button.setEnabled(False)
# Check if all verse tags are used.
self.on_verse_order_text_changed(self.verse_order_edit.text())
def on_verse_delete_button_clicked(self):
"""
Verse Delete button pressed
"""
self.verse_list_widget.removeRow(self.verse_list_widget.currentRow())
if not self.verse_list_widget.selectedItems():
self.verse_edit_button.setEnabled(False)
self.verse_delete_button.setEnabled(False)
def on_verse_order_text_changed(self, text):
"""
Checks if the verse order is complete or missing. Shows a error message according to the state of the verse
order.
:param text: The text of the verse order edit (ignored).
"""
# First make sure that all letters entered in the verse order field are uppercase
pos = self.verse_order_edit.cursorPosition()
self.verse_order_edit.setText(text.upper())
self.verse_order_edit.setCursorPosition(pos)
# Extract all verses which were used in the order.
verses_in_order = self._extract_verse_order(self.verse_order_edit.text())
# Find the verses which were not used in the order.
verses_not_used = []
for index in range(self.verse_list_widget.rowCount()):
verse = self.verse_list_widget.item(index, 0)
verse = verse.data(QtCore.Qt.UserRole)
if verse not in verses_in_order:
verses_not_used.append(verse)
# Set the label text.
label_text = ''
# No verse order was entered.
if not verses_in_order:
label_text = self.no_verse_order_entered_warning
# The verse order does not contain all verses.
elif verses_not_used:
label_text = self.not_all_verses_used_warning
self.warning_label.setText(label_text)
def on_copyright_insert_button_triggered(self):
"""
Copyright insert button pressed
"""
text = self.copyright_edit.text()
pos = self.copyright_edit.cursorPosition()
sign = SongStrings.CopyrightSymbol
text = text[:pos] + sign + text[pos:]
self.copyright_edit.setText(text)
self.copyright_edit.setFocus()
self.copyright_edit.setCursorPosition(pos + len(sign))
def on_maintenance_button_clicked(self):
"""
Maintenance button pressed
"""
self.media_item.song_maintenance_form.exec(True)
self.load_authors()
self.load_songbooks()
self.load_topics()
def on_preview(self, button):
"""
Save and Preview button clicked.
The Song is valid so as the plugin to add it to preview to see.
:param button: A button (QPushButton).
"""
log.debug('onPreview')
if button.objectName() == 'preview_button':
self.save_song(True)
Registry().execute('songs_preview')
def on_audio_add_from_file_button_clicked(self):
"""
Loads file(s) from the filesystem.
"""
filters = '{text} (*)'.format(text=UiStrings().AllFiles)
file_paths, filter_used = FileDialog.getOpenFileNames(
parent=self, caption=translate('SongsPlugin.EditSongForm', 'Open File(s)'), filter=filters)
for file_path in file_paths:
item = QtWidgets.QListWidgetItem(file_path.name)
item.setData(QtCore.Qt.UserRole, file_path)
self.audio_list_widget.addItem(item)
def on_audio_add_from_media_button_clicked(self):
"""
Loads file(s) from the media plugin.
"""
if self.media_form.exec():
for file_path in self.media_form.get_selected_files():
item = QtWidgets.QListWidgetItem(file_path.name)
item.setData(QtCore.Qt.UserRole, file_path)
self.audio_list_widget.addItem(item)
def on_audio_remove_button_clicked(self):
"""
Removes a file from the list.
"""
row = self.audio_list_widget.currentRow()
if row == -1:
return
self.audio_list_widget.takeItem(row)
def on_audio_remove_all_button_clicked(self):
"""
Removes all files from the list.
"""
self.audio_list_widget.clear()
def on_up_button_clicked(self):
"""
Moves a file up when the user clicks the up button on the audio tab.
"""
row = self.audio_list_widget.currentRow()
if row <= 0:
return
item = self.audio_list_widget.takeItem(row)
self.audio_list_widget.insertItem(row - 1, item)
self.audio_list_widget.setCurrentRow(row - 1)
def on_down_button_clicked(self):
"""
Moves a file down when the user clicks the up button on the audio tab.
"""
row = self.audio_list_widget.currentRow()
if row == -1 or row > self.audio_list_widget.count() - 1:
return
item = self.audio_list_widget.takeItem(row)
self.audio_list_widget.insertItem(row + 1, item)
self.audio_list_widget.setCurrentRow(row + 1)
def clear_caches(self):
"""
Free up auto-completion memory on dialog exit
"""
log.debug('SongEditForm.clearCaches')
self.authors = []
self.themes = []
self.songbooks = []
self.topics = []
def reject(self):
"""
Exit Dialog and do not save
"""
log.debug('SongEditForm.reject')
self.clear_caches()
QtWidgets.QDialog.reject(self)
def accept(self):
"""
Exit Dialog and save song if valid
"""
log.debug('SongEditForm.accept')
self.clear_caches()
if self._validate_song():
self.save_song()
self.song = None
QtWidgets.QDialog.accept(self)
def save_song(self, preview=False):
"""
Get all the data from the widgets on the form, and then save it to the database. The form has been validated
and all reference items (Authors, SongBooks and Topics) have been saved before this function is called.
:param preview: Should be ``True`` if the song is also previewed (boolean).
"""
# The Song() assignment. No database calls should be made while a
# Song() is in a partially complete state.
if not self.song:
self.song = Song()
self.song.title = self.title_edit.text()
self.song.alternate_title = self.alternative_edit.text()
self.song.copyright = self.copyright_edit.text()
# Values will be set when cleaning the song.
self.song.search_title = ''
self.song.search_lyrics = ''
self.song.verse_order = ''
self.song.comments = self.comments_edit.toPlainText()
order_text = self.verse_order_edit.text()
order = []
for item in order_text.split():
verse_tag = VerseType.tags[VerseType.from_translated_tag(item[0])]
verse_num = item[1:].lower()
order.append('{tag}{number}'.format(tag=verse_tag, number=verse_num))
self.song.verse_order = ' '.join(order)
self.song.ccli_number = self.ccli_number_edit.text()
theme_name = self.theme_combo_box.currentText()
if theme_name and theme_name != f"<{UiStrings().Default}>":
self.song.theme_name = theme_name
else:
self.song.theme_name = None
self._process_lyrics()
self.song.authors_songs = []
for row in range(self.authors_list_view.count()):
item = self.authors_list_view.item(row)
self.song.add_author(self.manager.get_object(Author, item.data(QtCore.Qt.UserRole)[0]),
item.data(QtCore.Qt.UserRole)[1])
self.song.topics = []
for row in range(self.topics_list_view.count()):
item = self.topics_list_view.item(row)
topic_id = (item.data(QtCore.Qt.UserRole))
topic = self.manager.get_object(Topic, topic_id)
if topic is not None:
self.song.topics.append(topic)
self.song.songbook_entries = []
for row in range(self.songbooks_list_view.count()):
item = self.songbooks_list_view.item(row)
songbook_id = item.data(QtCore.Qt.UserRole)[0]
songbook = self.manager.get_object(SongBook, songbook_id)
entry = item.data(QtCore.Qt.UserRole)[1]
self.song.add_songbook_entry(songbook, entry)
# Save the song here because we need a valid id for the audio files.
clean_song(self.manager, self.song)
self.manager.save_object(self.song)
audio_paths = [a.file_path for a in self.song.media_files]
log.debug(audio_paths)
save_path = AppLocation.get_section_data_path(self.media_item.plugin.name) / 'audio' / str(self.song.id)
create_paths(save_path)
self.song.media_files = []
file_paths = []
for row in range(self.audio_list_widget.count()):
item = self.audio_list_widget.item(row)
file_path = item.data(QtCore.Qt.UserRole)
if save_path not in file_path.parents:
old_file_path, file_path = file_path, save_path / file_path.name
copyfile(old_file_path, file_path)
file_paths.append(file_path)
media_file = MediaFile()
media_file.file_path = file_path
media_file.file_hash = sha256_file_hash(file_path)
media_file.type = 'audio'
media_file.weight = row
self.song.media_files.append(media_file)
for audio_path in audio_paths:
if audio_path not in file_paths:
try:
audio_path.unlink()
except Exception:
log.exception('Could not remove file: {audio}'.format(audio=audio_path))
if not file_paths:
try:
save_path.rmdir()
except OSError:
log.exception('Could not remove directory: {path}'.format(path=save_path))
clean_song(self.manager, self.song)
self.manager.save_object(self.song)
self.media_item.auto_select_id = self.song.id
def provide_help(self):
"""
Provide help within the form by opening the appropriate page of the openlp manual in the user's browser
"""
QtGui.QDesktopServices.openUrl(QtCore.QUrl("https://manual.openlp.org/songs.html#creating-or-editing-a-song"
"-slide"))