Merge lp:~raoul-snyman/openlp/fix-importers-2.4 into lp:openlp/2.4

Proposed by Raoul Snyman
Status: Merged
Merged at revision: 2675
Proposed branch: lp:~raoul-snyman/openlp/fix-importers-2.4
Merge into: lp:openlp/2.4
Diff against target: 420 lines (+149/-54)
9 files modified
openlp/core/__init__.py (+1/-1)
openlp/plugins/songs/lib/importers/openlp.py (+8/-2)
openlp/plugins/songs/lib/importers/presentationmanager.py (+33/-10)
openlp/plugins/songs/lib/songselect.py (+16/-3)
tests/functional/openlp_core/test_init.py (+32/-18)
tests/functional/openlp_plugins/remotes/test_router.py (+9/-5)
tests/functional/openlp_plugins/songs/test_songselect.py (+42/-7)
tests/interfaces/openlp_plugins/bibles/forms/test_bibleimportform.py (+7/-7)
tests/resources/presentationmanagersongs/Agnus Dei.sng (+1/-1)
To merge this branch: bzr merge lp:~raoul-snyman/openlp/fix-importers-2.4
Reviewer Review Type Date Requested Status
Tim Bentley Approve
Review via email: mp+318838@code.launchpad.net

This proposal supersedes a proposal from 2017-03-02.

Description of the change

- Fix SongSelect so that it detects the login URL
- Fix PresentationManager importer to handle weird XML
- Pull in OpenLP song importer fixes from Olli's branch

