More work on chords

This commit is contained in:
Tomas Groth 2016-07-25 22:07:07 +02:00
parent 530f119938
commit 611c970eb0
10 changed files with 169 additions and 54 deletions

View File

@ -27,6 +27,7 @@ OpenLP work.
import logging import logging
import os import os
import re import re
import math
from distutils.version import LooseVersion from distutils.version import LooseVersion
from PyQt5 import QtCore, QtGui, Qt, QtWidgets from PyQt5 import QtCore, QtGui, Qt, QtWidgets
@ -314,6 +315,63 @@ def expand_tags(text):
return text 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
"""
slimchars = 'fiíIÍjlĺľrtť.,;/ ()|"\'!:\\'
whitespaces = ''
chordlen = 0
taillen = 0
chord = match.group(1)
tail = match.group(2)
remainder = match.group(3)
end = match.group(4)
print('chord: %s, tail: %s, remainder: %s, end: %s' % (chord, tail, remainder, end))
for chord_char in chord:
if chord_char not in slimchars:
chordlen += 2
else:
chordlen += 1
for tail_char in tail:
if tail_char not in slimchars:
taillen += 2
else:
taillen += 1
for remainder_char in remainder:
if remainder_char not in slimchars:
taillen += 2
else:
taillen += 1
if chordlen >= taillen and end is None:
if tail:
if not remainder:
print()
for c in range(math.ceil((chordlen - taillen) / 2) + 1):
whitespaces += '_'
else:
for c in range(chordlen - taillen + 2):
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:
whitespaces = '<span class="ws">' + whitespaces + '</span>'
return '<span class="chord"><span><strong>' + chord + '</strong></span></span>' + tail + whitespaces + remainder
def expand_chords(text): def expand_chords(text):
""" """
Expand ChordPro tags Expand ChordPro tags
@ -322,21 +380,22 @@ def expand_chords(text):
""" """
text_lines = text.split('{br}') text_lines = text.split('{br}')
expanded_text_lines = [] expanded_text_lines = []
chords_on_last_line = False chords_on_prev_line = False
for line in text_lines: 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 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 '[' in line and ']' in line:
if chords_on_last_line: if chords_on_prev_line:
new_line = '<span class="chordline">' new_line = '<span class="chordline">'
else: else:
new_line = '<span class="chordline firstchordline">' new_line = '<span class="chordline firstchordline">'
chords_on_last_line = True chords_on_prev_line = True
new_line += re.sub(r'(.*?)\[(.+?)\](.*?)', new_line += re.sub(r'\[(.+?)\]([\u0080-\uFFFF,\w]*)([\u0080-\uFFFF,\w,\s,\.,\,,\!,\?,\;,\:,\|,\",\',\-,\_]*)(\Z)?', expand_and_align_chords_in_line, line)
r'\1<span class="chord"><span><strong>\2</strong></span></span>\3', line) #new_line += re.sub(r'(.*?)\[(.+?)\](.*?)',
# r'\1<span class="chord"><span><strong>\2</strong></span></span>\3', line)
new_line += '</span>' new_line += '</span>'
expanded_text_lines.append(new_line) expanded_text_lines.append(new_line)
else: else:
chords_on_last_line = False chords_on_prev_line = False
expanded_text_lines.append(line) expanded_text_lines.append(line)
return '{br}'.join(expanded_text_lines) return '{br}'.join(expanded_text_lines)

View File

@ -43,8 +43,8 @@
#plus, #plus,
#minus { #minus {
display: inline-block; display: inline-block;
width: 3vw; width: 1.2em;
line-height: 3vw; line-height: 1.2em;
vertical-align: middle; vertical-align: middle;
color: white; color: white;
background-color: gray; background-color: gray;

View File

@ -178,32 +178,43 @@ window.OpenLP = {
var transposeValue = getTransposeValue(OpenLP.currentSlides[0].text.split("\n")[0]); 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 chordclass=/class="[a-z\s]*chord[a-z\s]*"\s*style="display:\s?none"/g;
var chordclassshow='class="chord"'; var chordclassshow='class="chord"';
var regchord=/<span class="chord"><span><strong>([\(\w#b♭\+\*\d/\)-]+)<\/strong><\/span><\/span>([\u0080-\uFFFF,\w]*)([\u0080-\uFFFF,\w,\s,\.,\,,\!,\?,\;,\:,\|,\",\',\-,\_]*)(<br>)?/g; var regchord=/<span class="chord"><span><strong>([\(\w#b♭\+\*\d/\)-]+)<\/strong><\/span><\/span>([\u0080-\uFFFF,\w]*)(<span class="ws">.+?<\/span>)?([\u0080-\uFFFF,\w,\s,\.,\,,\!,\?,\;,\:,\|,\",\',\-,\_]*)(<br>)?/g;
var replaceChords=function(mstr,$1,$2,$3,$4) { // 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 v='', w=''; var replaceChords=function(mstr,$chord,$tail,$skips,$remainder,$end) {
var $1len = 0, $2len = 0, slimchars='fiíIÍjlĺľrtť.,;/ ()|"\'!:\\'; var v='';
$1 = transposeChord($1, transposeValue, OpenLP.chordNotation); var w='';
for (var i = 0; i < $1.length; i++) if (slimchars.indexOf($1.charAt(i)) === -1) {$1len += 2;} else {$1len += 1;} var $chordlen = 0;
for (var i = 0; i < $2.length; i++) if (slimchars.indexOf($2.charAt(i)) === -1) {$2len += 2;} else {$2len += 1;} var $taillen = 0;
for (var i = 0; i < $3.length; i++) if (slimchars.indexOf($2.charAt(i)) === -1) {$2len += 2;} else {$2len += 1;} var slimchars='fiíIÍjlĺľrtť.,;/ ()|"\'!:\\';
if ($1len >= $2len && !$4) { // Transpose chord as dictated by the transpose value in local storage
if ($2.length){ $chord = transposeChord($chord, transposeValue, OpenLP.chordNotation);
if (!$3.length) { // Replace any padding '_' added to tail
for (c = 0; c < Math.ceil(($1len - $2len) / 2) + 1; c++) {w += '_';} $tail = $tail.replace(/_+$/, '')
console.log('chord: ' +$chord +', tail: ' + $tail + ', remainder: ' + $remainder +', end: ' + $end +', match: ' + mstr)
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 { } else {
for (c = 0; c < $1len - $2len + 2; c++) {w += '&nbsp;';} for (c = 0; c < $chordlen - $taillen + 2; c++) {w += '&nbsp;';}
} }
} else { } else {
if (!$3.length) { if (!$remainder.length) {
for (c = 0; c < Math.floor(($1len - $2len) / 2) + 1; c++) {w += '_';} for (c = 0; c < Math.floor(($chordlen - $taillen) / 2) + 1; c++) {w += '_';}
} else { } else {
for (c = 0; c < $1len - $2len + 1; c++) {w += '&nbsp;';} for (c = 0; c < $chordlen - $taillen + 1; c++) {w += '&nbsp;';}
} }
}; };
} else { } else {
if (!$2 && $3.charAt(0) == ' ') {for (c = 0; c < $1len; c++) {w += '&nbsp;';}} if (!$tail && $remainder.charAt(0) == ' ') {for (c = 0; c < $chordlen; c++) {w += '&nbsp;';}}
} }
return $.grep(['<span class="chord"><span><strong>', $1, '</strong></span>', $2, w, $3, '</span>', $4], Boolean).join(''); if (w!='') {
w='<span class="ws">' + w + '</span>';
}
return $.grep(['<span class="chord"><span><strong>', $chord, '</strong></span></span>', $tail, w, $remainder, $end], Boolean).join('');
}; };
$("#verseorder span").removeClass("currenttag"); $("#verseorder span").removeClass("currenttag");
$("#tag" + OpenLP.currentTags[OpenLP.currentSlide]).addClass("currenttag"); $("#tag" + OpenLP.currentTags[OpenLP.currentSlide]).addClass("currenttag");

View File

@ -26,7 +26,7 @@ import re
from lxml import objectify from lxml import objectify
from lxml.etree import Error, LxmlError 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 import VerseType
from openlp.plugins.songs.lib.importers.songimport import SongImport from openlp.plugins.songs.lib.importers.songimport import SongImport
from openlp.plugins.songs.lib.ui import SongStrings from openlp.plugins.songs.lib.ui import SongStrings
@ -87,7 +87,7 @@ class OpenSongImport(SongImport):
All verses are imported and tagged appropriately. 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 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 . A7 Bm
1 Some____ Words 1 Some____ Words
@ -195,14 +195,34 @@ class OpenSongImport(SongImport):
lyrics = str(root.lyrics) lyrics = str(root.lyrics)
else: else:
lyrics = '' lyrics = ''
chords = []
for this_line in lyrics.split('\n'): for this_line in lyrics.split('\n'):
if not this_line.strip(): if not this_line.strip():
continue continue
# skip this line if it is a comment # skip this line if it is a comment
if this_line.startswith(';'): if this_line.startswith(';'):
continue continue
# skip guitar chords and page and column breaks # skip page and column breaks
if this_line.startswith('.') or this_line.startswith('---') or this_line.startswith('-!!'): 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 continue
# verse/chorus/etc. marker # verse/chorus/etc. marker
if this_line.startswith('['): if this_line.startswith('['):
@ -228,12 +248,19 @@ class OpenSongImport(SongImport):
# number at start of line.. it's verse number # number at start of line.. it's verse number
if this_line[0].isdigit(): if this_line[0].isdigit():
verse_num = this_line[0] verse_num = this_line[0]
this_line = this_line[1:].strip() this_line = this_line[1:]
verses.setdefault(verse_tag, {}) verses.setdefault(verse_tag, {})
verses[verse_tag].setdefault(verse_num, {}) verses[verse_tag].setdefault(verse_num, {})
if inst not in verses[verse_tag][verse_num]: if inst not in verses[verse_tag][verse_num]:
verses[verse_tag][verse_num][inst] = [] verses[verse_tag][verse_num][inst] = []
our_verse_order.append([verse_tag, verse_num, inst]) our_verse_order.append([verse_tag, verse_num, inst])
# If chords exists insert them
if 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 # Tidy text and remove the ____s from extended words
this_line = self.tidy_text(this_line) this_line = self.tidy_text(this_line)
this_line = this_line.replace('_', '') this_line = this_line.replace('_', '')

View File

@ -26,8 +26,9 @@ exproted from Lyrix."""
import logging import logging
import json import json
import os 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.importers.songimport import SongImport
from openlp.plugins.songs.lib.db import AuthorType from openlp.plugins.songs.lib.db import AuthorType
@ -113,7 +114,11 @@ class VideoPsalmImport(SongImport):
if 'Memo3' in song: if 'Memo3' in song:
self.add_comment(song['Memo3']) self.add_comment(song['Memo3'])
for verse in song['Verses']: for verse in song['Verses']:
self.add_verse(verse['Text'], 'v') verse_text = verse['Text']
# Strip out chords if set up to
if not Settings().value('songs/disable chords import'):
verse_text = re.sub(r'\[\w.*?\]', '', verse_text)
self.add_verse(verse_text, 'v')
if not self.finish(): if not self.finish():
self.log_error('Could not import {title}'.format(title=self.title)) self.log_error('Could not import {title}'.format(title=self.title))
except Exception as e: except Exception as e:

