Merge branch 'master' into remote-sync

This commit is contained in:
Tomas Groth 2024-04-10 22:34:47 +02:00
commit e1b3e3d9a9
34 changed files with 415 additions and 117 deletions

View File

@ -31,7 +31,7 @@ install:
# Update pip
- python -m pip install --upgrade pip
# Install generic dependencies from pypi.
- python -m pip install sqlalchemy alembic platformdirs chardet beautifulsoup4 lxml Mako mysql-connector-python pytest mock psycopg2-binary websockets waitress six requests QtAwesome PyQt5 PyQtWebEngine pymediainfo PyMuPDF QDarkStyle python-vlc flask-cors pytest-qt pyenchant pysword qrcode flask
- python -m pip install sqlalchemy alembic platformdirs chardet beautifulsoup4 lxml Mako mysql-connector-python pytest mock psycopg2-binary websockets waitress six requests QtAwesome PyQt5 PyQtWebEngine pymediainfo PyMuPDF QDarkStyle python-vlc flask-cors pytest-qt pyenchant pysword qrcode flask packaging
# Install Windows only dependencies
- cmd: python -m pip install pyodbc pypiwin32
- cmd: choco install vlc %CHOCO_VLC_ARG% --no-progress --limit-output

View File

@ -1 +1 @@
3.1.0
3.1.1

View File

@ -24,6 +24,7 @@ from flask import jsonify, request, abort, Blueprint
from PyQt5 import QtCore
from openlp.core.api.lib import login_required
from openlp.core.common.i18n import LanguageManager
from openlp.core.common.registry import Registry
from openlp.core.lib.plugin import PluginStatus, StringContent
from openlp.core.state import State
@ -56,16 +57,38 @@ def plugin_list():
return jsonify(searches)
@core.route('/shortcuts')
def shortcuts():
data = []
settings = Registry().get('settings_thread')
shortcut_prefix = 'shortcuts/'
for key in settings.allKeys():
if key.startswith(shortcut_prefix):
data.append(
{
'action': key.removeprefix(shortcut_prefix),
'shortcut': settings.value(key)
}
)
return jsonify(data)
@core.route('/system')
def system_information():
data = {}
data['websocket_port'] = Registry().get('settings_thread').value('api/websocket port')
data['login_required'] = Registry().get('settings_thread').value('api/authentication enabled')
data['api_version'] = 2
data['api_revision'] = 4
data['api_revision'] = 5
return jsonify(data)
@core.route('/language')
def language():
language = LanguageManager.get_language()
return jsonify({'language': language})
@core.route('/login', methods=['POST'])
def login():
data = request.json

View File

@ -485,12 +485,12 @@ def main():
# support dark mode on windows 10. This makes the titlebar dark, the rest is setup later
# by calling set_windows_darkmode
qt_args.extend(['-platform', 'windows:darkmode=1'])
elif is_macosx() and getattr(sys, 'frozen', False) and not os.environ.get('QTWEBENGINEPROCESS_PATH'):
# Work around an issue where PyInstaller is not setting this environment variable
os.environ['QTWEBENGINEPROCESS_PATH'] = str(AppLocation.get_directory(AppLocation.AppDir) / 'PyQt5' / 'Qt5' /
'lib' / 'QtWebEngineCore.framework' / 'Versions' / '5' /
'Helpers' / 'QtWebEngineProcess.app' / 'Contents' / 'MacOS' /
'QtWebEngineProcess')
elif is_macosx() and getattr(sys, 'frozen', False):
# Set the location to the QtWebEngineProcess binary, normally set by PyInstaller, but it moves around...
os.environ['QTWEBENGINEPROCESS_PATH'] = str((AppLocation.get_directory(AppLocation.AppDir) / '..' /
'Frameworks' / 'QtWebEngineCore.framework' / 'Versions' / '5' /
'Helpers' / 'QtWebEngineProcess.app' / 'Contents' / 'MacOS' /
'QtWebEngineProcess').resolve())
no_custom_factor_rounding = not ('QT_SCALE_FACTOR_ROUNDING_POLICY' in os.environ
and bool(os.environ['QT_SCALE_FACTOR_ROUNDING_POLICY'].strip()))
if no_custom_factor_rounding:
@ -502,9 +502,11 @@ def main():
app = OpenLP()
Registry.create()
QtWidgets.QApplication.setOrganizationName('OpenLP')
QtWidgets.QApplication.setOrganizationName('openlp.org')
QtWidgets.QApplication.setApplicationName('OpenLP')
QtWidgets.QApplication.setOrganizationDomain('openlp.org')
if args.portable:
# This has to be done here so that we can load the settings before instantiating the application object
QtWidgets.QApplication.setApplicationName('OpenLPPortable')
portable_path, settings = setup_portable_settings(args.portablepath)
else:
settings = Settings()
@ -529,7 +531,6 @@ def main():
font.setPointSizeF(font.pointSizeF() * application.devicePixelRatio())
application.setFont(font)
if args.portable:
application.setApplicationName('OpenLPPortable')
data_path = portable_path / 'Data'
set_up_logging(portable_path / 'Other')
set_up_web_engine_cache(portable_path / 'Other' / 'web_cache')
@ -540,7 +541,6 @@ def main():
settings.setValue('advanced/is portable', True)
settings.sync()
else:
application.setApplicationName('OpenLP')
set_up_logging(AppLocation.get_directory(AppLocation.CacheDir))
set_up_web_engine_cache(AppLocation.get_directory(AppLocation.CacheDir) / 'web_cache')
settings.init_default_shortcuts()

View File

@ -27,6 +27,7 @@ import sys
from collections import OrderedDict
from datetime import date
from packaging.version import parse
from PyQt5 import QtCore
from openlp.core.common.applocation import AppLocation
@ -114,8 +115,7 @@ class VersionWorker(ThreadWorker):
retries += 1
else:
self.no_internet.emit()
if remote_version and (QtCore.QVersionNumber.fromString(remote_version) >
QtCore.QVersionNumber.fromString(self.current_version['full'])):
if remote_version and (parse(remote_version) > parse(self.current_version['full'])):
self.new_version.emit(remote_version)
self.quit.emit()

View File

