mirror of https://gitlab.com/openlp/openlp.git
Change SongSelect import procedure to import when clicking download on webpage
This commit is contained in:
parent
eaa97f433d
commit
ad8dcb0f8a
|
@ -22,7 +22,6 @@
|
|||
from PyQt5 import QtWidgets
|
||||
|
||||
from openlp.core.common.i18n import UiStrings, translate
|
||||
from openlp.core.common.registry import Registry
|
||||
from openlp.core.lib.ui import create_button_box
|
||||
from openlp.core.ui.icons import UiIcons
|
||||
from openlp.core.widgets.edits import SpellTextEdit
|
||||
|
@ -68,22 +67,21 @@ class Ui_EditVerseDialog(object):
|
|||
self.verse_type_layout.addWidget(self.insert_button)
|
||||
self.verse_type_layout.addStretch()
|
||||
self.dialog_layout.addLayout(self.verse_type_layout)
|
||||
if Registry().get('settings').value('songs/enable chords'):
|
||||
self.transpose_widget = QtWidgets.QWidget()
|
||||
self.transpose_layout = QtWidgets.QHBoxLayout(self.transpose_widget)
|
||||
self.transpose_layout.setObjectName('transpose_layout')
|
||||
self.transpose_label = QtWidgets.QLabel(edit_verse_dialog)
|
||||
self.transpose_label.setObjectName('transpose_label')
|
||||
self.transpose_layout.addWidget(self.transpose_label)
|
||||
self.transpose_up_button = QtWidgets.QPushButton(edit_verse_dialog)
|
||||
self.transpose_up_button.setIcon(UiIcons().arrow_up)
|
||||
self.transpose_up_button.setObjectName('transpose_up')
|
||||
self.transpose_layout.addWidget(self.transpose_up_button)
|
||||
self.transpose_down_button = QtWidgets.QPushButton(edit_verse_dialog)
|
||||
self.transpose_down_button.setIcon(UiIcons().arrow_down)
|
||||
self.transpose_down_button.setObjectName('transpose_down')
|
||||
self.transpose_layout.addWidget(self.transpose_down_button)
|
||||
self.dialog_layout.addWidget(self.transpose_widget)
|
||||
self.transpose_widget = QtWidgets.QWidget()
|
||||
self.transpose_layout = QtWidgets.QHBoxLayout(self.transpose_widget)
|
||||
self.transpose_layout.setObjectName('transpose_layout')
|
||||
self.transpose_label = QtWidgets.QLabel(edit_verse_dialog)
|
||||
self.transpose_label.setObjectName('transpose_label')
|
||||
self.transpose_layout.addWidget(self.transpose_label)
|
||||
self.transpose_up_button = QtWidgets.QPushButton(edit_verse_dialog)
|
||||
self.transpose_up_button.setIcon(UiIcons().arrow_up)
|
||||
self.transpose_up_button.setObjectName('transpose_up')
|
||||
self.transpose_layout.addWidget(self.transpose_up_button)
|
||||
self.transpose_down_button = QtWidgets.QPushButton(edit_verse_dialog)
|
||||
self.transpose_down_button.setIcon(UiIcons().arrow_down)
|
||||
self.transpose_down_button.setObjectName('transpose_down')
|
||||
self.transpose_layout.addWidget(self.transpose_down_button)
|
||||
self.dialog_layout.addWidget(self.transpose_widget)
|
||||
self.button_box = create_button_box(edit_verse_dialog, 'button_box', ['cancel', 'ok'])
|
||||
self.dialog_layout.addWidget(self.button_box)
|
||||
self.retranslate_ui(edit_verse_dialog)
|
||||
|
@ -106,7 +104,6 @@ class Ui_EditVerseDialog(object):
|
|||
self.insert_button.setText(translate('SongsPlugin.EditVerseForm', '&Insert'))
|
||||
self.insert_button.setToolTip(translate('SongsPlugin.EditVerseForm',
|
||||
'Split a slide into two by inserting a verse splitter.'))
|
||||
if Registry().get('settings').value('songs/enable chords'):
|
||||
self.transpose_label.setText(translate('SongsPlugin.EditVerseForm', 'Transpose:'))
|
||||
self.transpose_up_button.setText(translate('SongsPlugin.EditVerseForm', 'Up'))
|
||||
self.transpose_down_button.setText(translate('SongsPlugin.EditVerseForm', 'Down'))
|
||||
self.transpose_label.setText(translate('SongsPlugin.EditVerseForm', 'Transpose:'))
|
||||
self.transpose_up_button.setText(translate('SongsPlugin.EditVerseForm', 'Up'))
|
||||
self.transpose_down_button.setText(translate('SongsPlugin.EditVerseForm', 'Down'))
|
||||
|
|
|
@ -54,9 +54,12 @@ class EditVerseForm(QtWidgets.QDialog, Ui_EditVerseDialog):
|
|||
self.verse_text_edit.cursorPositionChanged.connect(self.on_cursor_position_changed)
|
||||
self.verse_type_combo_box.currentIndexChanged.connect(self.on_verse_type_combo_box_changed)
|
||||
self.forced_split_button.clicked.connect(self.on_forced_split_button_clicked)
|
||||
if Registry().get('settings').value('songs/enable chords'):
|
||||
self.transpose_down_button.clicked.connect(self.on_transpose_down_button_clicked)
|
||||
self.transpose_up_button.clicked.connect(self.on_transpose_up_button_clicked)
|
||||
self.transpose_down_button.clicked.connect(self.on_transpose_down_button_clicked)
|
||||
self.transpose_up_button.clicked.connect(self.on_transpose_up_button_clicked)
|
||||
|
||||
def exec(self):
|
||||
self.transpose_widget.setVisible(Registry().get('settings').value('songs/enable chords'))
|
||||
super(EditVerseForm, self).exec()
|
||||
|
||||
def insert_verse(self, verse_tag, verse_num=1):
|
||||
"""
|
||||
|
|
|
@ -131,14 +131,6 @@ class Ui_SongSelectDialog(object):
|
|||
self.message_area.setWordWrap(True)
|
||||
self.message_area.setObjectName('message_area')
|
||||
self.bottom_button_layout.addWidget(self.message_area, 0, 1, 1, 7)
|
||||
self.view_button = QtWidgets.QPushButton(songselect_dialog)
|
||||
self.view_button.setIcon(UiIcons().search)
|
||||
self.view_button.setObjectName('view_button')
|
||||
self.bottom_button_layout.addWidget(self.view_button, 0, 8, 1, 1)
|
||||
self.import_button = QtWidgets.QPushButton(songselect_dialog)
|
||||
self.import_button.setIcon(UiIcons().download)
|
||||
self.import_button.setObjectName('import_button')
|
||||
self.bottom_button_layout.addWidget(self.import_button, 0, 9, 1, 1)
|
||||
# Add everything to the interface layout
|
||||
self.songselect_layout.addLayout(self.top_button_layout)
|
||||
self.songselect_layout.addWidget(self.stacked_widget)
|
||||
|
@ -151,7 +143,6 @@ class Ui_SongSelectDialog(object):
|
|||
Translate the GUI.
|
||||
"""
|
||||
songselect_dialog.setWindowTitle(translate('SongsPlugin.SongSelectForm', 'CCLI SongSelect Importer'))
|
||||
self.view_button.setText(translate('SongsPlugin.SongSelectForm', 'Preview'))
|
||||
self.title_label.setText(translate('SongsPlugin.SongSelectForm', 'Title:'))
|
||||
self.authors_label.setText(translate('SongsPlugin.SongSelectForm', 'Author(s):'))
|
||||
self.copyright_label.setText(translate('SongsPlugin.SongSelectForm', 'Copyright:'))
|
||||
|
@ -159,5 +150,4 @@ class Ui_SongSelectDialog(object):
|
|||
self.lyrics_label.setText(translate('SongsPlugin.SongSelectForm', 'Lyrics:'))
|
||||
self.back_button.setText(translate('SongsPlugin.SongSelectForm', 'Back',
|
||||
'When pressed takes user to the CCLI home page'))
|
||||
self.import_button.setText(translate('SongsPlugin.SongSelectForm', 'Import'))
|
||||
self.close_button.setText(translate('SongsPlugin.SongSelectForm', 'Close'))
|
||||
|
|
|
@ -22,15 +22,20 @@
|
|||
The :mod:`~openlp.plugins.songs.forms.songselectform` module contains the GUI for the SongSelect importer
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from PyQt5 import QtCore, QtWidgets
|
||||
from PyQt5 import QtCore, QtWidgets, QtWebEngineWidgets
|
||||
from sqlalchemy.sql import and_
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from openlp.core.common.i18n import translate
|
||||
from openlp.core.common.mixins import RegistryProperties
|
||||
from openlp.plugins.songs.forms.songselectdialog import Ui_SongSelectDialog
|
||||
from openlp.plugins.songs.lib.db import Song
|
||||
from openlp.plugins.songs.lib.songselect import SongSelectImport, Pages
|
||||
from openlp.plugins.songs.lib.importers.chordpro import ChordProImport
|
||||
from openlp.plugins.songs.lib.importers.cclifile import CCLIFileImport
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
@ -46,6 +51,8 @@ class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties)
|
|||
self.plugin = plugin
|
||||
self.db_manager = db_manager
|
||||
self.setup_ui(self)
|
||||
self.current_download_item = None
|
||||
self.tmp_folder = TemporaryDirectory(prefix='openlp_songselect_', ignore_cleanup_errors=True)
|
||||
|
||||
def initialise(self):
|
||||
"""
|
||||
|
@ -54,12 +61,68 @@ class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties)
|
|||
self.song = None
|
||||
self.song_select_importer = SongSelectImport(self.db_manager, self.webview)
|
||||
self.url_bar.returnPressed.connect(self.on_url_bar_return_pressed)
|
||||
self.view_button.clicked.connect(self.on_view_button_clicked)
|
||||
self.back_button.clicked.connect(self.on_back_button_clicked)
|
||||
self.close_button.clicked.connect(self.done)
|
||||
self.import_button.clicked.connect(self.on_import_button_clicked)
|
||||
self.webview.page().loadStarted.connect(self.page_load_started)
|
||||
self.webview.page().loadFinished.connect(self.page_loaded)
|
||||
self.webview.page().profile().downloadRequested.connect(self.on_download_requested)
|
||||
self.webview.urlChanged.connect(self.update_url)
|
||||
|
||||
def update_url(self, new_url):
|
||||
self.url_bar.setText(new_url.toString())
|
||||
|
||||
def download_finished(self):
|
||||
"""
|
||||
Callback for when download has finished
|
||||
"""
|
||||
if self.current_download_item:
|
||||
if self.current_download_item.state() == QtWebEngineWidgets.QWebEngineDownloadItem.DownloadCompleted:
|
||||
self.song_progress_bar.setValue(2)
|
||||
song_filename = self.current_download_item.downloadDirectory() + '/' \
|
||||
+ self.current_download_item.downloadFileName()
|
||||
song_file = open(song_filename, 'rt')
|
||||
song_content = song_file.read()
|
||||
song_file.seek(0)
|
||||
if self.check_for_duplicate(song_content):
|
||||
# if a chordpro title tag is in the file, assume it is chordpro format
|
||||
if '{title:' in song_content:
|
||||
# assume it is a ChordPro file
|
||||
chordpro_importer = ChordProImport(self.plugin.manager, file_path=song_filename)
|
||||
chordpro_importer.do_import_file(song_file)
|
||||
else:
|
||||
# assume it is a simple lyrics
|
||||
cccli_lyrics_importer = CCLIFileImport(self.plugin.manager, file_path=song_filename)
|
||||
lines = song_file.readlines()
|
||||
cccli_lyrics_importer.do_import_txt_file(lines)
|
||||
self.song_progress_bar.setValue(3)
|
||||
QtWidgets.QMessageBox.information(self, translate('SongsPlugin.SongSelectForm', 'Song Imported'),
|
||||
translate('SongsPlugin.SongSelectForm',
|
||||
'Your song has been imported'))
|
||||
song_file.close()
|
||||
self.song_progress_bar.setVisible(False)
|
||||
self.url_bar.setVisible(True)
|
||||
self.webview.setEnabled(True)
|
||||
|
||||
@QtCore.pyqtSlot(QtWebEngineWidgets.QWebEngineDownloadItem)
|
||||
def on_download_requested(self, download_item):
|
||||
"""
|
||||
Called when download is started
|
||||
"""
|
||||
# only import from txt is supported
|
||||
if download_item.suggestedFileName().endswith('.txt'):
|
||||
self.song_progress_bar.setMaximum(3)
|
||||
self.song_progress_bar.setValue(1)
|
||||
self.song_progress_bar.setVisible(True)
|
||||
self.webview.setEnabled(False)
|
||||
download_item.setDownloadDirectory(self.tmp_folder.name)
|
||||
download_item.accept()
|
||||
self.current_download_item = download_item
|
||||
self.current_download_item.finished.connect(self.download_finished)
|
||||
else:
|
||||
download_item.cancel()
|
||||
QtWidgets.QMessageBox.information(self, translate('SongsPlugin.SongSelectForm', 'Unsupported format'),
|
||||
translate('SongsPlugin.SongSelectForm',
|
||||
'OpenLP can only import simple lyrics or ChordPro'))
|
||||
|
||||
def exec(self):
|
||||
"""
|
||||
|
@ -67,9 +130,7 @@ class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties)
|
|||
values.
|
||||
"""
|
||||
self.song_select_importer.reset_webview()
|
||||
self.view_button.setEnabled(False)
|
||||
self.back_button.setEnabled(False)
|
||||
self.import_button.setEnabled(False)
|
||||
self.stacked_widget.setCurrentIndex(0)
|
||||
return QtWidgets.QDialog.exec(self)
|
||||
|
||||
|
@ -82,51 +143,26 @@ class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties)
|
|||
return QtWidgets.QDialog.done(self, result_code)
|
||||
|
||||
def page_load_started(self):
|
||||
self.song_progress_bar.setMaximum(0)
|
||||
self.song_progress_bar.setValue(0)
|
||||
self.song_progress_bar.setVisible(True)
|
||||
self.url_bar.setVisible(False)
|
||||
self.import_button.setEnabled(False)
|
||||
self.view_button.setEnabled(False)
|
||||
self.back_button.setEnabled(False)
|
||||
self.message_area.setText('')
|
||||
self.url_bar.setCursorPosition(0)
|
||||
self.url_bar.setVisible(True)
|
||||
self.message_area.setText(translate('SongsPlugin.SongSelectForm',
|
||||
'Import songs by clicking the "Download" in the Lyrics tab '
|
||||
'or "Download ChordPro" in the Chords tabs.'))
|
||||
|
||||
def page_loaded(self, successful):
|
||||
self.song = None
|
||||
page_type = self.song_select_importer.get_page_type()
|
||||
if page_type == Pages.Login:
|
||||
self.signin_page_loaded()
|
||||
elif page_type == Pages.Song:
|
||||
self.song_progress_bar.setMaximum(3)
|
||||
self.song_progress_bar.setValue(0)
|
||||
self.song = self.song_select_importer.get_song(self._update_song_progress)
|
||||
if self.song:
|
||||
self.import_button.setEnabled(True)
|
||||
self.view_button.setEnabled(True)
|
||||
else:
|
||||
message = translate('SongsPlugin.SongSelectForm', 'This song cannot be read. Perhaps your CCLI account '
|
||||
'does not give you access to this song.')
|
||||
self.message_area.setText(message)
|
||||
else:
|
||||
self.back_button.setEnabled(True)
|
||||
if page_type == Pages.Other:
|
||||
self.back_button.setEnabled(True)
|
||||
self.song_progress_bar.setVisible(False)
|
||||
self.url_bar.setText(self.webview.url().toString())
|
||||
self.url_bar.setCursorPosition(0)
|
||||
self.url_bar.setVisible(True)
|
||||
|
||||
def signin_page_loaded(self):
|
||||
username = self.settings.value('songs/songselect username')
|
||||
password = self.settings.value('songs/songselect password')
|
||||
self.song_select_importer.set_login_fields(username, password)
|
||||
|
||||
def _update_song_progress(self):
|
||||
"""
|
||||
Update the progress bar.
|
||||
"""
|
||||
self.song_progress_bar.setValue(self.song_progress_bar.value() + 1)
|
||||
self.application.process_events()
|
||||
|
||||
def _view_song(self):
|
||||
"""
|
||||
Load a song into the song view.
|
||||
|
@ -166,14 +202,6 @@ class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties)
|
|||
url = self.url_bar.text()
|
||||
self.song_select_importer.set_page(url)
|
||||
|
||||
def on_view_button_clicked(self):
|
||||
"""
|
||||
Import a song from SongSelect.
|
||||
"""
|
||||
self.view_button.setEnabled(False)
|
||||
self.url_bar.setEnabled(False)
|
||||
self._view_song()
|
||||
|
||||
def on_back_button_clicked(self, force_return_to_home=False):
|
||||
"""
|
||||
Go back to the search page or just to the webview if on the preview screen
|
||||
|
@ -181,17 +209,21 @@ class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties)
|
|||
if (self.stacked_widget.currentIndex() == 0 or force_return_to_home):
|
||||
self.song_select_importer.set_home_page()
|
||||
else:
|
||||
self.view_button.setEnabled(True)
|
||||
self.url_bar.setEnabled(True)
|
||||
self.stacked_widget.setCurrentIndex(0)
|
||||
|
||||
def on_import_button_clicked(self):
|
||||
def check_for_duplicate(self, song_content):
|
||||
"""
|
||||
Import a song from SongSelect.
|
||||
Warn user if a song exists in the database with the same ccli_number
|
||||
"""
|
||||
# Warn user if a song exists in the database with the same ccli_number
|
||||
# First extract the CCLI number of the song
|
||||
match = re.search(r'\nCCLI.+?#\s*(\d+)', song_content)
|
||||
if match:
|
||||
self.ccli_number = match.group(1)
|
||||
else:
|
||||
return True
|
||||
songs_with_same_ccli_number = self.plugin.manager.get_all_objects(
|
||||
Song, and_(Song.ccli_number.like(self.song['ccli_number']), Song.ccli_number != ''))
|
||||
Song, and_(Song.ccli_number.like(self.ccli_number), Song.ccli_number != ''))
|
||||
if len(songs_with_same_ccli_number) > 0:
|
||||
continue_import = QtWidgets.QMessageBox.question(self,
|
||||
translate('SongsPlugin.SongSelectForm',
|
||||
|
@ -202,10 +234,5 @@ class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties)
|
|||
'Are you sure you want to import this song?'),
|
||||
defaultButton=QtWidgets.QMessageBox.No)
|
||||
if continue_import == QtWidgets.QMessageBox.No:
|
||||
return
|
||||
self.song_select_importer.save_song(self.song)
|
||||
self.song = None
|
||||
QtWidgets.QMessageBox.information(self, translate('SongsPlugin.SongSelectForm', 'Song Imported'),
|
||||
translate('SongsPlugin.SongSelectForm',
|
||||
'Your song has been imported'))
|
||||
self.on_back_button_clicked(True)
|
||||
return False
|
||||
return True
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
import chardet
|
||||
import codecs
|
||||
import logging
|
||||
import re
|
||||
|
||||
from openlp.core.common.i18n import translate
|
||||
from openlp.plugins.songs.lib import VerseType
|
||||
|
@ -241,7 +242,7 @@ class CCLIFileImport(SongImport):
|
|||
<Empty line>
|
||||
<Empty line>
|
||||
Song CCLI number
|
||||
# e.g. CCLI Number (e.g.CCLI-Liednummer: 2672885)
|
||||
# e.g. CCLI Number (e.g.CCLI-Liednummer: 2672885).
|
||||
Song copyright (if it begins ©, otherwise after authors)
|
||||
# e.g. © 1999 Integrity's Hosanna! Music | LenSongs Publishing
|
||||
Song authors # e.g. Lenny LeBlanc | Paul Baloche
|
||||
|
@ -251,6 +252,18 @@ class CCLIFileImport(SongImport):
|
|||
CCLI Licence number of user
|
||||
# e.g. CCLI-Liedlizenznummer: 14 / CCLI License No. 14
|
||||
|
||||
|
||||
In the 2023 format the footer is as below:
|
||||
Song authors # e.g. Lenny LeBlanc | Paul Baloche
|
||||
Song CCLI number
|
||||
# e.g. CCLI Number (e.g.CCLI-Liednummer: 2672885).
|
||||
Song copyright (if it begins ©, otherwise after authors)
|
||||
# e.g. © 1999 Integrity's Hosanna! Music | LenSongs Publishing
|
||||
Licencing info
|
||||
# e.g. For use solely with the SongSelect Terms of Use.
|
||||
CCLI Licence number of user
|
||||
# e.g. CCLI-Liedlizenznummer: 14 / CCLI License No. 14
|
||||
|
||||
"""
|
||||
log.debug('TXT file text: {text}'.format(text=text_list))
|
||||
line_number = 0
|
||||
|
@ -259,8 +272,15 @@ class CCLIFileImport(SongImport):
|
|||
verse_type = VerseType.tags[VerseType.Verse]
|
||||
song_author = ''
|
||||
verse_start = False
|
||||
for line in text_list:
|
||||
for idx in range(len(text_list)):
|
||||
line = text_list[idx]
|
||||
clean_line = line.strip()
|
||||
if idx + 1 < len(text_list):
|
||||
next_line = text_list[idx + 1]
|
||||
next_clean_line = next_line.strip()
|
||||
else:
|
||||
next_line = None
|
||||
next_clean_line = None
|
||||
if not clean_line:
|
||||
if line_number == 0:
|
||||
continue
|
||||
|
@ -280,18 +300,26 @@ class CCLIFileImport(SongImport):
|
|||
# line_number=1, ccli number, first line after verses
|
||||
if clean_line.startswith('CCLI'):
|
||||
line_number += 1
|
||||
ccli_parts = clean_line.split(' ')
|
||||
self.ccli_number = ccli_parts[len(ccli_parts) - 1]
|
||||
ccli_parts = re.findall(r'\d+', clean_line)
|
||||
if ccli_parts:
|
||||
self.ccli_number = ccli_parts[0]
|
||||
# if CCLI is on the next line, this is 2023 format and the current line is authors
|
||||
elif next_clean_line and next_clean_line.startswith('CCLI'):
|
||||
line_number += 1
|
||||
song_author = clean_line
|
||||
elif not verse_start:
|
||||
# We have the verse descriptor
|
||||
verse_desc_parts = clean_line.split(' ')
|
||||
if len(verse_desc_parts):
|
||||
if verse_desc_parts[0].startswith('Ver'):
|
||||
first_verse_desc = verse_desc_parts[0].upper()
|
||||
if first_verse_desc.startswith(('VER', VerseType.translated_tags[VerseType.Verse])):
|
||||
verse_type = VerseType.tags[VerseType.Verse]
|
||||
elif verse_desc_parts[0].startswith('Ch'):
|
||||
elif first_verse_desc.startswith(('CH', VerseType.translated_tags[VerseType.Chorus])):
|
||||
verse_type = VerseType.tags[VerseType.Chorus]
|
||||
elif verse_desc_parts[0].startswith('Br'):
|
||||
elif first_verse_desc.startswith(('BR', VerseType.translated_tags[VerseType.Bridge])):
|
||||
verse_type = VerseType.tags[VerseType.Bridge]
|
||||
elif first_verse_desc.startswith(('END', VerseType.translated_tags[VerseType.Ending])):
|
||||
verse_type = VerseType.tags[VerseType.Ending]
|
||||
else:
|
||||
# we need to analyse the next line for
|
||||
# verse type, so set flag
|
||||
|
@ -318,14 +346,18 @@ class CCLIFileImport(SongImport):
|
|||
# last part. Add l so as to keep the CRLF
|
||||
verse_text = verse_text + line
|
||||
else:
|
||||
# line_number=2, copyright
|
||||
# line_number=2, copyright or CCLIE
|
||||
if line_number == 2:
|
||||
line_number += 1
|
||||
if clean_line.startswith('©'):
|
||||
self.add_copyright(clean_line)
|
||||
elif clean_line.startswith('CCLI'):
|
||||
ccli_parts = re.findall(r'\d+', clean_line)
|
||||
if ccli_parts:
|
||||
self.ccli_number = ccli_parts[0]
|
||||
else:
|
||||
song_author = clean_line
|
||||
# n=3, authors
|
||||
# n=3, authors or copyright
|
||||
elif line_number == 3:
|
||||
line_number += 1
|
||||
if song_author:
|
||||
|
@ -335,11 +367,6 @@ class CCLIFileImport(SongImport):
|
|||
# line_number=4, comments lines before last line
|
||||
elif line_number == 4 and not clean_line.startswith('CCL'):
|
||||
self.comments += clean_line
|
||||
# split on known separators
|
||||
author_list = song_author.split('/')
|
||||
if len(author_list) < 2:
|
||||
author_list = song_author.split('|')
|
||||
# Clean spaces before and after author names.
|
||||
for author_name in author_list:
|
||||
self.add_author(author_name.strip())
|
||||
# add author(s)
|
||||
self.parse_author(song_author)
|
||||
return self.finish()
|
||||
|
|
|
@ -63,12 +63,13 @@ class ChordProImport(SongImport):
|
|||
try:
|
||||
file_content = song_file.read()
|
||||
except UnicodeDecodeError:
|
||||
self.log_error(song_file.name, translate('SongsPlugin.CCLIFileImport',
|
||||
self.log_error(song_file.name, translate('SongsPlugin.ChordProFileImport',
|
||||
'The file contains unreadable characters.'))
|
||||
return
|
||||
current_verse = ''
|
||||
current_verse_type = 'v'
|
||||
skip_block = False
|
||||
chorus_added = []
|
||||
for line in file_content.splitlines():
|
||||
line = line.rstrip()
|
||||
# Detect tags
|
||||
|
@ -81,8 +82,10 @@ class ChordProImport(SongImport):
|
|||
self.alternate_title = tag_value
|
||||
elif tag_name == 'composer':
|
||||
self.parse_author(tag_value, AuthorType.Music)
|
||||
elif tag_name in ['lyricist', 'artist', 'author']: # author is not an official directive
|
||||
elif tag_name in ['lyricist', 'author']: # author is not an official directive
|
||||
self.parse_author(tag_value, AuthorType.Words)
|
||||
elif tag_name == 'artist':
|
||||
self.parse_author(tag_value)
|
||||
elif tag_name == 'meta':
|
||||
meta_tag_name, meta_tag_value = tag_value.split(' ', 1)
|
||||
# Skip, if no value
|
||||
|
@ -95,47 +98,52 @@ class ChordProImport(SongImport):
|
|||
self.alternate_title = meta_tag_value
|
||||
elif meta_tag_name == 'composer':
|
||||
self.parse_author(meta_tag_value, AuthorType.Music)
|
||||
elif meta_tag_name in ['lyricist', 'artist', 'author']:
|
||||
elif meta_tag_name in ['lyricist', 'author']:
|
||||
self.parse_author(meta_tag_value, AuthorType.Words)
|
||||
elif meta_tag_name == 'artist':
|
||||
self.parse_author(meta_tag_value)
|
||||
elif meta_tag_name in ['topic', 'topics']:
|
||||
for topic in meta_tag_value.split(','):
|
||||
self.topics.append(topic.strip())
|
||||
elif 'ccli' in meta_tag_name:
|
||||
self.ccli_number = meta_tag_value
|
||||
elif tag_name == 'ccli':
|
||||
self.ccli_number = tag_value
|
||||
elif tag_name == 'copyright':
|
||||
self.add_copyright(tag_value)
|
||||
elif tag_name in ['comment', 'c', 'comment_italic', 'ci', 'comment_box', 'cb']:
|
||||
# Detect if the comment is used as a chorus repeat marker
|
||||
if tag_value.lower().startswith('chorus'):
|
||||
# Detect if the comment is used as a verse type marker
|
||||
lower_tag_value = tag_value.lower()
|
||||
if lower_tag_value.startswith(('chorus', 'verse', 'bridge', 'ending')):
|
||||
if current_verse.strip():
|
||||
# Add collected verse to the lyrics
|
||||
# Strip out chords if set up to
|
||||
if not self.settings.value('songs/enable chords') or \
|
||||
self.settings.value('songs/disable chords import'):
|
||||
current_verse = re.sub(r'\[.*?\]', '', current_verse)
|
||||
self.add_verse(current_verse.rstrip(), current_verse_type)
|
||||
self.add_chordpro_verse(current_verse.rstrip(), current_verse_type)
|
||||
current_verse_type = 'v'
|
||||
current_verse = ''
|
||||
self.repeat_verse('c1')
|
||||
# Detect if the comment is used as a chorus repeat marker
|
||||
if lower_tag_value.startswith('chorus'):
|
||||
if lower_tag_value in chorus_added:
|
||||
self.repeat_verse('c%d' % (chorus_added.index(lower_tag_value) + 1))
|
||||
else:
|
||||
chorus_added.append(lower_tag_value)
|
||||
current_verse_type = 'c'
|
||||
elif lower_tag_value.startswith('verse'):
|
||||
current_verse_type = 'v'
|
||||
elif lower_tag_value.startswith('bridge'):
|
||||
current_verse_type = 'b'
|
||||
elif lower_tag_value.startswith('ending'):
|
||||
current_verse_type = 'e'
|
||||
else:
|
||||
self.add_comment(tag_value)
|
||||
elif tag_name in ['start_of_chorus', 'soc']:
|
||||
current_verse_type = 'c'
|
||||
elif tag_name in ['end_of_chorus', 'eoc']:
|
||||
# Add collected chorus to the lyrics
|
||||
# Strip out chords if set up to
|
||||
if not self.settings.value('songs/enable chords') or \
|
||||
self.settings.value('songs/disable chords import'):
|
||||
current_verse = re.sub(r'\[.*?\]', '', current_verse)
|
||||
self.add_verse(current_verse.rstrip(), current_verse_type)
|
||||
self.add_chordpro_verse(current_verse.rstrip(), current_verse_type)
|
||||
chorus_added.append('chorus')
|
||||
current_verse_type = 'v'
|
||||
current_verse = ''
|
||||
elif tag_name in ['start_of_tab', 'sot']:
|
||||
if current_verse.strip():
|
||||
# Add collected verse to the lyrics
|
||||
# Strip out chords if set up to
|
||||
if not self.settings.value('songs/enable chords') or \
|
||||
self.settings.value('songs/disable chords import'):
|
||||
current_verse = re.sub(r'\[.*?\]', '', current_verse)
|
||||
self.add_verse(current_verse.rstrip(), current_verse_type)
|
||||
self.add_chordpro_verse(current_verse.rstrip(), current_verse_type)
|
||||
current_verse_type = 'v'
|
||||
current_verse = ''
|
||||
skip_block = True
|
||||
|
@ -145,11 +153,7 @@ class ChordProImport(SongImport):
|
|||
# A new song starts below this tag
|
||||
if self.verses and self.title:
|
||||
if current_verse.strip():
|
||||
# Strip out chords if set up to
|
||||
if not self.settings.value('songs/enable chords') or \
|
||||
self.settings.value('songs/disable chords import'):
|
||||
current_verse = re.sub(r'\[.*?\]', '', current_verse)
|
||||
self.add_verse(current_verse.rstrip(), current_verse_type)
|
||||
self.add_chordpro_verse(current_verse.rstrip(), current_verse_type)
|
||||
if not self.finish():
|
||||
self.log_error(song_file.name)
|
||||
self.set_defaults()
|
||||
|
@ -164,16 +168,14 @@ class ChordProImport(SongImport):
|
|||
elif line == "['|]":
|
||||
# Found a vertical bar
|
||||
continue
|
||||
elif line.startswith('CCLI') or line.startswith('©'):
|
||||
# Found the CCLI footer or a copyright footer, stop here.
|
||||
break
|
||||
else:
|
||||
if skip_block:
|
||||
continue
|
||||
elif line == '' and current_verse.strip() and current_verse_type != 'c':
|
||||
# Add collected verse to the lyrics
|
||||
# Strip out chords if set up to
|
||||
if not self.settings.value('songs/enable chords') or \
|
||||
self.settings.value('songs/disable chords import'):
|
||||
current_verse = re.sub(r'\[.*?\]', '', current_verse)
|
||||
self.add_verse(current_verse.rstrip(), current_verse_type)
|
||||
self.add_chordpro_verse(current_verse.rstrip(), current_verse_type)
|
||||
current_verse_type = 'v'
|
||||
current_verse = ''
|
||||
else:
|
||||
|
@ -182,11 +184,7 @@ class ChordProImport(SongImport):
|
|||
else:
|
||||
current_verse += line + '\n'
|
||||
if current_verse.strip():
|
||||
# Strip out chords if set up to
|
||||
if not self.settings.value('songs/enable chords') or self.settings.value(
|
||||
'songs/disable chords import'):
|
||||
current_verse = re.sub(r'\[.*?\]', '', current_verse)
|
||||
self.add_verse(current_verse.rstrip(), current_verse_type)
|
||||
self.add_chordpro_verse(current_verse.rstrip(), current_verse_type)
|
||||
# if no title was in directives, get it from the first line
|
||||
if not self.title and self.verses:
|
||||
(verse_def, verse_text, lang) = self.verses[0]
|
||||
|
@ -212,3 +210,18 @@ class ChordProImport(SongImport):
|
|||
tag_name = line[:colon_idx]
|
||||
tag_value = line[colon_idx + 1:-1].strip()
|
||||
return tag_name, tag_value
|
||||
|
||||
def add_chordpro_verse(self, verse_text, verse_type):
|
||||
"""
|
||||
Strip out chords if set up to.
|
||||
In some cases (noteably CCLI) spaces and dashes are inserted for readability,
|
||||
but they do not look good on display so remove them
|
||||
:param verse_text:
|
||||
:param verse_type:
|
||||
"""
|
||||
if not self.settings.value('songs/enable chords') or self.settings.value('songs/disable chords import'):
|
||||
verse_text = re.sub(r'( +- +)?\[.*?\]( +- +)?', '', verse_text)
|
||||
else:
|
||||
verse_text = re.sub(r' +- +\[', '[', verse_text)
|
||||
verse_text = re.sub(r'\] +- +', ']', verse_text)
|
||||
self.add_verse(verse_text.rstrip(), verse_type)
|
||||
|
|
|
@ -32,7 +32,7 @@ from openlp.core.common.path import create_paths
|
|||
from openlp.core.common.registry import Registry
|
||||
from openlp.core.widgets.wizard import WizardStrings
|
||||
from openlp.plugins.songs.lib import VerseType, clean_song
|
||||
from openlp.plugins.songs.lib.db import Author, SongBook, MediaFile, Song, Topic
|
||||
from openlp.plugins.songs.lib.db import Author, AuthorType, SongBook, MediaFile, Song, Topic
|
||||
from openlp.plugins.songs.lib.openlyricsxml import SongXML
|
||||
from openlp.plugins.songs.lib.ui import SongStrings
|
||||
|
||||
|
@ -226,13 +226,65 @@ class SongImport(QtCore.QObject):
|
|||
self.copyright += ' '
|
||||
self.copyright += copyright
|
||||
|
||||
def _check_for_author_prefix(self, author, default_type):
|
||||
"""
|
||||
Detect if the author type is prefixed the author name. Looks for the prefix in entire string, in case multiple
|
||||
authors are listed, each prefixed with a type.
|
||||
:param author: String with author(s) to search for predefined prefixes
|
||||
:param default_type: The default or expected author type to use
|
||||
:return: True if a prefix was found, meaning the author will be added from this method. Else False is returned,
|
||||
and the caller must add the author.
|
||||
"""
|
||||
author_low = author.lower()
|
||||
music_prefixes = ['music by', translate('SongsPlugin.SongImport', 'music by').lower(),
|
||||
'music:', translate('SongsPlugin.SongImport', 'music:').lower(),
|
||||
'arranged by', translate('SongsPlugin.SongImport', 'arranged by').lower(),
|
||||
'arranged:', translate('SongsPlugin.SongImport', 'arranged:').lower(),
|
||||
'composed by', translate('SongsPlugin.SongImport', 'composed by').lower(),
|
||||
'composer:', translate('SongsPlugin.SongImport', 'composer:').lower()]
|
||||
words_prefixes = ['words by', translate('SongsPlugin.SongImport', 'words by').lower(),
|
||||
'words:', translate('SongsPlugin.SongImport', 'words:').lower(),
|
||||
'lyrics by', translate('SongsPlugin.SongImport', 'lyrics by').lower(),
|
||||
'lyrics:', translate('SongsPlugin.SongImport', 'lyrics:').lower(),
|
||||
'written by', translate('SongsPlugin.SongImport', 'written by').lower(),
|
||||
'writer:', translate('SongsPlugin.SongImport', 'writer:').lower(),
|
||||
'authored by', translate('SongsPlugin.SongImport', 'authored by').lower(),
|
||||
'author:', translate('SongsPlugin.SongImport', 'author:').lower()]
|
||||
translation_prefixes = ['tranlated by', translate('SongsPlugin.SongImport', 'tranlated by').lower(),
|
||||
'translation:', translate('SongsPlugin.SongImport', 'translation:').lower()]
|
||||
prefix_map = [(music_prefixes, AuthorType.Music), (words_prefixes, AuthorType.Words),
|
||||
(translation_prefixes, AuthorType.Translation)]
|
||||
for prefixes, prefix_type in prefix_map:
|
||||
for prefix in prefixes:
|
||||
idx = author_low.find(prefix)
|
||||
if idx == -1:
|
||||
pass # no hit
|
||||
elif idx == 0:
|
||||
# found the prefix right at the start
|
||||
author_without_prefix = author[len(prefix):].strip()
|
||||
self.parse_author(author_without_prefix, prefix_type)
|
||||
return True
|
||||
else:
|
||||
# found the prefix "inside" the string
|
||||
author_before_prefix = author[0:idx].strip()
|
||||
author_after_prefix = author[idx + len(prefix):].strip()
|
||||
# the type of the authors before the "prefix" must be of the type provided in the method parameter
|
||||
self.parse_author(author_before_prefix, default_type)
|
||||
self.parse_author(author_after_prefix, prefix_type)
|
||||
return True
|
||||
return False
|
||||
|
||||
def parse_author(self, text, type=None):
|
||||
"""
|
||||
Add the author. OpenLP stores them individually so split by 'and', '&' and comma. However need to check
|
||||
for 'Mr and Mrs Smith' and turn it to 'Mr Smith' and 'Mrs Smith'.
|
||||
Add the author. OpenLP stores them individually so split by 'and', '&' comma and semicolon.
|
||||
TODO: check for 'Mr and Mrs Smith' and turn it to 'Mr Smith' and 'Mrs Smith'.
|
||||
"""
|
||||
# check if the given text is prefixed with the author type, in which case the adding of the author will
|
||||
# be done in _check_for_author_prefix
|
||||
if self._check_for_author_prefix(text, type):
|
||||
return
|
||||
for author in text.split(','):
|
||||
authors = author.split('&')
|
||||
authors = re.split(r';|&| and |/|\|', author)
|
||||
for i in range(len(authors)):
|
||||
author2 = authors[i].strip()
|
||||
if author2.find(' ') == -1 and i < len(authors) - 1:
|
||||
|
|
|
@ -23,10 +23,7 @@ The :mod:`~openlp.plugins.songs.lib.songselect` module contains the SongSelect i
|
|||
"""
|
||||
import logging
|
||||
import re
|
||||
from html import unescape
|
||||
from urllib.error import URLError
|
||||
from PyQt5 import QtCore
|
||||
from bs4 import BeautifulSoup, NavigableString
|
||||
|
||||
from openlp.plugins.songs.lib import VerseType, clean_song
|
||||
from openlp.plugins.songs.lib.db import Song, Author, Topic
|
||||
|
@ -162,70 +159,6 @@ class SongSelectImport(object):
|
|||
return regex_matches.group(1)
|
||||
return None
|
||||
|
||||
def get_song(self, callback=None):
|
||||
"""
|
||||
Get the full song from SongSelect
|
||||
|
||||
:param song: The song page url
|
||||
:param callback: A callback which can be used to indicate progress
|
||||
:return: Dictionary containing the song info
|
||||
"""
|
||||
song = {}
|
||||
# Get current song
|
||||
current_url = self.webview.url().path()
|
||||
ccli_number = self.get_song_number_from_url(current_url)
|
||||
|
||||
if callback:
|
||||
callback()
|
||||
try:
|
||||
song_page = BeautifulSoup(self.get_page(SONG_PAGE + ccli_number), 'lxml')
|
||||
except (TypeError, URLError) as error:
|
||||
log.exception('Could not get song from SongSelect, {error}'.format(error=error))
|
||||
return None
|
||||
try:
|
||||
lyrics_link = song_page.find('section', 'page-section').find('a')['href']
|
||||
except KeyError:
|
||||
# can't find a link to the song - most likely the user account has no access to it
|
||||
return None
|
||||
if callback:
|
||||
callback()
|
||||
try:
|
||||
lyrics_page = BeautifulSoup(self.get_page(BASE_URL + lyrics_link), 'lxml')
|
||||
except (TypeError, URLError):
|
||||
log.exception('Could not get lyrics from SongSelect')
|
||||
return None
|
||||
if callback:
|
||||
callback()
|
||||
theme_elements = []
|
||||
# Themes regex only works if the ccli site is in english.
|
||||
themes_regex = re.compile(r'\bThemes\b')
|
||||
for ul in song_page.find_all('ul', 'song-meta-list'):
|
||||
if ul.find('li', string=themes_regex):
|
||||
theme_elements.extend(ul.find_all('li')[1:])
|
||||
copyright_elements = lyrics_page.find('ul', 'copyright').find_all('li')
|
||||
author_elements = song_page.find('div', 'content-title').find('ul', 'authors').find_all('li')
|
||||
song['title'] = unescape(song_page.find('div', 'content-title').find('h1').string.strip())
|
||||
song['authors'] = [unescape(li.find('a').string).strip() for li in author_elements]
|
||||
song['copyright'] = '/'.join([unescape(li.string).strip() for li in copyright_elements])
|
||||
song['topics'] = [unescape(li.string).strip() for li in theme_elements]
|
||||
song['ccli_number'] = song_page.find('div', 'song-content-data').find('ul').find('li')\
|
||||
.find('strong').string.strip()
|
||||
song['verses'] = []
|
||||
verses = lyrics_page.find('div', 'song-viewer lyrics').find_all('p')
|
||||
verse_labels = lyrics_page.find('div', 'song-viewer lyrics').find_all('h3')
|
||||
for verse, label in zip(verses, verse_labels):
|
||||
song_verse = {'label': unescape(label.string).strip(), 'lyrics': ''}
|
||||
for v in verse.contents:
|
||||
if isinstance(v, NavigableString):
|
||||
song_verse['lyrics'] += unescape(v.string).strip()
|
||||
else:
|
||||
song_verse['lyrics'] += '\n'
|
||||
song_verse['lyrics'] = song_verse['lyrics'].strip(' \n\r\t')
|
||||
song['verses'].append(song_verse)
|
||||
for counter, author in enumerate(song['authors']):
|
||||
song['authors'][counter] = unescape(author)
|
||||
return song
|
||||
|
||||
def save_song(self, song):
|
||||
"""
|
||||
Save a song to the database, using the db_manager
|
||||
|
|
|
@ -22,7 +22,6 @@
|
|||
All the tests
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from tempfile import mkdtemp
|
||||
from tempfile import mkstemp
|
||||
|
@ -31,8 +30,7 @@ from unittest.mock import MagicMock
|
|||
import pytest
|
||||
from pytestqt.qt_compat import qt_api
|
||||
|
||||
from PyQt5 import QtCore, QtWidgets # noqa
|
||||
sys.modules['PyQt5.QtWebEngineWidgets'] = MagicMock()
|
||||
from PyQt5 import QtCore # noqa
|
||||
|
||||
from openlp.core.app import OpenLP
|
||||
from openlp.core.state import State
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
# -*- 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/>. #
|
||||
##########################################################################
|
||||
"""
|
||||
This module contains tests for the ccli song importer.
|
||||
"""
|
||||
from tests.helpers.songfileimport import SongImportTestHelper
|
||||
from tests.utils.constants import RESOURCE_PATH
|
||||
|
||||
|
||||
TEST_PATH = RESOURCE_PATH / 'songs' / 'songselect'
|
||||
|
||||
|
||||
def test_ccli(mock_settings):
|
||||
|
||||
with SongImportTestHelper('CCLIFileImport', 'cclifile') as helper:
|
||||
helper.file_import([TEST_PATH / 'TestSong.bin'],
|
||||
helper.load_external_result_data(TEST_PATH / 'TestSong-bin.json'))
|
||||
helper.file_import([TEST_PATH / 'TestSong.txt'],
|
||||
helper.load_external_result_data(TEST_PATH / 'TestSong-txt.json'))
|
||||
helper.file_import([TEST_PATH / 'TestSong2023.txt'],
|
||||
helper.load_external_result_data(TEST_PATH / 'TestSong2023-txt.json'))
|
|
@ -0,0 +1,73 @@
|
|||
# -*- 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/>. #
|
||||
##########################################################################
|
||||
"""
|
||||
This module contains tests for the SongImport submodule of the Songs plugin.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from openlp.plugins.songs.lib.db import AuthorType
|
||||
from openlp.plugins.songs.lib.importers.songimport import SongImport
|
||||
|
||||
|
||||
def test_parse_author_simple_with_type(settings):
|
||||
# GIVEN: A SongImport object with a mocked DB manager and a dummy file path
|
||||
db_manager_mock = MagicMock()
|
||||
song_import = SongImport(db_manager_mock, file_path='/dummy/')
|
||||
# WHEN: Parsing simple author name with type
|
||||
song_import.parse_author('Random Author', AuthorType.Words)
|
||||
# THEN: The result should be an registered author of that type
|
||||
assert song_import.authors == [('Random Author', AuthorType.Words)]
|
||||
|
||||
|
||||
def test_parse_author_list_with_type(settings):
|
||||
# GIVEN: A SongImport object with a mocked DB manager and a dummy file path
|
||||
db_manager_mock = MagicMock()
|
||||
song_import = SongImport(db_manager_mock, file_path='/dummy/')
|
||||
# WHEN: Parsing simple list of author names with type
|
||||
song_import.parse_author('Random Author, Important Name; Very Important Person', AuthorType.Words)
|
||||
# THEN: The result should be registered authors of that type
|
||||
assert song_import.authors == [('Random Author', AuthorType.Words),
|
||||
('Important Name', AuthorType.Words),
|
||||
('Very Important Person', AuthorType.Words)]
|
||||
|
||||
|
||||
def test_parse_author_with_prefix(settings):
|
||||
# GIVEN: A SongImport object with a mocked DB manager and a dummy file path
|
||||
db_manager_mock = MagicMock()
|
||||
song_import = SongImport(db_manager_mock, file_path='/dummy/')
|
||||
# WHEN: Parsing author name with type prefixes
|
||||
song_import.parse_author('Lyrics: Random Author')
|
||||
# THEN: The result should be an registered author of that type
|
||||
assert song_import.authors == [('Random Author', AuthorType.Words)]
|
||||
|
||||
|
||||
def test_parse_author_with_multiple_prefixes(settings):
|
||||
# GIVEN: A SongImport object with a mocked DB manager and a dummy file path
|
||||
db_manager_mock = MagicMock()
|
||||
song_import = SongImport(db_manager_mock, file_path='/dummy/')
|
||||
# WHEN: Parsing author names with multiple inline prefixes
|
||||
song_import.parse_author('Music by Random Author, Important Name; Lyrics: Very Important Person. '
|
||||
'Translation: Very Clever Person')
|
||||
# THEN: The result should be registered authors of that type
|
||||
assert song_import.authors == [('Random Author', AuthorType.Music),
|
||||
('Important Name', AuthorType.Music),
|
||||
('Very Important Person', AuthorType.Words),
|
||||
('Very Clever Person', AuthorType.Translation)]
|
|
@ -29,7 +29,7 @@ re-downloading the HTML pages and changing the code to use the new layout.
|
|||
from unittest import TestCase
|
||||
from unittest.mock import MagicMock, patch, sentinel
|
||||
|
||||
from PyQt5 import QtWidgets, QtCore
|
||||
from PyQt5 import QtCore
|
||||
|
||||
from openlp.core.common.registry import Registry
|
||||
from openlp.plugins.songs.forms.songselectform import SongSelectForm
|
||||
|
@ -47,6 +47,7 @@ class TestSongSelectImport(TestCase, TestMixin):
|
|||
"""
|
||||
Test the :class:`~openlp.plugins.songs.lib.songselect.SongSelectImport` class
|
||||
"""
|
||||
|
||||
def test_constructor(self):
|
||||
"""
|
||||
Test that constructing a basic SongSelectImport object works correctly
|
||||
|
@ -261,146 +262,6 @@ class TestSongSelectImport(TestCase, TestMixin):
|
|||
# THEN: The returned value should be None
|
||||
assert result is None
|
||||
|
||||
@patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_song_number_from_url')
|
||||
@patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_page')
|
||||
def test_get_song_page_raises_exception(self, mocked_get_page, mock_get_num):
|
||||
"""
|
||||
Test that when BeautifulSoup gets a bad song page the get_song() method returns None
|
||||
"""
|
||||
# GIVEN: A mocked callback and an importer object
|
||||
mocked_get_page.side_effect = None
|
||||
mocked_callback = MagicMock()
|
||||
importer = SongSelectImport(None, MagicMock())
|
||||
|
||||
# WHEN: get_song is called
|
||||
result = importer.get_song(callback=mocked_callback)
|
||||
|
||||
# THEN: The callback should have been called once and None should be returned
|
||||
mocked_callback.assert_called_with()
|
||||
assert result is None, 'The get_song() method should have returned None'
|
||||
|
||||
@patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_song_number_from_url')
|
||||
@patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_page')
|
||||
@patch('openlp.plugins.songs.lib.songselect.BeautifulSoup')
|
||||
def test_get_song_lyrics_raise_exception(self, MockedBeautifulSoup, mocked_get_page, mock_get_num):
|
||||
"""
|
||||
Test that when BeautifulSoup gets a bad lyrics page the get_song() method returns None
|
||||
"""
|
||||
# GIVEN: A bunch of mocked out stuff and an importer object
|
||||
song_page = MagicMock(return_value={'href': '/lyricpage'})
|
||||
MockedBeautifulSoup.side_effect = [song_page, TypeError('Test Error')]
|
||||
mocked_callback = MagicMock()
|
||||
importer = SongSelectImport(None, MagicMock())
|
||||
|
||||
# WHEN: get_song is called
|
||||
result = importer.get_song(callback=mocked_callback)
|
||||
|
||||
# THEN: The callback should have been called twice and None should be returned
|
||||
assert 2 == mocked_callback.call_count, 'The callback should have been called twice'
|
||||
assert result is None, 'The get_song() method should have returned None'
|
||||
|
||||
@patch('openlp.plugins.songs.lib.songselect.log.exception')
|
||||
@patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_song_number_from_url')
|
||||
@patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_page')
|
||||
def test_get_song_no_access(self, mocked_get_page, mock_get_num, mock_log_exception):
|
||||
"""
|
||||
Test that the get_song() handles the case when the user's CCLI account has no access to the song
|
||||
"""
|
||||
fake_song_page = '''<!DOCTYPE html><html><body>
|
||||
<div class="content-title">
|
||||
<h1>Song Title</h1>
|
||||
<ul class="authors">
|
||||
<li><a>Author 1</a></li>
|
||||
<li><a>Author 2</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="song-content-data"><ul><li><strong>1234_cclinumber_5678</strong></li></ul></div>
|
||||
<section class="page-section">
|
||||
<a title="View song lyrics" data-open="ssUpgradeModal"></a>
|
||||
</section>
|
||||
<ul class="song-meta-list">
|
||||
<li>Themes</li><li><a>theme1</a></li><li><a>theme2</a></li>
|
||||
</ul>
|
||||
</body></html>
|
||||
'''
|
||||
fake_lyrics_page = '''<!DOCTYPE html><html><body>
|
||||
<div class="song-viewer lyrics">
|
||||
<h3>Verse 1</h3>
|
||||
<p>verse thing 1<br>line 2</p>
|
||||
<h3>Verse 2</h3>
|
||||
<p>verse thing 2</p>
|
||||
</div>
|
||||
<ul class="copyright">
|
||||
<li>Copy thing</li><li>Copy thing 2</li>
|
||||
</ul>
|
||||
</body></html>
|
||||
'''
|
||||
mocked_get_page.side_effect = [fake_song_page, fake_lyrics_page]
|
||||
mocked_callback = MagicMock()
|
||||
importer = SongSelectImport(None, MagicMock())
|
||||
|
||||
# WHEN: get_song is called
|
||||
result = importer.get_song(callback=mocked_callback)
|
||||
|
||||
# THEN: None should be returned
|
||||
assert result is None, 'The get_song() method should have returned None'
|
||||
|
||||
@patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_song_number_from_url')
|
||||
@patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_page')
|
||||
def test_get_song(self, mocked_get_page, mock_get_num):
|
||||
"""
|
||||
Test that the get_song() method returns the correct song details
|
||||
"""
|
||||
fake_song_page = '''<!DOCTYPE html><html><body>
|
||||
<div class="content-title">
|
||||
<h1>Song Title</h1>
|
||||
<ul class="authors">
|
||||
<li><a>Author 1</a></li>
|
||||
<li><a>Author 2</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="song-content-data"><ul><li><strong>1234_cclinumber_5678</strong></li></ul></div>
|
||||
<section class="page-section">
|
||||
<a title="View song lyrics" href="pretend link"></a>
|
||||
</section>
|
||||
<ul class="song-meta-list">
|
||||
<li>Themes</li><li><a>theme1</a></li><li><a>theme2</a></li>
|
||||
</ul>
|
||||
</body></html>
|
||||
'''
|
||||
fake_lyrics_page = '''<!DOCTYPE html><html><body>
|
||||
<div class="song-viewer lyrics">
|
||||
<h3>Verse 1</h3>
|
||||
<p>verse thing 1<br>line 2</p>
|
||||
<h3>Verse 2</h3>
|
||||
<p>verse thing 2</p>
|
||||
<h3>Spoken Words</h3>
|
||||
<p>completely custom verse type</p>
|
||||
</div>
|
||||
<ul class="copyright">
|
||||
<li>Copy thing</li><li>Copy thing 2</li>
|
||||
</ul>
|
||||
</body></html>
|
||||
'''
|
||||
mocked_get_page.side_effect = [fake_song_page, fake_lyrics_page]
|
||||
mocked_callback = MagicMock()
|
||||
importer = SongSelectImport(None, MagicMock())
|
||||
|
||||
# WHEN: get_song is called
|
||||
result = importer.get_song(callback=mocked_callback)
|
||||
|
||||
# THEN: The callback should have been called three times and the song should be returned
|
||||
assert 3 == mocked_callback.call_count, 'The callback should have been called twice'
|
||||
assert result is not None, 'The get_song() method should have returned a song dictionary'
|
||||
assert result['title'] == 'Song Title'
|
||||
assert result['authors'] == ['Author 1', 'Author 2']
|
||||
assert result['copyright'] == 'Copy thing/Copy thing 2'
|
||||
assert result['topics'] == ['theme1', 'theme2']
|
||||
assert result['ccli_number'] == '1234_cclinumber_5678'
|
||||
assert result['verses'] == [{'label': 'Verse 1', 'lyrics': 'verse thing 1\nline 2'},
|
||||
{'label': 'Verse 2', 'lyrics': 'verse thing 2'},
|
||||
{'label': 'Spoken Words', 'lyrics': 'completely custom verse type'}]
|
||||
|
||||
@patch('openlp.plugins.songs.lib.songselect.clean_song')
|
||||
@patch('openlp.plugins.songs.lib.songselect.Topic')
|
||||
@patch('openlp.plugins.songs.lib.songselect.Author')
|
||||
|
@ -605,7 +466,7 @@ class TestSongSelectForm(TestCase, TestMixin):
|
|||
# THEN: The import object should exist, song var should be None, and the page hooked up
|
||||
assert ssform.song is None
|
||||
assert isinstance(ssform.song_select_importer, SongSelectImport), 'SongSelectImport object should be created'
|
||||
assert ssform.webview.page.call_count == 2, 'Page should be called twice, once for each load handler'
|
||||
assert ssform.webview.page.call_count == 3, 'Page should be called 3 times, once for each load handler'
|
||||
|
||||
@patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QDialog.exec')
|
||||
def test_exec(self, mocked_exec):
|
||||
|
@ -647,9 +508,6 @@ class TestSongSelectForm(TestCase, TestMixin):
|
|||
"""
|
||||
# GIVEN: The SongSelectForm
|
||||
ssform = SongSelectForm(None, MagicMock(), MagicMock())
|
||||
ssform.song_progress_bar = MagicMock()
|
||||
ssform.import_button = MagicMock()
|
||||
ssform.view_button = MagicMock()
|
||||
ssform.back_button = MagicMock()
|
||||
ssform.url_bar = MagicMock()
|
||||
ssform.message_area = MagicMock()
|
||||
|
@ -657,13 +515,10 @@ class TestSongSelectForm(TestCase, TestMixin):
|
|||
# WHEN: The method is run
|
||||
ssform.page_load_started()
|
||||
|
||||
# THEN: The UI should be set up accordingly (working bar and disabled buttons)
|
||||
ssform.song_progress_bar.setMaximum.assert_called_with(0)
|
||||
ssform.song_progress_bar.setVisible.assert_called_with(True)
|
||||
ssform.import_button.setEnabled.assert_called_with(False)
|
||||
ssform.view_button.setEnabled.assert_called_with(False)
|
||||
# THEN: The UI should be set up accordingly
|
||||
ssform.back_button.setEnabled.assert_called_with(False)
|
||||
ssform.message_area.setText.assert_called_with('')
|
||||
ssform.message_area.setText.assert_called_with('Import songs by clicking the "Download" in the Lyrics tab '
|
||||
'or "Download ChordPro" in the Chords tabs.')
|
||||
|
||||
def test_page_loaded_login(self):
|
||||
"""
|
||||
|
@ -682,46 +537,6 @@ class TestSongSelectForm(TestCase, TestMixin):
|
|||
# THEN: The signin page method should be called
|
||||
ssform.signin_page_loaded.assert_called_once()
|
||||
|
||||
def test_page_loaded_song(self):
|
||||
"""
|
||||
Test the page_loaded method for a "Song" page
|
||||
"""
|
||||
# GIVEN: The SongSelectForm and mocked song page
|
||||
ssform = SongSelectForm(None, MagicMock(), MagicMock())
|
||||
ssform.song_select_importer = MagicMock()
|
||||
ssform.song_select_importer.get_page_type.return_value = Pages.Song
|
||||
ssform.song_progress_bar = MagicMock()
|
||||
ssform.url_bar = MagicMock()
|
||||
|
||||
# WHEN: The method is run
|
||||
ssform.page_loaded(True)
|
||||
|
||||
# THEN: Progress bar should have been set max 3 (for loading song)
|
||||
ssform.song_progress_bar.setMaximum.assert_called_with(3)
|
||||
ssform.song_progress_bar.setVisible.call_count == 2
|
||||
|
||||
@patch('openlp.plugins.songs.forms.songselectform.translate')
|
||||
def test_page_loaded_song_no_access(self, mocked_translate):
|
||||
"""
|
||||
Test the page_loaded method for a "Song" page to which the CCLI account has no access
|
||||
"""
|
||||
# GIVEN: The SongSelectForm and mocked song page and translate function
|
||||
ssform = SongSelectForm(None, MagicMock(), MagicMock())
|
||||
ssform.song_select_importer = MagicMock()
|
||||
ssform.song_select_importer.get_page_type.return_value = Pages.Song
|
||||
ssform.song_select_importer.get_song.return_value = None
|
||||
ssform.song_progress_bar = MagicMock()
|
||||
ssform.url_bar = MagicMock()
|
||||
ssform.message_area = MagicMock()
|
||||
mocked_translate.return_value = 'some message'
|
||||
|
||||
# WHEN: The method is run
|
||||
ssform.page_loaded(True)
|
||||
|
||||
# THEN: The no access message should be shown and the progress bar should be less than 3
|
||||
ssform.message_area.setText.assert_called_with('some message')
|
||||
ssform.song_progress_bar.setValue.call_count < 4
|
||||
|
||||
def test_page_loaded_other(self):
|
||||
"""
|
||||
Test the page_loaded method for an "Other" page
|
||||
|
@ -820,94 +635,6 @@ class TestSongSelectForm(TestCase, TestMixin):
|
|||
# THEN: Page should not have changed and a warning should show
|
||||
ssform.song_select_importer.set_page.assert_called_with("test")
|
||||
|
||||
@patch('openlp.plugins.songs.forms.songselectform.and_')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.Song')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.information')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.question')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.translate')
|
||||
def test_on_import(self, mocked_trans, mocked_quest, mocked_info, mocked_song, mocked_and):
|
||||
"""
|
||||
Test that when a song is imported and the user clicks the "yes" button, the UI goes back to the previous page
|
||||
"""
|
||||
# GIVEN: A valid SongSelectForm with a mocked out QMessageBox.question() method
|
||||
mocked_trans.side_effect = lambda *args: args[1]
|
||||
mocked_quest.return_value = QtWidgets.QMessageBox.Yes
|
||||
ssform = SongSelectForm(None, MagicMock(), MagicMock())
|
||||
mocked_song_select_importer = MagicMock()
|
||||
ssform.song_select_importer = mocked_song_select_importer
|
||||
ssform.song = {'ccli_number': '1234'}
|
||||
|
||||
# WHEN: The import button is clicked, and the user clicks Yes
|
||||
with patch.object(ssform, 'on_back_button_clicked') as mocked_on_back_button_clicked:
|
||||
ssform.on_import_button_clicked()
|
||||
|
||||
# THEN: The on_back_button_clicked() method should have been called
|
||||
mocked_song_select_importer.save_song.assert_called_with({'ccli_number': '1234'})
|
||||
mocked_quest.assert_not_called()
|
||||
mocked_info.assert_called_once()
|
||||
mocked_on_back_button_clicked.assert_called_with(True)
|
||||
assert ssform.song is None
|
||||
|
||||
@patch('openlp.plugins.songs.forms.songselectform.len')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.and_')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.Song')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.information')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.question')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.translate')
|
||||
def test_on_import_duplicate_yes_clicked(self, mock_trans, mock_q, mocked_info, mock_song, mock_and, mock_len):
|
||||
"""
|
||||
Test that when a duplicate song is imported and the user clicks the "yes" button, the song is imported
|
||||
"""
|
||||
# GIVEN: A valid SongSelectForm with a mocked out QMessageBox.question() method
|
||||
mock_len.return_value = 1
|
||||
mock_trans.side_effect = lambda *args: args[1]
|
||||
mock_q.return_value = QtWidgets.QMessageBox.Yes
|
||||
ssform = SongSelectForm(None, MagicMock(), MagicMock())
|
||||
mocked_song_select_importer = MagicMock()
|
||||
ssform.song_select_importer = mocked_song_select_importer
|
||||
ssform.song = {'ccli_number': '1234'}
|
||||
|
||||
# WHEN: The import button is clicked, and the user clicks Yes
|
||||
with patch.object(ssform, 'on_back_button_clicked') as mocked_on_back_button_clicked:
|
||||
ssform.on_import_button_clicked()
|
||||
|
||||
# THEN: Should have been saved and the on_back_button_clicked() method should have been called
|
||||
mocked_song_select_importer.save_song.assert_called_with({'ccli_number': '1234'})
|
||||
mock_q.assert_called_once()
|
||||
mocked_info.assert_called_once()
|
||||
mocked_on_back_button_clicked.assert_called_once()
|
||||
assert ssform.song is None
|
||||
|
||||
@patch('openlp.plugins.songs.forms.songselectform.len')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.and_')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.Song')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.information')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.question')
|
||||
@patch('openlp.plugins.songs.forms.songselectform.translate')
|
||||
def test_on_import_duplicate_no_clicked(self, mock_trans, mock_q, mocked_info, mock_song, mock_and, mock_len):
|
||||
"""
|
||||
Test that when a duplicate song is imported and the user clicks the "no" button, the UI exits
|
||||
"""
|
||||
# GIVEN: A valid SongSelectForm with a mocked out QMessageBox.question() method
|
||||
mock_len.return_value = 1
|
||||
mock_trans.side_effect = lambda *args: args[1]
|
||||
mock_q.return_value = QtWidgets.QMessageBox.No
|
||||
ssform = SongSelectForm(None, MagicMock(), MagicMock())
|
||||
mocked_song_select_importer = MagicMock()
|
||||
ssform.song_select_importer = mocked_song_select_importer
|
||||
ssform.song = {'ccli_number': '1234'}
|
||||
|
||||
# WHEN: The import button is clicked, and the user clicks No
|
||||
with patch.object(ssform, 'on_back_button_clicked') as mocked_on_back_button_clicked:
|
||||
ssform.on_import_button_clicked()
|
||||
|
||||
# THEN: Should have not been saved
|
||||
assert mocked_song_select_importer.save_song.call_count == 0
|
||||
mock_q.assert_called_once()
|
||||
mocked_info.assert_not_called()
|
||||
mocked_on_back_button_clicked.assert_not_called()
|
||||
assert ssform.song is not None
|
||||
|
||||
def test_on_back_button_clicked_preview(self):
|
||||
"""
|
||||
Test that when the back button is clicked on preview screen, the stacked widget is set back one page
|
||||
|
@ -967,35 +694,6 @@ class TestSongSelectForm(TestCase, TestMixin):
|
|||
mocked_stacked_widget.setCurrentIndex.assert_called_with(0)
|
||||
ssimporter.assert_called_with()
|
||||
|
||||
def test_update_song_progress(self):
|
||||
"""
|
||||
Test the _update_song_progress() method
|
||||
"""
|
||||
# GIVEN: A SongSelect form
|
||||
ssform = SongSelectForm(None, MagicMock(), MagicMock())
|
||||
|
||||
# WHEN: _update_song_progress() is called
|
||||
with patch.object(ssform, 'song_progress_bar') as mocked_song_progress_bar:
|
||||
mocked_song_progress_bar.value.return_value = 2
|
||||
ssform._update_song_progress()
|
||||
|
||||
# THEN: The song progress bar should be updated
|
||||
mocked_song_progress_bar.setValue.assert_called_with(3)
|
||||
|
||||
def test_on_view_button_clicked(self):
|
||||
"""
|
||||
Test that view song function is run when the view button is clicked
|
||||
"""
|
||||
# GIVEN: A SongSelect form
|
||||
ssform = SongSelectForm(None, MagicMock(), MagicMock())
|
||||
|
||||
# WHEN: A song result is double-clicked
|
||||
with patch.object(ssform, '_view_song') as mocked_view_song:
|
||||
ssform.on_view_button_clicked()
|
||||
|
||||
# THEN: The song is fetched and shown to the user
|
||||
mocked_view_song.assert_called_with()
|
||||
|
||||
|
||||
def test_songselect_file_import():
|
||||
"""
|
||||
|
@ -1006,3 +704,5 @@ def test_songselect_file_import():
|
|||
helper.load_external_result_data(TEST_PATH / 'TestSong-bin.json'))
|
||||
helper.file_import([TEST_PATH / 'TestSong.txt'],
|
||||
helper.load_external_result_data(TEST_PATH / 'TestSong-txt.json'))
|
||||
helper.file_import([TEST_PATH / 'TestSong2023.txt'],
|
||||
helper.load_external_result_data(TEST_PATH / 'TestSong2023-txt.json'))
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"title": "Amazing Grace",
|
||||
"authors": [
|
||||
"Words: John Newton (1725-1807)"
|
||||
["John Newton (1725-1807)", "words"]
|
||||
],
|
||||
"copyright" : "Public Domain",
|
||||
"verse_order_list": ["V1", "V2", "V3", "V4", "V5", "V6"],
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"authors": [
|
||||
"Author One",
|
||||
"Author Two"
|
||||
],
|
||||
"ccli_number": "123456",
|
||||
"song_number": 0,
|
||||
"title": "Test Song",
|
||||
"copyright": "© 2023 OpenLP Music",
|
||||
"verse_order_list": [],
|
||||
"verses": [
|
||||
[
|
||||
"Line One Verse One\nLine Two Verse One\nLine Three Verse One\nLine Four Verse One\n",
|
||||
"v"
|
||||
],
|
||||
[
|
||||
"Line One Verse Two\nLine Two Verse Two\nLine Three Verse Two\nLine Four Verse Two\n",
|
||||
"v"
|
||||
]
|
||||
]
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
Test Song
|
||||
|
||||
|
||||
Verse 1
|
||||
Line One Verse One
|
||||
Line Two Verse One
|
||||
Line Three Verse One
|
||||
Line Four Verse One
|
||||
|
||||
|
||||
Verse 2
|
||||
Line One Verse Two
|
||||
Line Two Verse Two
|
||||
Line Three Verse Two
|
||||
Line Four Verse Two
|
||||
|
||||
Author One, Author Two
|
||||
CCLI Song #123456
|
||||
© 2023 OpenLP Music
|
||||
For use solely with the OpenLP Song Test Suite. All rights reserved. http://openlp.org
|
||||
CCLI License #00000
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"authors": [
|
||||
"Words: Reginald Heber (1783-1826). Music: John B. Dykes (1823-1876)"
|
||||
["Reginald Heber (1783-1826)", "words"],
|
||||
["John B. Dykes (1823-1876)", "music"]
|
||||
],
|
||||
"title": "Holy Holy Holy Lord God Almighty_v2_1_2",
|
||||
"verse_order_list": [],
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"authors": [
|
||||
"Carl Brockhaus / Johann Georg Bäßler 1806"
|
||||
"Carl Brockhaus",
|
||||
"Johann Georg Bäßler 1806"
|
||||
],
|
||||
"title": "Du, Herr, verläßt mich nicht",
|
||||
"verse_order_list": [],
|
||||
|
|
Loading…
Reference in New Issue