View File

@ -61,7 +61,7 @@ import re
from lxml import etree, objectify 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.common.versionchecker import get_application_version
from openlp.core.lib import FormattingTags from openlp.core.lib import FormattingTags
from openlp.plugins.songs.lib import VerseType, clean_song from openlp.plugins.songs.lib import VerseType, clean_song
@ -154,7 +154,7 @@ class OpenLyrics(object):
OpenLP does not support the attribute *lang*. OpenLP does not support the attribute *lang*.
``<chord>`` ``<chord>``
This property is not supported. This property is fully supported.
``<comments>`` ``<comments>``
The ``<comments>`` property is fully supported. But comments in lyrics are not supported. The ``<comments>`` property is fully supported. But comments in lyrics are not supported.
@ -334,7 +334,7 @@ class OpenLyrics(object):
:return: the lyrics with the converted chords :return: the lyrics with the converted chords
""" """
# Process chords. # Process chords.
new_text = re.sub(r'\[(..?.?)\]', r'<chord name="\1"/>', text) new_text = re.sub(r'\[(\w.*?)\]', r'<chord name="\1"/>', text)
return new_text return new_text
def _get_missing_tags(self, text): def _get_missing_tags(self, text):
@ -607,8 +607,7 @@ class OpenLyrics(object):
def _process_lines_mixed_content(self, element, newlines=True): 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 Converts the xml text with mixed content to OpenLP representation. Chords and formatting tags are converted.
converted.
:param element: The property object (lxml.etree.Element). :param element: The property object (lxml.etree.Element).
:param newlines: The switch to enable/disable processing of line breaks <br/>. The <br/> is used since :param newlines: The switch to enable/disable processing of line breaks <br/>. The <br/> is used since
@ -620,13 +619,14 @@ class OpenLyrics(object):
# TODO: Verify format() with template variables # TODO: Verify format() with template variables
if element.tag == NSMAP % 'comment': if element.tag == NSMAP % 'comment':
if element.tail: if element.tail:
# Append tail text at chord element. # Append tail text at comment element.
text += element.tail text += element.tail
return text return text
# Convert chords to ChordPro format which OpenLP uses internally # Convert chords to ChordPro format which OpenLP uses internally
# TODO: Verify format() with template variables # TODO: Verify format() with template variables
elif element.tag == NSMAP % 'chord': elif element.tag == NSMAP % 'chord':
text += '[{chord}]'.format(chord=element.get('name')) if not Settings().value('songs/disable chords import'):
text += '[{chord}]'.format(chord=element.get('name'))
if element.tail: if element.tail:
# Append tail text at chord element. # Append tail text at chord element.
text += element.tail text += element.tail
@ -679,7 +679,7 @@ class OpenLyrics(object):
text = self._process_lines_mixed_content(element) text = self._process_lines_mixed_content(element)
# OpenLyrics version <= 0.7 contains <line> elements to represent lines. First child element is tested. # OpenLyrics version <= 0.7 contains <line> elements to represent lines. First child element is tested.
else: else:
# Loop over the "line" elements removing comments and chords. # Loop over the "line" elements removing comments
for line in element: for line in element:
# Skip comment lines. # Skip comment lines.
# TODO: Verify format() with template variables # TODO: Verify format() with template variables