@ -309,15 +309,15 @@ class Ui_EditSongDialog(object):
self.verse_delete_button.setText(UiStrings().Delete)
self.song_tab_widget.setTabText(self.song_tab_widget.indexOf(self.lyrics_tab),
translate('SongsPlugin.EditSongForm', 'Title && Lyrics'))
self.authors_group_box.setTitle(SongStrings.Authors)
self.authors_group_box.setTitle(SongStrings().Authors)
self.author_add_button.setText(translate('SongsPlugin.EditSongForm', '&Add to Song'))
self.author_edit_button.setText(translate('SongsPlugin.EditSongForm', '&Edit Author Type'))
self.author_remove_button.setText(translate('SongsPlugin.EditSongForm', '&Remove'))
self.maintenance_button.setText(translate('SongsPlugin.EditSongForm', '&Manage Authors, Topics, Songbooks'))
self.topics_group_box.setTitle(SongStrings.Topics)
self.topics_group_box.setTitle(SongStrings().Topics)
self.topic_add_button.setText(translate('SongsPlugin.EditSongForm', 'A&dd to Song'))
self.topic_remove_button.setText(translate('SongsPlugin.EditSongForm', 'R&emove'))
self.songbook_group_box.setTitle(SongStrings.SongBooks)
self.songbook_group_box.setTitle(SongStrings().SongBooks)
self.songbook_add_button.setText(translate('SongsPlugin.EditSongForm', 'Add &to Song'))
self.songbook_remove_button.setText(translate('SongsPlugin.EditSongForm', 'Re&move'))
self.song_tab_widget.setTabText(self.song_tab_widget.indexOf(self.authors_tab),
@ -325,7 +325,7 @@ class Ui_EditSongDialog(object):
self.theme_group_box.setTitle(UiStrings().Theme)
self.theme_add_button.setText(translate('SongsPlugin.EditSongForm', 'New &Theme'))
self.rights_group_box.setTitle(translate('SongsPlugin.EditSongForm', 'Copyright Information'))
self.copyright_insert_button.setText(SongStrings.CopyrightSymbol)
self.copyright_insert_button.setText(SongStrings().CopyrightSymbol)
self.ccli_label.setText(UiStrings().CCLISongNumberLabel)
self.comments_group_box.setTitle(translate('SongsPlugin.EditSongForm', 'Comments'))
self.song_tab_widget.setTabText(self.song_tab_widget.indexOf(self.theme_tab),

View File

@ -278,6 +278,12 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties):
name=VerseType.translated_name(tag[0]),
number=tag[1:]))
return False
if self.audio_list_widget.count() > 1:
self.song_tab_widget.setCurrentIndex(3)
critical_error_message_box(message=translate('SongsPlugin.EditSongForm',
'Cannot link more than one audio file. Remove items from '
'Linked Audio other than the one you wish to keep.'))
return False
return True
def _validate_tags(self, tags, first_time=True):
@ -911,7 +917,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties):
"""
text = self.copyright_edit.text()
pos = self.copyright_edit.cursorPosition()
sign = SongStrings.CopyrightSymbol
sign = SongStrings().CopyrightSymbol
text = text[:pos] + sign + text[pos:]
self.copyright_edit.setText(text)
self.copyright_edit.setFocus()
@ -942,6 +948,10 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties):
"""
Loads file(s) from the filesystem.
"""
if self.audio_list_widget.count() > 0:
critical_error_message_box(message=translate('SongsPlugin.EditSongForm',
'Cannot link more than one audio file.'))
return
filters = '{text} (*)'.format(text=UiStrings().AllFiles)
file_paths, filter_used = FileDialog.getOpenFileNames(
parent=self, caption=translate('SongsPlugin.EditSongForm', 'Open File(s)'), filter=filters)
@ -954,6 +964,10 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties):
"""
Loads file(s) from the media plugin.
"""
if self.audio_list_widget.count() > 0:
critical_error_message_box(message=translate('SongsPlugin.EditSongForm',
'Cannot link more than one audio file.'))
return
if self.media_form.exec():
for file_path in self.media_form.get_selected_files():
item = QtWidgets.QListWidgetItem(file_path.name)

View File

@ -141,10 +141,10 @@ class Ui_SongMaintenanceDialog(object):
"""
Translate the UI on the fly.
"""
song_maintenance_dialog.setWindowTitle(SongStrings.SongMaintenance)
self.authors_list_item.setText(SongStrings.Authors)
self.topics_list_item.setText(SongStrings.Topics)
self.books_list_item.setText(SongStrings.SongBooks)
song_maintenance_dialog.setWindowTitle(SongStrings().SongMaintenance)
self.authors_list_item.setText(SongStrings().Authors)
self.topics_list_item.setText(SongStrings().Topics)
self.books_list_item.setText(SongStrings().SongBooks)
self.add_author_button.setText(UiStrings().Add)
self.edit_author_button.setText(UiStrings().Edit)
self.delete_author_button.setText(UiStrings().Delete)
@ -154,7 +154,7 @@ class Ui_SongMaintenanceDialog(object):
self.add_book_button.setText(UiStrings().Add)
self.edit_book_button.setText(UiStrings().Edit)
self.delete_book_button.setText(UiStrings().Delete)
type_list_width = max(self.fontMetrics().width(SongStrings.Authors),
self.fontMetrics().width(SongStrings.Topics),
self.fontMetrics().width(SongStrings.SongBooks))
type_list_width = max(self.fontMetrics().width(SongStrings().Authors),
self.fontMetrics().width(SongStrings().Topics),
self.fontMetrics().width(SongStrings().SongBooks))
self.type_list_widget.setFixedWidth(type_list_width + self.type_list_widget.iconSize().width() + 32)

View File

@ -125,7 +125,7 @@ class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties)
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_file = open(song_filename, 'rt', encoding='utf-8')
song_content = song_file.read()
song_file.seek(0)
if self.check_for_duplicate(song_content):

View File

@ -384,7 +384,7 @@ def clean_song(manager, song):
song.search_lyrics = ' '.join([clean_string(remove_tags(verse[1], True)) for verse in verses])
# The song does not have any author, add one.
if not song.authors_songs:
name = SongStrings.AuthorUnknown
name = SongStrings().AuthorUnknown
author = manager.get_object_filtered(Author, Author.display_name == name)
if author is None:
author = Author(display_name=name, last_name='', first_name='')

View File

@ -166,28 +166,29 @@ class SongFormat(object):
EasyWorshipDB = 7
EasyWorshipSqliteDB = 8
EasyWorshipService = 9
FoilPresenter = 10
LiveWorship = 11
Lyrix = 12
MediaShout = 13
OpenSong = 14
OPSPro = 15
PowerPraise = 16
PowerSong = 17
PresentationManager = 18
ProPresenter = 19
SingingTheFaith = 20
SongBeamer = 21
SongPro = 22
SongShowPlus = 23
SongsOfFellowship = 24
SundayPlus = 25
VideoPsalm = 26
WordsOfWorship = 27
WorshipAssistant = 28
WorshipCenterPro = 29
ZionWorx = 30
Datasoul = 31
EasyWorshipServiceSqliteDB = 10
FoilPresenter = 11
LiveWorship = 12
Lyrix = 13
MediaShout = 14
OpenSong = 15
OPSPro = 16
PowerPraise = 17
PowerSong = 18
PresentationManager = 19
ProPresenter = 20
SingingTheFaith = 21
SongBeamer = 22
SongPro = 23
SongShowPlus = 24
SongsOfFellowship = 25
SundayPlus = 26
VideoPsalm = 27
WordsOfWorship = 28
WorshipAssistant = 29
WorshipCenterPro = 30
ZionWorx = 31
Datasoul = 32
# Set optional attribute defaults
__defaults__ = {
@ -278,6 +279,14 @@ class SongFormat(object):
'filter': '{text} (*.ews)'.format(text=translate('SongsPlugin.ImportWizardForm',
'EasyWorship 2007/2009 Service File'))
},
EasyWorshipServiceSqliteDB: {
'class': EasyWorshipSongImport,
'name': 'EasyWorship 6/7 Service File',
'prefix': 'ew',
'selectMode': SongFormatSelect.SingleFile,
'filter': '{text} (*.ewsx)'.format(text=translate('SongsPlugin.ImportWizardForm',
'EasyWorship 6/7 Service File'))
},
FoilPresenter: {
'class': FoilPresenterImport,
'name': 'Foilpresenter',
@ -487,6 +496,7 @@ class SongFormat(object):
SongFormat.EasyWorshipDB,
SongFormat.EasyWorshipSqliteDB,
SongFormat.EasyWorshipService,
SongFormat.EasyWorshipServiceSqliteDB,
SongFormat.FoilPresenter,
SongFormat.LiveWorship,
SongFormat.Lyrix,

View File

@ -56,7 +56,7 @@ class DatasoulImport(SongImport):
tree = objectify.parse(str(file_path), parser)
except etree.XMLSyntaxError:
log.exception('XML syntax error in file {name}'.format(name=file_path))
self.log_error(file_path, SongStrings.XMLSyntaxError)
self.log_error(file_path, SongStrings().XMLSyntaxError)
continue
song_xml = tree.getroot()
if song_xml.tag != 'Song':

View File

@ -93,16 +93,16 @@ class DreamBeamImport(SongImport):
parsed_file = etree.parse(xml_file, parser)
except etree.XMLSyntaxError:
log.exception('XML syntax error in file {name}'.format(name=file_path))
self.log_error(file_path, SongStrings.XMLSyntaxError)
self.log_error(file_path, SongStrings().XMLSyntaxError)
continue
except UnicodeDecodeError:
log.exception('Unreadable characters in {name}'.format(name=file_path))
self.log_error(file_path, SongStrings.XMLSyntaxError)
self.log_error(file_path, SongStrings().XMLSyntaxError)
continue
file_str = etree.tostring(parsed_file)
if not file_str:
log.exception('Could not find XML in file {name}'.format(name=file_path))
self.log_error(file_path, SongStrings.XMLSyntaxError)
self.log_error(file_path, SongStrings().XMLSyntaxError)
continue
xml = file_str.decode()
song_xml = objectify.fromstring(xml)
@ -151,7 +151,7 @@ class DreamBeamImport(SongImport):
author_copyright = song_xml.Text2.Text.text
if author_copyright:
author_copyright = str(author_copyright)
if author_copyright.find(SongStrings.CopyrightSymbol) >= 0:
if author_copyright.find(SongStrings().CopyrightSymbol) >= 0:
self.add_copyright(author_copyright)
else:
self.parse_author(author_copyright)

View File

@ -54,16 +54,16 @@ class EasySlidesImport(SongImport):
parsed_file = etree.parse(xml_file, parser)
except etree.XMLSyntaxError:
log.exception('XML syntax error in file {name}'.format(name=self.import_source))
self.log_error(self.import_source, SongStrings.XMLSyntaxError)
self.log_error(self.import_source, SongStrings().XMLSyntaxError)
return
except UnicodeDecodeError:
log.exception('Unreadable characters in {name}'.format(name=self.import_source))
self.log_error(self.import_source, SongStrings.XMLSyntaxError)
self.log_error(self.import_source, SongStrings().XMLSyntaxError)
return
file_str = etree.tostring(parsed_file)
if not file_str:
log.exception('Could not find XML in file {name}'.format(name=self.import_source))
self.log_error(self.import_source, SongStrings.XMLSyntaxError)
self.log_error(self.import_source, SongStrings().XMLSyntaxError)
return
xml = file_str.decode()
song_xml = objectify.fromstring(xml)

View File

@ -28,6 +28,8 @@ import sqlite3
import struct
import zlib
from pathlib import Path
from tempfile import NamedTemporaryFile
from zipfile import ZipFile
from openlp.core.common.i18n import translate
from openlp.plugins.songs.lib import VerseType, retrieve_windows_encoding, strip_rtf
@ -83,6 +85,8 @@ class EasyWorshipSongImport(SongImport):
self.import_ews()
elif ext == '.db':
self.import_db()
elif ext == '.ewsx':
self.import_ewsx()
else:
self.import_sqlite_db()
except Exception:
@ -346,6 +350,65 @@ class EasyWorshipSongImport(SongImport):
db_file.close()
self.memo_file.close()
def import_ewsx(self):
"""
Imports songs from an EasyWorship 6/7 service file, which is just a zip file with an Sqlite DB with text
resources. Non-text recources is also in the zip file, but is ignored.
"""
invalid_ewsx_msg = translate('SongsPlugin.EasyWorshipSongImport',
'This is not a valid Easy Worship 6/7 service file.')
# Open ewsx file if it exists
if not self.import_source.is_file():
log.debug('Given ewsx file does not exists.')
return
tmp_db_file = NamedTemporaryFile(delete=False)
with ZipFile(self.import_source, 'r') as eswx_file:
db_zfile = eswx_file.open('main.db')
# eswx has bad CRC for the database for some reason (custom CRC?), so skip the CRC
db_zfile._expected_crc = None
db_data = db_zfile.read()
tmp_db_file.write(db_data)
tmp_db_file.close()
ewsx_conn = sqlite3.connect(tmp_db_file.file.name)
if ewsx_conn is None:
self.log_error(self.import_source, invalid_ewsx_msg)
return
ewsx_db = ewsx_conn.cursor()
# Take a stab at how text is encoded
self.encoding = 'cp1252'
self.encoding = retrieve_windows_encoding(self.encoding)
if not self.encoding:
log.debug('No encoding set.')
return
# get list of songs in service file, presentation_type=6 means songs
songs_exec = ewsx_db.execute('SELECT rowid, title, author, copyright, reference_number '
'FROM presentation WHERE presentation_type=6;')
songs = songs_exec.fetchall()
for song in songs:
self.title = title = song[1]
self.author = song[2]
self.copyright = song[3]
self.ccli_number = song[4]
# get slides for the song, element_type=6 means songs, element_style_type=4 means song text
slides = ewsx_db.execute('SELECT rt.rtf '
'FROM element as e '
'JOIN slide as s ON e.slide_id = s.rowid '
'JOIN resource_text as rt ON rt.resource_id = e.foreground_resource_id '
'WHERE e.element_type=6 AND e.element_style_type=4 AND s.presentation_id = ? '
'ORDER BY s.order_index;', (song[0],))
for slide in slides:
if slide:
self.set_song_import_object(self.author, slide[0].encode())
# save song
if not self.finish():
self.log_error(self.import_source,
translate('SongsPlugin.EasyWorshipSongImport',
'"{title}" could not be imported. {entry}').
format(title=title, entry=self.entry_error_log))
# close database handles
ewsx_conn.close()
Path(tmp_db_file.file.name).unlink()
def import_sqlite_db(self):
"""
Import the songs from an EasyWorship 6 SQLite database

