Merge lp:~tomasgroth/openlp/remote-sync into lp:openlp

Proposed by Tomas Groth
Status: Needs review
Proposed branch: lp:~tomasgroth/openlp/remote-sync
Merge into: lp:openlp
Diff against target: 1428 lines (+1300/-3)
15 files modified
openlp/plugins/remotesync/__init__.py (+25/-0)
openlp/plugins/remotesync/lib/__init__.py (+25/-0)
openlp/plugins/remotesync/lib/backends/__init__.py (+21/-0)
openlp/plugins/remotesync/lib/backends/foldersynchronizer.py (+328/-0)
openlp/plugins/remotesync/lib/backends/ftpsynchronizer.py (+78/-0)
openlp/plugins/remotesync/lib/backends/synchronizer.py (+195/-0)
openlp/plugins/remotesync/lib/backends/webservicesynchronizer.py (+75/-0)
openlp/plugins/remotesync/lib/db.py (+87/-0)
openlp/plugins/remotesync/lib/remotesynctab.py (+151/-0)
openlp/plugins/remotesync/remotesyncplugin.py (+300/-0)
openlp/plugins/songs/forms/editsongform.py (+1/-0)
openlp/plugins/songs/lib/db.py (+3/-0)
openlp/plugins/songs/lib/mediaitem.py (+1/-0)
openlp/plugins/songs/lib/openlyricsxml.py (+9/-3)
openlp/plugins/songs/songsplugin.py (+1/-0)
To merge this branch: bzr merge lp:~tomasgroth/openlp/remote-sync
Reviewer Review Type Date Requested Status
OpenLP Core Pending
Review via email: mp+336573@code.launchpad.net

Description of the change

This merge request is meant for initial feedback for this implementation of remote sync.

This implementation aims to support synchronizing songs, custom slides and services files, though only songs are currently supported.

The idea is to define a "framework" where it will relatively easy to add new backends. Currently there is one (mostly) working backend, which uses a simple folder structure (the folder could be placed in dropbox or SMB/NFS share). An untested backend that uses FTP is a simple extension of the folder based backend.

The main files to look at are:
openlp/plugins/remotesync/remotesyncplugin.py
openlp/plugins/remotesync/lib/backends/synchronizer.py
openlp/plugins/remotesync/lib/backends/foldersynchronizer.py

There are many todos:
 * Use Path Objects
 * Implement sync of custom slides
 * Implement sync services
 * Implement handling of detected conflicts.

To post a comment you must log in.
Revision history for this message
Raoul Snyman (raoul-snyman) wrote :

Some initial comments:

 - Put the "backends" folder at the same level as the "lib" folder
 - I'd put the "synchronizer" module on the same level as backends since it contains base classes, and only put the actual backends in backends
 - Get rid of the "lib" folder if you can
 - Also, I think this should be in core

Revision history for this message
Simon Hanna (thelinuxguy) wrote :

I like the idea very much. I'm currently working on a side project to implement a server with a database to view and eventually also sync songs. So I will like to consume what you are doing here :-)

I'm not sure if this is the right place to discuss things, point me somewhere else if you think so!

- Could you add comments to the Synchronizer(Interface) documenting what the methods should do?
- There should be a method to get remote changes(uuids prob) which probably takes a datetime object.

Things that I would like to see:
- Use something other than xml (personal dislike). maybe json?
- Allow multiple remotes to exist, possibly only taking care of specific object types (One remote for songs, other for images, ...) That would allow remotes to not have to support everything.

Revision history for this message
Tomas Groth (tomasgroth) wrote :

> I like the idea very much. I'm currently working on a side project to
> implement a server with a database to view and eventually also sync songs. So
> I will like to consume what you are doing here :-)
I hope you will - after all I've based this on your old branch :)

> - Could you add comments to the Synchronizer(Interface) documenting what the
> methods should do?
Will do.

> - There should be a method to get remote changes(uuids prob) which probably
> takes a datetime object.
Actually there is - it is called check_for_remote_changes

> Things that I would like to see:
> - Use something other than xml (personal dislike). maybe json?
The OpenLyrics XML format is the only export format OpenLP supports, and it supports the needed information, so in this case it is just a great match IMO.

> - Allow multiple remotes to exist, possibly only taking care of specific
> object types (One remote for songs, other for images, ...) That would allow
> remotes to not have to support everything.
I like the idea and it makes great sense, but it also makes things more complicated, so I'll have to think about how to actually implement it.

lp:~tomasgroth/openlp/remote-sync updated
2763. By Tomas Groth

Updated copyright year and added more docstrings.

2764. By Tomas Groth

merge trunk

2765. By Tomas Groth

pep8 fixes

2766. By Tomas Groth

trunk

2767. By Tomas Groth

trunk

2768. By Tomas Groth

merge trunk

2769. By Tomas Groth

merge trunk

2770. By Tomas Groth

Updated copyright headers

2771. By Tomas Groth

merge trunk

Unmerged revisions

2771. By Tomas Groth

merge trunk

2770. By Tomas Groth

Updated copyright headers

2769. By Tomas Groth

merge trunk

2768. By Tomas Groth

merge trunk

2767. By Tomas Groth

trunk

2766. By Tomas Groth

trunk

2765. By Tomas Groth

pep8 fixes

2764. By Tomas Groth

merge trunk

2763. By Tomas Groth

Updated copyright year and added more docstrings.

2762. By Tomas Groth