View File

@ -42,10 +42,16 @@ class TestOpenSongFileImport(SongImportTestHelper):
self.importer_module_name = 'opensong' self.importer_module_name = 'opensong'
super(TestOpenSongFileImport, self).__init__(*args, **kwargs) super(TestOpenSongFileImport, self).__init__(*args, **kwargs)
def test_song_import(self): @patch('openlp.plugins.songs.lib.importers.opensong.Settings')
def test_song_import(self, mocked_settings):
""" """
Test that loading an OpenSong file works correctly on various files Test that loading an OpenSong file works correctly on various files
""" """
# Mock out the settings - always return False
mocked_returned_settings = MagicMock()
mocked_returned_settings.value.return_value = False
mocked_settings.return_value = mocked_returned_settings
# Do the test import
self.file_import([os.path.join(TEST_PATH, 'Amazing Grace')], self.file_import([os.path.join(TEST_PATH, 'Amazing Grace')],
self.load_external_result_data(os.path.join(TEST_PATH, 'Amazing Grace.json'))) self.load_external_result_data(os.path.join(TEST_PATH, 'Amazing Grace.json')))
self.file_import([os.path.join(TEST_PATH, 'Beautiful Garden Of Prayer')], self.file_import([os.path.join(TEST_PATH, 'Beautiful Garden Of Prayer')],

View File

@ -25,6 +25,7 @@ This module contains tests for the VideoPsalm song importer.
import os import os
from tests.helpers.songfileimport import SongImportTestHelper from tests.helpers.songfileimport import SongImportTestHelper
from tests.functional import patch, MagicMock
TEST_PATH = os.path.abspath( TEST_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), '..', '..', '..', 'resources', 'videopsalmsongs')) os.path.join(os.path.dirname(__file__), '..', '..', '..', 'resources', 'videopsalmsongs'))
@ -37,9 +38,15 @@ class TestVideoPsalmFileImport(SongImportTestHelper):
self.importer_module_name = 'videopsalm' self.importer_module_name = 'videopsalm'
super(TestVideoPsalmFileImport, self).__init__(*args, **kwargs) super(TestVideoPsalmFileImport, self).__init__(*args, **kwargs)
def test_song_import(self): @patch('openlp.plugins.songs.lib.importers.videopsalm.Settings')
def test_song_import(self, mocked_settings):
""" """
Test that loading an VideoPsalm file works correctly on various files Test that loading an VideoPsalm file works correctly on various files
""" """
# Mock out the settings - always return False
mocked_returned_settings = MagicMock()
mocked_returned_settings.value.return_value = False
mocked_settings.return_value = mocked_returned_settings
# Do the test import
self.file_import(os.path.join(TEST_PATH, 'videopsalm-as-safe-a-stronghold.json'), self.file_import(os.path.join(TEST_PATH, 'videopsalm-as-safe-a-stronghold.json'),
self.load_external_result_data(os.path.join(TEST_PATH, 'as-safe-a-stronghold.json'))) self.load_external_result_data(os.path.join(TEST_PATH, 'as-safe-a-stronghold.json')))

