diff --git a/openlp/core/__init__.py b/openlp/core/__init__.py index aa44d1d1b..50375ca57 100644 --- a/openlp/core/__init__.py +++ b/openlp/core/__init__.py @@ -246,7 +246,7 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): Settings().setValue('core/application version', openlp_version) # If data_version is different from the current version ask if we should backup the data folder elif data_version != openlp_version: - if self.splash.isVisible(): + if can_show_splash and self.splash.isVisible(): self.splash.hide() if QtWidgets.QMessageBox.question(None, translate('OpenLP', 'Backup'), translate('OpenLP', 'OpenLP has been upgraded, do you want to create\n' diff --git a/openlp/core/common/__init__.py b/openlp/core/common/__init__.py index c766e84c9..fde02506d 100644 --- a/openlp/core/common/__init__.py +++ b/openlp/core/common/__init__.py @@ -24,7 +24,7 @@ The :mod:`common` module contains most of the components and libraries that make OpenLP work. """ import hashlib - +import importlib import logging import os import re @@ -32,6 +32,7 @@ import sys import traceback from chardet.universaldetector import UniversalDetector from ipaddress import IPv4Address, IPv6Address, AddressValueError +from pathlib import Path from shutil import which from subprocess import check_output, CalledProcessError, STDOUT @@ -79,6 +80,49 @@ def check_directory_exists(directory, do_not_log=False): log.exception('failed to check if directory exists or create directory') +def extension_loader(glob_pattern, excluded_files=[]): + """ + A utility function to find and load OpenLP extensions, such as plugins, presentation and media controllers and + importers. + + :param glob_pattern: A glob pattern used to find the extension(s) to be imported. Should be relative to the + application directory. i.e. openlp/plugins/*/*plugin.py + :type glob_pattern: str + + :param excluded_files: A list of file names to exclude that the glob pattern may find. + :type excluded_files: list of strings + + :return: None + :rtype: None + """ + app_dir = Path(AppLocation.get_directory(AppLocation.AppDir)).parent + for extension_path in app_dir.glob(glob_pattern): + extension_path = extension_path.relative_to(app_dir) + if extension_path.name in excluded_files: + continue + module_name = path_to_module(extension_path) + try: + importlib.import_module(module_name) + except (ImportError, OSError): + # On some platforms importing vlc.py might cause OSError exceptions. (e.g. Mac OS X) + log.warning('Failed to import {module_name} on path {extension_path}' + .format(module_name=module_name, extension_path=str(extension_path))) + + +def path_to_module(path): + """ + Convert a path to a module name (i.e openlp.core.common) + + :param path: The path to convert to a module name. + :type path: Path + + :return: The module name. + :rtype: str + """ + module_path = path.with_suffix('') + return '.'.join(module_path.parts) + + def get_frozen_path(frozen_option, non_frozen_option): """ Return a path based on the system status. diff --git a/openlp/core/common/httputils.py b/openlp/core/common/httputils.py index fb82913e1..b48a1e10c 100644 --- a/openlp/core/common/httputils.py +++ b/openlp/core/common/httputils.py @@ -252,4 +252,5 @@ def url_get_file(callback, url, f_path, sha256=None): os.remove(f_path) return True + __all__ = ['get_web_page'] diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index caaa0ff57..a8b5771b6 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -23,10 +23,11 @@ The :mod:`lib` module contains most of the components and libraries that make OpenLP work. """ - +import html import logging import os -from distutils.version import LooseVersion +import re +import math from PyQt5 import QtCore, QtGui, Qt, QtWidgets @@ -34,6 +35,8 @@ from openlp.core.common import translate log = logging.getLogger(__name__ + '.__init__') +SLIMCHARS = 'fiíIÍjlĺľrtť.,;/ ()|"\'!:\\' + class ServiceItemContext(object): """ @@ -281,11 +284,12 @@ def check_item_selected(list_widget, message): return True -def clean_tags(text): +def clean_tags(text, remove_chords=False): """ Remove Tags from text for display :param text: Text to be cleaned + :param remove_chords: Clean ChordPro tags """ text = text.replace('
', '\n') text = text.replace('{br}', '\n') @@ -293,21 +297,296 @@ def clean_tags(text): for tag in FormattingTags.get_html_tags(): text = text.replace(tag['start tag'], '') text = text.replace(tag['end tag'], '') + # Remove ChordPro tags + if remove_chords: + text = re.sub(r'\[.+?\]', r'', text) return text -def expand_tags(text): +def expand_tags(text, expand_chord_tags=False, for_printing=False): """ Expand tags HTML for display :param text: The text to be expanded. """ + if expand_chord_tags: + if for_printing: + text = expand_chords_for_printing(text, '{br}') + else: + text = expand_chords(text) for tag in FormattingTags.get_html_tags(): text = text.replace(tag['start tag'], tag['start html']) text = text.replace(tag['end tag'], tag['end html']) return text +def expand_and_align_chords_in_line(match): + """ + Expand the chords in the line and align them using whitespaces. + NOTE: There is equivalent javascript code in chords.js, in the updateSlide function. Make sure to update both! + + :param match: + :return: The line with expanded html-chords + """ + whitespaces = '' + chordlen = 0 + taillen = 0 + # The match could be "[G]sweet the " from a line like "A[D]mazing [D7]grace! How [G]sweet the [D]sound!" + # The actual chord, would be "G" in match "[G]sweet the " + chord = match.group(1) + # The tailing word of the chord, would be "sweet" in match "[G]sweet the " + tail = match.group(2) + # The remainder of the line, until line end or next chord. Would be " the " in match "[G]sweet the " + remainder = match.group(3) + # Line end if found, else None + end = match.group(4) + # Based on char width calculate width of chord + for chord_char in chord: + if chord_char not in SLIMCHARS: + chordlen += 2 + else: + chordlen += 1 + # Based on char width calculate width of tail + for tail_char in tail: + if tail_char not in SLIMCHARS: + taillen += 2 + else: + taillen += 1 + # Based on char width calculate width of remainder + for remainder_char in remainder: + if remainder_char not in SLIMCHARS: + taillen += 2 + else: + taillen += 1 + # If the chord is wider than the tail+remainder and the line goes on, some padding is needed + if chordlen >= taillen and end is None: + # Decide if the padding should be "_" for drawing out words or spaces + if tail: + if not remainder: + for c in range(math.ceil((chordlen - taillen) / 2) + 2): + whitespaces += '_' + else: + for c in range(chordlen - taillen + 1): + whitespaces += ' ' + else: + if not remainder: + for c in range(math.floor((chordlen - taillen) / 2)): + whitespaces += '_' + else: + for c in range(chordlen - taillen + 1): + whitespaces += ' ' + else: + if not tail and remainder and remainder[0] == ' ': + for c in range(chordlen): + whitespaces += ' ' + if whitespaces: + if '_' in whitespaces: + ws_length = len(whitespaces) + if ws_length == 1: + whitespaces = '–' + else: + wsl_mod = ws_length // 2 + ws_right = ws_left = ' ' * wsl_mod + whitespaces = ws_left + '–' + ws_right + whitespaces = '' + whitespaces + '' + return '' + html.escape(chord) + '' + html.escape(tail) + \ + whitespaces + html.escape(remainder) + + +def expand_chords(text): + """ + Expand ChordPro tags + + :param text: + """ + text_lines = text.split('{br}') + expanded_text_lines = [] + chords_on_prev_line = False + for line in text_lines: + # If a ChordPro is detected in the line, replace it with a html-span tag and wrap the line in a span tag. + if '[' in line and ']' in line: + if chords_on_prev_line: + new_line = '' + else: + new_line = '' + chords_on_prev_line = True + # Matches a chord, a tail, a remainder and a line end. See expand_and_align_chords_in_line() for more info. + new_line += re.sub(r'\[(.*?)\]([\u0080-\uFFFF,\w]*)' + '([\u0080-\uFFFF,\w,\s,\.,\,,\!,\?,\;,\:,\|,\",\',\-,\_]*)(\Z)?', + expand_and_align_chords_in_line, line) + new_line += '' + expanded_text_lines.append(new_line) + else: + chords_on_prev_line = False + expanded_text_lines.append(html.escape(line)) + return '{br}'.join(expanded_text_lines) + + +def compare_chord_lyric(chord, lyric): + """ + Compare the width of chord and lyrics. If chord width is greater than the lyric width the diff is returned. + + :param chord: + :param lyric: + :return: + """ + chordlen = 0 + if chord == ' ': + return 0 + chord = re.sub(r'\{.*?\}', r'', chord) + lyric = re.sub(r'\{.*?\}', r'', lyric) + for chord_char in chord: + if chord_char not in SLIMCHARS: + chordlen += 2 + else: + chordlen += 1 + lyriclen = 0 + for lyric_char in lyric: + if lyric_char not in SLIMCHARS: + lyriclen += 2 + else: + lyriclen += 1 + if chordlen > lyriclen: + return chordlen - lyriclen + else: + return 0 + + +def find_formatting_tags(text, active_formatting_tags): + """ + Look for formatting tags in lyrics and adds/removes them to/from the given list. Returns the update list. + + :param text: + :param active_formatting_tags: + :return: + """ + if not re.search(r'\{.*?\}', text): + return active_formatting_tags + word_it = iter(text) + # Loop through lyrics to find any formatting tags + for char in word_it: + if char == '{': + tag = '' + char = next(word_it) + start_tag = True + if char == '/': + start_tag = False + char = next(word_it) + while char != '}': + tag += char + char = next(word_it) + # See if the found tag has an end tag + for formatting_tag in FormattingTags.get_html_tags(): + if formatting_tag['start tag'] == '{' + tag + '}': + if formatting_tag['end tag']: + if start_tag: + # prepend the new tag to the list of active formatting tags + active_formatting_tags[:0] = [tag] + else: + # remove the tag from the list + active_formatting_tags.remove(tag) + # Break out of the loop matching the found tag against the tag list. + break + return active_formatting_tags + + +def expand_chords_for_printing(text, line_split): + """ + Expand ChordPro tags + + :param text: + :param line_split: + """ + if not re.search(r'\[.*?\]', text): + return text + text_lines = text.split(line_split) + expanded_text_lines = [] + for line in text_lines: + # If a ChordPro is detected in the line, build html tables. + new_line = '
' + active_formatting_tags = [] + if re.search(r'\[.*?\]', line): + words = line.split(' ') + in_chord = False + for word in words: + chords = [] + lyrics = [] + new_line += '' + # If the word contains a chord, we need to handle it. + if re.search(r'\[.*?\]', word): + chord = '' + lyric = '' + # Loop over each character of the word + for char in word: + if char == '[': + in_chord = True + if lyric != '': + if chord == '': + chord = ' ' + chords.append(chord) + lyrics.append(lyric) + chord = '' + lyric = '' + elif char == ']' and in_chord: + in_chord = False + elif in_chord: + chord += char + else: + lyric += char + if lyric != '' or chord != '': + if chord == '': + chord = ' ' + if lyric == '': + lyric = ' ' + chords.append(chord) + lyrics.append(lyric) + new_chord_line = '' + new_lyric_line = '' + for i in range(len(lyrics)): + spacer = compare_chord_lyric(chords[i], lyrics[i]) + # Handle formatting tags + start_formatting_tags = '' + if active_formatting_tags: + start_formatting_tags = '{' + '}{'.join(active_formatting_tags) + '}' + # Update list of active formatting tags + active_formatting_tags = find_formatting_tags(lyrics[i], active_formatting_tags) + end_formatting_tags = '' + if active_formatting_tags: + end_formatting_tags = '{/' + '}{/'.join(active_formatting_tags) + '}' + new_chord_line += '' % chords[i] + # Check if this is the last column, if so skip spacing calc and instead insert a single space + if i + 1 == len(lyrics): + new_lyric_line += ''.format( + starttags=start_formatting_tags, lyrics=lyrics[i], endtags=end_formatting_tags) + else: + spacing = '' + if spacer > 0: + space = ' ' * int(math.ceil(spacer / 2)) + spacing = '%s-%s' % (space, space) + new_lyric_line += ''.format( + starttags=start_formatting_tags, lyrics=lyrics[i], spacing=spacing, + endtags=end_formatting_tags) + new_line += new_chord_line + new_lyric_line + '' + else: + start_formatting_tags = '' + if active_formatting_tags: + start_formatting_tags = '{' + '}{'.join(active_formatting_tags) + '}' + active_formatting_tags = find_formatting_tags(word, active_formatting_tags) + end_formatting_tags = '' + if active_formatting_tags: + end_formatting_tags = '{/' + '}{/'.join(active_formatting_tags) + '}' + new_line += ''.format( + starttags=start_formatting_tags, lyrics=word, endtags=end_formatting_tags) + new_line += '
%s{starttags}{lyrics} {endtags}{starttags}{lyrics}{spacing}{endtags}
 
' \ + '{starttags}{lyrics} {endtags}
' + else: + new_line += line + new_line += '
' + expanded_text_lines.append(new_line) + # the {br} tag used to split lines is not inserted again since the style of the line-tables makes them redundant. + return ''.join(expanded_text_lines) + + def create_separated_list(string_list): """ Returns a string that represents a join of a list of strings with a localized separator. @@ -337,10 +616,10 @@ from .plugin import PluginStatus, StringContent, Plugin from .pluginmanager import PluginManager from .settingstab import SettingsTab from .serviceitem import ServiceItem, ServiceItemType, ItemCapabilities -from .htmlbuilder import build_html, build_lyrics_format_css, build_lyrics_outline_css +from .htmlbuilder import build_html, build_lyrics_format_css, build_lyrics_outline_css, build_chords_css from .imagemanager import ImageManager from .renderer import Renderer from .mediamanageritem import MediaManagerItem from .projector.db import ProjectorDB, Projector -from .projector.pjlink1 import PJLink1 +from .projector.pjlink1 import PJLink from .projector.constants import PJLINK_PORT, ERROR_MSG, ERROR_STRING diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index 0627259eb..3e2187b77 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -172,6 +172,7 @@ def upgrade_db(url, upgrade): else: version = int(version_meta.value) if version > upgrade.__version__: + session.remove() return version, upgrade.__version__ version += 1 try: @@ -194,7 +195,7 @@ def upgrade_db(url, upgrade): session.commit() upgrade_version = upgrade.__version__ version = int(version_meta.value) - session.close() + session.remove() return version, upgrade_version diff --git a/openlp/core/lib/htmlbuilder.py b/openlp/core/lib/htmlbuilder.py index 69ae6402d..df88f5f91 100644 --- a/openlp/core/lib/htmlbuilder.py +++ b/openlp/core/lib/htmlbuilder.py @@ -124,6 +124,25 @@ is the function which has to be called from outside. The generated and returned position: relative; top: -0.3em; } + /* Chords css */ + .chordline { + line-height: 1.0em; + } + .chordline span.chord span { + position: relative; + } + .chordline span.chord span strong { + position: absolute; + top: -0.8em; + left: 0; + font-size: 75%; + font-weight: normal; + line-height: normal; + display: none; + } + .firstchordline { + line-height: 1.0em; + }
""") self.web.setHtml(html.substitute(format_css=build_lyrics_format_css(theme_data, self.page_width, self.page_height), - outline_css=build_lyrics_outline_css(theme_data))) + outline_css=build_lyrics_outline_css(theme_data), + chords_css=build_chords_css())) self.empty_height = self.web_frame.contentsSize().height() def _paginate_slide(self, lines, line_end): diff --git a/openlp/core/lib/serviceitem.py b/openlp/core/lib/serviceitem.py index 27378d9ce..c8040aa58 100644 --- a/openlp/core/lib/serviceitem.py +++ b/openlp/core/lib/serviceitem.py @@ -34,7 +34,7 @@ import ntpath from PyQt5 import QtGui from openlp.core.common import RegistryProperties, Settings, translate, AppLocation, md5_hash -from openlp.core.lib import ImageSource, build_icon, clean_tags, expand_tags +from openlp.core.lib import ImageSource, build_icon, clean_tags, expand_tags, expand_chords, create_thumb log = logging.getLogger(__name__) @@ -117,7 +117,6 @@ class ItemCapabilities(object): ``HasThumbnails`` The item has related thumbnails available - """ CanPreview = 1 CanEdit = 2 @@ -247,6 +246,8 @@ class ServiceItem(RegistryProperties): self.renderer.set_item_theme(self.theme) self.theme_data, self.main, self.footer = self.renderer.pre_render() if self.service_item_type == ServiceItemType.Text: + expand_chord_tags = hasattr(self, 'name') and self.name == 'songs' and Settings().value( + 'songs/enable chords') log.debug('Formatting slides: {title}'.format(title=self.title)) # Save rendered pages to this dict. In the case that a slide is used twice we can use the pages saved to # the dict instead of rendering them again. @@ -260,13 +261,16 @@ class ServiceItem(RegistryProperties): previous_pages[verse_tag] = (slide['raw_slide'], pages) for page in pages: page = page.replace('
', '{br}') - html_data = expand_tags(html.escape(page.rstrip())) - self._display_frames.append({ + html_data = expand_tags(page.rstrip(), expand_chord_tags) + new_frame = { 'title': clean_tags(page), - 'text': clean_tags(page.rstrip()), + 'text': clean_tags(page.rstrip(), expand_chord_tags), + 'chords_text': expand_chords(clean_tags(page.rstrip(), False)), 'html': html_data.replace(' ', ' '), - 'verseTag': verse_tag - }) + 'printing_html': expand_tags(html.escape(page.rstrip()), expand_chord_tags, True), + 'verseTag': verse_tag, + } + self._display_frames.append(new_frame) elif self.service_item_type == ServiceItemType.Image or self.service_item_type == ServiceItemType.Command: pass else: diff --git a/openlp/core/ui/media/__init__.py b/openlp/core/ui/media/__init__.py index ba5c6c733..1b799125c 100644 --- a/openlp/core/ui/media/__init__.py +++ b/openlp/core/ui/media/__init__.py @@ -143,6 +143,7 @@ def format_milliseconds(milliseconds): seconds=seconds, millis=millis) + from .mediacontroller import MediaController from .playertab import PlayerTab diff --git a/openlp/core/ui/media/mediacontroller.py b/openlp/core/ui/media/mediacontroller.py index 5a0dfb042..13bdf3bd5 100644 --- a/openlp/core/ui/media/mediacontroller.py +++ b/openlp/core/ui/media/mediacontroller.py @@ -28,7 +28,8 @@ import os import datetime from PyQt5 import QtCore, QtWidgets -from openlp.core.common import OpenLPMixin, Registry, RegistryMixin, RegistryProperties, Settings, UiStrings, translate +from openlp.core.common import OpenLPMixin, Registry, RegistryMixin, RegistryProperties, Settings, UiStrings, \ + extension_loader, translate from openlp.core.lib import ItemCapabilities from openlp.core.lib.ui import critical_error_message_box from openlp.core.common import AppLocation @@ -39,6 +40,7 @@ from openlp.core.ui.media import MediaState, MediaInfo, MediaType, get_media_pla parse_optical_path from openlp.core.ui.lib.toolbar import OpenLPToolbar + log = logging.getLogger(__name__) TICK_TIME = 200 @@ -172,19 +174,9 @@ class MediaController(RegistryMixin, OpenLPMixin, RegistryProperties): Check to see if we have any media Player's available. """ log.debug('_check_available_media_players') - controller_dir = os.path.join(AppLocation.get_directory(AppLocation.AppDir), 'core', 'ui', 'media') - for filename in os.listdir(controller_dir): - if filename.endswith('player.py') and filename != 'mediaplayer.py': - path = os.path.join(controller_dir, filename) - if os.path.isfile(path): - module_name = 'openlp.core.ui.media.' + os.path.splitext(filename)[0] - log.debug('Importing controller %s', module_name) - try: - __import__(module_name, globals(), locals(), []) - # On some platforms importing vlc.py might cause - # also OSError exceptions. (e.g. Mac OS X) - except (ImportError, OSError): - log.warning('Failed to import %s on path %s', module_name, path) + controller_dir = os.path.join('openlp', 'core', 'ui', 'media') + glob_pattern = os.path.join(controller_dir, '*player.py') + extension_loader(glob_pattern, ['mediaplayer.py']) player_classes = MediaPlayer.__subclasses__() for player_class in player_classes: self.register_players(player_class(self)) diff --git a/openlp/core/ui/printservicedialog.py b/openlp/core/ui/printservicedialog.py index fba7771ba..925b88599 100644 --- a/openlp/core/ui/printservicedialog.py +++ b/openlp/core/ui/printservicedialog.py @@ -95,7 +95,7 @@ class Ui_PrintServiceDialog(object): self.main_layout.addWidget(self.preview_widget) self.options_widget = QtWidgets.QWidget(print_service_dialog) self.options_widget.hide() - self.options_widget.resize(400, 300) + self.options_widget.resize(400, 350) self.options_widget.setAutoFillBackground(True) self.options_layout = QtWidgets.QVBoxLayout(self.options_widget) self.options_layout.setContentsMargins(8, 8, 8, 8) @@ -121,6 +121,8 @@ class Ui_PrintServiceDialog(object): self.group_layout.addWidget(self.notes_check_box) self.meta_data_check_box = QtWidgets.QCheckBox() self.group_layout.addWidget(self.meta_data_check_box) + self.show_chords_check_box = QtWidgets.QCheckBox() + self.group_layout.addWidget(self.show_chords_check_box) self.group_layout.addStretch(1) self.options_group_box.setLayout(self.group_layout) self.options_layout.addWidget(self.options_group_box) @@ -144,6 +146,7 @@ class Ui_PrintServiceDialog(object): self.page_break_after_text.setText(translate('OpenLP.PrintServiceForm', 'Add page break before each text item')) self.notes_check_box.setText(translate('OpenLP.PrintServiceForm', 'Include service item notes')) self.meta_data_check_box.setText(translate('OpenLP.PrintServiceForm', 'Include play length of media items')) + self.show_chords_check_box.setText(translate('OpenLP.PrintServiceForm', 'Show chords')) self.title_line_edit.setText(translate('OpenLP.PrintServiceForm', 'Service Sheet')) # Do not change the order. self.zoom_combo_box.addItems([ diff --git a/openlp/core/ui/printserviceform.py b/openlp/core/ui/printserviceform.py index 0b2eaefc6..7b3d80c8b 100644 --- a/openlp/core/ui/printserviceform.py +++ b/openlp/core/ui/printserviceform.py @@ -37,7 +37,7 @@ from openlp.core.common import AppLocation DEFAULT_CSS = """/* Edit this file to customize the service order print. Note, that not all CSS properties are supported. See: -http://doc.trolltech.com/4.7/richtext-html-subset.html#css-properties +https://doc.qt.io/qt-5/richtext-html-subset.html#css-properties */ .serviceTitle { @@ -101,6 +101,19 @@ http://doc.trolltech.com/4.7/richtext-html-subset.html#css-properties .newPage { page-break-before: always; } + +table.line {} + +table.segment { + float: left; +} + +td.chord { + font-size: 80%; +} + +td.lyrics { +} """ @@ -172,6 +185,12 @@ class PrintServiceForm(QtWidgets.QDialog, Ui_PrintServiceDialog, RegistryPropert self._add_element('h1', html.escape(self.title_line_edit.text()), html_data.body, classId='serviceTitle') for index, item in enumerate(self.service_manager.service_items): self._add_preview_item(html_data.body, item['service_item'], index) + if not self.show_chords_check_box.isChecked(): + # Remove chord row and spacing span elements when not printing chords + for chord_row in html_data.find_class('chordrow'): + chord_row.drop_tree() + for spacing_span in html_data.find_class('chordspacing'): + spacing_span.drop_tree() # Add the custom service notes: if self.footer_text_edit.toPlainText(): div = self._add_element('div', parent=html_data.body, classId='customNotes') @@ -196,13 +215,13 @@ class PrintServiceForm(QtWidgets.QDialog, Ui_PrintServiceDialog, RegistryPropert verse_def = None verse_html = None for slide in item.get_frames(): - if not verse_def or verse_def != slide['verseTag'] or verse_html == slide['html']: + if not verse_def or verse_def != slide['verseTag'] or verse_html == slide['printing_html']: text_div = self._add_element('div', parent=div, classId='itemText') - else: + elif 'chordspacing' not in slide['printing_html']: self._add_element('br', parent=text_div) - self._add_element('span', slide['html'], text_div) + self._add_element('span', slide['printing_html'], text_div) verse_def = slide['verseTag'] - verse_html = slide['html'] + verse_html = slide['printing_html'] # Break the page before the div element. if index != 0 and self.page_break_after_text.isChecked(): div.set('class', 'item newPage') diff --git a/openlp/core/ui/projector/manager.py b/openlp/core/ui/projector/manager.py index 06ddd9dee..747a63a85 100644 --- a/openlp/core/ui/projector/manager.py +++ b/openlp/core/ui/projector/manager.py @@ -38,7 +38,7 @@ from openlp.core.lib.projector.constants import ERROR_MSG, ERROR_STRING, E_AUTHE E_NETWORK, E_NOT_CONNECTED, E_UNKNOWN_SOCKET_ERROR, STATUS_STRING, S_CONNECTED, S_CONNECTING, S_COOLDOWN, \ S_INITIALIZE, S_NOT_CONNECTED, S_OFF, S_ON, S_STANDBY, S_WARMUP from openlp.core.lib.projector.db import ProjectorDB -from openlp.core.lib.projector.pjlink1 import PJLink1 +from openlp.core.lib.projector.pjlink1 import PJLink from openlp.core.ui.projector.editform import ProjectorEditForm from openlp.core.ui.projector.sourceselectform import SourceSelectTabs, SourceSelectSingle @@ -690,19 +690,19 @@ class ProjectorManager(OpenLPMixin, RegistryMixin, QtWidgets.QWidget, UiProjecto Helper app to build a projector instance :param projector: Dict of projector database information - :returns: PJLink1() instance + :returns: PJLink() instance """ log.debug('_add_projector()') - return PJLink1(dbid=projector.id, - ip=projector.ip, - port=int(projector.port), - name=projector.name, - location=projector.location, - notes=projector.notes, - pin=None if projector.pin == '' else projector.pin, - poll_time=self.poll_time, - socket_timeout=self.socket_timeout - ) + return PJLink(dbid=projector.id, + ip=projector.ip, + port=int(projector.port), + name=projector.name, + location=projector.location, + notes=projector.notes, + pin=None if projector.pin == '' else projector.pin, + poll_time=self.poll_time, + socket_timeout=self.socket_timeout + ) def add_projector(self, projector, start=False): """ @@ -961,7 +961,7 @@ class ProjectorItem(QtCore.QObject): """ Initialization for ProjectorItem instance - :param link: PJLink1 instance for QListWidgetItem + :param link: PJLink instance for QListWidgetItem """ self.link = link self.thread = None diff --git a/openlp/plugins/bibles/lib/manager.py b/openlp/plugins/bibles/lib/manager.py index 6154c83a9..c9a159634 100644 --- a/openlp/plugins/bibles/lib/manager.py +++ b/openlp/plugins/bibles/lib/manager.py @@ -429,4 +429,5 @@ class BibleManager(OpenLPMixin, RegistryProperties): for bible in self.db_cache: self.db_cache[bible].finalise() + __all__ = ['BibleFormat'] diff --git a/openlp/plugins/presentations/lib/impresscontroller.py b/openlp/plugins/presentations/lib/impresscontroller.py index 57828f4db..1f751bbdc 100644 --- a/openlp/plugins/presentations/lib/impresscontroller.py +++ b/openlp/plugins/presentations/lib/impresscontroller.py @@ -58,7 +58,8 @@ from PyQt5 import QtCore from openlp.core.lib import ScreenList from openlp.core.common import get_uno_command, get_uno_instance -from .presentationcontroller import PresentationController, PresentationDocument, TextType +from openlp.plugins.presentations.lib.presentationcontroller import PresentationController, PresentationDocument, \ + TextType log = logging.getLogger(__name__) diff --git a/openlp/plugins/presentations/lib/pdfcontroller.py b/openlp/plugins/presentations/lib/pdfcontroller.py index 47d7e3161..d36db36f0 100644 --- a/openlp/plugins/presentations/lib/pdfcontroller.py +++ b/openlp/plugins/presentations/lib/pdfcontroller.py @@ -29,7 +29,7 @@ from subprocess import check_output, CalledProcessError from openlp.core.common import AppLocation, check_binary_exists from openlp.core.common import Settings, is_win from openlp.core.lib import ScreenList -from .presentationcontroller import PresentationController, PresentationDocument +from openlp.plugins.presentations.lib.presentationcontroller import PresentationController, PresentationDocument if is_win(): from subprocess import STARTUPINFO, STARTF_USESHOWWINDOW diff --git a/openlp/plugins/presentations/lib/powerpointcontroller.py b/openlp/plugins/presentations/lib/powerpointcontroller.py index 08dcc4165..0ee165deb 100644 --- a/openlp/plugins/presentations/lib/powerpointcontroller.py +++ b/openlp/plugins/presentations/lib/powerpointcontroller.py @@ -43,7 +43,7 @@ if is_win(): from openlp.core.lib import ScreenList from openlp.core.lib.ui import UiStrings, critical_error_message_box, translate from openlp.core.common import trace_error_handler, Registry -from .presentationcontroller import PresentationController, PresentationDocument +from openlp.plugins.presentations.lib.presentationcontroller import PresentationController, PresentationDocument log = logging.getLogger(__name__) diff --git a/openlp/plugins/presentations/lib/pptviewcontroller.py b/openlp/plugins/presentations/lib/pptviewcontroller.py index aafe37121..0c33d1559 100644 --- a/openlp/plugins/presentations/lib/pptviewcontroller.py +++ b/openlp/plugins/presentations/lib/pptviewcontroller.py @@ -35,7 +35,7 @@ if is_win(): from openlp.core.common import AppLocation from openlp.core.lib import ScreenList -from .presentationcontroller import PresentationController, PresentationDocument +from openlp.plugins.presentations.lib.presentationcontroller import PresentationController, PresentationDocument log = logging.getLogger(__name__) diff --git a/openlp/plugins/presentations/lib/pptviewlib/ppttest.py b/openlp/plugins/presentations/lib/pptviewlib/ppttest.py index 612206f5d..bea341a49 100644 --- a/openlp/plugins/presentations/lib/pptviewlib/ppttest.py +++ b/openlp/plugins/presentations/lib/pptviewlib/ppttest.py @@ -197,6 +197,7 @@ class PPTViewer(QtWidgets.QWidget): def openDialog(self): self.pptEdit.setText(QtWidgets.QFileDialog.getOpenFileName(self, 'Open file')[0]) + if __name__ == '__main__': pptdll = cdll.LoadLibrary(r'pptviewlib.dll') pptdll.SetDebug(1) diff --git a/openlp/plugins/presentations/lib/presentationtab.py b/openlp/plugins/presentations/lib/presentationtab.py index e12fc90b4..2af73d369 100644 --- a/openlp/plugins/presentations/lib/presentationtab.py +++ b/openlp/plugins/presentations/lib/presentationtab.py @@ -26,7 +26,7 @@ from openlp.core.common import Settings, UiStrings, translate from openlp.core.lib import SettingsTab, build_icon from openlp.core.lib.ui import critical_error_message_box from openlp.core.ui.lib import PathEdit -from .pdfcontroller import PdfController +from openlp.plugins.presentations.lib.pdfcontroller import PdfController class PresentationTab(SettingsTab): diff --git a/openlp/plugins/presentations/presentationplugin.py b/openlp/plugins/presentations/presentationplugin.py index 91b98d801..884f155a2 100644 --- a/openlp/plugins/presentations/presentationplugin.py +++ b/openlp/plugins/presentations/presentationplugin.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- + # -*- coding: utf-8 -*- # vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 ############################################################################### @@ -20,19 +20,18 @@ # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### """ -The :mod:`presentationplugin` module provides the ability for OpenLP to display presentations from a variety of document -formats. +The :mod:`openlp.plugins.presentations.presentationplugin` module provides the ability for OpenLP to display +presentations from a variety of document formats. """ import os import logging from PyQt5 import QtCore -from openlp.core.common import AppLocation, translate +from openlp.core.common import AppLocation, extension_loader, translate from openlp.core.lib import Plugin, StringContent, build_icon from openlp.plugins.presentations.lib import PresentationController, PresentationMediaItem, PresentationTab - log = logging.getLogger(__name__) @@ -122,17 +121,9 @@ class PresentationPlugin(Plugin): Check to see if we have any presentation software available. If not do not install the plugin. """ log.debug('check_pre_conditions') - controller_dir = os.path.join(AppLocation.get_directory(AppLocation.PluginsDir), 'presentations', 'lib') - for filename in os.listdir(controller_dir): - if filename.endswith('controller.py') and filename != 'presentationcontroller.py': - path = os.path.join(controller_dir, filename) - if os.path.isfile(path): - module_name = 'openlp.plugins.presentations.lib.' + os.path.splitext(filename)[0] - log.debug('Importing controller {name}'.format(name=module_name)) - try: - __import__(module_name, globals(), locals(), []) - except ImportError: - log.warning('Failed to import {name} on path {path}'.format(name=module_name, path=path)) + controller_dir = os.path.join('openlp', 'plugins', 'presentations', 'lib') + glob_pattern = os.path.join(controller_dir, '*controller.py') + extension_loader(glob_pattern, ['presentationcontroller.py']) controller_classes = PresentationController.__subclasses__() for controller_class in controller_classes: controller = controller_class(self) diff --git a/openlp/plugins/remotes/html/chords.html b/openlp/plugins/remotes/html/chords.html new file mode 100644 index 000000000..4abafbca3 --- /dev/null +++ b/openlp/plugins/remotes/html/chords.html @@ -0,0 +1,46 @@ + + + + + + ${chords_title} + + + + + + + + + + +
+
+ + diff --git a/openlp/plugins/remotes/html/css/chords.css b/openlp/plugins/remotes/html/css/chords.css new file mode 100644 index 000000000..2bb357886 --- /dev/null +++ b/openlp/plugins/remotes/html/css/chords.css @@ -0,0 +1,96 @@ +/****************************************************************************** +* OpenLP - Open Source Lyrics Projection * +* --------------------------------------------------------------------------- * +* Copyright (c) 2008-2017 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; version 2 of the License. * +* * +* This program is distributed in the hope that it will be useful, but WITHOUT * +* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * +* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * +* more details. * +* * +* You should have received a copy of the GNU General Public License along * +* with this program; if not, write to the Free Software Foundation, Inc., 59 * +* Temple Place, Suite 330, Boston, MA 02111-1307 USA * +******************************************************************************/ + +#header { + padding-bottom: 1em; +} + +#transpose, +#transposevalue, +#capodisplay { + display: inline-block; + font-size: 30pt; + color: gray; + vertical-align: middle; +} + +.button { + display: inline-block; + box-sizing: border-box; + border: 1px solid gray; + border-radius: .3em; + padding: 0 .2em; + min-width: 1.2em; + line-height: 1.2em; + font-size: 25pt; + font-weight: bold; + text-align: center; + text-decoration: none; + text-shadow: 0px 1px 0px white; + color: black; + background: linear-gradient(to bottom, white 5%, gray 100%); + background-color: gray; + cursor: pointer; +} +.button:hover { + background: linear-gradient(to bottom, white 10%, gray 150%); + color: darkslategray ; + background-color: gray; +} +.button:active { + position:relative; + top:1px; +} + +/* Extending existing definition in stage.css */ +#verseorder { + line-height: 1.5; + display: inline-block; + vertical-align: middle; +} + +.chordline { + line-height: 2.0; +} + +.chordline1 { + line-height: 1.0 +} + +.chordline span.chord span { + position: relative; +} + +.chordline span.chord span strong { + position: absolute; + top: -0.8em; + left: 0; + font-size: 30pt; + font-weight: normal; + line-height: normal; + color: yellow; +} + +.ws { + white-space: pre-wrap; +} + +#nextslide .chordline span.chord span strong { + color: gray; +} diff --git a/openlp/plugins/remotes/html/css/stage.css b/openlp/plugins/remotes/html/css/stage.css index c18242b02..2a78b249c 100644 --- a/openlp/plugins/remotes/html/css/stage.css +++ b/openlp/plugins/remotes/html/css/stage.css @@ -21,6 +21,10 @@ body { background-color: black; font-family: sans-serif; overflow: hidden; + -webkit-user-select: none; /* Chrome/Safari */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* IE 10+ */ + user-select: none; /* Future */ } #currentslide { diff --git a/openlp/plugins/remotes/html/js/chords.js b/openlp/plugins/remotes/html/js/chords.js new file mode 100644 index 000000000..07d0548d4 --- /dev/null +++ b/openlp/plugins/remotes/html/js/chords.js @@ -0,0 +1,331 @@ +/****************************************************************************** + * OpenLP - Open Source Lyrics Projection * + * --------------------------------------------------------------------------- * + * Copyright (c) 2008-2017 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; version 2 of the License. * + * * + * This program is distributed in the hope that it will be useful, but WITHOUT * + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * + * more details. * + * * + * You should have received a copy of the GNU General Public License along * + * with this program; if not, write to the Free Software Foundation, Inc., 59 * + * Temple Place, Suite 330, Boston, MA 02111-1307 USA * + ******************************************************************************/ +var lastChord; + +var notesSharpNotation = {} +var notesFlatNotation = {} + +// See https://en.wikipedia.org/wiki/Musical_note#12-tone_chromatic_scale +notesSharpNotation['german'] = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','H']; +notesFlatNotation['german'] = ['C','Db','D','Eb','Fb','F','Gb','G','Ab','A','B','H']; +notesSharpNotation['english'] = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; +notesFlatNotation['english'] = ['C','Db','D','Eb','Fb','F','Gb','G','Ab','A','Bb','B']; +notesSharpNotation['neo-latin'] = ['Do','Do#','Re','Re#','Mi','Fa','Fa#','Sol','Sol#','La','La#','Si']; +notesFlatNotation['neo-latin'] = ['Do','Reb','Re','Mib','Fab','Fa','Solb','Sol','Lab','La','Sib','Si']; + +function getTransposeValue(songId) { + if (localStorage.getItem(songId + '_transposeValue')) {return localStorage.getItem(songId + '_transposeValue');} + else {return 0;} +} + +function storeTransposeValue(songId,transposeValueToSet) { + localStorage.setItem(songId + '_transposeValue', transposeValueToSet); +} + +// NOTE: This function has a python equivalent in openlp/plugins/songs/lib/__init__.py - make sure to update both! +function transposeChord(chord, transposeValue, notation) { + var chordSplit = chord.replace('♭', 'b').split(/[\/]/); + var transposedChord = '', note, notenumber, rest, currentChord; + var notesSharp = notesSharpNotation[notation]; + var notesFlat = notesFlatNotation[notation]; + var notesPreferred = ['b','#','#','#','#','#','#','#','#','#','#','#']; + for (i = 0; i <= chordSplit.length - 1; i++) { + if (i > 0) { + transposedChord += '/'; + } + currentchord = chordSplit[i]; + if (currentchord.length > 0 && currentchord.charAt(0) === '(') { + transposedChord += '('; + if (currentchord.length > 1) { + currentchord = currentchord.substr(1); + } else { + currentchord = ""; + } + } + if (currentchord.length > 0) { + if (currentchord.length > 1) { + if ('#b'.indexOf(currentchord.charAt(1)) === -1) { + note = currentchord.substr(0, 1); + rest = currentchord.substr(1); + } else { + note = currentchord.substr(0, 2); + rest = currentchord.substr(2); + } + } else { + note = currentchord; + rest = ""; + } + notenumber = (notesSharp.indexOf(note) === -1 ? notesFlat.indexOf(note) : notesSharp.indexOf(note)); + notenumber += parseInt(transposeValue); + while (notenumber > 11) { + notenumber -= 12; + } + while (notenumber < 0) { + notenumber += 12; + } + if (i === 0) { + currentChord = notesPreferred[notenumber] === '#' ? notesSharp[notenumber] : notesFlat[notenumber]; + lastChord = currentChord; + } else { + currentChord = notesSharp.indexOf(lastChord) === -1 ? notesFlat[notenumber] : notesSharp[notenumber]; + } + if (!(notesFlat.indexOf(note) === -1 && notesSharp.indexOf(note) === -1)) { + transposedChord += currentChord + rest; + } else { + transposedChord += note + rest; + } + } + } + return transposedChord; +} + +var OpenLPChordOverflowFillCount = 0; +window.OpenLP = { + showchords:true, + loadService: function (event) { + $.getJSON( + "/api/service/list", + function (data, status) { + OpenLP.nextSong = ""; + $("#notes").html(""); + for (idx in data.results.items) { + idx = parseInt(idx, 10); + if (data.results.items[idx]["selected"]) { + $("#notes").html(data.results.items[idx]["notes"].replace(/\n/g, "
")); + if (data.results.items.length > idx + 1) { + OpenLP.nextSong = data.results.items[idx + 1]["title"]; + } + break; + } + } + OpenLP.updateSlide(); + } + ); + }, + loadSlides: function (event) { + $.getJSON( + "/api/controller/live/text", + function (data, status) { + OpenLP.currentSlides = data.results.slides; + $('#transposevalue').text(getTransposeValue(OpenLP.currentSlides[0].text.split("\n")[0])); + OpenLP.currentSlide = 0; + OpenLP.currentTags = Array(); + var div = $("#verseorder"); + div.html(""); + var tag = ""; + var tags = 0; + var lastChange = 0; + $.each(data.results.slides, function(idx, slide) { + var prevtag = tag; + tag = slide["tag"]; + if (tag != prevtag) { + // If the tag has changed, add new one to the list + lastChange = idx; + tags = tags + 1; + div.append(" "); + $("#verseorder span").last().attr("id", "tag" + tags).text(tag); + } + else { + if ((slide["chords_text"] == data.results.slides[lastChange]["chords_text"]) && + (data.results.slides.length > idx + (idx - lastChange))) { + // If the tag hasn't changed, check to see if the same verse + // has been repeated consecutively. Note the verse may have been + // split over several slides, so search through. If so, repeat the tag. + var match = true; + for (var idx2 = 0; idx2 < idx - lastChange; idx2++) { + if(data.results.slides[lastChange + idx2]["chords_text"] != data.results.slides[idx + idx2]["chords_text"]) { + match = false; + break; + } + } + if (match) { + lastChange = idx; + tags = tags + 1; + div.append(" "); + $("#verseorder span").last().attr("id", "tag" + tags).text(tag); + } + } + } + OpenLP.currentTags[idx] = tags; + if (slide["selected"]) + OpenLP.currentSlide = idx; + }) + OpenLP.loadService(); + } + ); + }, + updateSlide: function() { + // Show the current slide on top. Any trailing slides for the same verse + // are shown too underneath in grey. + // Then leave a blank line between following verses + var transposeValue = getTransposeValue(OpenLP.currentSlides[0].text.split("\n")[0]); + var chordclass=/class="[a-z\s]*chord[a-z\s]*"\s*style="display:\s?none"/g; + var chordclassshow='class="chord"'; + var regchord=/([\(\w#b♭\+\*\d/\)-]+)<\/strong><\/span><\/span>([\u0080-\uFFFF,\w]*)(.+?<\/span>)?([\u0080-\uFFFF,\w,\s,\.,\,,\!,\?,\;,\:,\|,\",\',\-,\_]*)(
)?/g; + // NOTE: There is equivalent python code in openlp/core/lib/__init__.py, in the expand_and_align_chords_in_line function. Make sure to update both! + var replaceChords=function(mstr,$chord,$tail,$skips,$remainder,$end) { + var w=''; + var $chordlen = 0; + var $taillen = 0; + var slimchars='fiíIÍjlĺľrtť.,;/ ()|"\'!:\\'; + // Transpose chord as dictated by the transpose value in local storage + if (transposeValue != 0) { + $chord = transposeChord($chord, transposeValue, OpenLP.chordNotation); + } + for (var i = 0; i < $chord.length; i++) if (slimchars.indexOf($chord.charAt(i)) === -1) {$chordlen += 2;} else {$chordlen += 1;} + for (var i = 0; i < $tail.length; i++) if (slimchars.indexOf($tail.charAt(i)) === -1) {$taillen += 2;} else {$taillen += 1;} + for (var i = 0; i < $remainder.length; i++) if (slimchars.indexOf($tail.charAt(i)) === -1) {$taillen += 2;} else {$taillen += 1;} + if ($chordlen >= $taillen && !$end) { + if ($tail.length){ + if (!$remainder.length) { + for (c = 0; c < Math.ceil(($chordlen - $taillen) / 2) + 1; c++) {w += '_';} + } else { + for (c = 0; c < $chordlen - $taillen + 2; c++) {w += ' ';} + } + } else { + if (!$remainder.length) { + for (c = 0; c < Math.floor(($chordlen - $taillen) / 2) + 1; c++) {w += '_';} + } else { + for (c = 0; c < $chordlen - $taillen + 1; c++) {w += ' ';} + } + }; + } else { + if (!$tail && $remainder.charAt(0) == ' ') {for (c = 0; c < $chordlen; c++) {w += ' ';}} + } + if (w!='') { + if (w[0] == '_') { + ws_length = w.length; + if (ws_length==1) { + w = '–'; + } else { + wsl_mod = Math.floor(ws_length / 2); + ws_right = ws_left = new Array(wsl_mod + 1).join(' '); + w = ws_left + '–' + ws_right; + } + } + w='' + w + ''; + } + return $.grep(['', $chord, '', $tail, w, $remainder, $end], Boolean).join(''); + }; + $("#verseorder span").removeClass("currenttag"); + $("#tag" + OpenLP.currentTags[OpenLP.currentSlide]).addClass("currenttag"); + var slide = OpenLP.currentSlides[OpenLP.currentSlide]; + var text = ""; + // use title if available + if (slide["title"]) { + text = slide["title"]; + } else { + text = slide["chords_text"]; + if(OpenLP.showchords) { + text = text.replace(chordclass,chordclassshow); + text = text.replace(regchord, replaceChords); + } + } + // use thumbnail if available + if (slide["img"]) { + text += "

"; + } + // use notes if available + if (slide["slide_notes"]) { + text += '
' + slide["slide_notes"]; + } + text = text.replace(/\n/g, "
"); + $("#currentslide").html(text); + text = ""; + if (OpenLP.currentSlide < OpenLP.currentSlides.length - 1) { + for (var idx = OpenLP.currentSlide + 1; idx < OpenLP.currentSlides.length; idx++) { + if (OpenLP.currentTags[idx] != OpenLP.currentTags[idx - 1]) + text = text + "

"; + if (OpenLP.currentSlides[idx]["title"]) { + text = text + OpenLP.currentSlides[idx]["title"]; + } else { + text = text + OpenLP.currentSlides[idx]["chords_text"]; + if(OpenLP.showchords) { + text = text.replace(chordclass,chordclassshow); + text = text.replace(regchord, replaceChords); + } + } + if (OpenLP.currentTags[idx] != OpenLP.currentTags[idx - 1]) + text = text + "

"; + else + text = text + "
"; + } + text = text.replace(/\n/g, "
"); + $("#nextslide").html(text); + } + else { + text = "

" + $("#next-text").val() + ": " + OpenLP.nextSong + "

"; + $("#nextslide").html(text); + } + if(!OpenLP.showchords) { + $(".chordline").toggleClass('chordline1'); + $(".chord").toggle(); + $(".ws").toggle(); + } + }, + updateClock: function(data) { + var div = $("#clock"); + var t = new Date(); + var h = t.getHours(); + if (data.results.twelve && h > 12) + h = h - 12; + if (h < 10) h = '0' + h + ''; + var m = t.getMinutes(); + if (m < 10) + m = '0' + m + ''; + div.html(h + ":" + m); + }, + pollServer: function () { + $.getJSON( + "/api/poll", + function (data, status) { + OpenLP.updateClock(data); + OpenLP.chordNotation = data.results.chordNotation; + if (OpenLP.currentItem != data.results.item || OpenLP.currentService != data.results.service) { + OpenLP.currentItem = data.results.item; + OpenLP.currentService = data.results.service; + OpenLP.loadSlides(); + } + else if (OpenLP.currentSlide != data.results.slide) { + OpenLP.currentSlide = parseInt(data.results.slide, 10); + OpenLP.updateSlide(); + } + } + ); + } +} +$.ajaxSetup({ cache: false }); +setInterval("OpenLP.pollServer();", 500); +OpenLP.pollServer(); +$(document).ready(function() { + $('#transposeup').click(function(e) { + $('#transposevalue').text(parseInt($('#transposevalue').text()) + 1); + storeTransposeValue(OpenLP.currentSlides[0].text.split("\n")[0], $('#transposevalue').text()); + OpenLP.loadSlides(); + }); + $('#transposedown').click(function(e) { + $('#transposevalue').text(parseInt($('#transposevalue').text()) - 1); + storeTransposeValue(OpenLP.currentSlides[0].text.split("\n")[0], $('#transposevalue').text()); + OpenLP.loadSlides(); + }); + $('#chords').click(function () { + OpenLP.showchords = OpenLP.showchords ? false : true; + OpenLP.loadSlides(); + }); +}); diff --git a/openlp/plugins/remotes/lib/httprouter.py b/openlp/plugins/remotes/lib/httprouter.py index ea5b79655..b3b45b03f 100644 --- a/openlp/plugins/remotes/lib/httprouter.py +++ b/openlp/plugins/remotes/lib/httprouter.py @@ -152,6 +152,7 @@ class HttpRouter(RegistryProperties): ('^/$', {'function': self.serve_file, 'secure': False}), ('^/(stage)$', {'function': self.serve_file, 'secure': False}), ('^/(stage)/(.*)$', {'function': self.stages, 'secure': False}), + ('^/(chords)$', {'function': self.serve_file, 'secure': False}), ('^/(main)$', {'function': self.serve_file, 'secure': False}), (r'^/(\w+)/thumbnails([^/]+)?/(.*)$', {'function': self.serve_thumbnail, 'secure': False}), (r'^/api/poll$', {'function': self.poll, 'secure': False}), @@ -318,10 +319,12 @@ class HttpRouter(RegistryProperties): """ remote = translate('RemotePlugin.Mobile', 'Remote') stage = translate('RemotePlugin.Mobile', 'Stage View') + chords = translate('RemotePlugin.Mobile', 'Chords View') live = translate('RemotePlugin.Mobile', 'Live View') self.template_vars = { 'app_title': "{main} {remote}".format(main=UiStrings().OLPV2x, remote=remote), 'stage_title': "{main} {stage}".format(main=UiStrings().OLPV2x, stage=stage), + 'chords_title': "{main} {chords}".format(main=UiStrings().OLPV2x, chords=chords), 'live_title': "{main} {live}".format(main=UiStrings().OLPV2x, live=live), 'service_manager': translate('RemotePlugin.Mobile', 'Service Manager'), 'slide_controller': translate('RemotePlugin.Mobile', 'Slide Controller'), @@ -482,7 +485,8 @@ class HttpRouter(RegistryProperties): 'display': self.live_controller.desktop_screen.isChecked(), 'version': 2, 'isSecure': Settings().value(self.settings_section + '/authentication enabled'), - 'isAuthorised': self.authorised + 'isAuthorised': self.authorised, + 'chordNotation': Settings().value('songs/chord notation'), } self.do_json_header() return json.dumps({'results': result}).encode() @@ -554,6 +558,7 @@ class HttpRouter(RegistryProperties): item['tag'] = str(frame['verseTag']) else: item['tag'] = str(index + 1) + item['chords_text'] = str(frame['chords_text']) item['text'] = str(frame['text']) item['html'] = str(frame['html']) # Handle images, unless a custom thumbnail is given or if thumbnails is disabled diff --git a/openlp/plugins/remotes/lib/remotetab.py b/openlp/plugins/remotes/lib/remotetab.py index 314781546..6e847afc2 100644 --- a/openlp/plugins/remotes/lib/remotetab.py +++ b/openlp/plugins/remotes/lib/remotetab.py @@ -81,6 +81,12 @@ class RemoteTab(SettingsTab): self.stage_url.setObjectName('stage_url') self.stage_url.setOpenExternalLinks(True) self.http_setting_layout.addRow(self.stage_url_label, self.stage_url) + self.chords_url_label = QtWidgets.QLabel(self.http_settings_group_box) + self.chords_url_label.setObjectName('chords_url_label') + self.chords_url = QtWidgets.QLabel(self.http_settings_group_box) + self.chords_url.setObjectName('chords_url') + self.chords_url.setOpenExternalLinks(True) + self.http_setting_layout.addRow(self.chords_url_label, self.chords_url) self.live_url_label = QtWidgets.QLabel(self.http_settings_group_box) self.live_url_label.setObjectName('live_url_label') self.live_url = QtWidgets.QLabel(self.http_settings_group_box) @@ -148,6 +154,7 @@ class RemoteTab(SettingsTab): self.port_label.setText(translate('RemotePlugin.RemoteTab', 'Port number:')) self.remote_url_label.setText(translate('RemotePlugin.RemoteTab', 'Remote URL:')) self.stage_url_label.setText(translate('RemotePlugin.RemoteTab', 'Stage view URL:')) + self.chords_url_label.setText(translate('RemotePlugin.RemoteTab', 'Chords view URL:')) self.live_url_label.setText(translate('RemotePlugin.RemoteTab', 'Live view URL:')) self.twelve_hour_check_box.setText(translate('RemotePlugin.RemoteTab', 'Display stage time in 12h format')) self.thumbnails_check_box.setText(translate('RemotePlugin.RemoteTab', diff --git a/openlp/plugins/songs/forms/editversedialog.py b/openlp/plugins/songs/forms/editversedialog.py index 957d97adf..e87d1d4ea 100644 --- a/openlp/plugins/songs/forms/editversedialog.py +++ b/openlp/plugins/songs/forms/editversedialog.py @@ -25,6 +25,7 @@ from PyQt5 import QtWidgets from openlp.core.ui.lib import SpellTextEdit from openlp.core.lib import build_icon, translate from openlp.core.lib.ui import UiStrings, create_button_box +from openlp.core.common import Settings from openlp.plugins.songs.lib import VerseType @@ -63,6 +64,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 Settings().value('songs/enable chords'): + self.transpose_layout = QtWidgets.QHBoxLayout() + 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(build_icon(':/services/service_up.png')) + 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(build_icon(':/services/service_down.png')) + self.transpose_down_button.setObjectName('transpose_down') + self.transpose_layout.addWidget(self.transpose_down_button) + self.dialog_layout.addLayout(self.transpose_layout) self.button_box = create_button_box(edit_verse_dialog, 'button_box', ['cancel', 'ok']) self.dialog_layout.addWidget(self.button_box) self.retranslateUi(edit_verse_dialog) @@ -82,3 +98,7 @@ 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 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')) diff --git a/openlp/plugins/songs/forms/editverseform.py b/openlp/plugins/songs/forms/editverseform.py index 226647adc..f033f47d9 100644 --- a/openlp/plugins/songs/forms/editverseform.py +++ b/openlp/plugins/songs/forms/editverseform.py @@ -25,7 +25,9 @@ import logging from PyQt5 import QtCore, QtGui, QtWidgets -from openlp.plugins.songs.lib import VerseType +from openlp.plugins.songs.lib import VerseType, transpose_lyrics +from openlp.core.lib.ui import critical_error_message_box +from openlp.core.common import translate, Settings from .editversedialog import Ui_EditVerseDialog log = logging.getLogger(__name__) @@ -48,6 +50,9 @@ class EditVerseForm(QtWidgets.QDialog, Ui_EditVerseDialog): self.split_button.clicked.connect(self.on_split_button_clicked) 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) + if Settings().value('songs/enable chords'): + self.transpose_down_button.clicked.connect(self.on_transepose_down_button_clicked) + self.transpose_up_button.clicked.connect(self.on_transepose_up_button_clicked) def insert_verse(self, verse_tag, verse_num=1): """ @@ -95,6 +100,41 @@ class EditVerseForm(QtWidgets.QDialog, Ui_EditVerseDialog): """ self.update_suggested_verse_number() + def on_transepose_up_button_clicked(self): + """ + The transpose up button clicked + """ + try: + transposed_lyrics = transpose_lyrics(self.verse_text_edit.toPlainText(), 1) + self.verse_text_edit.setPlainText(transposed_lyrics) + except ValueError as ve: + # Transposing failed + critical_error_message_box(title=translate('SongsPlugin.EditVerseForm', 'Transposing failed'), + message=translate('SongsPlugin.EditVerseForm', + 'Transposing failed because of invalid chord:\n{err_msg}' + .format(err_msg=ve))) + return + self.verse_text_edit.setFocus() + self.verse_text_edit.moveCursor(QtGui.QTextCursor.End) + + def on_transepose_down_button_clicked(self): + """ + The transpose down button clicked + """ + try: + transposed_lyrics = transpose_lyrics(self.verse_text_edit.toPlainText(), -1) + self.verse_text_edit.setPlainText(transposed_lyrics) + except ValueError as ve: + # Transposing failed + critical_error_message_box(title=translate('SongsPlugin.EditVerseForm', 'Transposing failed'), + message=translate('SongsPlugin.EditVerseForm', + 'Transposing failed because of invalid chord:\n{err_msg}' + .format(err_msg=ve))) + return + self.verse_text_edit.setPlainText(transposed_lyrics) + self.verse_text_edit.setFocus() + self.verse_text_edit.moveCursor(QtGui.QTextCursor.End) + def update_suggested_verse_number(self): """ Adjusts the verse number SpinBox in regard to the selected verse type and the cursor's position. @@ -169,3 +209,20 @@ class EditVerseForm(QtWidgets.QDialog, Ui_EditVerseDialog): if not text.startswith('---['): text = '---[{tag}:1]---\n{text}'.format(tag=VerseType.translated_names[VerseType.Verse], text=text) return text + + def accept(self): + """ + Test if any invalid chords has been entered before closing the verse editor + """ + if Settings().value('songs/enable chords'): + try: + transposed_lyrics = transpose_lyrics(self.verse_text_edit.toPlainText(), 1) + super(EditVerseForm, self).accept() + except ValueError as ve: + # Transposing failed + critical_error_message_box(title=translate('SongsPlugin.EditVerseForm', 'Invalid Chord'), + message=translate('SongsPlugin.EditVerseForm', + 'An invalid chord was detected:\n{err_msg}' + .format(err_msg=ve))) + else: + super(EditVerseForm, self).accept() diff --git a/openlp/plugins/songs/lib/__init__.py b/openlp/plugins/songs/lib/__init__.py index fd25608db..7563b9f1a 100644 --- a/openlp/plugins/songs/lib/__init__.py +++ b/openlp/plugins/songs/lib/__init__.py @@ -29,7 +29,7 @@ import re from PyQt5 import QtWidgets -from openlp.core.common import AppLocation, CONTROL_CHARS +from openlp.core.common import AppLocation, CONTROL_CHARS, Settings from openlp.core.lib import translate, clean_tags from openlp.plugins.songs.lib.db import Author, MediaFile, Song, Topic from openlp.plugins.songs.lib.ui import SongStrings @@ -380,7 +380,7 @@ def clean_song(manager, song): if isinstance(song.lyrics, bytes): song.lyrics = str(song.lyrics, encoding='utf8') verses = SongXML().get_verses(song.lyrics) - song.search_lyrics = ' '.join([clean_string(clean_tags(verse[1])) for verse in verses]) + song.search_lyrics = ' '.join([clean_string(clean_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 @@ -541,3 +541,123 @@ def delete_song(song_id, song_plugin): except OSError: log.exception('Could not remove directory: {path}'.format(path=save_path)) song_plugin.manager.delete_object(Song, song_id) + + +def transpose_lyrics(lyrics, transepose_value): + """ + Transepose lyrics + + :param lyrcs: The lyrics to be transposed + :param transepose_value: The value to transpose the lyrics with + :return: The transposed lyrics + """ + # Split text by verse delimiter - both normal and optional + verse_list = re.split('(---\[.+?:.+?\]---|\[---\])', lyrics) + transposed_lyrics = '' + notation = Settings().value('songs/chord notation') + for verse in verse_list: + if verse.startswith('---[') or verse == '[---]': + transposed_lyrics += verse + else: + transposed_lyrics += transpose_verse(verse, transepose_value, notation) + return transposed_lyrics + + +def transpose_verse(verse_text, transepose_value, notation): + """ + Transepose lyrics + + :param lyrcs: The lyrics to be transposed + :param transepose_value: The value to transpose the lyrics with + :return: The transposed lyrics + """ + if '[' not in verse_text: + return verse_text + # Split the lyrics based on chord tags + lyric_list = re.split('(\[|\]|/)', verse_text) + transposed_lyrics = '' + in_tag = False + for word in lyric_list: + if not in_tag: + transposed_lyrics += word + if word == '[': + in_tag = True + else: + if word == ']': + in_tag = False + transposed_lyrics += word + elif word == '/': + transposed_lyrics += word + else: + # This MUST be a chord + transposed_lyrics += transpose_chord(word, transepose_value, notation) + # If still inside a chord tag something is wrong! + if in_tag: + return verse_text + else: + return transposed_lyrics + + +def transpose_chord(chord, transpose_value, notation): + """ + Transpose chord according to the notation used. + NOTE: This function has a javascript equivalent in chords.js - make sure to update both! + + :param chord: The chord to transpose. + :param transpose_value: The value the chord should be transposed. + :param notation: The notation to use when transposing. + :return: The transposed chord. + """ + # See https://en.wikipedia.org/wiki/Musical_note#12-tone_chromatic_scale + notes_sharp_notation = {} + notes_flat_notation = {} + notes_sharp_notation['german'] = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'H'] + notes_flat_notation['german'] = ['C', 'Db', 'D', 'Eb', 'Fb', 'F', 'Gb', 'G', 'Ab', 'A', 'B', 'H'] + notes_sharp_notation['english'] = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + notes_flat_notation['english'] = ['C', 'Db', 'D', 'Eb', 'Fb', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'] + notes_sharp_notation['neo-latin'] = ['Do', 'Do#', 'Re', 'Re#', 'Mi', 'Fa', 'Fa#', 'Sol', 'Sol#', 'La', 'La#', 'Si'] + notes_flat_notation['neo-latin'] = ['Do', 'Reb', 'Re', 'Mib', 'Fab', 'Fa', 'Solb', 'Sol', 'Lab', 'La', 'Sib', 'Si'] + chord_split = chord.replace('♭', 'b').split('/') + transposed_chord = '' + last_chord = '' + notes_sharp = notes_sharp_notation[notation] + notes_flat = notes_flat_notation[notation] + notes_preferred = ['b', '#', '#', '#', '#', '#', '#', '#', '#', '#', '#', '#'] + for i in range(0, len(chord_split)): + if i > 0: + transposed_chord += '/' + currentchord = chord_split[i] + if currentchord and currentchord[0] == '(': + transposed_chord += '(' + if len(currentchord) > 1: + currentchord = currentchord[1:] + else: + currentchord = '' + if len(currentchord) > 0: + if len(currentchord) > 1: + if '#b'.find(currentchord[1]) == -1: + note = currentchord[0:1] + rest = currentchord[1:] + else: + note = currentchord[0:2] + rest = currentchord[2:] + else: + note = currentchord + rest = '' + notenumber = notes_flat.index(note) if note not in notes_sharp else notes_sharp.index(note) + notenumber += transpose_value + while notenumber > 11: + notenumber -= 12 + while notenumber < 0: + notenumber += 12 + if i == 0: + current_chord = notes_sharp[notenumber] if notes_preferred[notenumber] == '#' else notes_flat[ + notenumber] + last_chord = current_chord + else: + current_chord = notes_flat[notenumber] if last_chord not in notes_sharp else notes_sharp[notenumber] + if not (note not in notes_flat and note not in notes_sharp): + transposed_chord += current_chord + rest + else: + transposed_chord += note + rest + return transposed_chord diff --git a/openlp/plugins/songs/lib/importer.py b/openlp/plugins/songs/lib/importer.py index 577d580f5..e384d1090 100644 --- a/openlp/plugins/songs/lib/importer.py +++ b/openlp/plugins/songs/lib/importer.py @@ -48,6 +48,7 @@ from .importers.powerpraise import PowerPraiseImport from .importers.presentationmanager import PresentationManagerImport from .importers.lyrix import LyrixImport from .importers.videopsalm import VideoPsalmImport +from .importers.chordpro import ChordProImport log = logging.getLogger(__name__) @@ -155,29 +156,30 @@ class SongFormat(object): OpenLP2 = 1 Generic = 2 CCLI = 3 - DreamBeam = 4 - EasySlides = 5 - EasyWorshipDB = 6 - EasyWorshipService = 7 - FoilPresenter = 8 - Lyrix = 9 - MediaShout = 10 - OpenSong = 11 - OPSPro = 12 - PowerPraise = 13 - PowerSong = 14 - PresentationManager = 15 - ProPresenter = 16 - SongBeamer = 17 - SongPro = 18 - SongShowPlus = 19 - SongsOfFellowship = 20 - SundayPlus = 21 - VideoPsalm = 22 - WordsOfWorship = 23 - WorshipAssistant = 24 - WorshipCenterPro = 25 - ZionWorx = 26 + ChordPro = 4 + DreamBeam = 5 + EasySlides = 6 + EasyWorshipDB = 7 + EasyWorshipService = 8 + FoilPresenter = 9 + Lyrix = 10 + MediaShout = 11 + OpenSong = 12 + OPSPro = 13 + PowerPraise = 14 + PowerSong = 15 + PresentationManager = 16 + ProPresenter = 17 + SongBeamer = 18 + SongPro = 19 + SongShowPlus = 20 + SongsOfFellowship = 21 + SundayPlus = 22 + VideoPsalm = 23 + WordsOfWorship = 24 + WorshipAssistant = 25 + WorshipCenterPro = 26 + ZionWorx = 27 # Set optional attribute defaults __defaults__ = { @@ -224,6 +226,13 @@ class SongFormat(object): 'filter': '{text} (*.usr *.txt *.bin)'.format(text=translate('SongsPlugin.ImportWizardForm', 'CCLI SongSelect Files')) }, + ChordPro: { + 'class': ChordProImport, + 'name': 'ChordPro', + 'prefix': 'chordPro', + 'filter': '{text} (*.cho *.crd *.chordpro *.chopro *.txt)'.format( + text=translate('SongsPlugin.ImportWizardForm', 'ChordPro Files')) + }, DreamBeam: { 'class': DreamBeamImport, 'name': 'DreamBeam', @@ -427,6 +436,7 @@ class SongFormat(object): SongFormat.OpenLP2, SongFormat.Generic, SongFormat.CCLI, + SongFormat.ChordPro, SongFormat.DreamBeam, SongFormat.EasySlides, SongFormat.EasyWorshipDB, diff --git a/openlp/plugins/songs/lib/importers/chordpro.py b/openlp/plugins/songs/lib/importers/chordpro.py new file mode 100644 index 000000000..4f185a713 --- /dev/null +++ b/openlp/plugins/songs/lib/importers/chordpro.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2016 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; version 2 of the License. # +# # +# This program is distributed in the hope that it will be useful, but WITHOUT # +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for # +# more details. # +# # +# You should have received a copy of the GNU General Public License along # +# with this program; if not, write to the Free Software Foundation, Inc., 59 # +# Temple Place, Suite 330, Boston, MA 02111-1307 USA # +############################################################################### +""" +The :mod:`chordpro` module provides the functionality for importing +ChordPro files into the current database. +""" + +import logging +import re + +from openlp.core.common import Settings + +from .songimport import SongImport + + +log = logging.getLogger(__name__) + + +class ChordProImport(SongImport): + """ + The :class:`ChordProImport` class provides OpenLP with the + ability to import ChordPro files. + This importer is based on the information available on these webpages: + http://webchord.sourceforge.net/tech.html + http://www.vromans.org/johan/projects/Chordii/chordpro/ + http://www.tenbyten.com/software/songsgen/help/HtmlHelp/files_reference.htm + http://linkesoft.com/songbook/chordproformat.html + """ + def do_import(self): + self.import_wizard.progress_bar.setMaximum(len(self.import_source)) + for filename in self.import_source: + if self.stop_import_flag: + return + song_file = open(filename, 'rt') + self.do_import_file(song_file) + song_file.close() + + def do_import_file(self, song_file): + """ + Imports the songs in the given file + :param song_file: The file object to be imported from. + """ + self.set_defaults() + # Loop over the lines of the file + file_content = song_file.read() + current_verse = '' + current_verse_type = 'v' + skip_block = False + for line in file_content.splitlines(): + line = line.rstrip() + # Detect tags + if line.startswith('{'): + tag_name, tag_value = self.parse_tag(line) + # Detect which tag + if tag_name in ['title', 't']: + self.title = tag_value + elif tag_name in ['subtitle', 'su', 'st']: + self.alternate_title = 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'): + if current_verse.strip(): + # Add collected verse to the lyrics + # Strip out chords if set up to + if not Settings().value('songs/enable chords') or Settings().value( + 'songs/disable chords import'): + current_verse = re.sub(r'\[.*?\]', '', current_verse) + self.add_verse(current_verse.rstrip(), current_verse_type) + current_verse_type = 'v' + current_verse = '' + self.repeat_verse('c1') + 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 Settings().value('songs/enable chords') or Settings().value('songs/disable chords import'): + current_verse = re.sub(r'\[.*?\]', '', current_verse) + self.add_verse(current_verse.rstrip(), current_verse_type) + 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 Settings().value('songs/enable chords') or Settings().value( + 'songs/disable chords import'): + current_verse = re.sub(r'\[.*?\]', '', current_verse) + self.add_verse(current_verse.rstrip(), current_verse_type) + current_verse_type = 'v' + current_verse = '' + skip_block = True + elif tag_name in ['end_of_tab', 'eot']: + skip_block = False + elif tag_name in ['new_song', 'ns']: + # 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 Settings().value('songs/enable chords') or Settings().value( + 'songs/disable chords import'): + current_verse = re.sub(r'\[.*?\]', '', current_verse) + self.add_verse(current_verse.rstrip(), current_verse_type) + if not self.finish(): + self.log_error(song_file.name) + self.set_defaults() + current_verse_type = 'v' + current_verse = '' + else: + # Unsupported tag + log.debug('unsupported tag: %s' % line) + elif line.startswith('#'): + # Found a comment line, which is ignored... + continue + elif line == "['|]": + # Found a vertical bar + continue + 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 Settings().value('songs/enable chords') or Settings().value('songs/disable chords import'): + current_verse = re.sub(r'\[.*?\]', '', current_verse) + self.add_verse(current_verse.rstrip(), current_verse_type) + current_verse_type = 'v' + current_verse = '' + else: + if current_verse.strip() == '': + current_verse = line + '\n' + else: + current_verse += line + '\n' + if current_verse.strip(): + # Strip out chords if set up to + if not Settings().value('songs/enable chords') or Settings().value( + 'songs/disable chords import'): + current_verse = re.sub(r'\[.*?\]', '', current_verse) + self.add_verse(current_verse.rstrip(), current_verse_type) + if not self.finish(): + self.log_error(song_file.name) + + def parse_tag(self, line): + """ + :param line: Line with the tag to be parsed + :return: A tuple with tag name and tag value (if any) + """ + # Strip the first '}' + line = line[1:].strip() + colon_idx = line.find(':') + # check if this is a tag without value + if colon_idx < 0: + # strip the final '}' and return the tag name + return line[:-1], None + tag_name = line[:colon_idx] + tag_value = line[colon_idx + 1:-1].strip() + return tag_name, tag_value diff --git a/openlp/plugins/songs/lib/importers/opensong.py b/openlp/plugins/songs/lib/importers/opensong.py index 9cbbc50cf..e184b2847 100644 --- a/openlp/plugins/songs/lib/importers/opensong.py +++ b/openlp/plugins/songs/lib/importers/opensong.py @@ -26,7 +26,7 @@ import re from lxml import objectify from lxml.etree import Error, LxmlError -from openlp.core.common import translate +from openlp.core.common import translate, Settings from openlp.plugins.songs.lib import VerseType from openlp.plugins.songs.lib.importers.songimport import SongImport from openlp.plugins.songs.lib.ui import SongStrings @@ -87,7 +87,7 @@ class OpenSongImport(SongImport): All verses are imported and tagged appropriately. Guitar chords can be provided "above" the lyrics (the line is preceded by a period "."), and one or more "_" can - be used to signify long-drawn-out words. Chords and "_" are removed by this importer. For example:: + be used to signify long-drawn-out words. For example:: . A7 Bm 1 Some____ Words @@ -195,14 +195,34 @@ class OpenSongImport(SongImport): lyrics = str(root.lyrics) else: lyrics = '' + chords = [] for this_line in lyrics.split('\n'): if not this_line.strip(): continue # skip this line if it is a comment if this_line.startswith(';'): continue - # skip guitar chords and page and column breaks - if this_line.startswith('.') or this_line.startswith('---') or this_line.startswith('-!!'): + # skip page and column breaks + if this_line.startswith('---') or this_line.startswith('-!!'): + continue + # guitar chords marker + if this_line.startswith('.'): + # Find the position of the chords so they can be inserted in the lyrics + chords = [] + this_line = this_line[1:] + chord = '' + i = 0 + while i < len(this_line): + if this_line[i] != ' ': + chord_pos = i + chord += this_line[i] + i += 1 + while i < len(this_line) and this_line[i] != ' ': + chord += this_line[i] + i += 1 + chords.append((chord_pos, chord)) + chord = '' + i += 1 continue # verse/chorus/etc. marker if this_line.startswith('['): @@ -228,12 +248,20 @@ class OpenSongImport(SongImport): # number at start of line.. it's verse number if this_line[0].isdigit(): verse_num = this_line[0] - this_line = this_line[1:].strip() + this_line = this_line[1:] verses.setdefault(verse_tag, {}) verses[verse_tag].setdefault(verse_num, {}) if inst not in verses[verse_tag][verse_num]: verses[verse_tag][verse_num][inst] = [] our_verse_order.append([verse_tag, verse_num, inst]) + # If chords exists insert them + if chords and Settings().value('songs/enable chords') and not Settings().value( + 'songs/disable chords import'): + offset = 0 + for (column, chord) in chords: + this_line = '{pre}[{chord}]{post}'.format(pre=this_line[:offset + column], chord=chord, + post=this_line[offset + column:]) + offset += len(chord) + 2 # Tidy text and remove the ____s from extended words this_line = self.tidy_text(this_line) this_line = this_line.replace('_', '') diff --git a/openlp/plugins/songs/lib/importers/songbeamer.py b/openlp/plugins/songs/lib/importers/songbeamer.py index b8170196c..a48a57e96 100644 --- a/openlp/plugins/songs/lib/importers/songbeamer.py +++ b/openlp/plugins/songs/lib/importers/songbeamer.py @@ -25,10 +25,12 @@ The :mod:`songbeamer` module provides the functionality for importing SongBeamer import logging import os import re +import base64 +import math -from openlp.core.common import get_file_encoding from openlp.plugins.songs.lib import VerseType from openlp.plugins.songs.lib.importers.songimport import SongImport +from openlp.core.common import Settings, is_win, is_macosx, get_file_encoding log = logging.getLogger(__name__) @@ -60,6 +62,13 @@ class SongBeamerTypes(object): } +class VerseTagMode(object): + Unknown = 0 + ContainsTags = 1 + ContainsNoTags = 2 + ContainsNoTagsRestart = 3 + + class SongBeamerImport(SongImport): """ Import Song Beamer files(s). Song Beamer file format is text based in the beginning are one or more control tags @@ -109,7 +118,7 @@ class SongBeamerImport(SongImport): self.set_defaults() self.current_verse = '' self.current_verse_type = VerseType.tags[VerseType.Verse] - read_verses = False + self.chord_table = None file_name = os.path.split(import_file)[1] if os.path.isfile(import_file): # Detect the encoding @@ -125,33 +134,103 @@ class SongBeamerImport(SongImport): continue self.title = file_name.split('.sng')[0] read_verses = False - for line in song_data: - # Just make sure that the line is of the type 'Unicode'. - line = str(line).strip() + # The first verse separator doesn't count, but the others does, so line count starts at -1 + line_number = -1 + verse_tags_mode = VerseTagMode.Unknown + first_verse = True + idx = -1 + while idx + 1 < len(song_data): + idx = idx + 1 + line = song_data[idx].rstrip() + stripped_line = line.strip() if line.startswith('#') and not read_verses: - self.parseTags(line) - elif line.startswith('--'): - # --- and -- allowed for page-breaks (difference in Songbeamer only in printout) + self.parse_tags(line) + elif stripped_line.startswith('---'): + # '---' is a verse breaker if self.current_verse: self.replace_html_tags() self.add_verse(self.current_verse, self.current_verse_type) self.current_verse = '' self.current_verse_type = VerseType.tags[VerseType.Verse] + first_verse = False read_verses = True verse_start = True + # Songbeamer allows chord on line "-1", meaning the first line has only chords + if line_number == -1: + first_line = self.insert_chords(line_number, '') + if first_line: + self.current_verse = first_line.strip() + '\n' + line_number += 1 + elif stripped_line.startswith('--'): + # '--' is a page breaker, we convert to optional page break + self.current_verse += '[---]\n' + line_number += 1 elif read_verses: if verse_start: verse_start = False - if not self.check_verse_marks(line): - self.current_verse = line + '\n' + verse_mark = self.check_verse_marks(line) + # To ensure that linenumbers are mapped correctly when inserting chords, we attempt to detect + # if verse tags are inserted manually or by SongBeamer. If they are inserted manually the lines + # should be counted, otherwise not. If all verses start with a tag we assume it is inserted by + # SongBeamer. + if first_verse and verse_tags_mode == VerseTagMode.Unknown: + if verse_mark: + verse_tags_mode = VerseTagMode.ContainsTags + else: + verse_tags_mode = VerseTagMode.ContainsNoTags + elif verse_tags_mode != VerseTagMode.ContainsNoTagsRestart: + if not verse_mark and verse_tags_mode == VerseTagMode.ContainsTags: + # A verse mark was expected but not found, which means that verse marks has not been + # inserted by songbeamer, but are manually added headings. So restart the loop, and + # count tags as lines. + self.set_defaults() + self.title = file_name.split('.sng')[0] + verse_tags_mode = VerseTagMode.ContainsNoTagsRestart + read_verses = False + # The first verseseparator doesn't count, but the others does, so linecount starts at -1 + line_number = -1 + first_verse = True + idx = -1 + continue + if not verse_mark: + line = self.insert_chords(line_number, line) + self.current_verse += line.strip() + '\n' + line_number += 1 + elif verse_tags_mode in [VerseTagMode.ContainsNoTags, VerseTagMode.ContainsNoTagsRestart]: + line_number += 1 else: - self.current_verse += line + '\n' + line = self.insert_chords(line_number, line) + self.current_verse += line.strip() + '\n' + line_number += 1 if self.current_verse: self.replace_html_tags() self.add_verse(self.current_verse, self.current_verse_type) if not self.finish(): self.log_error(import_file) + def insert_chords(self, line_number, line): + """ + Insert chords into text if any exists and chords import is enabled + + :param linenumber: Number of the current line + :param line: The line of lyrics to insert chords + """ + if self.chord_table and Settings().value('songs/enable chords') and not Settings().value( + 'songs/disable chords import') and line_number in self.chord_table: + line_idx = sorted(self.chord_table[line_number].keys(), reverse=True) + for idx in line_idx: + # In SongBeamer the column position of the chord can be a decimal, we just round it up. + int_idx = int(math.ceil(idx)) + if int_idx < 0: + int_idx = 0 + elif int_idx > len(line): + # If a chord is placed beyond the current end of the line, extend the line with spaces. + line += ' ' * (int_idx - len(line)) + chord = self.chord_table[line_number][idx] + chord = chord.replace('<', '♭') + line = line[:int_idx] + '[' + chord + ']' + line[int_idx:] + return line + def replace_html_tags(self): """ This can be called to replace SongBeamer's specific (html) tags with OpenLP's specific (html) tags. @@ -159,7 +238,7 @@ class SongBeamerImport(SongImport): for pair in SongBeamerImport.HTML_TAG_PAIRS: self.current_verse = pair[0].sub(pair[1], self.current_verse) - def parseTags(self, line): + def parse_tags(self, line): """ Parses a meta data line. @@ -176,8 +255,10 @@ class SongBeamerImport(SongImport): self.add_copyright(tag_val[1]) elif tag_val[0] == '#AddCopyrightInfo': pass + elif tag_val[0] == '#AudioFile': + self.parse_audio_file(tag_val[1]) elif tag_val[0] == '#Author': - self.parse_author(tag_val[1]) + self.parse_author(tag_val[1], 'words') elif tag_val[0] == '#BackgroundImage': pass elif tag_val[0] == '#Bible': @@ -187,13 +268,16 @@ class SongBeamerImport(SongImport): elif tag_val[0] == '#CCLI': self.ccli_number = tag_val[1] elif tag_val[0] == '#Chords': - pass + self.chord_table = self.parse_chords(tag_val[1]) elif tag_val[0] == '#ChurchSongID': pass elif tag_val[0] == '#ColorChords': pass elif tag_val[0] == '#Comments': - self.comments = tag_val[1] + try: + self.comments = base64.b64decode(tag_val[1]).decode(self.input_file_encoding) + except ValueError: + self.comments = tag_val[1] elif tag_val[0] == '#Editor': pass elif tag_val[0] == '#Font': @@ -217,7 +301,7 @@ class SongBeamerImport(SongImport): elif tag_val[0] == '#LangCount': pass elif tag_val[0] == '#Melody': - self.parse_author(tag_val[1]) + self.parse_author(tag_val[1], 'music') elif tag_val[0] == '#NatCopyright': pass elif tag_val[0] == '#OTitle': @@ -243,7 +327,7 @@ class SongBeamerImport(SongImport): elif tag_val[0] == '#TextAlign': pass elif tag_val[0] == '#Title': - self.title = str(tag_val[1]).strip() + self.title = tag_val[1].strip() elif tag_val[0] == '#TitleAlign': pass elif tag_val[0] == '#TitleFontSize': @@ -263,25 +347,80 @@ class SongBeamerImport(SongImport): elif tag_val[0] == '#Version': pass elif tag_val[0] == '#VerseOrder': - # TODO: add the verse order. - pass + verse_order = tag_val[1].strip() + for verse_mark in verse_order.split(','): + new_verse_mark = self.convert_verse_marks(verse_mark) + if new_verse_mark: + self.verse_order_list.append(new_verse_mark) def check_verse_marks(self, line): """ Check and add the verse's MarkType. Returns ``True`` if the given line contains a correct verse mark otherwise ``False``. - :param line: The line to check for marks (unicode). + :param line: The line to check for marks. """ - marks = line.split(' ') - if len(marks) <= 2 and marks[0].lower() in SongBeamerTypes.MarkTypes: - self.current_verse_type = SongBeamerTypes.MarkTypes[marks[0].lower()] - if len(marks) == 2: - # If we have a digit, we append it to current_verse_type. - if marks[1].isdigit(): - self.current_verse_type += marks[1] - return True - elif marks[0].lower().startswith('$$m='): # this verse-mark cannot be numbered - self.current_verse_type = SongBeamerTypes.MarkTypes['$$m='] + new_verse_mark = self.convert_verse_marks(line) + if new_verse_mark: + self.current_verse_type = new_verse_mark return True return False + + def convert_verse_marks(self, line): + """ + Convert the verse's MarkType. Returns the OpenLP versemark if the given line contains a correct SongBeamer verse + mark otherwise ``None``. + + :param line: The line to check for marks. + """ + new_verse_mark = None + marks = line.split(' ') + if len(marks) <= 2 and marks[0].lower() in SongBeamerTypes.MarkTypes: + new_verse_mark = SongBeamerTypes.MarkTypes[marks[0].lower()] + if len(marks) == 2: + # If we have a digit, we append it to the converted verse mark + if marks[1].isdigit(): + new_verse_mark += marks[1] + elif marks[0].lower().startswith('$$m='): # this verse-mark cannot be numbered + new_verse_mark = SongBeamerTypes.MarkTypes['$$m='] + return new_verse_mark + + def parse_chords(self, chords): + """ + Parse chords. The chords are in a base64 encode string. The decoded string is an index of chord placement + separated by "\r", like this: ",,\r" + + :param chords: Chords in a base64 encoded string + """ + chord_list = base64.b64decode(chords).decode(self.input_file_encoding).split('\r') + chord_table = {} + for chord_index in chord_list: + if not chord_index: + continue + [col_str, line_str, chord] = chord_index.split(',') + col = float(col_str) + line = int(line_str) + if line not in chord_table: + chord_table[line] = {} + chord_table[line][col] = chord + return chord_table + + def parse_audio_file(self, audio_file_path): + """ + Parse audio file. The path is relative to the SongsBeamer Songs folder. + + :param audio_file_path: Path to the audio file + """ + # The path is relative to SongBeamers Song folder + if is_win(): + user_doc_folder = os.path.expandvars('$DOCUMENTS') + elif is_macosx(): + user_doc_folder = os.path.join(os.path.expanduser('~'), 'Documents') + else: + # SongBeamer only runs on mac and win... + return + audio_file_path = os.path.normpath(os.path.join(user_doc_folder, 'SongBeamer', 'Songs', audio_file_path)) + if os.path.isfile(audio_file_path): + self.add_media_file(audio_file_path) + else: + log.debug('Could not import mediafile "%s" since it does not exists!' % audio_file_path) diff --git a/openlp/plugins/songs/lib/importers/songimport.py b/openlp/plugins/songs/lib/importers/songimport.py index b6a8a6a59..7ea66a3da 100644 --- a/openlp/plugins/songs/lib/importers/songimport.py +++ b/openlp/plugins/songs/lib/importers/songimport.py @@ -242,7 +242,7 @@ class SongImport(QtCore.QObject): self.copyright += ' ' self.copyright += copyright - def parse_author(self, text): + 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'. @@ -256,7 +256,10 @@ class SongImport(QtCore.QObject): if author2.endswith('.'): author2 = author2[:-1] if author2: - self.add_author(author2) + if type: + self.add_author(author2, type) + else: + self.add_author(author2) def add_author(self, author, type=None): """ @@ -304,12 +307,23 @@ class SongImport(QtCore.QObject): if verse_def not in self.verse_order_list_generated: self.verse_order_list_generated.append(verse_def) - def repeat_verse(self): + def repeat_verse(self, verse_def=None): """ - Repeat the previous verse in the verse order + Repeat the verse with the given verse_def or default to repeating the previous verse in the verse order + + :param verse_def: verse_def of the verse to be repeated """ if self.verse_order_list_generated: - self.verse_order_list_generated.append(self.verse_order_list_generated[-1]) + if verse_def: + # If the given verse_def is only one char (like 'v' or 'c'), postfix it with '1' + if len(verse_def) == 1: + verse_def += '1' + if verse_def in self.verse_order_list_generated: + self.verse_order_list_generated.append(verse_def) + else: + log.warning('Trying to add unknown verse_def "%s"' % verse_def) + else: + self.verse_order_list_generated.append(self.verse_order_list_generated[-1]) self.verse_order_list_generated_useful = True def check_complete(self): diff --git a/openlp/plugins/songs/lib/importers/videopsalm.py b/openlp/plugins/songs/lib/importers/videopsalm.py index 36a28c583..5e7f0e8ef 100644 --- a/openlp/plugins/songs/lib/importers/videopsalm.py +++ b/openlp/plugins/songs/lib/importers/videopsalm.py @@ -26,8 +26,9 @@ exproted from Lyrix.""" import logging import json import os +import re -from openlp.core.common import translate +from openlp.core.common import translate, Settings from openlp.plugins.songs.lib.importers.songimport import SongImport from openlp.plugins.songs.lib.db import AuthorType @@ -123,7 +124,11 @@ class VideoPsalmImport(SongImport): for verse in song['Verses']: if 'Text' not in verse: continue - self.add_verse(verse['Text'], 'v') + verse_text = verse['Text'] + # Strip out chords if set up to + if not Settings().value('songs/enable chords') or Settings().value('songs/disable chords import'): + verse_text = re.sub(r'\[.*?\]', '', verse_text) + self.add_verse(verse_text, 'v') if not self.finish(): self.log_error('Could not import {title}'.format(title=self.title)) except Exception as e: diff --git a/openlp/plugins/songs/lib/openlyricsxml.py b/openlp/plugins/songs/lib/openlyricsxml.py index 868e74840..bc02043ac 100644 --- a/openlp/plugins/songs/lib/openlyricsxml.py +++ b/openlp/plugins/songs/lib/openlyricsxml.py @@ -61,7 +61,7 @@ import re from lxml import etree, objectify -from openlp.core.common import translate +from openlp.core.common import translate, Settings from openlp.core.common.versionchecker import get_application_version from openlp.core.lib import FormattingTags from openlp.plugins.songs.lib import VerseType, clean_song @@ -154,7 +154,7 @@ class OpenLyrics(object): OpenLP does not support the attribute *lang*. ```` - This property is not supported. + This property is fully supported. ```` The ```` property is fully supported. But comments in lyrics are not supported. @@ -323,7 +323,19 @@ class OpenLyrics(object): # Do not add the break attribute to the last lines element. if index < len(optional_verses) - 1: lines_element.set('break', 'optional') - return self._extract_xml(song_xml).decode() + xml_text = self._extract_xml(song_xml).decode() + return self._chordpro_to_openlyrics(xml_text) + + def _chordpro_to_openlyrics(self, text): + """ + Convert chords from Chord Pro format to Open Lyrics format + + :param text: the lyric with chords + :return: the lyrics with the converted chords + """ + # Process chords. + new_text = re.sub(r'\[(\w.*?)\]', r'', text) + return new_text def _get_missing_tags(self, text): """ @@ -595,8 +607,7 @@ class OpenLyrics(object): def _process_lines_mixed_content(self, element, newlines=True): """ - Converts the xml text with mixed content to OpenLP representation. Chords are skipped and formatting tags are - converted. + Converts the xml text with mixed content to OpenLP representation. Chords and formatting tags are converted. :param element: The property object (lxml.etree.Element). :param newlines: The switch to enable/disable processing of line breaks
. The
is used since @@ -608,12 +619,14 @@ class OpenLyrics(object): # TODO: Verify format() with template variables if element.tag == NSMAP % 'comment': if element.tail: - # Append tail text at chord element. + # Append tail text at comment element. text += element.tail return text - # Skip element - not yet supported. + # Convert chords to ChordPro format which OpenLP uses internally # TODO: Verify format() with template variables elif element.tag == NSMAP % 'chord': + if Settings().value('songs/enable chords') and not Settings().value('songs/disable chords import'): + text += '[{chord}]'.format(chord=element.get('name')) if element.tail: # Append tail text at chord element. text += element.tail @@ -666,7 +679,7 @@ class OpenLyrics(object): text = self._process_lines_mixed_content(element) # OpenLyrics version <= 0.7 contains elements to represent lines. First child element is tested. else: - # Loop over the "line" elements removing comments and chords. + # Loop over the "line" elements removing comments for line in element: # Skip comment lines. # TODO: Verify format() with template variables diff --git a/openlp/plugins/songs/lib/songstab.py b/openlp/plugins/songs/lib/songstab.py index 69f3526d7..d1044b6c3 100644 --- a/openlp/plugins/songs/lib/songstab.py +++ b/openlp/plugins/songs/lib/songstab.py @@ -60,6 +60,35 @@ class SongsTab(SettingsTab): self.display_copyright_check_box.setObjectName('copyright_check_box') self.mode_layout.addWidget(self.display_copyright_check_box) self.left_layout.addWidget(self.mode_group_box) + # Chords group box + self.chords_group_box = QtWidgets.QGroupBox(self.left_column) + self.chords_group_box.setObjectName('chords_group_box') + self.chords_group_box.setCheckable(True) + self.chords_layout = QtWidgets.QVBoxLayout(self.chords_group_box) + self.chords_layout.setObjectName('chords_layout') + self.chords_info_label = QtWidgets.QLabel(self.chords_group_box) + self.chords_info_label.setWordWrap(True) + self.chords_layout.addWidget(self.chords_info_label) + self.mainview_chords_check_box = QtWidgets.QCheckBox(self.mode_group_box) + self.mainview_chords_check_box.setObjectName('mainview_chords_check_box') + self.chords_layout.addWidget(self.mainview_chords_check_box) + self.disable_chords_import_check_box = QtWidgets.QCheckBox(self.mode_group_box) + self.disable_chords_import_check_box.setObjectName('disable_chords_import_check_box') + self.chords_layout.addWidget(self.disable_chords_import_check_box) + # Chords notation group box + self.chord_notation_label = QtWidgets.QLabel(self.chords_group_box) + self.chord_notation_label.setWordWrap(True) + self.chords_layout.addWidget(self.chord_notation_label) + self.english_notation_radio_button = QtWidgets.QRadioButton(self.chords_group_box) + self.english_notation_radio_button.setObjectName('english_notation_radio_button') + self.chords_layout.addWidget(self.english_notation_radio_button) + self.german_notation_radio_button = QtWidgets.QRadioButton(self.chords_group_box) + self.german_notation_radio_button.setObjectName('german_notation_radio_button') + self.chords_layout.addWidget(self.german_notation_radio_button) + self.neolatin_notation_radio_button = QtWidgets.QRadioButton(self.chords_group_box) + self.neolatin_notation_radio_button.setObjectName('neolatin_notation_radio_button') + self.chords_layout.addWidget(self.neolatin_notation_radio_button) + self.left_layout.addWidget(self.chords_group_box) self.left_layout.addStretch() self.right_layout.addStretch() self.tool_bar_active_check_box.stateChanged.connect(self.on_tool_bar_active_check_box_changed) @@ -68,6 +97,11 @@ class SongsTab(SettingsTab): self.display_songbook_check_box.stateChanged.connect(self.on_songbook_check_box_changed) self.display_written_by_check_box.stateChanged.connect(self.on_written_by_check_box_changed) self.display_copyright_check_box.stateChanged.connect(self.on_copyright_check_box_changed) + self.mainview_chords_check_box.stateChanged.connect(self.on_mainview_chords_check_box_changed) + self.disable_chords_import_check_box.stateChanged.connect(self.on_disable_chords_import_check_box_changed) + self.english_notation_radio_button.clicked.connect(self.on_english_notation_button_clicked) + self.german_notation_radio_button.clicked.connect(self.on_german_notation_button_clicked) + self.neolatin_notation_radio_button.clicked.connect(self.on_neolatin_notation_button_clicked) def retranslateUi(self): self.mode_group_box.setTitle(translate('SongsPlugin.SongsTab', 'Song related settings')) @@ -82,6 +116,17 @@ class SongsTab(SettingsTab): self.display_copyright_check_box.setText(translate('SongsPlugin.SongsTab', 'Display "{symbol}" symbol before copyright ' 'info').format(symbol=SongStrings.CopyrightSymbol)) + self.chords_info_label.setText(translate('SongsPlugin.SongsTab', 'If enabled all text between "[" and "]" will ' + 'be regarded as chords.')) + self.chords_group_box.setTitle(translate('SongsPlugin.SongsTab', 'Chords')) + self.mainview_chords_check_box.setText(translate('SongsPlugin.SongsTab', 'Display chords in the main view')) + self.disable_chords_import_check_box.setText(translate('SongsPlugin.SongsTab', + 'Ignore chords when importing songs')) + self.chord_notation_label.setText(translate('SongsPlugin.SongsTab', 'Chord notation to use:')) + self.english_notation_radio_button.setText(translate('SongsPlugin.SongsTab', 'English') + ' (C-D-E-F-G-A-B)') + self.german_notation_radio_button.setText(translate('SongsPlugin.SongsTab', 'German') + ' (C-D-E-F-G-A-H)') + self.neolatin_notation_radio_button.setText( + translate('SongsPlugin.SongsTab', 'Neo-Latin') + ' (Do-Re-Mi-Fa-Sol-La-Si)') def on_search_as_type_check_box_changed(self, check_state): self.song_search = (check_state == QtCore.Qt.Checked) @@ -104,6 +149,21 @@ class SongsTab(SettingsTab): def on_copyright_check_box_changed(self, check_state): self.display_copyright_symbol = (check_state == QtCore.Qt.Checked) + def on_mainview_chords_check_box_changed(self, check_state): + self.mainview_chords = (check_state == QtCore.Qt.Checked) + + def on_disable_chords_import_check_box_changed(self, check_state): + self.disable_chords_import = (check_state == QtCore.Qt.Checked) + + def on_english_notation_button_clicked(self): + self.chord_notation = 'english' + + def on_german_notation_button_clicked(self): + self.chord_notation = 'german' + + def on_neolatin_notation_button_clicked(self): + self.chord_notation = 'neo-latin' + def load(self): settings = Settings() settings.beginGroup(self.settings_section) @@ -113,12 +173,25 @@ class SongsTab(SettingsTab): self.display_songbook = settings.value('display songbook') self.display_written_by = settings.value('display written by') self.display_copyright_symbol = settings.value('display copyright symbol') + self.enable_chords = settings.value('enable chords') + self.chord_notation = settings.value('chord notation') + self.mainview_chords = settings.value('mainview chords') + self.disable_chords_import = settings.value('disable chords import') self.tool_bar_active_check_box.setChecked(self.tool_bar) self.update_on_edit_check_box.setChecked(self.update_edit) self.add_from_service_check_box.setChecked(self.update_load) self.display_songbook_check_box.setChecked(self.display_songbook) self.display_written_by_check_box.setChecked(self.display_written_by) self.display_copyright_check_box.setChecked(self.display_copyright_symbol) + self.chords_group_box.setChecked(self.enable_chords) + self.mainview_chords_check_box.setChecked(self.mainview_chords) + self.disable_chords_import_check_box.setChecked(self.disable_chords_import) + if self.chord_notation == 'german': + self.german_notation_radio_button.setChecked(True) + elif self.chord_notation == 'neo-latin': + self.neolatin_notation_radio_button.setChecked(True) + else: + self.english_notation_radio_button.setChecked(True) settings.endGroup() def save(self): @@ -130,6 +203,10 @@ class SongsTab(SettingsTab): settings.setValue('display songbook', self.display_songbook) settings.setValue('display written by', self.display_written_by) settings.setValue('display copyright symbol', self.display_copyright_symbol) + settings.setValue('enable chords', self.chords_group_box.isChecked()) + settings.setValue('mainview chords', self.mainview_chords) + settings.setValue('disable chords import', self.disable_chords_import) + settings.setValue('chord notation', self.chord_notation) settings.endGroup() if self.tab_visited: self.settings_form.register_post_process('songs_config_updated') diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py index 2b18b8316..4494ade49 100644 --- a/openlp/plugins/songs/songsplugin.py +++ b/openlp/plugins/songs/songsplugin.py @@ -66,7 +66,11 @@ __default_settings__ = { 'songs/last directory export': '', 'songs/songselect username': '', 'songs/songselect password': '', - 'songs/songselect searches': '' + 'songs/songselect searches': '', + 'songs/enable chords': True, + 'songs/chord notation': 'english', # Can be english, german or neo-latin + 'songs/mainview chords': False, + 'songs/disable chords import': False, } diff --git a/scripts/check_dependencies.py b/scripts/check_dependencies.py index bba9adc4b..f2e6ebde2 100755 --- a/scripts/check_dependencies.py +++ b/scripts/check_dependencies.py @@ -250,5 +250,6 @@ def main(): print_qt_image_formats() print_enchant_backends_and_languages() + if __name__ == '__main__': main() diff --git a/scripts/jenkins_script.py b/scripts/jenkins_script.py index 5b8c61950..1684e6084 100755 --- a/scripts/jenkins_script.py +++ b/scripts/jenkins_script.py @@ -217,5 +217,6 @@ def main(): else: parser.print_help() + if __name__ == '__main__': main() diff --git a/setup.cfg b/setup.cfg index 2abc34df1..0ecc03ae8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,4 @@ [pep8] exclude=resources.py,vlc.py max-line-length = 120 -ignore = E402 +ignore = E402,E722 diff --git a/tests/functional/openlp_core_common/test_actions.py b/tests/functional/openlp_core_common/test_actions.py index d37c3a846..1b28d9216 100644 --- a/tests/functional/openlp_core_common/test_actions.py +++ b/tests/functional/openlp_core_common/test_actions.py @@ -121,11 +121,11 @@ class TestCategoryActionList(TestCase): self.list.add(self.action2) # WHEN: Iterating over the list - l = [a for a in self.list] + list = [a for a in self.list] # THEN: Make sure they are returned in correct order self.assertEquals(len(self.list), 2) - self.assertIs(l[0], self.action1) - self.assertIs(l[1], self.action2) + self.assertIs(list[0], self.action1) + self.assertIs(list[1], self.action2) def test_remove(self): """ diff --git a/tests/functional/openlp_core_common/test_common.py b/tests/functional/openlp_core_common/test_common.py index 7960fb7be..e70a82328 100644 --- a/tests/functional/openlp_core_common/test_common.py +++ b/tests/functional/openlp_core_common/test_common.py @@ -22,11 +22,13 @@ """ Functional tests to test the AppLocation class and related methods. """ +from pathlib import Path from unittest import TestCase -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch -from openlp.core.common import check_directory_exists, de_hump, trace_error_handler, translate, is_win, is_macosx, \ - is_linux, clean_button_text +from openlp.core import common +from openlp.core.common import check_directory_exists, clean_button_text, de_hump, extension_loader, is_macosx, \ + is_linux, is_win, path_to_module, trace_error_handler, translate class TestCommonFunctions(TestCase): @@ -72,6 +74,72 @@ class TestCommonFunctions(TestCase): mocked_exists.assert_called_with(directory_to_check) self.assertRaises(ValueError, check_directory_exists, directory_to_check) + def test_extension_loader_no_files_found(self): + """ + Test the `extension_loader` function when no files are found + """ + # GIVEN: A mocked `Path.glob` method which does not match any files + with patch('openlp.core.common.AppLocation.get_directory', return_value='/app/dir/openlp'), \ + patch.object(common.Path, 'glob', return_value=[]), \ + patch('openlp.core.common.importlib.import_module') as mocked_import_module: + + # WHEN: Calling `extension_loader` + extension_loader('glob', ['file2.py', 'file3.py']) + + # THEN: `extension_loader` should not try to import any files + self.assertFalse(mocked_import_module.called) + + def test_extension_loader_files_found(self): + """ + Test the `extension_loader` function when it successfully finds and loads some files + """ + # GIVEN: A mocked `Path.glob` method which returns a list of files + with patch('openlp.core.common.AppLocation.get_directory', return_value='/app/dir/openlp'), \ + patch.object(common.Path, 'glob', return_value=[Path('/app/dir/openlp/import_dir/file1.py'), + Path('/app/dir/openlp/import_dir/file2.py'), + Path('/app/dir/openlp/import_dir/file3.py'), + Path('/app/dir/openlp/import_dir/file4.py')]), \ + patch('openlp.core.common.importlib.import_module') as mocked_import_module: + + # WHEN: Calling `extension_loader` with a list of files to exclude + extension_loader('glob', ['file2.py', 'file3.py']) + + # THEN: `extension_loader` should only try to import the files that are matched by the blob, excluding the + # files listed in the `excluded_files` argument + mocked_import_module.assert_has_calls([call('openlp.import_dir.file1'), call('openlp.import_dir.file4')]) + + def test_extension_loader_import_error(self): + """ + Test the `extension_loader` function when `SourceFileLoader` raises a `ImportError` + """ + # GIVEN: A mocked `import_module` which raises an `ImportError` + with patch('openlp.core.common.AppLocation.get_directory', return_value='/app/dir/openlp'), \ + patch.object(common.Path, 'glob', return_value=[Path('/app/dir/openlp/import_dir/file1.py')]), \ + patch('openlp.core.common.importlib.import_module', side_effect=ImportError()), \ + patch('openlp.core.common.log') as mocked_logger: + + # WHEN: Calling `extension_loader` + extension_loader('glob') + + # THEN: The `ImportError` should be caught and logged + self.assertTrue(mocked_logger.warning.called) + + def test_extension_loader_os_error(self): + """ + Test the `extension_loader` function when `import_module` raises a `ImportError` + """ + # GIVEN: A mocked `SourceFileLoader` which raises an `OSError` + with patch('openlp.core.common.AppLocation.get_directory', return_value='/app/dir/openlp'), \ + patch.object(common.Path, 'glob', return_value=[Path('/app/dir/openlp/import_dir/file1.py')]), \ + patch('openlp.core.common.importlib.import_module', side_effect=OSError()), \ + patch('openlp.core.common.log') as mocked_logger: + + # WHEN: Calling `extension_loader` + extension_loader('glob') + + # THEN: The `OSError` should be caught and logged + self.assertTrue(mocked_logger.warning.called) + def test_de_hump_conversion(self): """ Test the de_hump function with a class name @@ -83,7 +151,7 @@ class TestCommonFunctions(TestCase): new_string = de_hump(string) # THEN: the new string should be converted to python format - self.assertTrue(new_string == "my_class", 'The class name should have been converted') + self.assertEqual(new_string, "my_class", 'The class name should have been converted') def test_de_hump_static(self): """ @@ -96,7 +164,20 @@ class TestCommonFunctions(TestCase): new_string = de_hump(string) # THEN: the new string should be converted to python format - self.assertTrue(new_string == "my_class", 'The class name should have been preserved') + self.assertEqual(new_string, "my_class", 'The class name should have been preserved') + + def test_path_to_module(self): + """ + Test `path_to_module` when supplied with a `Path` object + """ + # GIVEN: A `Path` object + path = Path('openlp/core/ui/media/webkitplayer.py') + + # WHEN: Calling path_to_module with the `Path` object + result = path_to_module(path) + + # THEN: path_to_module should return the module name + self.assertEqual(result, 'openlp.core.ui.media.webkitplayer') def test_trace_error_handler(self): """ diff --git a/tests/functional/openlp_core_lib/test_htmlbuilder.py b/tests/functional/openlp_core_lib/test_htmlbuilder.py index a095eef95..6e6806733 100644 --- a/tests/functional/openlp_core_lib/test_htmlbuilder.py +++ b/tests/functional/openlp_core_lib/test_htmlbuilder.py @@ -8,7 +8,7 @@ from PyQt5 import QtCore, QtWebKit from openlp.core.common import Settings from openlp.core.lib.htmlbuilder import build_html, build_background_css, build_lyrics_css, build_lyrics_outline_css, \ - build_lyrics_format_css, build_footer_css, webkit_version + build_lyrics_format_css, build_footer_css, webkit_version, build_chords_css from openlp.core.lib.theme import HorizontalType, VerticalType from tests.helpers.testmixin import TestMixin @@ -60,6 +60,29 @@ HTML = """ position: relative; top: -0.3em; } + /* Chords css */ + .chordline { + line-height: 1.0em; + } + .chordline span.chord span { + position: relative; + } + .chordline span.chord span strong { + position: absolute; + top: -0.8em; + left: 0; + font-size: 75%; + font-weight: normal; + line-height: normal; + display: none; + } + .firstchordline { + line-height: 1.0em; + } + .ws { + display: none; + white-space: pre-wrap; + }