View File

@ -125,7 +125,7 @@ class FoilPresenterImport(SongImport):
xml = etree.tostring(parsed_file).decode()
self.foil_presenter.xml_to_song(xml)
except etree.XMLSyntaxError:
self.log_error(file_path, SongStrings.XMLSyntaxError)
self.log_error(file_path, SongStrings().XMLSyntaxError)
log.exception('XML syntax error in file {path}'.format(path=file_path))
except AttributeError:
self.log_error(file_path, translate('SongsPlugin.FoilPresenterSongImport',

View File

@ -72,7 +72,7 @@ class LiveWorshipImport(SongImport):
try:
self.root = etree.fromstring(xml_content, parser)
except etree.XMLSyntaxError:
self.log_error(self.dump_file, SongStrings.XMLSyntaxError)
self.log_error(self.dump_file, SongStrings().XMLSyntaxError)
log.exception('XML syntax error in file {path}'.format(path=str(self.dump_file)))
def extract_songs(self):

View File

@ -73,7 +73,7 @@ class OpenLyricsImport(SongImport):
self.open_lyrics.xml_to_song(xml)
except etree.XMLSyntaxError:
log.exception('XML syntax error in file {path}'.format(path=file_path))
self.log_error(file_path, SongStrings.XMLSyntaxError)
self.log_error(file_path, SongStrings().XMLSyntaxError)
except OpenLyricsError as exception:
log.exception('OpenLyricsException {error:d} in file {name}: {text}'.format(error=exception.type,
name=file_path,

View File

@ -129,7 +129,7 @@ class OpenSongImport(SongImport):
try:
tree = objectify.parse(file)
except (etree.Error, etree.LxmlError):
self.log_error(file.name, SongStrings.XMLSyntaxError)
self.log_error(file.name, SongStrings().XMLSyntaxError)
log.exception('Error parsing XML')
return
root = tree.getroot()

View File

@ -50,11 +50,11 @@ class PowerPraiseImport(SongImport):
root = objectify.parse(xml_file).getroot()
except etree.XMLSyntaxError:
log.exception('XML syntax error in file {name}'.format(name=file_path))
self.log_error(file_path, SongStrings.XMLSyntaxError)
self.log_error(file_path, SongStrings().XMLSyntaxError)
continue
except UnicodeDecodeError:
log.exception('Unreadable characters in {name}'.format(name=file_path))
self.log_error(file_path, SongStrings.XMLSyntaxError)
self.log_error(file_path, SongStrings().XMLSyntaxError)
continue
self.process_song(root, file_path)

View File

@ -54,11 +54,11 @@ class ProPresenterImport(SongImport):
root = objectify.parse(xml_file).getroot()
except etree.XMLSyntaxError:
log.exception('XML syntax error in file {name}'.format(name=file_path))
self.log_error(file_path, SongStrings.XMLSyntaxError)
self.log_error(file_path, SongStrings().XMLSyntaxError)
continue
except UnicodeDecodeError:
log.exception('Unreadable characters in {name}'.format(name=file_path))
self.log_error(file_path, SongStrings.XMLSyntaxError)
self.log_error(file_path, SongStrings().XMLSyntaxError)
continue
try:
self.process_song(root, file_path)

View File

@ -104,7 +104,7 @@ class SongImport(QtCore.QObject):
self.verse_counts = {}
self.copyright_string = translate('SongsPlugin.SongImport', 'copyright')
def log_error(self, file_path, reason=SongStrings.SongIncomplete):
def log_error(self, file_path, reason=SongStrings().SongIncomplete):
"""
This should be called, when a song could not be imported.
@ -151,11 +151,11 @@ class SongImport(QtCore.QObject):
:param text: Some text
"""
lines = text.split('\n')
if text.lower().find(self.copyright_string) >= 0 or text.find(str(SongStrings.CopyrightSymbol)) >= 0:
if text.lower().find(self.copyright_string) >= 0 or text.find(str(SongStrings().CopyrightSymbol)) >= 0:
copyright_found = False
for line in lines:
if (copyright_found or line.lower().find(self.copyright_string) >= 0 or
line.find(str(SongStrings.CopyrightSymbol)) >= 0):
line.find(str(SongStrings().CopyrightSymbol)) >= 0):
copyright_found = True
self.add_copyright(line)
else:

View File

@ -124,7 +124,7 @@ class SongMediaItem(MediaManagerItem):
def retranslate_ui(self):
self.search_text_label.setText('{text}:'.format(text=UiStrings().Search))
self.search_text_button.setText(UiStrings().Search)
self.maintenance_action.setText(SongStrings.SongMaintenance)
self.maintenance_action.setText(SongStrings().SongMaintenance)
self.maintenance_action.setToolTip(translate('SongsPlugin.MediaItem',
'Maintain the lists of authors, topics and books.'))
@ -145,11 +145,11 @@ class SongMediaItem(MediaManagerItem):
(SongSearch.Lyrics, UiIcons().search_lyrics,
translate('SongsPlugin.MediaItem', 'Lyrics'),
translate('SongsPlugin.MediaItem', 'Search Lyrics...')),
(SongSearch.Authors, UiIcons().user, SongStrings.Authors,
(SongSearch.Authors, UiIcons().user, SongStrings().Authors,
translate('SongsPlugin.MediaItem', 'Search Authors...')),
(SongSearch.Topics, UiIcons().light_bulb, SongStrings.Topics,
(SongSearch.Topics, UiIcons().light_bulb, SongStrings().Topics,
translate('SongsPlugin.MediaItem', 'Search Topics...')),
(SongSearch.Books, UiIcons().address, SongStrings.SongBooks,
(SongSearch.Books, UiIcons().address, SongStrings().SongBooks,
translate('SongsPlugin.MediaItem', 'Search Songbooks...')),
(SongSearch.Themes, UiIcons().theme, UiStrings().Themes, UiStrings().SearchThemes),
(SongSearch.Copyright, UiIcons().copyright,
@ -674,7 +674,7 @@ class SongMediaItem(MediaManagerItem):
authors=create_separated_list(authors.translation))
)
if song.copyright:
item.raw_footer.append("{symbol} {song}".format(symbol=SongStrings.CopyrightSymbol,
item.raw_footer.append("{symbol} {song}".format(symbol=SongStrings().CopyrightSymbol,
song=song.copyright))
if song.songbook_entries:
item.raw_footer.append(", ".join(songbooks))

View File

@ -32,18 +32,19 @@ class SongStrings(object):
"""
Provide standard strings for use throughout the songs plugin.
"""
# These strings should need a good reason to be retranslated elsewhere.
Author = translate('OpenLP.Ui', 'Author', 'Singular')
Authors = translate('OpenLP.Ui', 'Authors', 'Plural')
AuthorUnknown = translate('OpenLP.Ui', 'Author Unknown') # Used to populate the database.
CopyrightSymbol = '\xa9'
SongBook = translate('OpenLP.Ui', 'Songbook', 'Singular')
SongBooks = translate('OpenLP.Ui', 'Songbooks', 'Plural')
SongIncomplete = translate('OpenLP.Ui', 'Title and/or verses not found')
SongMaintenance = translate('OpenLP.Ui', 'Song Maintenance')
Topic = translate('OpenLP.Ui', 'Topic', 'Singular')
Topics = translate('OpenLP.Ui', 'Topics', 'Plural')
XMLSyntaxError = translate('OpenLP.Ui', 'XML syntax error')
def __init__(self):
# These strings should need a good reason to be retranslated elsewhere.
self.Author = translate('OpenLP.Ui', 'Author', 'Singular')
self.Authors = translate('OpenLP.Ui', 'Authors', 'Plural')
self.AuthorUnknown = translate('OpenLP.Ui', 'Author Unknown') # Used to populate the database.
self.CopyrightSymbol = '\xa9'
self.SongBook = translate('OpenLP.Ui', 'Songbook', 'Singular')
self.SongBooks = translate('OpenLP.Ui', 'Songbooks', 'Plural')
self.SongIncomplete = translate('OpenLP.Ui', 'Title and/or verses not found')
self.SongMaintenance = translate('OpenLP.Ui', 'Song Maintenance')
self.Topic = translate('OpenLP.Ui', 'Topic', 'Singular')
self.Topics = translate('OpenLP.Ui', 'Topics', 'Plural')
self.XMLSyntaxError = translate('OpenLP.Ui', 'XML syntax error')
def show_key_warning(parent):

View File

@ -107,8 +107,7 @@
</column>
<item row="0" column="0">
<property name="text">
<string>
Authors</string>
<string>Authors</string>
</property>
<property name="textAlignment">
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
@ -120,8 +119,7 @@ Authors</string>
</item>
<item row="1" column="0">
<property name="text">
<string>
Topics</string>
<string>Topics</string>
</property>
<property name="textAlignment">
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
@ -133,8 +131,7 @@ Topics</string>
</item>
<item row="2" column="0">
<property name="text">
<string>
Books/Hymnals</string>
<string>Books/Hymnals</string>
</property>
<property name="textAlignment">
<set>AlignHCenter|AlignVCenter|AlignCenter</set>

View File

@ -95,6 +95,7 @@ MODULES = [
'pymediainfo',
'vlc',
'qrcode',
'packaging',
]

View File

@ -105,6 +105,7 @@ using a computer and a display/projector.""",
'flask-cors',
'lxml',
'Mako',
'packaging',
'platformdirs',
'PyICU',
'pymediainfo >= 2.2',

View File

@ -29,7 +29,7 @@ from openlp.core.state import State
from openlp.core.lib.plugin import PluginStatus, StringContent
def test_plugins_returns_list(flask_client: FlaskClient, registry: Registry, settings: Settings):
def test_plugins_returns_list(flask_client: FlaskClient):
State().load_settings()
res = flask_client.get('/api/v2/core/plugins').get_json()
assert len(res) == 0
@ -52,14 +52,30 @@ def test_plugins_returns_list(flask_client: FlaskClient, registry: Registry, set
assert res[0]['name'] == plugin.text_strings[StringContent.Name]['plural']
def test_system_information(flask_client: FlaskClient, registry: Registry, settings: Settings):
def test_system_information(flask_client: FlaskClient, settings: Settings):
Registry().get('settings_thread').setValue('api/authentication enabled', False)
res = flask_client.get('/api/v2/core/system').get_json()
assert res['websocket_port'] > 0
assert not res['login_required']
def test_poll_backend(registry: Registry, settings: Settings):
def test_shortcuts(flask_client: FlaskClient, settings: Settings):
action = 'shortcuts/aboutItem'
shortcut = 'Ctrl+F1'
Registry().get('settings_thread').setValue(action, shortcut)
res = flask_client.get('/api/v2/core/shortcuts')
assert res.status_code == 200
assert res.get_json()[0]['action'] == action.removeprefix('shortcuts/')
assert res.get_json()[0]['shortcut'] == shortcut
def test_language(flask_client: FlaskClient, settings: Settings):
res = flask_client.get('/api/v2/core/language')
assert res.status_code == 200
assert res.get_json()['language']
def test_poll_backend(settings: Settings):
"""
Test the raw poll function returns the correct JSON
"""
@ -100,12 +116,12 @@ def test_login_without_data_returns_400(flask_client: FlaskClient):
assert res.status_code == 400
def test_login_with_invalid_credetials_returns_401(flask_client: FlaskClient, registry: Registry, settings: Settings):
def test_login_with_invalid_credetials_returns_401(flask_client: FlaskClient, settings: Settings):
res = flask_client.post('/api/v2/core/login', json=dict(username='openlp', password='invalid'))
assert res.status_code == 401
def test_login_with_valid_credetials_returns_token(flask_client: FlaskClient, registry: Registry, settings: Settings):
def test_login_with_valid_credetials_returns_token(flask_client: FlaskClient, settings: Settings):
Registry().register('authentication_token', 'foobar')
res = flask_client.post('/api/v2/core/login', json=dict(username='openlp', password='password'))
assert res.status_code == 200
@ -125,7 +141,7 @@ def test_retrieving_image(flask_client: FlaskClient):
assert res['binary_image'] != ''
def test_toggle_display_requires_login(flask_client: FlaskClient, registry: Registry, settings: Settings):
def test_toggle_display_requires_login(flask_client: FlaskClient, settings: Settings):
settings.setValue('api/authentication enabled', True)
Registry().register('authentication_token', 'foobar')
res = flask_client.post('/api/v2/core/display')
@ -138,18 +154,17 @@ def test_toggle_display_does_not_allow_get(flask_client: FlaskClient):
assert res.status_code == 405
def test_toggle_display_invalid_action(flask_client: FlaskClient, registry: Registry, settings: Settings):
def test_toggle_display_invalid_action(flask_client: FlaskClient, settings: Settings):
res = flask_client.post('/api/v2/core/display', json={'display': 'foo'})
assert res.status_code == 400
def test_toggle_display_no_data(flask_client: FlaskClient, registry: Registry, settings: Settings):
def test_toggle_display_no_data(flask_client: FlaskClient, settings: Settings):
res = flask_client.post('/api/v2/core/display', json={})
assert res.status_code == 400
def test_toggle_display_valid_action_updates_controller(flask_client: FlaskClient, registry: Registry,
settings: Settings):
def test_toggle_display_valid_action_updates_controller(flask_client: FlaskClient, settings: Settings):
class FakeController:
class Emitter:
def emit(self, value):
@ -162,7 +177,7 @@ def test_toggle_display_valid_action_updates_controller(flask_client: FlaskClien
assert controller.slidecontroller_toggle_display.set == 'show'
def test_cors_headers_are_present(flask_client: FlaskClient, registry: Registry, settings: Settings):
def test_cors_headers_are_present(flask_client: FlaskClient, settings: Settings):
res = flask_client.get('/api/v2/core/system')
assert 'Access-Control-Allow-Origin' in res.headers
assert res.headers['Access-Control-Allow-Origin'] == '*'

View File

@ -19,15 +19,15 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
import sys
import pytest
from argparse import Namespace
from pathlib import Path
from tempfile import mkdtemp
from unittest.mock import MagicMock, patch
import pytest
from PyQt5 import QtCore, QtWidgets
# Mock QtWebEngineWidgets
sys.modules['PyQt5.QtWebEngineWidgets'] = MagicMock()
@ -37,14 +37,20 @@ from openlp.core.common.settings import Settings
@pytest.fixture
def app_main_env():
def mocked_qapp():
patcher = patch('openlp.core.app.QtWidgets.QApplication')
yield patcher.start()
patcher.stop()
@pytest.fixture
def app_main_env(mocked_qapp):
with patch('openlp.core.app.Settings') as mock_settings, \
patch('openlp.core.app.Registry') as mock_registry, \
patch('openlp.core.app.AppLocation') as mock_apploc, \
patch('openlp.core.app.LanguageManager'), \
patch('openlp.core.app.qInitResources'), \
patch('openlp.core.app.parse_options'), \
patch('openlp.core.app.QtWidgets.QApplication') as mock_qapp, \
patch('openlp.core.app.parse_options') as mocked_parse_options, \
patch('openlp.core.app.QtWidgets.QMessageBox.warning') as mock_warn, \
patch('openlp.core.app.QtWidgets.QMessageBox.information'), \
patch('openlp.core.app.OpenLP') as mock_openlp, \
@ -58,7 +64,10 @@ def app_main_env():
openlp_server.is_another_instance_running.return_value = False
mock_apploc.get_data_path.return_value = Path()
mock_apploc.get_directory.return_value = Path()
mock_qapp.return_value.devicePixelRatio.return_value = 1.0
mocked_parse_options.return_value = Namespace(no_error_form=False, loglevel='warning', portable=False,
portablepath=None, no_web_server=False, display_custom_path=None,
rargs=[])
mocked_qapp.return_value.devicePixelRatio.return_value = 1.0
mock_warn.return_value = True
openlp_instance = MagicMock()
mock_openlp.return_value = openlp_instance
@ -311,7 +320,8 @@ def test_backup_on_upgrade(mocked_question, mocked_get_version, qapp, settings):
@patch('openlp.core.app.backup_if_version_changed')
@patch('openlp.core.app.set_up_web_engine_cache')
@patch('openlp.core.app.set_up_logging')
def test_main(mock_logging, mock_web_cache, mock_backup, mock_sys, mock_openlp, app_main_env):
def test_main(mock_logging: MagicMock, mock_web_cache: MagicMock, mock_backup: MagicMock, mock_sys: MagicMock,
mock_openlp: MagicMock, mocked_qapp: MagicMock, app_main_env: None):
"""
Test the main method performs primary actions
"""
@ -329,6 +339,9 @@ def test_main(mock_logging, mock_web_cache, mock_backup, mock_sys, mock_openlp,
mock_logging.assert_called_once()
mock_web_cache.assert_called_once()
mock_sys.exit.assert_called_once()
mocked_qapp.setOrganizationName.assert_called_once_with('OpenLP')
mocked_qapp.setApplicationName.assert_called_once_with('OpenLP')
mocked_qapp.setOrganizationDomain.assert_called_once_with('openlp.org')
@patch('openlp.core.app.QtWidgets.QMessageBox.warning')

View File

@ -135,7 +135,7 @@ def test_worker_start_nightly_version(mock_get_web_page, mock_platform):
"""
# GIVEN: A last check date, current version, and an instance of worker
last_check_date = '1970-01-01'
current_version = {'full': '2.1-bzr2345', 'version': '2.1', 'build': '2345'}
current_version = {'full': '2.1.0+git2345', 'version': '2.1', 'build': '2345'}
mock_platform.system.return_value = 'Linux'
mock_platform.release.return_value = '4.12.0-1-amd64'
mock_get_web_page.return_value = '2.4.6'
@ -148,7 +148,7 @@ def test_worker_start_nightly_version(mock_get_web_page, mock_platform):
# THEN: The check completes and the signal is emitted
expected_download_url = 'https://get.openlp.org/versions/nightly_version.txt'
expected_headers = {'User-Agent': 'OpenLP/2.1-bzr2345 Linux/4.12.0-1-amd64; '}
expected_headers = {'User-Agent': 'OpenLP/2.1.0+git2345 Linux/4.12.0-1-amd64; '}
mock_get_web_page.assert_called_once_with(expected_download_url, headers=expected_headers)
mock_new_version.emit.assert_called_once_with('2.4.6')
mock_quit.emit.assert_called_once_with()
@ -162,7 +162,7 @@ def test_worker_empty_response(mock_get_web_page, mock_platform):
"""
# GIVEN: A last check date, current version, and an instance of worker
last_check_date = '1970-01-01'
current_version = {'full': '2.1-bzr2345', 'version': '2.1', 'build': '2345'}
current_version = {'full': '2.1+git2345', 'version': '2.1', 'build': '2345'}
mock_platform.system.return_value = 'Linux'
mock_platform.release.return_value = '4.12.0-1-amd64'
mock_get_web_page.return_value = '\n'
@ -175,7 +175,7 @@ def test_worker_empty_response(mock_get_web_page, mock_platform):
# THEN: The check completes and the signal is emitted
expected_download_url = 'https://get.openlp.org/versions/nightly_version.txt'
expected_headers = {'User-Agent': 'OpenLP/2.1-bzr2345 Linux/4.12.0-1-amd64; '}
expected_headers = {'User-Agent': 'OpenLP/2.1+git2345 Linux/4.12.0-1-amd64; '}
mock_get_web_page.assert_called_once_with(expected_download_url, headers=expected_headers)
assert mock_new_version.emit.call_count == 0
mock_quit.emit.assert_called_once_with()

View File

@ -21,10 +21,16 @@
"""
This module contains tests for the lib submodule of the Songs plugin.
"""
import logging
import pytest
from unittest.mock import MagicMock, patch
from PyQt5 import QtCore, QtWidgets
from openlp.core.common.registry import Registry
from openlp.core.common.settings import Settings
from openlp.plugins.songs.forms.editsongform import EditSongForm
from openlp.plugins.songs.lib import VerseType
from openlp.plugins.songs.lib.db import Author
@pytest.fixture()
@ -33,6 +39,17 @@ def edit_song_form():
return EditSongForm(None, MagicMock(), MagicMock())
@pytest.fixture()
def edit_song_form_with_ui(settings: Settings) -> EditSongForm:
main_window = QtWidgets.QMainWindow()
Registry().register('main_window', main_window)
Registry().register('theme_manager', MagicMock())
form = EditSongForm(None, main_window, MagicMock())
yield form
del form
del main_window
def test_validate_matching_tags(edit_song_form):
# Given a set of tags
tags = ['{r}', '{/r}', '{bl}', '{/bl}', '{su}', '{/su}']
@ -86,3 +103,104 @@ def test_load_objects(mocked_set_case_insensitive_completer, edit_song_form, set
mocked_set_case_insensitive_completer.assert_called_once_with(mocked_cache, mocked_combo)
mocked_combo.setCurrentIndex.assert_called_once_with(-1)
mocked_combo.setCurrentText.assert_called_once_with('')
def test_add_multiple_audio_from_file(edit_song_form_with_ui: EditSongForm):
"""
Test that not more than one Linked Audio item can be added
"""
# GIVEN: A Linked Audio list with 1 item and mocked error message handler
item = QtWidgets.QListWidgetItem('Audio file')
edit_song_form_with_ui.audio_list_widget.addItem(item)
mocked_error_message = MagicMock()
Registry().get('main_window').error_message = mocked_error_message
# WHEN: Add File is clicked
edit_song_form_with_ui.on_audio_add_from_file_button_clicked()
# THEN: A call to show an error message should have been made and no items should have been added
mocked_error_message.assert_called_once()
assert edit_song_form_with_ui.audio_list_widget.count() == 1
def test_add_multiple_audio_from_media(edit_song_form_with_ui: EditSongForm):
"""
Test that not more than one Linked Audio item can be added
"""
# GIVEN: A Linked Audio list with 1 item and mocked error message handler
item = QtWidgets.QListWidgetItem('Audio file')
edit_song_form_with_ui.audio_list_widget.addItem(item)
mocked_error_message = MagicMock()
Registry().get('main_window').error_message = mocked_error_message
# WHEN: Add Media is clicked
edit_song_form_with_ui.on_audio_add_from_media_button_clicked()
# THEN: A call to show an error message should have been made and no items should have been added
mocked_error_message.assert_called_once()
assert edit_song_form_with_ui.audio_list_widget.count() == 1
def test_validate_song_multiple_audio(edit_song_form_with_ui: EditSongForm):
"""
Test that a form with multiple Linked Audio items does not pass validation
"""
# GIVEN: A form with title, lyrics, an author, and 2 Linked Audio items
edit_song_form_with_ui.title_edit.setText('Song Title')
verse_def = '{tag}{number}'.format(tag=VerseType.tags[0], number=1)
song_lyrics = 'Song Lyrics'
verse_item = QtWidgets.QTableWidgetItem(song_lyrics)
verse_item.setData(QtCore.Qt.UserRole, verse_def)
verse_item.setText(song_lyrics)
edit_song_form_with_ui.verse_list_widget.setRowCount(1)
edit_song_form_with_ui.verse_list_widget.setItem(0, 0, verse_item)
item_1 = QtWidgets.QListWidgetItem('Audio file 1')
item_2 = QtWidgets.QListWidgetItem('Audio file 2')
edit_song_form_with_ui.audio_list_widget.addItem(item_1)
edit_song_form_with_ui.audio_list_widget.addItem(item_2)
author = Author(first_name='', last_name='', display_name='Author')
author_type = edit_song_form_with_ui.author_types_combo_box.itemData(0)
edit_song_form_with_ui._add_author_to_list(author, author_type)
mocked_error_message = MagicMock()
Registry().get('main_window').error_message = mocked_error_message
# WHEN: Song is validated
song_valid = edit_song_form_with_ui._validate_song()
# THEN: It should not be valid
assert song_valid is False
def test_validate_song_one_audio(edit_song_form_with_ui: EditSongForm):
"""
Test that a form with one Linked Audio item passes validation
"""
# GIVEN: A form with title, lyrics, an author, and 1 Linked Audio item
edit_song_form_with_ui.title_edit.setText('Song Title')
verse_def = '{tag}{number}'.format(tag=VerseType.tags[0], number=1)
song_lyrics = 'Song Lyrics'
verse_item = QtWidgets.QTableWidgetItem(song_lyrics)
verse_item.setData(QtCore.Qt.UserRole, verse_def)
verse_item.setText(song_lyrics)
edit_song_form_with_ui.verse_list_widget.setRowCount(1)
edit_song_form_with_ui.verse_list_widget.setItem(0, 0, verse_item)
item_1 = QtWidgets.QListWidgetItem('Audio file 1')
edit_song_form_with_ui.audio_list_widget.addItem(item_1)
author = Author(first_name='', last_name='', display_name='Author')
author_type = edit_song_form_with_ui.author_types_combo_box.itemData(0)
edit_song_form_with_ui._add_author_to_list(author, author_type)
# If the validation does fail for some reason it will likely try to display an error message,
# so make sure error_message exists to avoid an error
mocked_error_message = MagicMock()
Registry().get('main_window').error_message = mocked_error_message
# WHEN: Song is validated
song_valid = edit_song_form_with_ui._validate_song()
# Log the error message to help determine the cause in the case validation failed
if mocked_error_message.called:
_title, message = mocked_error_message.call_args.args
logging.error('Validation error message: {message}'.format(message=message))
# THEN: It should be valid
assert song_valid is True

View File

@ -498,6 +498,48 @@ def test_ews_file_import(mocked_retrieve_windows_encoding: MagicMock, MockSongIm
mocked_finish.assert_called_with()
@patch('openlp.plugins.songs.lib.importers.easyworship.SongImport')
@patch('openlp.plugins.songs.lib.importers.easyworship.retrieve_windows_encoding')
def test_ewsx_file_import(mocked_retrieve_windows_encoding: MagicMock, MockSongImport: MagicMock,
registry: Registry, settings: Settings):
"""
Test the actual import of song from ewsx file and check that the imported data is correct.
"""
# GIVEN: Test files with a mocked out SongImport class, a mocked out "manager", a mocked out "import_wizard",
# and mocked out "author", "add_copyright", "add_verse", "finish" methods.
mocked_retrieve_windows_encoding.return_value = 'cp1252'
mocked_manager = MagicMock()
mocked_import_wizard = MagicMock()
mocked_add_author = MagicMock()
mocked_add_verse = MagicMock()
mocked_finish = MagicMock()
mocked_title = MagicMock()
mocked_finish.return_value = True
importer = EasyWorshipSongImportLogger(mocked_manager)
importer.import_wizard = mocked_import_wizard
importer.stop_import_flag = False
importer.add_author = mocked_add_author
importer.add_verse = mocked_add_verse
importer.title = mocked_title
importer.finish = mocked_finish
importer.topics = []
# WHEN: Importing ews file
importer.import_source = str(TEST_PATH / 'test1.ewsx')
import_result = importer.do_import()
# THEN: do_import should return none, the song data should be as expected, and finish should have been
# called.
title = EWS_SONG_TEST_DATA['title']
assert import_result is None, 'do_import should return None when it has completed'
assert title in importer._title_assignment_list, 'title for should be "%s"' % title
mocked_add_author.assert_any_call(EWS_SONG_TEST_DATA['authors'][0])
for verse_text, verse_tag in EWS_SONG_TEST_DATA['verses']:
mocked_add_verse.assert_any_call(verse_text, verse_tag)
mocked_finish.assert_called_with()
@patch('openlp.plugins.songs.lib.importers.easyworship.SongImport')
def test_import_rtf_unescaped_unicode(MockSongImport: MagicMock, registry: Registry, settings: Settings):
"""

View File

@ -111,14 +111,14 @@ def test_can_parse_file_having_a_processing_instruction(mocked_logger: MagicMock
# otherwise we don't care about it now (but should in other tests...)
assert ex is not etree.XMLSyntaxError
# THEN: the importer's log_error method was never called with SongStrings.XMLSyntaxError as its second
# THEN: the importer's log_error method was never called with SongStrings().XMLSyntaxError as its second
# positional argument
if importer.log_error.called:
for call_args in importer.log_error.call_args_list:
args = call_args[0]
# there are at least two positional arguments
if len(args) > 1:
assert args[1] is not SongStrings.XMLSyntaxError
assert args[1] is not SongStrings().XMLSyntaxError
# THEN: the logger's 'exception' method was never called with a first positional argument
# which is a string and starts with 'XML syntax error in file'

Binary file not shown.