View File

@ -19,23 +19,23 @@
"verse_order_list": [], "verse_order_list": [],
"verses": [ "verses": [
[ [
"Amazing grace! How sweet the sound!\nThat saved a wretch like me!\nI once was lost, but now am found;\nWas blind, but now I see.", "A[D]mazing [D7]grace! How [G]sweet the [D]sound!\nThat [Bm]saved a [E]wretch like [A]me![A7]\nI [D]once was [D7]lost, but [G]now am [D]found;\nWas [Bm]blind, but [A]now I [G]see.[D]",
"v1" "v1"
], ],
[ [
"'Twas grace that taught my heart to fear,\nAnd grace my fears relieved.\nHow precious did that grace appear,\nThe hour I first believed.", "'Twas [D]grace that [D7]taught my [G]heart to [D]fear,\nAnd [Bm]grace my [E]fears re[A]lieved.[A7]\nHow [D]precious [D7]did that [G]grace ap[D]pear,\nThe [Bm]hour I [A]first be[G]lieved.[D]",
"v2" "v2"
], ],
[ [
"The Lord has promised good to me,\nHis Word my hope secures.\nHe will my shield and portion be\nAs long as life endures.", "The [D]Lord has [D7]promised [G]good to [D]me,\nHis [Bm]Word my [E]hope se[A]cures.[A7]\nHe [D]will my [D7]shield and [G]portion [D]be\nAs [Bm]long as [A]life en[G]dures.[D]",
"v3" "v3"
], ],
[ [
"Thro' many dangers, toils and snares\nI have already come.\n'Tis grace that brought me safe thus far,\nAnd grace will lead me home.", "Thro' [D]many [D7]dangers, [G]toils and [D]snares\nI [Bm]have al[E]ready [A]come.[A7]\n'Tis [D]grace that [D7]brought me [G]safe thus [D]far,\nAnd [Bm]grace will [A]lead me [G]home.[D]",
"v4" "v4"
], ],
[ [
"When we've been there ten thousand years,\nBright shining as the sun,\nWe've no less days to sing God's praise,\nThan when we first begun.", "When [D]we've been [D7]there ten [G]thousand [D]years,\nBright [Bm]shining [E]as the [A]sun,[A7]\nWe've [D]no less [D7]days to [G]sing God's [D]praise,\nThan [Bm]when we [A]first be[G]gun.[D]",
"v5" "v5"
] ]
] ]