move shared code to new method

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added directory 'openlp/plugins/remotesync'
2=== added file 'openlp/plugins/remotesync/__init__.py'
3--- openlp/plugins/remotesync/__init__.py 1970-01-01 00:00:00 +0000
4+++ openlp/plugins/remotesync/__init__.py 2019-09-17 19:27:12 +0000
5@@ -0,0 +1,25 @@
6+# -*- coding: utf-8 -*-
7+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
8+
9+##########################################################################
10+# OpenLP - Open Source Lyrics Projection #
11+# ---------------------------------------------------------------------- #
12+# Copyright (c) 2008-2019 OpenLP Developers #
13+# ---------------------------------------------------------------------- #
14+# This program is free software: you can redistribute it and/or modify #
15+# it under the terms of the GNU General Public License as published by #
16+# the Free Software Foundation, either version 3 of the License, or #
17+# (at your option) any later version. #
18+# #
19+# This program is distributed in the hope that it will be useful, #
20+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
21+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
22+# GNU General Public License for more details. #
23+# #
24+# You should have received a copy of the GNU General Public License #
25+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
26+##########################################################################
27+"""
28+The :mod:`remotesync` module contains the Remote Sync plugin. The remotesync plugin provides the ability to synchronize
29+songs, custom slides and service-files between multiple OpenLP instances.
30+"""
31
32=== added directory 'openlp/plugins/remotesync/lib'
33=== added file 'openlp/plugins/remotesync/lib/__init__.py'
34--- openlp/plugins/remotesync/lib/__init__.py 1970-01-01 00:00:00 +0000
35+++ openlp/plugins/remotesync/lib/__init__.py 2019-09-17 19:27:12 +0000
36@@ -0,0 +1,25 @@
37+# -*- coding: utf-8 -*-
38+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
39+
40+##########################################################################
41+# OpenLP - Open Source Lyrics Projection #
42+# ---------------------------------------------------------------------- #
43+# Copyright (c) 2008-2019 OpenLP Developers #
44+# ---------------------------------------------------------------------- #
45+# This program is free software: you can redistribute it and/or modify #
46+# it under the terms of the GNU General Public License as published by #
47+# the Free Software Foundation, either version 3 of the License, or #
48+# (at your option) any later version. #
49+# #
50+# This program is distributed in the hope that it will be useful, #
51+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
52+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
53+# GNU General Public License for more details. #
54+# #
55+# You should have received a copy of the GNU General Public License #
56+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
57+##########################################################################
58+
59+from .remotesynctab import RemoteSyncTab
60+
61+__all__ = ['RemoteSyncTab']
62
63=== added directory 'openlp/plugins/remotesync/lib/backends'
64=== added file 'openlp/plugins/remotesync/lib/backends/__init__.py'
65--- openlp/plugins/remotesync/lib/backends/__init__.py 1970-01-01 00:00:00 +0000
66+++ openlp/plugins/remotesync/lib/backends/__init__.py 2019-09-17 19:27:12 +0000
67@@ -0,0 +1,21 @@
68+# -*- coding: utf-8 -*-
69+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
70+
71+##########################################################################
72+# OpenLP - Open Source Lyrics Projection #
73+# ---------------------------------------------------------------------- #
74+# Copyright (c) 2008-2019 OpenLP Developers #
75+# ---------------------------------------------------------------------- #
76+# This program is free software: you can redistribute it and/or modify #
77+# it under the terms of the GNU General Public License as published by #
78+# the Free Software Foundation, either version 3 of the License, or #
79+# (at your option) any later version. #
80+# #
81+# This program is distributed in the hope that it will be useful, #
82+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
83+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
84+# GNU General Public License for more details. #
85+# #
86+# You should have received a copy of the GNU General Public License #
87+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
88+##########################################################################
89
90=== added file 'openlp/plugins/remotesync/lib/backends/foldersynchronizer.py'
91--- openlp/plugins/remotesync/lib/backends/foldersynchronizer.py 1970-01-01 00:00:00 +0000
92+++ openlp/plugins/remotesync/lib/backends/foldersynchronizer.py 2019-09-17 19:27:12 +0000
93@@ -0,0 +1,328 @@
94+# -*- coding: utf-8 -*-
95+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
96+
97+##########################################################################
98+# OpenLP - Open Source Lyrics Projection #
99+# ---------------------------------------------------------------------- #
100+# Copyright (c) 2008-2019 OpenLP Developers #
101+# ---------------------------------------------------------------------- #
102+# This program is free software: you can redistribute it and/or modify #
103+# it under the terms of the GNU General Public License as published by #
104+# the Free Software Foundation, either version 3 of the License, or #
105+# (at your option) any later version. #
106+# #
107+# This program is distributed in the hope that it will be useful, #
108+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
109+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
110+# GNU General Public License for more details. #
111+# #
112+# You should have received a copy of the GNU General Public License #
113+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
114+##########################################################################
115+import datetime
116+import os
117+import glob
118+import shutil
119+
120+import logging
121+
122+from openlp.plugins.remotesync.lib.backends.synchronizer import Synchronizer, SyncItemType, ConflictException, \
123+ LockException, ConflictReason
124+from openlp.plugins.remotesync.lib.db import RemoteSyncItem
125+
126+log = logging.getLogger(__name__)
127+
128+
129+class FolderSynchronizer(Synchronizer):
130+ """
131+ The FolderSynchronizer uses xml-files in a simple holder structure for synchronizing data.
132+ The folder-structure looks like this:
133+ <base-folder>
134+ +---songs
135+ | +---history
136+ | +---deleted
137+ +---customs
138+ | +---history
139+ | +---deleted
140+ +---services
141+ The files are named after the uuid generated for each song or custom slide, the id of the
142+ OpenLP instance and the version of the song, like this: {uuid}={version}={computer_id}.xml
143+ An example could be: bd5bc6c2-4fd2-4a42-925f-48d00de835ec=4=churchpc1.xml
144+ When a file is updated a lock file is created to signal that the song is locked. The filename
145+ of the lock file is: {uuid}.lock={computer_id}={version}
146+ As part of the updating of the file, the old version is moved to the appropriate history folder.
147+ When a song/custom slide is deleted, its file is moved to the history-folder, and an empty file
148+ named as the items uuid is placed in the deleted-folder.
149+ """
150+
151+ def __init__(self, manager, base_folder_path, pc_id):
152+ """
153+ Initilize the synchronizer
154+ :param manager:
155+ :type Manager:
156+ :param base_folder_path:
157+ :type str:
158+ :param pc_id:
159+ :type str:
160+ """
161+ super(FolderSynchronizer, self).__init__(manager)
162+ self.base_folder_path = base_folder_path
163+ self.pc_id = pc_id
164+ self.song_folder_path = os.path.join(self.base_folder_path, 'songs')
165+ self.song_history_folder_path = os.path.join(self.song_folder_path, 'history')
166+ self.song_deleted_folder_path = os.path.join(self.song_folder_path, 'deleted')
167+ self.custom_folder_path = os.path.join(self.base_folder_path, 'customs')
168+ self.custom_history_folder_path = os.path.join(self.custom_folder_path, 'history')
169+ self.custom_deleted_folder_path = os.path.join(self.custom_folder_path, 'deleted')
170+ self.service_folder_path = os.path.join(self.base_folder_path, 'services')
171+
172+ def check_configuration(self):
173+ return True
174+
175+ def check_connection(self):
176+ return os.path.exists(self.base_folder_path) and os.path.exists(
177+ self.song_history_folder_path) and os.path.exists(self.custom_folder_path)
178+
179+ def initialize_remote(self):
180+ os.makedirs(self.song_history_folder_path, exist_ok=True)
181+ os.makedirs(self.custom_folder_path, exist_ok=True)
182+ os.makedirs(self.service_folder_path, exist_ok=True)
183+
184+ def _get_file_list(self, path, mask):
185+ return glob.glob(os.path.join(path, mask))
186+
187+ def _remove_lock_file(self, lock_filename):
188+ os.remove(lock_filename)
189+
190+ def _move_file(self, src, dst):
191+ shutil.move(src, dst)
192+
193+ def _create_file(self, filename, file_content):
194+ out_file = open(filename, 'wt')
195+ out_file.write(file_content)
196+ out_file.close()
197+
198+ def _read_file(self, filename):
199+ in_file = open(filename, 'rt')
200+ content = in_file.read()
201+ in_file.close()
202+ return content
203+
204+ def get_remote_changes(self):
205+ """
206+ Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
207+ fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
208+ remotely, they are also deleted locally.
209+ :return: True if one or more songs was updated, otherwise False
210+ """
211+ updated = False
212+ song_files = self._get_file_list(self.song_folder_path, '*.xml')
213+ conflicts = []
214+ for song_file in song_files:
215+ # skip conflicting files
216+ if song_file in conflicts:
217+ continue
218+ # Check if this song is already sync'ed
219+ filename = os.path.basename(song_file)
220+ filename_elements = filename.split('=', 1)
221+ uuid = filename_elements[0]
222+ file_version = filename_elements[1].replace('.xml', '')
223+ # Detect if there are multiple files for the same song, which would mean that we have a conflict
224+ files = []
225+ for song_file2 in song_files:
226+ if uuid in song_file2:
227+ files.append(song_file2)
228+ # if more than one song file has the same uuid, then we have a conflict
229+ if len(files) > 1:
230+ # Add conflicting files to the "blacklist"
231+ conflicts += files
232+ # Mark song as conflicted!
233+ self.mark_item_for_conflict(SyncItemType.Song, uuid, ConflictReason.MultipleRemoteEntries)
234+ existing_item = self.get_sync_item(uuid, SyncItemType.Song)
235+ song_id = existing_item.item_id if existing_item else None
236+ # If we do not have a local version or if the remote version is different, then we update
237+ if not existing_item or existing_item.version != file_version:
238+ log.debug('Local version (%s) and file version (%s) mismatch - updated triggered!' % (
239+ existing_item.version, file_version))
240+ log.debug('About to fetch song: %s %d' % (uuid, song_id))
241+ try:
242+ self.fetch_song(uuid, song_id)
243+ except ConflictException as ce:
244+ log.debug('Conflict detected while fetching song %d / %s!' % (song_id, uuid))
245+ self.mark_item_for_conflict(SyncItemType.Song, uuid, ce.reason)
246+ continue
247+ updated = True
248+ # TODO: Check for deleted files
249+ return updated
250+
251+ def _check_for_lock_file(self, type, path, uuid, first_sync_attempt, prev_lock_id):
252+ """
253+ Check for lock file. Raises exception if a valid lock file is found. If an expired lock file is found
254+ it is deleted.
255+ :param type:
256+ :type str:
257+ :param path:
258+ :type str:
259+ :param uuid:
260+ :type str:
261+ :param first_sync_attempt:
262+ :type datetime:
263+ :param prev_lock_id:
264+ :type str:
265+ """
266+ existing_lock_file = self._get_file_list(path, uuid + '.lock*')
267+ if existing_lock_file:
268+ log.debug('Found a lock file!')
269+ current_lock_id = existing_lock_file[0].split('.lock=')[-1]
270+ if first_sync_attempt:
271+ # Have we seen this lock before?
272+ if current_lock_id == prev_lock_id:
273+ # If the lock is more than 60 seconds old it is deleted
274+ delta = datetime.datetime.now() - first_sync_attempt
275+ if delta.total_seconds() > 60:
276+ # Remove expired lock
277+ self._remove_lock_file(existing_lock_file[0])
278+ else:
279+ # Lock is still valid, keep waiting
280+ raise LockException(type, uuid, current_lock_id, first_sync_attempt)
281+ else:
282+ # New lock encountered, now we have to wait - again
283+ raise LockException(type, uuid, current_lock_id, datetime.datetime.now())
284+ else:
285+ # New lock encountered, now we have to wait for it
286+ raise LockException(type, uuid, current_lock_id, datetime.datetime.now())
287+
288+ def send_song(self, song, song_uuid, last_known_version, first_sync_attempt, prev_lock_id):
289+ """
290+ Sends a song to the shared folder. Does the following:
291+ 1. Check for an existing lock, raise LockException if one found.
292+ 2. Check if the song already exists on remote. If so, check if the latest version is available locally, raise
293+ ConflictException if the remote version is not known locally. If the latest version is known, create a lock
294+ file and move the existing file to the history folder. If the song does not exists already, just create a
295+ lock file.
296+ 3. Place file with song in folder.
297+ 4. Delete lock file.
298+ :param song: The song object to synchronize
299+ :param song_uuid: The uuid of the song
300+ :param last_known_version: The last known version of the song
301+ :param first_sync_attempt: If the song has been attempted synchronized before,
302+ this is the timestamp of the first sync attempt.
303+ :param prev_lock_id: If the song has been attempted synchronized before, this is the id of the lock that
304+ prevented the synchronization.
305+ :return: The new version.
306+ """
307+ # Check for lock file. Will raise exception on lock
308+ self._check_for_lock_file(SyncItemType.Song, self.song_folder_path, song_uuid, first_sync_attempt, prev_lock_id)
309+ # Check if song already exists
310+ existing_song_files = self._get_file_list(self.song_folder_path, song_uuid + '*.xml')
311+ counter = -1
312+ if existing_song_files:
313+ # Handle case with multiple files returned, which indicates a conflict!
314+ if len(existing_song_files) > 1:
315+ raise ConflictException(SyncItemType.Song, song_uuid, ConflictReason.MultipleRemoteEntries)
316+ existing_file = os.path.basename(existing_song_files[0])
317+ filename_elements = existing_file.split('=')
318+ counter = int(filename_elements[1])
319+ if last_known_version:
320+ current_local_counter = int(last_known_version.split('=')[0])
321+ # Check if we do have the latest version locally, if not we flag a conflict
322+ if current_local_counter != counter:
323+ raise ConflictException(SyncItemType.Song, song_uuid, ConflictReason.VersionMismatch)
324+ counter += 1
325+ # Create lock file
326+ lock_filename = '{path}.lock={pcid}={counter}'.format(path=os.path.join(self.song_folder_path, song_uuid),
327+ pcid=self.pc_id, counter=counter)
328+ self._create_file(lock_filename, '')
329+ # Move old file to history folder
330+ self._move_file(os.path.join(self.song_folder_path, existing_file), self.song_history_folder_path)
331+ else:
332+ # TODO: Check for missing (deleted) file
333+ lock_filename = '{path}.lock={pcid}={counter}'.format(path=os.path.join(self.song_folder_path, song_uuid),
334+ pcid=self.pc_id, counter=counter)
335+ counter += 1
336+ # Create lock file
337+ self._create_file(lock_filename, '')
338+ # Put xml in file
339+ version = '{counter}={computer_id}'.format(counter=counter, computer_id=self.pc_id)
340+ xml = self.open_lyrics.song_to_xml(song, version)
341+ new_filename = os.path.join(self.song_folder_path, song_uuid + "=" + version + '.xml')
342+ new_tmp_filename = new_filename + '-tmp'
343+ self._create_file(new_tmp_filename, xml)
344+ self._move_file(new_tmp_filename, new_filename)
345+ # Delete lock file
346+ self._remove_lock_file(lock_filename)
347+ return version
348+
349+ def fetch_song(self, song_uuid, song_id):
350+ """
351+ Fetch a specific song from the shared folder
352+ :param song_uuid: uuid of the song
353+ :param song_id: song db id, None if song does not yet exists in the song db
354+ :return: The song object
355+ """
356+ # Check for lock file - is this actually needed? should we create a read lock?
357+ if self._get_file_list(self.song_folder_path, song_uuid + '.lock'):
358+ log.debug('Found a lock file! Ignoring it for now.')
359+ existing_song_files = self._get_file_list(self.song_folder_path, song_uuid + '*')
360+ if existing_song_files:
361+ # Handle case with multiple files returned, which indicates a conflict!
362+ if len(existing_song_files) > 1:
363+ raise ConflictException(SyncItemType.Song, song_uuid, ConflictReason.MultipleRemoteEntries)
364+ existing_file = os.path.basename(existing_song_files[0])
365+ filename_elements = existing_file.split('=', 1)
366+ song_uuid = filename_elements[0]
367+ version = filename_elements[1]
368+ xml = self._read_file(existing_song_files[0])
369+ song = self.open_lyrics.xml_to_song(xml, update_song_id=song_id)
370+ sync_item = self.manager.get_object_filtered(RemoteSyncItem, RemoteSyncItem.uuid == song_uuid)
371+ if not sync_item:
372+ sync_item = RemoteSyncItem()
373+ sync_item.type = SyncItemType.Song
374+ sync_item.item_id = song.id
375+ sync_item.uuid = song_uuid
376+ sync_item.version = version
377+ self.manager.save_object(sync_item, True)
378+ return song
379+ else:
380+ return None
381+
382+ def delete_song(self, song_uuid, first_del_attempt, prev_lock_id):
383+ """
384+ Delete song from the remote location. Does the following:
385+ 1. Check for an existing lock, raise LockException if one found.
386+ 2. Create a lock file and move the existing file to the history folder.
387+ 3. Place a file in the deleted folder, named after the song uuid.
388+ 4. Delete lock file.
389+ :param song_uuid:
390+ :type str:
391+ :param first_del_attempt:
392+ :type DateTime:
393+ :param prev_lock_id:
394+ :type str:
395+ """
396+ # Check for lock file. Will raise exception on lock
397+ self._check_for_lock_file(SyncItemType.Song, self.song_folder_path, song_uuid, first_del_attempt, prev_lock_id)
398+ # Move the song xml file to the history folder
399+ existing_song_files = self._get_file_list(self.song_folder_path, song_uuid + '*.xml')
400+ if existing_song_files:
401+ # Handle case with multiple files returned, which indicates a conflict!
402+ if len(existing_song_files) > 1:
403+ raise ConflictException(SyncItemType.Song, song_uuid, ConflictReason.MultipleRemoteEntries)
404+ existing_file = os.path.basename(existing_song_files[0])
405+ # Move old file to deleted folder
406+ self._move_file(os.path.join(self.song_folder_path, existing_file), self.song_history_folder_path)
407+ # Create a file in the deleted-folder
408+ delete_filename = os.path.join(self.song_deleted_folder_path, song_uuid)
409+ self._create_file(delete_filename, '')
410+
411+ def send_custom(self, custom):
412+ pass
413+
414+ def fetch_custom(self):
415+ pass
416+
417+ def send_service(self, service):
418+ pass
419+
420+ def fetch_service(self):
421+ pass
422
423=== added file 'openlp/plugins/remotesync/lib/backends/ftpsynchronizer.py'
424--- openlp/plugins/remotesync/lib/backends/ftpsynchronizer.py 1970-01-01 00:00:00 +0000
425+++ openlp/plugins/remotesync/lib/backends/ftpsynchronizer.py 2019-09-17 19:27:12 +0000
426@@ -0,0 +1,78 @@
427+# -*- coding: utf-8 -*-
428+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
429+
430+##########################################################################
431+# OpenLP - Open Source Lyrics Projection #
432+# ---------------------------------------------------------------------- #
433+# Copyright (c) 2008-2019 OpenLP Developers #
434+# ---------------------------------------------------------------------- #
435+# This program is free software: you can redistribute it and/or modify #
436+# it under the terms of the GNU General Public License as published by #
437+# the Free Software Foundation, either version 3 of the License, or #
438+# (at your option) any later version. #
439+# #
440+# This program is distributed in the hope that it will be useful, #
441+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
442+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
443+# GNU General Public License for more details. #
444+# #
445+# You should have received a copy of the GNU General Public License #
446+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
447+##########################################################################
448+
449+import fnmatch
450+from ftplib import FTP
451+from io import TextIOBase
452+
453+from openlp.plugins.remotesync.lib.backends.foldersynchronizer import FolderSynchronizer
454+
455+
456+class FtpSynchronizer(FolderSynchronizer):
457+
458+ def __init__(self, manager, base_folder_path, pc_id):
459+ super(FtpSynchronizer, self).__init__(manager, base_folder_path, pc_id)
460+ self.ftp = None
461+
462+ def check_configuration(self):
463+ return True
464+
465+ def check_connection(self):
466+ self.connect()
467+ base_folder_content = self._get_file_list(self.base_folder_path, '*')
468+ self.disconnect()
469+
470+ def initialize_remote(self):
471+ self.connect()
472+ self.ftp.mkd(self.song_history_folder_path)
473+ self.ftp.mkd(self.custom_folder_path)
474+ self.ftp.mkd(self.service_folder_path)
475+ self.disconnect()
476+
477+ def connect(self):
478+ # TODO: Also support FTP_TLS
479+ self.ftp = FTP('123.server.ip', 'username', 'password')
480+
481+ def disconnect(self):
482+ self.ftp.close()
483+ self.ftp = None
484+
485+ def _get_file_list(self, path, mask):
486+ file_list = self.ftp.nlst(path)
487+ filtered_list = fnmatch.filter(file_list, mask)
488+ return filtered_list
489+
490+ def _remove_lock_file(self, lock_filename):
491+ self.ftp.remove(lock_filename)
492+
493+ def _move_file(self, src, dst):
494+ self.ftp.move(src, dst)
495+
496+ def _create_file(self, filename, file_content):
497+ text_stream = TextIOBase()
498+ text_stream.write(file_content)
499+ self.ftp.storbinary('STOR ' + filename, text_stream)
500+
501+ def _read_file(self, filename):
502+ text_stream = TextIOBase()
503+ self.ftp.retrbinary('RETR ' + filename, text_stream, 1024)
504+ return text_stream.read()
505
506=== added file 'openlp/plugins/remotesync/lib/backends/synchronizer.py'
507--- openlp/plugins/remotesync/lib/backends/synchronizer.py 1970-01-01 00:00:00 +0000
508+++ openlp/plugins/remotesync/lib/backends/synchronizer.py 2019-09-17 19:27:12 +0000
509@@ -0,0 +1,195 @@
510+# -*- coding: utf-8 -*-
511+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
512+
513+##########################################################################
514+# OpenLP - Open Source Lyrics Projection #
515+# ---------------------------------------------------------------------- #
516+# Copyright (c) 2008-2019 OpenLP Developers #
517+# ---------------------------------------------------------------------- #
518+# This program is free software: you can redistribute it and/or modify #
519+# it under the terms of the GNU General Public License as published by #
520+# the Free Software Foundation, either version 3 of the License, or #
521+# (at your option) any later version. #
522+# #
523+# This program is distributed in the hope that it will be useful, #
524+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
525+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
526+# GNU General Public License for more details. #
527+# #
528+# You should have received a copy of the GNU General Public License #
529+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
530+##########################################################################
531+
532+from sqlalchemy.sql import and_
533+
534+from openlp.core.common.settings import Settings
535+from openlp.core.common.registry import Registry
536+from openlp.plugins.remotesync.lib.db import RemoteSyncItem, ConflictItem
537+from openlp.plugins.songs.lib.openlyricsxml import OpenLyrics
538+
539+
540+class ConflictReason:
541+ """
542+ Conflict reason type definitions
543+ """
544+ MultipleRemoteEntries = 'MultipleRemoteEntries'
545+ VersionMismatch = 'VersionMismatch'
546+
547+
548+class ConflictException(Exception):
549+ """
550+ Exception thrown in case of conflicts
551+ """
552+ def __init__(self, type, uuid, reason):
553+ self.type = type
554+ self.uuid = uuid
555+ self.reason = reason
556+
557+
558+class LockException(Exception):
559+ """
560+ Exception thrown in case of a locked item
561+ """
562+ def __init__(self, type, uuid, lock_id, first_attempt):
563+ self.type = type
564+ self.uuid = uuid
565+ self.lock_id = lock_id
566+ self.first_attempt = first_attempt
567+
568+
569+class SyncItemType:
570+ """
571+ Sync item type definitions
572+ """
573+ Song = 'song'
574+ Custom = 'custom'
575+
576+
577+class SyncItemAction:
578+ """
579+ Sync item Action definitions
580+ """
581+ Update = 'update'
582+ Delete = 'delete'
583+
584+
585+class Synchronizer(object):
586+ """
587+ The base class used for synchronization.
588+ Any Synchronizer implementation must override the functions needed to actually synchronize songs, custom slides
589+ and services.
590+ """
591+
592+ def __init__(self, manager):
593+ self.manager = manager
594+ self.song_manager = Registry().get('songs_manager')
595+ self.open_lyrics = OpenLyrics(Registry().get('songs_manager'))
596+
597+ def connect(self):
598+ pass
599+
600+ def disconnect(self):
601+ pass
602+
603+ def get_sync_item(self, uuid, type):
604+ item = self.manager.get_object_filtered(RemoteSyncItem, and_(RemoteSyncItem.uuid == uuid,
605+ RemoteSyncItem.type == type))
606+ if item:
607+ return item
608+ else:
609+ return None
610+
611+ def mark_item_for_conflict(self, type, uuid, reason):
612+ """
613+ Marks item as having a conflict
614+ :param type: Type of the item
615+ :param uuid: The uuid of the item
616+ :param reason: The reason for the conflict
617+ """
618+ # Check if it is already marked with a conflict
619+ item = self.manager.get_object_filtered(ConflictItem, and_(ConflictItem.uuid == uuid,
620+ ConflictItem.conflict_reason == reason))
621+ if not item:
622+ item = ConflictItem()
623+ item.type = type
624+ item.uuid = uuid
625+ item.conflict_reason = reason
626+ self.manager.save_object(item)
627+
628+ def check_configuration(self):
629+ return False
630+
631+ def check_connection(self):
632+ """
633+ Check that it is possible to connect to the remote server/folder.
634+ """
635+ return False
636+
637+ def initialize_remote(self):
638+ """
639+ Setup connection to the remote server and do remote initialization.
640+ """
641+ pass
642+
643+ def get_remote_changes(self):
644+ """
645+ Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
646+ fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
647+ remotely, they are also deleted locally.
648+ :return: True if one or more songs was updated, otherwise False
649+ """
650+ pass
651+
652+ def send_song(self, song, song_uuid, last_known_version, first_sync_attempt, prev_lock_id):
653+ """
654+ Sends a song to the remote location
655+ :param song: The song object to synchronize
656+ :param song_uuid: The uuid of the song
657+ :param last_known_version: The last known version of the song
658+ :param first_sync_attempt: If the song has been attempted synchronized before,
659+ this is the timestamp of the first sync attempt.
660+ :param prev_lock_id: If the song has been attempted synchronized before, this is the id of the lock that
661+ prevented the synchronization.
662+ :return: The new version.
663+ """
664+ pass
665+
666+ def fetch_song(self, song_uuid, song_id):
667+ """
668+ Fetch a specific song from the remote location and saves it to the song db.
669+ :param song_uuid: uuid of the song
670+ :param song_id: song db id, None if song does not yet exists in the song db
671+ :return: The song object
672+ """
673+ pass
674+
675+ def delete_song(self, song_uuid, first_del_attempt, prev_lock_id):
676+ """
677+ Delete song from the remote location
678+ :param song_uuid:
679+ :type str:
680+ :param first_del_attempt:
681+ :type DateTime:
682+ :param prev_lock_id:
683+ :type str:
684+ """
685+ pass
686+
687+ def send_custom(self, custom):
688+ pass
689+
690+ def fetch_custom(self):
691+ pass
692+
693+ def delete_custom(self):
694+ pass
695+
696+ def send_service(self, service):
697+ pass
698+
699+ def fetch_service(self):
700+ pass
701+
702+ def serialize_custom(custom):
703+ j_data = dict()
704+ return j_data
705
706=== added file 'openlp/plugins/remotesync/lib/backends/webservicesynchronizer.py'
707--- openlp/plugins/remotesync/lib/backends/webservicesynchronizer.py 1970-01-01 00:00:00 +0000
708+++ openlp/plugins/remotesync/lib/backends/webservicesynchronizer.py 2019-09-17 19:27:12 +0000
709@@ -0,0 +1,75 @@
710+# -*- coding: utf-8 -*-
711+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
712+
713+##########################################################################
714+# OpenLP - Open Source Lyrics Projection #
715+# ---------------------------------------------------------------------- #
716+# Copyright (c) 2008-2019 OpenLP Developers #
717+# ---------------------------------------------------------------------- #
718+# This program is free software: you can redistribute it and/or modify #
719+# it under the terms of the GNU General Public License as published by #
720+# the Free Software Foundation, either version 3 of the License, or #
721+# (at your option) any later version. #
722+# #
723+# This program is distributed in the hope that it will be useful, #
724+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
725+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
726+# GNU General Public License for more details. #
727+# #
728+# You should have received a copy of the GNU General Public License #
729+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
730+##########################################################################
731+
732+import requests
733+
734+from openlp.core.common import Settings, registry
735+from openlp.core.lib.db import Manager
736+from openlp.plugins.remotesync.lib.backends.synchronizer import Synchronizer
737+from openlp.plugins.songs.lib.db import init_schema, Song
738+
739+
740+class WebServiceSynchronizer(Synchronizer):
741+ baseurl = 'http://localhost:8000/'
742+ auth_token = 'afd9a4aa979534edf0015f7379cd6d61a52f9e10'
743+
744+ def __init__(self, *args, **kwargs):
745+ port = kwargs['port']
746+ address = kwargs['address']
747+ self.auth_token = kwargs['auth_token']
748+ self.base_url = '{}:{}/'.format(address, port)
749+ self.manager = registry.Registry().get('songs_manager')
750+ registry.Registry().register('remote_synchronizer', self)
751+
752+ @staticmethod
753+ def _handle(response, expected_status_code=200):
754+ if response.status_code != expected_status_code:
755+ print('whoops got {} expected {}'.format(response.status_code, expected_status_code))
756+ return response
757+
758+ def _get(self, url):
759+ return self._handle(requests.get(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)}),
760+ 200)
761+
762+ def _post(self, url, data, return_code):
763+ return self._handle(requests.post(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)},
764+ json=data), return_code)
765+
766+ def _put(self, url, data):
767+ return self._handle(requests.put(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)},
768+ data=data))
769+
770+ def _delete(self, url):
771+ return self._handle(requests.delete(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)}))
772+
773+ def check_connection(self):
774+ return False
775+
776+ def send_song(self, song):
777+ self._post('http://localhost:8000/songs/', song, 201)
778+
779+ def receive_songs(self):
780+ self._get('http://localhost:8000/songs/')
781+
782+ def send_all_songs(self):
783+ for song in self.manager.get_all_objects(Song):
784+ self._post('http://localhost:8000/songs/', self.open_lyrics.song_to_xml(song), 201)
785
786=== added file 'openlp/plugins/remotesync/lib/db.py'
787--- openlp/plugins/remotesync/lib/db.py 1970-01-01 00:00:00 +0000
788+++ openlp/plugins/remotesync/lib/db.py 2019-09-17 19:27:12 +0000
789@@ -0,0 +1,87 @@
790+# -*- coding: utf-8 -*-
791+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
792+
793+##########################################################################
794+# OpenLP - Open Source Lyrics Projection #
795+# ---------------------------------------------------------------------- #
796+# Copyright (c) 2008-2019 OpenLP Developers #
797+# ---------------------------------------------------------------------- #
798+# This program is free software: you can redistribute it and/or modify #
799+# it under the terms of the GNU General Public License as published by #
800+# the Free Software Foundation, either version 3 of the License, or #
801+# (at your option) any later version. #
802+# #
803+# This program is distributed in the hope that it will be useful, #
804+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
805+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
806+# GNU General Public License for more details. #
807+# #
808+# You should have received a copy of the GNU General Public License #
809+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
810+##########################################################################
811+"""
812+The :mod:`db` module provides the database and schema that is the backend for
813+the Custom plugin
814+"""
815+from sqlalchemy import Column, Table, types
816+from sqlalchemy.orm import mapper
817+
818+from openlp.core.lib.db import BaseModel, init_db
819+
820+
821+class RemoteSyncItem(BaseModel):
822+ """
823+ RemosteSync model
824+ """
825+ pass
826+
827+
828+class SyncQueueItem(BaseModel):
829+ """
830+ SyncQueue model
831+ """
832+ pass
833+
834+
835+class ConflictItem(BaseModel):
836+ """
837+ Conflict model
838+ """
839+ pass
840+
841+
842+def init_schema(url):
843+ """
844+ Setup the custom database connection and initialise the database schema
845+
846+ :param url: The database to setup
847+ """
848+ session, metadata = init_db(url)
849+
850+ remote_sync_table = Table('remote_sync_map', metadata,
851+ Column('item_id', types.Integer(), primary_key=True),
852+ Column('type', types.Unicode(64), primary_key=True),
853+ Column('uuid', types.Unicode(36), nullable=False),
854+ Column('version', types.Unicode(64), nullable=False),
855+ )
856+
857+ sync_queue_table = Table('sync_queue_table', metadata,
858+ Column('item_id', types.Integer(), primary_key=True, nullable=False),
859+ Column('type', types.Unicode(64), primary_key=True, nullable=False),
860+ Column('action', types.Unicode(32)),
861+ Column('lock_id', types.Unicode(128)),
862+ Column('first_attempt', types.DateTime()),
863+ )
864+
865+ conflicts_table = Table('conflicts_table', metadata,
866+ Column('type', types.Unicode(64), primary_key=True, nullable=False),
867+ Column('uuid', types.Unicode(36), nullable=False),
868+ Column('conflict_reason', types.Unicode(64), nullable=False),
869+ )
870+
871+ mapper(RemoteSyncItem, remote_sync_table)
872+ mapper(SyncQueueItem, sync_queue_table)
873+ mapper(ConflictItem, conflicts_table)
874+
875+ metadata.create_all(checkfirst=True)
876+ return session
877
878=== added file 'openlp/plugins/remotesync/lib/remotesynctab.py'
879--- openlp/plugins/remotesync/lib/remotesynctab.py 1970-01-01 00:00:00 +0000
880+++ openlp/plugins/remotesync/lib/remotesynctab.py 2019-09-17 19:27:12 +0000
881@@ -0,0 +1,151 @@
882+# -*- coding: utf-8 -*-
883+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
884+
885+##########################################################################
886+# OpenLP - Open Source Lyrics Projection #
887+# ---------------------------------------------------------------------- #
888+# Copyright (c) 2008-2019 OpenLP Developers #
889+# ---------------------------------------------------------------------- #
890+# This program is free software: you can redistribute it and/or modify #
891+# it under the terms of the GNU General Public License as published by #
892+# the Free Software Foundation, either version 3 of the License, or #
893+# (at your option) any later version. #
894+# #
895+# This program is distributed in the hope that it will be useful, #
896+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
897+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
898+# GNU General Public License for more details. #
899+# #
900+# You should have received a copy of the GNU General Public License #
901+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
902+##########################################################################
903+
904+import os.path
905+
906+from PyQt5 import QtCore, QtGui, QtNetwork, QtWidgets
907+
908+from openlp.core.common.settings import Settings
909+from openlp.core.common.registry import Registry
910+from openlp.core.common.applocation import AppLocation
911+from openlp.core.common.i18n import translate
912+from openlp.core.lib import SettingsTab, build_icon
913+
914+
915+class RemoteSyncTab(SettingsTab):
916+ """
917+ RemoteSyncTab is the RemoteSync settings tab in the settings dialog.
918+ """
919+ def __init__(self, parent, title, visible_title, icon_path):
920+ super(RemoteSyncTab, self).__init__(parent, title, visible_title, icon_path)
921+
922+ def setupUi(self):
923+ self.setObjectName('RemoteSyncTab')
924+ super(RemoteSyncTab, self).setupUi()
925+ self.server_settings_group_box = QtWidgets.QGroupBox(self.left_column)
926+ self.server_settings_group_box.setObjectName('server_settings_group_box')
927+ self.server_settings_layout = QtWidgets.QFormLayout(self.server_settings_group_box)
928+ self.server_settings_layout.setObjectName('server_settings_layout')
929+ self.address_label = QtWidgets.QLabel(self.server_settings_group_box)
930+ self.address_label.setObjectName('address_label')
931+ self.address_edit = QtWidgets.QLineEdit(self.server_settings_group_box)
932+ self.address_edit.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
933+ self.address_edit.setValidator(QtGui.QRegExpValidator(QtCore.QRegExp('\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}'),
934+ self))
935+ self.address_edit.setObjectName('address_edit')
936+ self.server_settings_layout.addRow(self.address_label, self.address_edit)
937+ self.port_label = QtWidgets.QLabel(self.server_settings_group_box)
938+ self.port_label.setObjectName('port_label')
939+ self.port_spin_box = QtWidgets.QSpinBox(self.server_settings_group_box)
940+ self.port_spin_box.setMaximum(32767)
941+ self.port_spin_box.setObjectName('port_spin_box')
942+ self.server_settings_layout.addRow(self.port_label, self.port_spin_box)
943+ self.left_layout.addWidget(self.server_settings_group_box)
944+ self.auth_token_label = QtWidgets.QLabel(self.server_settings_group_box)
945+ self.auth_token_label.setObjectName('auth_token_label')
946+ self.auth_token = QtWidgets.QLineEdit(self.server_settings_group_box)
947+ self.auth_token.setObjectName('auth_token')
948+ self.server_settings_layout.addRow(self.auth_token_label, self.auth_token)
949+ self.left_layout.addWidget(self.server_settings_group_box)
950+
951+ self.actions_group_box = QtWidgets.QGroupBox(self.left_column)
952+ self.actions_group_box.setObjectName('actions_group_box')
953+ self.actions_layout = QtWidgets.QFormLayout(self.actions_group_box)
954+ self.actions_layout.setObjectName('actions_layout')
955+
956+ self.send_songs_btn = QtWidgets.QPushButton(self.actions_group_box)
957+ self.send_songs_btn.setObjectName('send_songs_btn')
958+ self.send_songs_btn.clicked.connect(self.on_send_songs_clicked)
959+
960+ self.receive_songs_btn = QtWidgets.QPushButton(self.actions_group_box)
961+ self.receive_songs_btn.setObjectName('receive_songs_btn')
962+ self.receive_songs_btn.clicked.connect(self.on_receive_songs_clicked)
963+ self.actions_layout.addRow(self.send_songs_btn, self.receive_songs_btn)
964+ self.left_layout.addWidget(self.actions_group_box)
965+
966+ self.remote_statistics_group_box = QtWidgets.QGroupBox(self.left_column)
967+ self.remote_statistics_group_box.setObjectName('remote_statistics_group_box')
968+ self.remote_statistics_layout = QtWidgets.QFormLayout(self.remote_statistics_group_box)
969+ self.remote_statistics_layout.setObjectName('remote_statistics_layout')
970+ self.update_policy_label = QtWidgets.QLabel(self.remote_statistics_group_box)
971+ self.update_policy_label.setObjectName('update_policy_label')
972+ self.update_policy = QtWidgets.QLabel(self.remote_statistics_group_box)
973+ self.update_policy.setObjectName('update_policy')
974+ self.remote_statistics_layout.addRow(self.update_policy_label, self.update_policy)
975+ self.last_sync_label = QtWidgets.QLabel(self.remote_statistics_group_box)
976+ self.last_sync_label.setObjectName('last_sync_label')
977+ self.last_sync = QtWidgets.QLabel(self.remote_statistics_group_box)
978+ self.last_sync.setObjectName('last_sync')
979+ self.remote_statistics_layout.addRow(self.last_sync_label, self.last_sync)
980+ self.left_layout.addWidget(self.remote_statistics_group_box)
981+
982+ self.left_layout.addStretch()
983+ self.right_column.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
984+ self.right_layout.addStretch()
985+
986+ def retranslateUi(self):
987+ self.server_settings_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Server Settings'))
988+ self.actions_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Actions'))
989+ self.remote_statistics_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Remote Statistics'))
990+ self.address_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Remote server ip address:'))
991+ self.port_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Port number:'))
992+ self.auth_token_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Auth Token:'))
993+ self.receive_songs_btn.setText(translate('RemotePlugin.RemoteSyncTab', 'Receive Songs'))
994+ self.send_songs_btn.setText(translate('RemotePlugin.RemoteSyncTab', 'Send Songs'))
995+ self.update_policy_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Update Policy:'))
996+ self.last_sync_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Last Sync:'))
997+
998+ def load(self):
999+ """
1000+ Load the configuration and update the server configuration if necessary
1001+ """
1002+ #self.port_spin_box.setValue(Settings().value(self.settings_section + '/port'))
1003+ #self.address_edit.setText(Settings().value(self.settings_section + '/ip address'))
1004+ #self.auth_token.setText(Settings().value(self.settings_section + '/auth token'))
1005+ pass
1006+
1007+ def save(self):
1008+ """
1009+ Save the configuration and update the server configuration if necessary
1010+ """
1011+ #Settings().setValue(self.settings_section + '/port', self.port_spin_box.value())
1012+ #Settings().setValue(self.settings_section + '/ip address', self.address_edit.text())
1013+ #Settings().setValue(self.settings_section + '/auth token', self.auth_token.text())
1014+ self.generate_icon()
1015+
1016+ def on_send_songs_clicked(self):
1017+ Registry().execute('synchronize_to_remote')
1018+ #self.remote_synchronizer.send_all_songs()
1019+
1020+ def on_receive_songs_clicked(self):
1021+ Registry().execute('synchronize_from_remote')
1022+ #self.remote_synchronizer.receive_songs()
1023+
1024+ def generate_icon(self):
1025+ """
1026+ Generate icon for main window
1027+ """
1028+ self.remote_sync_icon.hide()
1029+ icon = QtGui.QImage(':/remote/network_server.png')
1030+ icon = icon.scaled(80, 80, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
1031+ self.remote_sync_icon.setPixmap(QtGui.QPixmap.fromImage(icon))
1032+ self.remote_sync_icon.show()
1033
1034=== added file 'openlp/plugins/remotesync/remotesyncplugin.py'
1035--- openlp/plugins/remotesync/remotesyncplugin.py 1970-01-01 00:00:00 +0000
1036+++ openlp/plugins/remotesync/remotesyncplugin.py 2019-09-17 19:27:12 +0000
1037@@ -0,0 +1,300 @@
1038+# -*- coding: utf-8 -*-
1039+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
1040+
1041+##########################################################################
1042+# OpenLP - Open Source Lyrics Projection #
1043+# ---------------------------------------------------------------------- #
1044+# Copyright (c) 2008-2019 OpenLP Developers #
1045+# ---------------------------------------------------------------------- #
1046+# This program is free software: you can redistribute it and/or modify #
1047+# it under the terms of the GNU General Public License as published by #
1048+# the Free Software Foundation, either version 3 of the License, or #
1049+# (at your option) any later version. #
1050+# #
1051+# This program is distributed in the hope that it will be useful, #
1052+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
1053+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
1054+# GNU General Public License for more details. #
1055+# #
1056+# You should have received a copy of the GNU General Public License #
1057+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
1058+##########################################################################
1059+"""
1060+The RemoteSync plugin makes it possible to synchronize songs, custom slides and service files.
1061+There is currently 2 different Synchronizer backends: FolderSynchronizer and FtpSynchronizer.
1062+When synchronizing there is 3 things to do:
1063+ 1. Pull updates from the remote.
1064+ 2. Push updates to the remote.
1065+ 3. Handle conflicts.
1066+"""
1067+import logging
1068+import uuid
1069+from sqlalchemy.sql import and_
1070+from PyQt5 import QtWidgets, QtCore
1071+
1072+from openlp.core.common.settings import Settings
1073+from openlp.core.common.registry import Registry
1074+from openlp.core.lib import Plugin, StringContent, translate, build_icon
1075+from openlp.core.lib.db import Manager
1076+from openlp.plugins.remotesync.lib.backends.synchronizer import SyncItemType, SyncItemAction, ConflictException, \
1077+ LockException
1078+from openlp.plugins.songs.lib.db import Song
1079+
1080+from openlp.plugins.remotesync.lib import RemoteSyncTab
1081+from openlp.plugins.remotesync.lib.backends.foldersynchronizer import FolderSynchronizer
1082+from openlp.plugins.remotesync.lib.db import init_schema, SyncQueueItem, RemoteSyncItem
1083+
1084+log = logging.getLogger(__name__)
1085+
1086+__default_settings__ = {
1087+ 'remotesync/db type': 'sqlite',
1088+ 'remotesync/db username': '',
1089+ 'remotesync/db password': '',
1090+ 'remotesync/db hostname': '',
1091+ 'remotesync/db database': '',
1092+ 'remotesync/type': 'folder', # folder or ftp
1093+ 'remotesync/folder path': '/tmp/openlp_remote_sync',
1094+ 'remotesync/folder pc id': 'firstpc',
1095+ 'remotesync/ftp host': 'ftp.openlp.io',
1096+ 'remotesync/ftp port': '21',
1097+ 'remotesync/ftp ssl': False,
1098+ 'remotesync/ftp username': 'username',
1099+ 'remotesync/ftp password': 'password',
1100+}
1101+
1102+
1103+class RemoteSyncPlugin(Plugin):
1104+ log.info('RemoteSync Plugin loaded')
1105+
1106+ def __init__(self):
1107+ """
1108+ remotes constructor
1109+ """
1110+ super(RemoteSyncPlugin, self).__init__('remotesync', __default_settings__, settings_tab_class=RemoteSyncTab)
1111+ self.manager = Manager('remotesync', init_schema)
1112+ self.icon_path = ':/plugins/plugin_remote.png'
1113+ self.icon = build_icon(self.icon_path)
1114+ self.weight = -1
1115+ self.synchronizer = None
1116+
1117+ def initialise(self):
1118+ """
1119+ Initialise the remotesync plugin
1120+ """
1121+ log.debug('initialise')
1122+ super(RemoteSyncPlugin, self).initialise()
1123+ if not hasattr(self, 'remote_sync_icon'):
1124+ self.remote_sync_icon = QtWidgets.QLabel(self.main_window.status_bar)
1125+ size_policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
1126+ size_policy.setHorizontalStretch(0)
1127+ size_policy.setVerticalStretch(0)
1128+ size_policy.setHeightForWidth(self.remote_sync_icon.sizePolicy().hasHeightForWidth())
1129+ self.remote_sync_icon.setSizePolicy(size_policy)
1130+ self.remote_sync_icon.setFrameShadow(QtWidgets.QFrame.Plain)
1131+ self.remote_sync_icon.setLineWidth(1)
1132+ self.remote_sync_icon.setScaledContents(True)
1133+ self.remote_sync_icon.setFixedSize(20, 20)
1134+ self.remote_sync_icon.setObjectName('remote_sync_icon')
1135+ self.main_window.status_bar.insertPermanentWidget(2, self.remote_sync_icon)
1136+ self.settings_tab.remote_sync_icon = self.remote_sync_icon
1137+ # TODO: Generate a pc id
1138+ self.settings_tab.generate_icon()
1139+ sync_type = Settings().value('remotesync/type')
1140+ if sync_type == 'folder':
1141+ self.synchronizer = FolderSynchronizer(self.manager, Settings().value('remotesync/folder path'),
1142+ Settings().value('remotesync/folder pc id'))
1143+ else:
1144+ self.synchronizer = None
1145+ if not self.synchronizer.check_connection():
1146+ self.synchronizer.initialize_remote()
1147+ # TODO: register delete functions
1148+ Registry().register_function('song_changed', self.queue_song_for_sync)
1149+ Registry().register_function('custom_changed', self.queue_custom_for_sync)
1150+ Registry().register_function('service_changed', self.save_service)
1151+ Registry().register_function('synchronize_to_remote', self.push_to_remote)
1152+ Registry().register_function('synchronize_from_remote', self.pull_from_remote)
1153+ Registry().register_function('song_deleted', self.queue_song_for_deletion)
1154+ self.startup_check()
1155+ # Set a timer to start the processing of the queue in 10 seconds
1156+ QtCore.QTimer.singleShot(10000, self.synchronize)
1157+
1158+ def finalise(self):
1159+ log.debug('finalise')
1160+ super(RemoteSyncPlugin, self).finalise()
1161+
1162+ @staticmethod
1163+ def about():
1164+ """
1165+ Information about this plugin
1166+ """
1167+ about_text = translate('RemoteSyncPlugin', '<strong>RemoteSync Plugin</strong>'
1168+ '<br />The remotesync plugin provides the ability to synchronize '
1169+ 'songs, custom slides and service-files between multiple OpenLP '
1170+ 'instances.')
1171+ return about_text
1172+
1173+ def set_plugin_text_strings(self):
1174+ """
1175+ Called to define all translatable texts of the plugin
1176+ """
1177+ # Name PluginList
1178+ self.text_strings[StringContent.Name] = {
1179+ 'singular': translate('RemoteSyncPlugin', 'RemoteSync', 'name singular'),
1180+ 'plural': translate('RemoteSyncPlugin', 'RemotesSync', 'name plural')
1181+ }
1182+ # Name for MediaDockManager, SettingsManager
1183+ self.text_strings[StringContent.VisibleName] = {
1184+ 'title': translate('RemoteSyncPlugin', 'RemoteSync', 'container title')
1185+ }
1186+
1187+ def startup_check(self):
1188+ """
1189+ Run through all songs and custom slides to see if they have been synchronized. Queue them if they have not
1190+ """
1191+ song_manager = Registry().get('songs_manager')
1192+ all_songs = song_manager.get_all_objects(Song)
1193+ for song in all_songs:
1194+ # TODO: Check that songs actually exists remotely - should we delete if not?
1195+ synced_songs = self.manager.get_object_filtered(RemoteSyncItem,
1196+ and_(RemoteSyncItem.type == SyncItemType.Song,
1197+ RemoteSyncItem.item_id == song.id))
1198+ if not synced_songs:
1199+ self.queue_song_for_sync(song.id)
1200+ # TODO: Also check custom slides
1201+
1202+ def synchronize(self):
1203+ """
1204+ Synchronize by first pulling data from remote and then pushing local changes to the remote
1205+ """
1206+ self.synchronizer.connect()
1207+ self.pull_from_remote()
1208+ self.push_to_remote()
1209+ self.synchronizer.disconnect()
1210+ # Set a timer to start the synchronization again in 10 minutes.
1211+ QtCore.QTimer.singleShot(600000, self.synchronize)
1212+
1213+ def push_to_remote(self):
1214+ """
1215+ Run through the queue and push songs and custom slides to remote
1216+ """
1217+ queue_items = self.manager.get_all_objects(SyncQueueItem)
1218+ song_manager = Registry().get('songs_manager')
1219+ for queue_item in queue_items:
1220+ if queue_item.type == SyncItemType.Song:
1221+ if queue_item.action == SyncItemAction.Update:
1222+ song = song_manager.get_object(Song, queue_item.item_id)
1223+ # If song has not been sync'ed before we generate a uuid
1224+ sync_item = self.manager.get_object_filtered(RemoteSyncItem,
1225+ and_(RemoteSyncItem.type == SyncItemType.Song,
1226+ RemoteSyncItem.item_id == song.id))
1227+ if not sync_item:
1228+ sync_item = RemoteSyncItem()
1229+ sync_item.type = SyncItemType.Song
1230+ sync_item.item_id = song.id
1231+ sync_item.uuid = str(uuid.uuid4())
1232+ # Synchronize the song
1233+ try:
1234+ version = self.synchronizer.send_song(song, sync_item.uuid, sync_item.version,
1235+ queue_item.first_attempt, queue_item.lock_id)
1236+ except ConflictException:
1237+ log.debug('Conflict detected for song %d / %s' % (sync_item.item_id, sync_item.uuid))
1238+ # TODO: Store the conflict in the DB and turn on the conflict icon
1239+ continue
1240+ except LockException as le:
1241+ # Store the lock time in the DB and keep it in the queue
1242+ log.debug('Lock detected for song %d / %s' % (sync_item.item_id, sync_item.uuid))
1243+ queue_item.first_attempt = le.first_attempt
1244+ queue_item.lock_id = le.lock_id
1245+ self.manager.save_object(queue_item)
1246+ continue
1247+ sync_item.version = version
1248+ # Save the RemoteSyncItem so we know which version we have locally
1249+ self.manager.save_object(sync_item, True)
1250+ elif queue_item.action == SyncItemAction.Delete:
1251+ # Delete the song
1252+ try:
1253+ version = self.synchronizer.delete_song(sync_item.uuid, sync_item.version,
1254+ queue_item.first_attempt, queue_item.lock_id)
1255+ except ConflictException:
1256+ log.debug('Conflict detected for song %d / %s' % (sync_item.item_id, sync_item.uuid))
1257+ # TODO: Store the conflict in the DB and turn on the conflict icon
1258+ continue
1259+ except LockException as le:
1260+ # Store the lock time in the DB and keep it in the queue
1261+ log.debug('Lock detected for song %d / %s' % (sync_item.item_id, sync_item.uuid))
1262+ queue_item.first_attempt = le.first_attempt
1263+ queue_item.lock_id = le.lock_id
1264+ self.manager.save_object(queue_item)
1265+ continue
1266+ # Delete the SyncQueueItem from the queue since the synchronization is now complete
1267+ self.manager.delete_all_objects(SyncQueueItem, and_(SyncQueueItem.item_id == queue_item.item_id,
1268+ SyncQueueItem.type == SyncItemType.Song))
1269+
1270+ elif queue_item.type == SyncItemType.Custom:
1271+ # TODO: Handle custom slides
1272+ pass
1273+
1274+ def queue_song_for_sync(self, song_id):
1275+ """
1276+ Put song in queue to be sync'ed
1277+ :param song_id:
1278+ """
1279+ # First check that the song isn't already in the queue
1280+ queue_item = self.manager.get_object_filtered(SyncQueueItem, and_(SyncQueueItem.item_id == song_id,
1281+ SyncQueueItem.type == SyncItemType.Song))
1282+ if not queue_item:
1283+ queue_item = SyncQueueItem()
1284+ queue_item.item_id = song_id
1285+ queue_item.type = SyncItemType.Song
1286+ queue_item.action = SyncItemAction.Update
1287+ self.manager.save_object(queue_item, True)
1288+
1289+ def queue_custom_for_sync(self, custom_id):
1290+ """
1291+ Put custom slide in queue to be sync'ed
1292+ :param custom_id:
1293+ """
1294+ # First check that the custom slide isn't already in the queue
1295+ queue_item = self.manager.get_object_filtered(SyncQueueItem, and_(SyncQueueItem.item_id == custom_id,
1296+ SyncQueueItem.type == SyncItemType.Custom))
1297+ if not queue_item:
1298+ queue_item = SyncQueueItem()
1299+ queue_item.item_id = custom_id
1300+ queue_item.type = SyncItemType.Custom
1301+ queue_item.action = SyncItemAction.Update
1302+ self.manager.save_object(queue_item, True)
1303+
1304+ def queue_song_for_deletion(self, song_id):
1305+ """
1306+ Put song in queue to be deleted
1307+ :param song_id:
1308+ """
1309+ queue_item = SyncQueueItem()
1310+ queue_item.item_id = song_id
1311+ queue_item.type = SyncItemType.Song
1312+ queue_item.action = SyncItemAction.Delete
1313+ self.manager.save_object(queue_item, True)
1314+
1315+ def queue_custom_for_deletion(self, custom_id):
1316+ """
1317+ Put custom slide in queue to be deleted
1318+ :param custom_id:
1319+ """
1320+ queue_item = SyncQueueItem()
1321+ queue_item.item_id = custom_id
1322+ queue_item.type = SyncItemType.Song
1323+ queue_item.action = SyncItemAction.Delete
1324+ self.manager.save_object(queue_item, True)
1325+
1326+ def pull_from_remote(self):
1327+ updated = self.synchronizer.get_remote_changes()
1328+ if updated:
1329+ Registry().execute('songs_load_list')
1330+
1331+ def save_service(self, service_item):
1332+ pass
1333+
1334+ def handle_conflicts(self):
1335+ # (Re)use the duplicate song UI to let the user manually handle the conflicts. Also allow for batch
1336+ # processing, where either local or remote always wins.
1337+ pass
1338
1339=== modified file 'openlp/plugins/songs/forms/editsongform.py'
1340--- openlp/plugins/songs/forms/editsongform.py 2019-08-04 13:13:33 +0000
1341+++ openlp/plugins/songs/forms/editsongform.py 2019-09-17 19:27:12 +0000
1342@@ -1100,3 +1100,4 @@
1343 clean_song(self.manager, self.song)
1344 self.manager.save_object(self.song)
1345 self.media_item.auto_select_id = self.song.id
1346+ Registry().execute('song_changed', self.song.id)
1347
1348=== modified file 'openlp/plugins/songs/lib/db.py'
1349--- openlp/plugins/songs/lib/db.py 2019-07-30 19:52:10 +0000
1350+++ openlp/plugins/songs/lib/db.py 2019-09-17 19:27:12 +0000
1351@@ -263,6 +263,9 @@
1352 * theme_name
1353 * search_title
1354 * search_lyrics
1355+ * created_date
1356+ * last_modified
1357+ * temporary
1358
1359 **songs_songsbooks Table**
1360 This is a mapping table between the *songs* and the *song_books* tables. It has the following columns:
1361
1362=== modified file 'openlp/plugins/songs/lib/mediaitem.py'
1363--- openlp/plugins/songs/lib/mediaitem.py 2019-05-22 06:47:00 +0000
1364+++ openlp/plugins/songs/lib/mediaitem.py 2019-09-17 19:27:12 +0000
1365@@ -523,6 +523,7 @@
1366 item_id = item.data(QtCore.Qt.UserRole)
1367 delete_song(item_id, self.plugin)
1368 self.main_window.increment_progress_bar()
1369+ Registry().execute('song_deleted', self.song.id)
1370 self.main_window.finished_progress_bar()
1371 self.application.set_normal_cursor()
1372 self.on_search_text_button_clicked()
1373
1374=== modified file 'openlp/plugins/songs/lib/openlyricsxml.py'
1375--- openlp/plugins/songs/lib/openlyricsxml.py 2019-07-03 13:23:23 +0000
1376+++ openlp/plugins/songs/lib/openlyricsxml.py 2019-09-17 19:27:12 +0000
1377@@ -228,7 +228,7 @@
1378 self.manager = manager
1379 FormattingTags.load_tags()
1380
1381- def song_to_xml(self, song):
1382+ def song_to_xml(self, song, version=None):
1383 """
1384 Convert the song to OpenLyrics Format.
1385 """
1386@@ -257,6 +257,9 @@
1387 'verseOrder', properties, song.verse_order.lower())
1388 if song.ccli_number:
1389 self._add_text_to_element('ccliNo', properties, song.ccli_number)
1390+ # Add a custom version
1391+ if version:
1392+ self._add_text_to_element('version', properties, version)
1393 if song.authors_songs:
1394 authors = etree.SubElement(properties, 'authors')
1395 for author_song in song.authors_songs:
1396@@ -369,7 +372,7 @@
1397 end_tags.reverse()
1398 return ''.join(start_tags), ''.join(end_tags)
1399
1400- def xml_to_song(self, xml, parse_and_temporary_save=False):
1401+ def xml_to_song(self, xml, parse_and_temporary_save=False, update_song_id=None):
1402 """
1403 Create and save a song from OpenLyrics format xml to the database. Since we also export XML from external
1404 sources (e. g. OpenLyrics import), we cannot ensure, that it completely conforms to the OpenLyrics standard.
1405@@ -391,7 +394,10 @@
1406 # Formatting tags are new in OpenLyrics 0.8
1407 if float(song_xml.get('version')) > 0.7:
1408 self._process_formatting_tags(song_xml, parse_and_temporary_save)
1409- song = Song()
1410+ if update_song_id:
1411+ song = self.manager.get_object(Song, update_song_id)
1412+ else:
1413+ song = Song()
1414 # Values will be set when cleaning the song.
1415 song.search_lyrics = ''
1416 song.verse_order = ''
1417
1418=== modified file 'openlp/plugins/songs/songsplugin.py'
1419--- openlp/plugins/songs/songsplugin.py 2019-06-19 20:52:50 +0000
1420+++ openlp/plugins/songs/songsplugin.py 2019-09-17 19:27:12 +0000
1421@@ -144,6 +144,7 @@
1422 """
1423 super(SongsPlugin, self).__init__('songs', __default_settings__, SongMediaItem, SongsTab)
1424 self.manager = Manager('songs', init_schema, upgrade_mod=upgrade)
1425+ Registry().register('songs_manager', self.manager)
1426 self.weight = -10
1427 self.icon_path = UiIcons().music
1428 self.icon = build_icon(self.icon_path)