Source code for openlp.plugins.songs.lib.importers.opensong

# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4

###############################################################################
# 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                          #
###############################################################################

import logging
import re

from lxml import objectify
from lxml.etree import Error, LxmlError

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

log = logging.getLogger(__name__)


[docs]class OpenSongImport(SongImport): """ Import songs exported from OpenSong The format is described loosely on the `OpenSong File Format Specification <http://www.opensong.org/d/manual/song_file_format_specification>`_ page on the OpenSong web site. However, it doesn't describe the <lyrics> section, so here's an attempt: If the first character of a line is a space, then the rest of that line is lyrics. If it is not a space the following applies. Verses can be expressed in one of 2 ways, either in complete verses, or by line grouping, i.e. grouping all line 1's of a verse together, all line 2's of a verse together, and so on. An example of complete verses:: <lyrics> [v1] List of words Another Line [v2] Some words for the 2nd verse etc... </lyrics> The 'v' in the verse specifiers above can be left out, it is implied. An example of line grouping:: <lyrics> [V] 1List of words 2Some words for the 2nd Verse 1Another Line 2etc... </lyrics> Either or both forms can be used in one song. The number does not necessarily appear at the start of the line. Additionally, the [v1] labels can have either upper or lower case Vs. Other labels can be used also: C Chorus B Bridge 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. For example:: . A7 Bm 1 Some____ Words Lines that contain only whitespace are ignored. | indicates a blank line, and || a new slide. Slide 1 Line 1|Slide 1 Line 2||Slide 2 Line 1|Slide 2 Line 2 Lines beginning with ; are comments The <presentation> tag is used to populate the OpenLP verse display order field. The Author and Copyright tags are also imported to the appropriate places. """ def __init__(self, manager, **kwargs): """ Initialise the class. """ super(OpenSongImport, self).__init__(manager, **kwargs)
[docs] def do_import(self): """ Receive a single file or a list of files to import. """ if not isinstance(self.import_source, list): return 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, 'rb') self.do_import_file(song_file) song_file.close()
[docs] def do_import_file(self, file): """ Process the OpenSong file - pass in a file-like object, not a file path. """ self.set_defaults() try: tree = objectify.parse(file) except (Error, LxmlError): self.log_error(file.name, SongStrings.XMLSyntaxError) log.exception('Error parsing XML') return root = tree.getroot() if root.tag != 'song': self.log_error(file.name, str( translate('SongsPlugin.OpenSongImport', 'Invalid OpenSong song file. Missing song tag.'))) return fields = dir(root) decode = { 'copyright': self.add_copyright, 'ccli': 'ccli_number', 'author': self.parse_author, 'title': 'title', 'aka': 'alternate_title', 'hymn_number': self.parse_song_book_name_and_number, 'user1': self.add_comment, 'user2': self.add_comment, 'user3': self.add_comment } for attr, fn_or_string in list(decode.items()): if attr in fields: ustring = str(root.__getattr__(attr)) if isinstance(fn_or_string, str): if attr in ['ccli']: ustring = ''.join(re.findall('\d+', ustring)) if ustring: setattr(self, fn_or_string, int(ustring)) else: setattr(self, fn_or_string, None) else: setattr(self, fn_or_string, ustring) else: fn_or_string(ustring) # Themes look like "God: Awe/Wonder", but we just want # "Awe" and "Wonder". We use a set to ensure each topic # is only added once, in case it is already there, which # is actually quite likely if the alttheme is set topics = set(self.topics) if 'theme' in fields: theme = str(root.theme) subthemes = theme[theme.find(':') + 1:].split('/') for topic in subthemes: topics.add(topic.strip()) if 'alttheme' in fields: theme = str(root.alttheme) subthemes = theme[theme.find(':') + 1:].split('/') for topic in subthemes: topics.add(topic.strip()) self.topics = list(topics) self.topics.sort() # data storage while importing verses = {} # keep track of verses appearance order our_verse_order = [] # default verse verse_tag = VerseType.tags[VerseType.Verse] verse_num = '1' # for the case where song has several sections with same marker inst = 1 if 'lyrics' in fields: 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 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('['): # drop the square brackets right_bracket = this_line.find(']') content = this_line[1:right_bracket].lower() # have we got any digits? If so, verse number is everything from the digits to the end (openlp does not # have concept of part verses, so just ignore any non integers on the end (including floats)) match = re.match('(\D*)(\d+)', content) if match is not None: verse_tag = match.group(1) verse_num = match.group(2) else: # otherwise we assume number 1 and take the whole prefix as the verse tag verse_tag = content verse_num = '1' verse_index = VerseType.from_loose_input(verse_tag) if verse_tag else 0 verse_tag = VerseType.tags[verse_index] inst = 1 if [verse_tag, verse_num, inst] in our_verse_order and verse_num in verses.get(verse_tag, {}): inst = len(verses[verse_tag][verse_num]) + 1 continue # number at start of line.. it's verse number if this_line[0].isdigit(): verse_num = this_line[0] 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('_', '') this_line = this_line.replace('||', '\n[---]\n') this_line = this_line.strip() # If the line consists solely of a '|', then just use the implicit newline # Otherwise, add a newline for each '|' if this_line == '|': this_line = '' else: this_line = this_line.replace('|', '\n') verses[verse_tag][verse_num][inst].append(this_line) # done parsing # add verses in original order verse_joints = {} for (verse_tag, verse_num, inst) in our_verse_order: lines = '\n'.join(verses[verse_tag][verse_num][inst]) length = 0 while length < len(verse_num) and verse_num[length].isnumeric(): length += 1 verse_def = '{tag}{number}'.format(tag=verse_tag, number=verse_num[:length]) verse_joints[verse_def] = '{verse}\n[---]\n{lines}'.format(verse=verse_joints[verse_def], lines=lines) \ if verse_def in verse_joints else lines # Parsing the dictionary produces the elements in a non-intuitive order. While it "works", it's not a # natural layout should the user come back to edit the song. Instead we sort by the verse type, so that we # get all the verses in order (v1, v2, ...), then the chorus(es), bridge(s), pre-chorus(es) etc. We use a # tuple for the key, since tuples naturally sort in this manner. verse_defs = sorted(verse_joints.keys(), key=lambda verse_def: (VerseType.from_tag(verse_def[0]), int(verse_def[1:]))) for verse_def in verse_defs: lines = verse_joints[verse_def] self.add_verse(lines, verse_def) if not self.verses: self.add_verse('') # figure out the presentation order, if present if 'presentation' in fields and root.presentation: order = str(root.presentation) # We make all the tags in the lyrics lower case, so match that here and then split into a list on the # whitespace. order = order.lower().split() for verse_def in order: match = re.match('(\D*)(\d+.*)', verse_def) if match is not None: verse_tag = match.group(1) verse_num = match.group(2) if not verse_tag: verse_tag = VerseType.tags[VerseType.Verse] else: # Assume it's no.1 if there are no digits verse_tag = verse_def verse_num = '1' verse_index = VerseType.from_loose_input(verse_tag) verse_tag = VerseType.tags[verse_index] verse_def = '{tag}{number}'.format(tag=verse_tag, number=verse_num) if verse_num in verses.get(verse_tag, {}): self.verse_order_list.append(verse_def) else: log.info('Got order {order} but not in verse tags, dropping this item from presentation ' 'order'.format(order=verse_def)) if not self.finish(): self.log_error(file.name)