View File

@ -19,24 +19,24 @@
"verse_order_list": [], "verse_order_list": [],
"verses": [ "verses": [
[ [
"Amazing grace! How sweet the sound!\nThat saved a wretch like me!\nI once was lost, but now am found;\nWas blind, but now I see.", "A[D]mazing [D7]grace! How [G]sweet the [D]sound!\nThat [Bm]saved a [E]wretch like [A]me![A7]\nI [D]once was [D7]lost, but [G]now am [D]found;\nWas [Bm]blind, but [A]now I [G]see.[D]",
"v1" "v1"
], ],
[ [
"'Twas grace that taught my heart to fear,\nAnd grace my fears relieved.\nHow precious did that grace appear,\nThe hour I first believed.", "'Twas [D]grace that [D7]taught my [G]heart to [D]fear,\nAnd [Bm]grace my [E]fears re[A]lieved.[A7]\nHow [D]precious [D7]did that [G]grace ap[D]pear,\nThe [Bm]hour I [A]first be[G]lieved.[D]",
"v2" "v2"
], ],
[ [
"The Lord has promised good to me,\nHis Word my hope secures.\nHe will my shield and portion be\nAs long as life endures.", "The [D]Lord has [D7]promised [G]good to [D]me,\nHis [Bm]Word my [E]hope se[A]cures.[A7]\nHe [D]will my [D7]shield and [G]portion [D]be\nAs [Bm]long as [A]life en[G]dures.[D]",
"v3" "v3"
], ],
[ [
"Thro' many dangers, toils and snares\nI have already come.\n'Tis grace that brought me safe thus far,\nAnd grace will lead me home.", "Thro' [D]many [D7]dangers, [G]toils and [D]snares\nI [Bm]have al[E]ready [A]come.[A7]\n'Tis [D]grace that [D7]brought me [G]safe thus [D]far,\nAnd [Bm]grace will [A]lead me [G]home.[D]",
"v4" "v4"
], ],
[ [
"When we've been there ten thousand years,\nBright shining as the sun,\nWe've no less days to sing God's praise,\nThan when we first begun.", "When [D]we've been [D7]there ten [G]thousand [D]years,\nBright [Bm]shining [E]as the [A]sun,[A7]\nWe've [D]no less [D7]days to [G]sing God's [D]praise,\nThan [Bm]when we [A]first be[G]gun.[D]",
"v5" "v5"
] ]
] ]
} }