To post a comment you must log in.
Revision history for this message
Tim Bentley (trb143) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'openlp/core/__init__.py'
--- openlp/core/__init__.py 2017-01-22 20:01:53 +0000
+++ openlp/core/__init__.py 2017-03-02 21:00:50 +0000
@@ -317,7 +317,7 @@
317 return QtWidgets.QApplication.event(self, event)317 return QtWidgets.QApplication.event(self, event)
318318
319319
320def parse_options(args):320def parse_options(args=None):
321 """321 """
322 Parse the command line arguments322 Parse the command line arguments
323323
324324
=== modified file 'openlp/plugins/songs/lib/importers/openlp.py'
--- openlp/plugins/songs/lib/importers/openlp.py 2016-12-31 11:05:48 +0000
+++ openlp/plugins/songs/lib/importers/openlp.py 2017-03-02 21:00:50 +0000
@@ -221,11 +221,17 @@
221 if not existing_book:221 if not existing_book:
222 existing_book = Book.populate(name=entry.songbook.name, publisher=entry.songbook.publisher)222 existing_book = Book.populate(name=entry.songbook.name, publisher=entry.songbook.publisher)
223 new_song.add_songbook_entry(existing_book, entry.entry)223 new_song.add_songbook_entry(existing_book, entry.entry)
224 elif song.book:224 elif hasattr(song, 'book') and song.book:
225 existing_book = self.manager.get_object_filtered(Book, Book.name == song.book.name)225 existing_book = self.manager.get_object_filtered(Book, Book.name == song.book.name)
226 if not existing_book:226 if not existing_book:
227 existing_book = Book.populate(name=song.book.name, publisher=song.book.publisher)227 existing_book = Book.populate(name=song.book.name, publisher=song.book.publisher)
228 new_song.add_songbook_entry(existing_book, '')228 # Get the song_number from "songs" table "song_number" field. (This is db structure from 2.2.1)
229 # If there's a number, add it to the song, otherwise it will be "".
230 existing_number = song.song_number if hasattr(song, 'song_number') else ''
231 if existing_number:
232 new_song.add_songbook_entry(existing_book, existing_number)
233 else:
234 new_song.add_songbook_entry(existing_book, "")
229 # Find or create all the media files and add them to the new song object235 # Find or create all the media files and add them to the new song object
230 if has_media_files and song.media_files:236 if has_media_files and song.media_files:
231 for media_file in song.media_files:237 for media_file in song.media_files:
232238
=== modified file 'openlp/plugins/songs/lib/importers/presentationmanager.py'
--- openlp/plugins/songs/lib/importers/presentationmanager.py 2016-12-31 11:05:48 +0000
+++ openlp/plugins/songs/lib/importers/presentationmanager.py 2017-03-02 21:00:50 +0000
@@ -23,7 +23,7 @@
23The :mod:`presentationmanager` module provides the functionality for importing23The :mod:`presentationmanager` module provides the functionality for importing
24Presentationmanager song files into the current database.24Presentationmanager song files into the current database.
25"""25"""
2626import logging
27import os27import os
28import re28import re
29import chardet29import chardet
@@ -32,6 +32,8 @@
32from openlp.core.ui.wizard import WizardStrings32from openlp.core.ui.wizard import WizardStrings
33from .songimport import SongImport33from .songimport import SongImport
3434
35log = logging.getLogger(__name__)
36
3537
36class PresentationManagerImport(SongImport):38class PresentationManagerImport(SongImport):
37 """39 """
@@ -62,15 +64,36 @@
62 'File is not in XML-format, which is the only format supported.'))64 'File is not in XML-format, which is the only format supported.'))
63 continue65 continue
64 root = objectify.fromstring(etree.tostring(tree))66 root = objectify.fromstring(etree.tostring(tree))
65 self.process_song(root)67 self.process_song(root, file_path)
6668
67 def process_song(self, root):69 def _get_attr(self, elem, name):
70 """
71 Due to PresentationManager's habit of sometimes capitilising the first letter of an element, we have to do
72 some gymnastics.
73 """
74 if hasattr(elem, name):
75 log.debug('%s: %s', name, getattr(elem, name))
76 return str(getattr(elem, name))
77 name = name[0].upper() + name[1:]
78 if hasattr(elem, name):
79 log.debug('%s: %s', name, getattr(elem, name))
80 return str(getattr(elem, name))
81 else:
82 return ''
83
84 def process_song(self, root, file_path):
68 self.set_defaults()85 self.set_defaults()
69 self.title = str(root.attributes.title)86 attrs = None
70 self.add_author(str(root.attributes.author))87 if hasattr(root, 'attributes'):
71 self.copyright = str(root.attributes.copyright)88 attrs = root.attributes
72 self.ccli_number = str(root.attributes.ccli_number)89 elif hasattr(root, 'Attributes'):
73 self.comments = str(root.attributes.comments)90 attrs = root.Attributes
91 if attrs is not None:
92 self.title = self._get_attr(root.attributes, 'title')
93 self.add_author(self._get_attr(root.attributes, 'author'))
94 self.copyright = self._get_attr(root.attributes, 'copyright')
95 self.ccli_number = self._get_attr(root.attributes, 'ccli_number')
96 self.comments = str(root.attributes.comments) if hasattr(root.attributes, 'comments') else None
74 verse_order_list = []97 verse_order_list = []
75 verse_count = {}98 verse_count = {}
76 duplicates = []99 duplicates = []
@@ -102,4 +125,4 @@
102125
103 self.verse_order_list = verse_order_list126 self.verse_order_list = verse_order_list
104 if not self.finish():127 if not self.finish():
105 self.log_error(self.import_source)128 self.log_error(os.path.basename(file_path))
106129
=== modified file 'openlp/plugins/songs/lib/songselect.py'
--- openlp/plugins/songs/lib/songselect.py 2016-12-31 11:05:48 +0000
+++ openlp/plugins/songs/lib/songselect.py 2017-03-02 21:00:50 +0000
@@ -47,7 +47,7 @@
47BASE_URL = 'https://songselect.ccli.com'47BASE_URL = 'https://songselect.ccli.com'
48LOGIN_PAGE = 'https://profile.ccli.com/account/signin?appContext=SongSelect&returnUrl=' \48LOGIN_PAGE = 'https://profile.ccli.com/account/signin?appContext=SongSelect&returnUrl=' \
49 'https%3a%2f%2fsongselect.ccli.com%2f'49 'https%3a%2f%2fsongselect.ccli.com%2f'
50LOGIN_URL = 'https://profile.ccli.com/'50LOGIN_URL = 'https://profile.ccli.com'
51LOGOUT_URL = BASE_URL + '/account/logout'51LOGOUT_URL = BASE_URL + '/account/logout'
52SEARCH_URL = BASE_URL + '/search/results'52SEARCH_URL = BASE_URL + '/search/results'
5353
@@ -97,14 +97,27 @@
97 'password': password,97 'password': password,
98 'RememberMe': 'false'98 'RememberMe': 'false'
99 })99 })
100 login_form = login_page.find('form')
101 if login_form:
102 login_url = login_form.attrs['action']
103 else:
104 login_url = '/Account/SignIn'
105 if not login_url.startswith('http'):
106 if login_url[0] != '/':
107 login_url = '/' + login_url
108 login_url = LOGIN_URL + login_url
100 try:109 try:
101 posted_page = BeautifulSoup(self.opener.open(LOGIN_URL, data.encode('utf-8')).read(), 'lxml')110 posted_page = BeautifulSoup(self.opener.open(login_url, data.encode('utf-8')).read(), 'lxml')
102 except (TypeError, URLError) as error:111 except (TypeError, URLError) as error:
103 log.exception('Could not login to SongSelect, %s', error)112 log.exception('Could not login to SongSelect, %s', error)
104 return False113 return False
105 if callback:114 if callback:
106 callback()115 callback()
107 return posted_page.find('input', id='SearchText') is not None116 if posted_page.find('input', id='SearchText') is not None:
117 return True
118 else:
119 log.debug(posted_page)
120 return False
108121
109 def logout(self):122 def logout(self):
110 """123 """
111124
=== added file 'tests/functional/openlp_core/__init__.py'
=== modified file 'tests/functional/openlp_core/test_init.py'
--- tests/functional/openlp_core/test_init.py 2016-12-31 11:05:48 +0000
+++ tests/functional/openlp_core/test_init.py 2017-03-02 21:00:50 +0000
@@ -22,12 +22,12 @@
2222
23import sys23import sys
24from unittest import TestCase24from unittest import TestCase
2525from unittest.mock import MagicMock, patch
26from openlp.core import parse_options26
27from tests.helpers.testmixin import TestMixin27from openlp.core import OpenLP, parse_options
2828
2929
30class TestInitFunctions(TestMixin, TestCase):30class TestInitFunctions(TestCase):
3131
32 def parse_options_basic_test(self):32 def parse_options_basic_test(self):
33 """33 """
@@ -116,7 +116,7 @@
116116
117 def parse_options_file_and_debug_test(self):117 def parse_options_file_and_debug_test(self):
118 """118 """
119 Test the parse options process works with a file119 Test the parse options process works with a file and the debug log level
120120
121 """121 """
122 # GIVEN: a a set of system arguments.122 # GIVEN: a a set of system arguments.
@@ -131,14 +131,28 @@
131 self.assertEquals(args.style, None, 'There are no style flags to be processed')131 self.assertEquals(args.style, None, 'There are no style flags to be processed')
132 self.assertEquals(args.rargs, 'dummy_temp', 'The service file should not be blank')132 self.assertEquals(args.rargs, 'dummy_temp', 'The service file should not be blank')
133133
134 def parse_options_two_files_test(self):134
135 """135class TestOpenLP(TestCase):
136 Test the parse options process works with a file136 """
137137 Test the OpenLP app class
138 """138 """
139 # GIVEN: a a set of system arguments.139 @patch('openlp.core.QtWidgets.QApplication.exec')
140 sys.argv[1:] = ['dummy_temp', 'dummy_temp2']140 def test_exec(self, mocked_exec):
141 # WHEN: We we parse them to expand to options141 """
142 args = parse_options()142 Test the exec method
143 # THEN: the following fields will have been extracted.143 """
144 self.assertEquals(args, None, 'The args should be None')144 # GIVEN: An app
145 app = OpenLP([])
146 app.shared_memory = MagicMock()
147 mocked_exec.return_value = False
148
149 # WHEN: exec() is called
150 result = app.exec()
151
152 # THEN: The right things should be called
153 assert app.is_event_loop_active is True
154 mocked_exec.assert_called_once_with()
155 app.shared_memory.detach.assert_called_once_with()
156 assert result is False
157
158 del app
145159
=== modified file 'tests/functional/openlp_plugins/remotes/test_router.py'
--- tests/functional/openlp_plugins/remotes/test_router.py 2016-12-31 11:05:48 +0000
+++ tests/functional/openlp_plugins/remotes/test_router.py 2017-03-02 21:00:50 +0000
@@ -25,11 +25,11 @@
25import os25import os
26import urllib.request26import urllib.request
27from unittest import TestCase27from unittest import TestCase
28from unittest.mock import MagicMock, patch, mock_open
2829
29from openlp.core.common import Settings, Registry30from openlp.core.common import Settings, Registry
30from openlp.core.ui import ServiceManager31from openlp.core.ui import ServiceManager
31from openlp.plugins.remotes.lib.httpserver import HttpRouter32from openlp.plugins.remotes.lib.httpserver import HttpRouter
32from tests.functional import MagicMock, patch, mock_open
33from tests.helpers.testmixin import TestMixin33from tests.helpers.testmixin import TestMixin
3434
35__default_settings__ = {35__default_settings__ = {
@@ -313,11 +313,13 @@
313 with patch.object(self.service_manager, 'setup_ui'), \313 with patch.object(self.service_manager, 'setup_ui'), \
314 patch.object(self.router, 'do_json_header'):314 patch.object(self.router, 'do_json_header'):
315 self.service_manager.bootstrap_initialise()315 self.service_manager.bootstrap_initialise()
316 self.app.processEvents()316 # Not sure why this is here, it doesn't make sense in the test
317 # self.app.processEvents()
317318
318 # WHEN: Remote next is received319 # WHEN: Remote next is received
319 self.router.service(action='next')320 self.router.service(action='next')
320 self.app.processEvents()321 # Not sure why this is here, it doesn't make sense in the test
322 # self.app.processEvents()
321323
322 # THEN: service_manager.next_item() should have been called324 # THEN: service_manager.next_item() should have been called
323 self.assertTrue(mocked_next_item.called, 'next_item() should have been called in service_manager')325 self.assertTrue(mocked_next_item.called, 'next_item() should have been called in service_manager')
@@ -334,11 +336,13 @@
334 with patch.object(self.service_manager, 'setup_ui'), \336 with patch.object(self.service_manager, 'setup_ui'), \
335 patch.object(self.router, 'do_json_header'):337 patch.object(self.router, 'do_json_header'):
336 self.service_manager.bootstrap_initialise()338 self.service_manager.bootstrap_initialise()
337 self.app.processEvents()339 # Not sure why this is here, it doesn't make sense in the test
340 # self.app.processEvents()
338341
339 # WHEN: Remote next is received342 # WHEN: Remote next is received
340 self.router.service(action='previous')343 self.router.service(action='previous')
341 self.app.processEvents()344 # Not sure why this is here, it doesn't make sense in the test
345 # self.app.processEvents()
342346
343 # THEN: service_manager.next_item() should have been called347 # THEN: service_manager.next_item() should have been called
344 self.assertTrue(mocked_previous_item.called, 'previous_item() should have been called in service_manager')348 self.assertTrue(mocked_previous_item.called, 'previous_item() should have been called in service_manager')
345349
=== modified file 'tests/functional/openlp_plugins/songs/test_songselect.py'
--- tests/functional/openlp_plugins/songs/test_songselect.py 2016-12-31 11:05:48 +0000
+++ tests/functional/openlp_plugins/songs/test_songselect.py 2017-03-02 21:00:50 +0000
@@ -32,7 +32,7 @@
32from openlp.core import Registry32from openlp.core import Registry
33from openlp.plugins.songs.forms.songselectform import SongSelectForm, SearchWorker33from openlp.plugins.songs.forms.songselectform import SongSelectForm, SearchWorker
34from openlp.plugins.songs.lib import Song34from openlp.plugins.songs.lib import Song
35from openlp.plugins.songs.lib.songselect import SongSelectImport, LOGOUT_URL, BASE_URL35from openlp.plugins.songs.lib.songselect import SongSelectImport, LOGIN_PAGE, LOGOUT_URL, BASE_URL
3636
37from tests.functional import MagicMock, patch, call37from tests.functional import MagicMock, patch, call
38from tests.helpers.songfileimport import SongImportTestHelper38from tests.helpers.songfileimport import SongImportTestHelper
@@ -81,7 +81,7 @@
8181
82 # THEN: callback was called 3 times, open was called twice, find was called twice, and False was returned82 # THEN: callback was called 3 times, open was called twice, find was called twice, and False was returned
83 self.assertEqual(2, mock_callback.call_count, 'callback should have been called 3 times')83 self.assertEqual(2, mock_callback.call_count, 'callback should have been called 3 times')
84 self.assertEqual(1, mocked_login_page.find.call_count, 'find should have been called twice')84 self.assertEqual(2, mocked_login_page.find.call_count, 'find should have been called twice')
85 self.assertEqual(2, mocked_opener.open.call_count, 'opener should have been called twice')85 self.assertEqual(2, mocked_opener.open.call_count, 'opener should have been called twice')
86 self.assertFalse(result, 'The login method should have returned False')86 self.assertFalse(result, 'The login method should have returned False')
8787
@@ -96,7 +96,9 @@
96 mocked_build_opener.return_value = mocked_opener96 mocked_build_opener.return_value = mocked_opener
97 mocked_login_page = MagicMock()97 mocked_login_page = MagicMock()
98 mocked_login_page.find.side_effect = [{'value': 'blah'}, None]98 mocked_login_page.find.side_effect = [{'value': 'blah'}, None]
99 MockedBeautifulSoup.return_value = mocked_login_page99 mocked_posted_page = MagicMock()
100 mocked_posted_page.find.return_value = None
101 MockedBeautifulSoup.side_effect = [mocked_login_page, mocked_posted_page]
100 mock_callback = MagicMock()102 mock_callback = MagicMock()
101 importer = SongSelectImport(None)103 importer = SongSelectImport(None)
102104
@@ -105,7 +107,8 @@
105107
106 # THEN: callback was called 3 times, open was called twice, find was called twice, and False was returned108 # THEN: callback was called 3 times, open was called twice, find was called twice, and False was returned
107 self.assertEqual(3, mock_callback.call_count, 'callback should have been called 3 times')109 self.assertEqual(3, mock_callback.call_count, 'callback should have been called 3 times')
108 self.assertEqual(2, mocked_login_page.find.call_count, 'find should have been called twice')110 self.assertEqual(2, mocked_login_page.find.call_count, 'find should have been called twice on the login page')
111 self.assertEqual(1, mocked_posted_page.find.call_count, 'find should have been called once on the posted page')
109 self.assertEqual(2, mocked_opener.open.call_count, 'opener should have been called twice')112 self.assertEqual(2, mocked_opener.open.call_count, 'opener should have been called twice')
110 self.assertFalse(result, 'The login method should have returned False')113 self.assertFalse(result, 'The login method should have returned False')
111114
@@ -136,8 +139,10 @@
136 mocked_opener = MagicMock()139 mocked_opener = MagicMock()
137 mocked_build_opener.return_value = mocked_opener140 mocked_build_opener.return_value = mocked_opener
138 mocked_login_page = MagicMock()141 mocked_login_page = MagicMock()
139 mocked_login_page.find.side_effect = [{'value': 'blah'}, MagicMock()]142 mocked_login_page.find.side_effect = [{'value': 'blah'}, None]
140 MockedBeautifulSoup.return_value = mocked_login_page143 mocked_posted_page = MagicMock()
144 mocked_posted_page.find.return_value = MagicMock()
145 MockedBeautifulSoup.side_effect = [mocked_login_page, mocked_posted_page]
141 mock_callback = MagicMock()146 mock_callback = MagicMock()
142 importer = SongSelectImport(None)147 importer = SongSelectImport(None)
143148
@@ -146,11 +151,41 @@
146151
147 # THEN: callback was called 3 times, open was called twice, find was called twice, and True was returned152 # THEN: callback was called 3 times, open was called twice, find was called twice, and True was returned
148 self.assertEqual(3, mock_callback.call_count, 'callback should have been called 3 times')153 self.assertEqual(3, mock_callback.call_count, 'callback should have been called 3 times')
149 self.assertEqual(2, mocked_login_page.find.call_count, 'find should have been called twice')154 self.assertEqual(2, mocked_login_page.find.call_count, 'find should have been called twice on the login page')
155 self.assertEqual(1, mocked_posted_page.find.call_count, 'find should have been called once on the posted page')
150 self.assertEqual(2, mocked_opener.open.call_count, 'opener should have been called twice')156 self.assertEqual(2, mocked_opener.open.call_count, 'opener should have been called twice')
151 self.assertTrue(result, 'The login method should have returned True')157 self.assertTrue(result, 'The login method should have returned True')
152158
153 @patch('openlp.plugins.songs.lib.songselect.build_opener')159 @patch('openlp.plugins.songs.lib.songselect.build_opener')
160 @patch('openlp.plugins.songs.lib.songselect.BeautifulSoup')
161 def login_url_from_form_test(self, MockedBeautifulSoup, mocked_build_opener):
162 """
163 Test that the login URL is from the form
164 """
165 # GIVEN: A bunch of mocked out stuff and an importer object
166 mocked_opener = MagicMock()
167 mocked_build_opener.return_value = mocked_opener
168 mocked_form = MagicMock()
169 mocked_form.attrs = {'action': 'do/login'}
170 mocked_login_page = MagicMock()
171 mocked_login_page.find.side_effect = [{'value': 'blah'}, mocked_form]
172 mocked_posted_page = MagicMock()
173 mocked_posted_page.find.return_value = MagicMock()
174 MockedBeautifulSoup.side_effect = [mocked_login_page, mocked_posted_page]
175 mock_callback = MagicMock()
176 importer = SongSelectImport(None)
177
178 # WHEN: The login method is called after being rigged to fail
179 result = importer.login('username', 'password', mock_callback)
180
181 # THEN: callback was called 3 times, open was called twice, find was called twice, and True was returned
182 self.assertEqual(3, mock_callback.call_count, 'callback should have been called 3 times')
183 self.assertEqual(2, mocked_login_page.find.call_count, 'find should have been called twice on the login page')
184 self.assertEqual(1, mocked_posted_page.find.call_count, 'find should have been called once on the posted page')
185 self.assertEqual('https://profile.ccli.com/do/login', mocked_opener.open.call_args_list[1][0][0])
186 self.assertTrue(result, 'The login method should have returned True')
187
188 @patch('openlp.plugins.songs.lib.songselect.build_opener')
154 def logout_test(self, mocked_build_opener):189 def logout_test(self, mocked_build_opener):
155 """190 """
156 Test that when the logout method is called, it logs the user out of SongSelect191 Test that when the logout method is called, it logs the user out of SongSelect
157192
=== added file 'tests/interfaces/openlp_plugins/bibles/forms/__init__.py'
=== modified file 'tests/interfaces/openlp_plugins/bibles/forms/test_bibleimportform.py'
--- tests/interfaces/openlp_plugins/bibles/forms/test_bibleimportform.py 2016-12-31 11:05:48 +0000
+++ tests/interfaces/openlp_plugins/bibles/forms/test_bibleimportform.py 2017-03-02 21:00:50 +0000
@@ -22,7 +22,7 @@
22"""22"""
23Package to test the openlp.plugins.bibles.forms.bibleimportform package.23Package to test the openlp.plugins.bibles.forms.bibleimportform package.
24"""24"""
25from unittest import TestCase25from unittest import TestCase, skip
2626
27from PyQt5 import QtWidgets27from PyQt5 import QtWidgets
2828
@@ -48,12 +48,12 @@
48 Registry().register('main_window', self.main_window)48 Registry().register('main_window', self.main_window)
49 self.form = BibleImportForm(self.main_window, MagicMock(), MagicMock())49 self.form = BibleImportForm(self.main_window, MagicMock(), MagicMock())
5050
51 def tearDown(self):51 # def tearDown(self):
52 """52 # """
53 Delete all the C++ objects at the end so that we don't have a segfault53 # Delete all the C++ objects at the end so that we don't have a segfault
54 """54 # """
55 del self.form55 # del self.form
56 del self.main_window56 # del self.main_window
5757
58 @patch('openlp.plugins.bibles.forms.bibleimportform.CWExtract.get_bibles_from_http')58 @patch('openlp.plugins.bibles.forms.bibleimportform.CWExtract.get_bibles_from_http')
59 @patch('openlp.plugins.bibles.forms.bibleimportform.BGExtract.get_bibles_from_http')59 @patch('openlp.plugins.bibles.forms.bibleimportform.BGExtract.get_bibles_from_http')
6060
=== added file 'tests/interfaces/openlp_plugins/songusage/__init__.py'
=== modified file 'tests/resources/presentationmanagersongs/Agnus Dei.sng'
--- tests/resources/presentationmanagersongs/Agnus Dei.sng 2014-10-13 11:38:13 +0000
+++ tests/resources/presentationmanagersongs/Agnus Dei.sng 2017-03-02 21:00:50 +0000
@@ -1,7 +1,7 @@
1<?xml version="1.0" encoding="UTF-8"?>1<?xml version="1.0" encoding="UTF-8"?>
2<song xmlns="creativelifestyles/song">2<song xmlns="creativelifestyles/song">
3<attributes>3<attributes>
4<title>Agnus Dei</title>4<Title>Agnus Dei</Title>
5<author></author>5<author></author>
6<copyright></copyright>6<copyright></copyright>
7<ccli_number></ccli_number>7<ccli_number></ccli_number>

Subscribers

People subscribed via source and target branches

to all changes: