Merge branch 'remote-sync' into 'master'

WIP: Add new remote-sync plugin

See merge request openlp/openlp!9
This commit is contained in:
Tomas Groth 2024-05-02 16:50:27 +00:00
commit 90e417684a
24 changed files with 2859 additions and 2 deletions

View File

@ -149,6 +149,25 @@ class SongSearch(IntEnum):
@unique
class SyncType(IntEnum):
"""
The synchronization types available
"""
Disabled = 0
Folder = 1
Ftp = 2
WebService = 3
@unique
class FtpType(IntEnum):
"""
The supported FTP types
"""
Ftp = 1
FtpTls = 2
class SongFirstSlideMode(IntEnum):
"""
An enumeration for song first slide types.

View File

@ -33,7 +33,7 @@ from PyQt5 import QtCore, QtGui
from openlp.core.common import SlideLimits, ThemeLevel
from openlp.core.common.enum import AlertLocation, BibleSearch, CustomSearch, HiDPIMode, ImageThemeMode, LayoutStyle, \
DisplayStyle, LanguageSelection, SongFirstSlideMode, SongSearch, PluginStatus
DisplayStyle, LanguageSelection, SongFirstSlideMode, SongSearch, PluginStatus, SyncType, FtpType
from openlp.core.common.json import OpenLPJSONDecoder, OpenLPJSONEncoder, is_serializable
from openlp.core.common.path import files_to_paths, str_to_path
from openlp.core.common.platform import is_linux, is_win
@ -410,7 +410,21 @@ class Settings(QtCore.QSettings):
'projector/poll time': 20, # PJLink timeout is 30 seconds
'projector/socket timeout': 5, # 5 second socket timeout
'projector/source dialog type': 0, # Source select dialog box type
'projector/udp broadcast listen': False # Enable/disable listening for PJLink 2 UDP broadcast packets
'projector/udp broadcast listen': False, # Enable/disable listening for PJLink 2 UDP broadcast packets
'remotesync/status': PluginStatus.Active,
'remotesync/db type': 'sqlite',
'remotesync/db username': '',
'remotesync/db password': '',
'remotesync/db hostname': '',
'remotesync/db database': '',
'remotesync/type': SyncType.Disabled,
'remotesync/folder path': '',
'remotesync/folder pc id': None,
'remotesync/ftp server': '',
'remotesync/ftp type': FtpType.Ftp,
'remotesync/ftp username': '',
'remotesync/ftp password': '',
'remotesync/ftp data folder': '',
}
__file_path__ = ''
# Settings upgrades prior to 3.0

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
The :mod:`remotesync` module contains the Remote Sync plugin. The remotesync plugin provides the ability to synchronize
songs, custom slides and service-files between multiple OpenLP instances.
"""

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
from .remotesynctab import RemoteSyncTab
__all__ = ['RemoteSyncTab']

View File

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################

View File

@ -0,0 +1,522 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
import datetime
import shutil
import logging
from pathlib import Path
from openlp.core.common.registry import Registry
from openlp.plugins.custom.lib.customxmlhandler import CustomXML
from openlp.plugins.custom.lib.db import CustomSlide
from openlp.plugins.songs.lib import delete_song as delete_in_song_plugin
from openlp.plugins.remotesync.lib.backends.synchronizer import Synchronizer, SyncItemType, ConflictException, \
LockException, ConflictReason
from openlp.plugins.remotesync.lib.db import RemoteSyncItem
log = logging.getLogger(__name__)
class FolderSynchronizer(Synchronizer):
"""
The FolderSynchronizer uses xml-files in a simple holder structure for synchronizing data.
The folder-structure looks like this:
<base-folder>
+---songs
| +---history
| +---deleted
+---customs
| +---history
| +---deleted
+---services
The files are named after the uuid generated for each song or custom slide, the id of the
OpenLP instance and the version of the song, like this: {uuid}={version}={computer_id}.xml
An example could be: bd5bc6c2-4fd2-4a42-925f-48d00de835ec=4=churchpc1.xml
When a file is updated a lock file is created to signal that the song is locked. The filename
of the lock file is: {uuid}.lock={computer_id}={version}
As part of the updating of the file, the old version is moved to the appropriate history folder.
When a song/custom slide is deleted, its file is moved to the history-folder, and an empty file
named as the items uuid is placed in the deleted-folder.
"""
def __init__(self, manager, base_folder_path, pc_id):
"""
Initilize the synchronizer
:param manager:
:type Manager:
:param base_folder_path:
:type str:
:param pc_id:
:type str:
"""
super(FolderSynchronizer, self).__init__(manager)
self.base_folder_path = base_folder_path
self.pc_id = pc_id
self.song_folder_path = self.base_folder_path / 'songs'
self.song_history_folder_path = self.song_folder_path / 'history'
self.song_deleted_folder_path = self.song_folder_path / 'deleted'
self.custom_folder_path = self.base_folder_path / 'customs'
self.custom_history_folder_path = self.custom_folder_path / 'history'
self.custom_deleted_folder_path = self.custom_folder_path / 'deleted'
self.service_folder_path = self.base_folder_path / 'services'
def check_configuration(self):
return True
def check_connection(self):
return self.base_folder_path.exists() and self.song_history_folder_path.exists() and \
self.song_deleted_folder_path.exists() and self.custom_folder_path.exists() and \
self.custom_history_folder_path.exists() and self.custom_deleted_folder_path.exists() and \
self.service_folder_path.exists()
def initialize_remote(self):
self.song_history_folder_path.mkdir(parents=True, exist_ok=True)
self.song_deleted_folder_path.mkdir(parents=True, exist_ok=True)
self.custom_history_folder_path.mkdir(parents=True, exist_ok=True)
self.custom_deleted_folder_path.mkdir(parents=True, exist_ok=True)
self.service_folder_path.mkdir(parents=True, exist_ok=True)
def _get_file_list(self, path, mask):
return list(path.glob(mask))
def _remove_lock_file(self, lock_filename):
Path(lock_filename).unlink()
def _move_file(self, src, dst):
shutil.move(src, dst)
def _create_file(self, filename, file_content):
out_file = open(filename, 'wt')
out_file.write(file_content)
out_file.close()
def _read_file(self, filename):
in_file = open(filename, 'rt')
content = in_file.read()
in_file.close()
return content
def _check_for_lock_file(self, type, path, uuid, first_sync_attempt, prev_lock_id):
"""
Check for lock file. Raises exception if a valid lock file is found. If an expired lock file is found
it is deleted.
:param type:
:type str:
:param path:
:type str:
:param uuid:
:type str:
:param first_sync_attempt:
:type datetime:
:param prev_lock_id:
:type str:
"""
existing_lock_file = self._get_file_list(path, uuid + '.lock*')
if existing_lock_file:
log.debug('Found a lock file!')
current_lock_id = str(existing_lock_file[0]).split('.lock=')[-1]
if first_sync_attempt:
# Have we seen this lock before?
if current_lock_id == prev_lock_id:
# If the lock is more than 60 seconds old it is deleted
delta = datetime.datetime.now() - first_sync_attempt
if delta.total_seconds() > 60:
# Remove expired lock
self._remove_lock_file(existing_lock_file[0])
else:
# Lock is still valid, keep waiting
raise LockException(type, uuid, current_lock_id, first_sync_attempt)
else:
# New lock encountered, now we have to wait - again
raise LockException(type, uuid, current_lock_id, datetime.datetime.now())
else:
# New lock encountered, now we have to wait for it
raise LockException(type, uuid, current_lock_id, datetime.datetime.now())
def get_remote_song_changes(self):
"""
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
remotely, they are also deleted locally.
:return: True if one or more songs was updated, otherwise False
"""
updated = self._get_remote_changes(SyncItemType.Song)
return updated
def send_song(self, song, song_uuid, last_known_version, first_sync_attempt, prev_lock_id):
"""
Sends a song to the shared folder.
:param song: The song object to synchronize
:param song_uuid: The uuid of the song
:param last_known_version: The last known version of the song
:param first_sync_attempt: If the song has been attempted synchronized before,
this is the timestamp of the first sync attempt.
:param prev_lock_id: If the song has been attempted synchronized before, this is the id of the lock that
prevented the synchronization.
:return: The new version.
"""
if last_known_version:
counter = int(last_known_version.split('=')[0]) + 1
else:
counter = 0
version = '{counter}={computer_id}'.format(counter=counter, computer_id=self.pc_id)
content = self.open_lyrics.song_to_xml(song, version)
self._send_item(SyncItemType.Song, content, song_uuid, version, last_known_version, first_sync_attempt,
prev_lock_id)
return version
def fetch_song(self, song_uuid, song_id):
"""
Fetch a specific song from the shared folder and stores it in the song db
:param song_uuid: uuid of the song
:param song_id: song db id, None if song does not yet exists in the song db
:return: The song object
"""
version, item_content = self._fetch_item(SyncItemType.Song, song_uuid)
if not version:
return None
# this also stores the song in the song database
song = self.open_lyrics.xml_to_song(item_content, update_song_id=song_id)
sync_item = self.manager.get_object_filtered(RemoteSyncItem, RemoteSyncItem.uuid == song_uuid)
if not sync_item:
sync_item = RemoteSyncItem()
sync_item.type = SyncItemType.Song
sync_item.item_id = song.id
sync_item.uuid = song_uuid
sync_item.version = version
self.manager.save_object(sync_item, True)
return song
def delete_song(self, song_uuid, first_del_attempt, prev_lock_id):
"""
Delete song from the remote location. Does the following:
1. Check for an existing lock, raise LockException if one found.
2. Create a lock file and move the existing file to the history folder.
3. Place a file in the deleted folder, named after the song uuid.
4. Delete lock file.
:param song_uuid:
:type str:
:param first_del_attempt:
:type DateTime:
:param prev_lock_id:
:type str:
"""
self._delete_item(SyncItemType.Song, song_uuid, first_del_attempt, prev_lock_id)
def get_remote_custom_changes(self):
"""
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
fetch_custom method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
remotely, they are also deleted locally.
:return: True if one or more songs was updated, otherwise False
"""
updated = self._get_remote_changes(SyncItemType.Custom)
return updated
def send_custom(self, custom, custom_uuid, last_known_version, first_sync_attempt, prev_lock_id):
"""
Sends a custom slide to the shared folder.
:param custom: The custom object to synchronize
:param custom_uuid: The uuid of the custom slide
:param last_known_version: The last known version of the custom slide
:param first_sync_attempt: If the custom slide has been attempted synchronized before,
this is the timestamp of the first sync attempt.
:param prev_lock_id: If the custom slide has been attempted synchronized before, this is the id of the lock
that prevented the synchronization.
:return: The new version.
"""
if last_known_version:
counter = int(last_known_version.split('=')[0]) + 1
else:
counter = 0
version = '{counter}={computer_id}'.format(counter=counter, computer_id=self.pc_id)
custom_xml = CustomXML(custom.text)
custom_xml.add_title_and_credit(custom.title, custom.credits)
content = str(custom_xml.extract_xml(True), 'utf-8')
self._send_item(SyncItemType.Custom, content, custom_uuid, version, last_known_version, first_sync_attempt,
prev_lock_id)
return version
def fetch_custom(self, custom_uuid, custom_id):
"""
Fetch a specific custom slide from the shared folder and stores it in the custom db
:param custom_uuid: uuid of the custom slide
:param custom_id: custom db id, None if the custom slide does not yet exists in the custom db
:return: The custom object
"""
version, item_content = self._fetch_item(SyncItemType.Custom, custom_uuid)
if not version:
return None
custom = CustomXML(item_content)
# save the slide in the custom db
custom_manager = Registry().get('custom_manager')
log.debug('fetched custom item: %s' % (custom_id))
if custom_id:
custom_slide = custom_manager.get_object(CustomSlide, custom_id)
else:
custom_slide = CustomSlide()
custom_slide.title = custom.get_title()
custom_slide.text = str(custom.extract_xml(), 'utf-8')
custom_slide.credits = custom.get_credit()
# custom_slide.theme_name =
custom_manager.save_object(custom_slide)
# save to the sync map
sync_item = self.manager.get_object_filtered(RemoteSyncItem, RemoteSyncItem.uuid == custom_uuid)
if not sync_item:
sync_item = RemoteSyncItem()
sync_item.type = SyncItemType.Custom
sync_item.item_id = custom_slide.id
sync_item.uuid = custom_uuid
sync_item.version = version
self.manager.save_object(sync_item, True)
return custom
def delete_custom(self, custom_uuid, first_del_attempt, prev_lock_id):
"""
Delete custom slide from the remote location. Does the following:
1. Check for an existing lock, raise LockException if one found.
2. Create a lock file and move the existing file to the history folder.
3. Place a file in the deleted folder, named after the custom slide uuid.
4. Delete lock file.
:param custom_uuid:
:type str:
:param first_del_attempt:
:type DateTime:
:param prev_lock_id:
:type str:
"""
self._delete_item(SyncItemType.Custom, custom_uuid, first_del_attempt, prev_lock_id)
def send_service(self, service):
pass
def fetch_service(self):
pass
def _get_remote_changes(self, item_type):
"""
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
remotely, they are also deleted locally.
:return: True if one or more songs was updated or deleted, otherwise False
"""
updated = False
# Check for updated or new files
if item_type == SyncItemType.Song:
folder_path = self.song_folder_path
else:
folder_path = self.custom_folder_path
item_files = self._get_file_list(folder_path, '*.xml')
conflicts = []
for item_file in item_files:
# skip conflicting files
if item_file in conflicts:
continue
# Check if this item is already sync'ed
filename = item_file.name
filename_elements = filename.split('=', 1)
uuid = filename_elements[0]
file_version = filename_elements[1].replace('.xml', '')
# Detect if there are multiple files for the same item, which would mean that we have a conflict
files = []
for item_file2 in item_files:
if uuid in str(item_file2):
files.append(item_file2)
# if more than one item file has the same uuid, then we have a conflict
if len(files) > 1:
# Add conflicting files to the "blacklist"
conflicts += files
# Mark item as conflicted!
self.mark_item_for_conflict(item_type, uuid, ConflictReason.MultipleRemoteEntries)
existing_item = self.get_sync_item(uuid, item_type)
item_id = existing_item.item_id if existing_item else None
# If we do not have a local version or if the remote version is different, then we update
if not existing_item or existing_item.version != file_version:
if existing_item:
log.debug('Local version (%s) and file version (%s) mismatch - updated triggered!' % (
existing_item.version, file_version))
log.debug('About to fetch item: %s %d' % (uuid, item_id))
else:
log.debug('About to fetch new item: %s' % (uuid))
try:
if item_type == SyncItemType.Song:
self.fetch_song(uuid, item_id)
else:
self.fetch_custom(uuid, item_id)
except ConflictException as ce:
log.debug('Conflict detected while fetching item %d / %s!' % (item_id, uuid))
self.mark_item_for_conflict(item_type, uuid, ce.reason)
continue
updated = True
# Check for deleted files
if item_type == SyncItemType.Song:
deleted_folder_path = self.custom_deleted_folder_path
else:
deleted_folder_path = self.song_deleted_folder_path
deleted_item_files = self._get_file_list(deleted_folder_path, '*')
for deleted_item_file in deleted_item_files:
# if the a deleted item exists in the local database it means it must be deleted locally
sync_item = self.manager.get_object_filtered(RemoteSyncItem, RemoteSyncItem.uuid == deleted_item_file.name)
if sync_item:
updated = True
# delete the item from its plugin db
if item_type == SyncItemType.Song:
delete_in_song_plugin(sync_item.item_id, False)
else:
custom_manager = Registry().get('custom_manager')
custom_manager.delete_object(CustomSlide, sync_item.item_id)
# remove the item from the remotesync db
self.manager.delete_all_objects(RemoteSyncItem, RemoteSyncItem.item_id == sync_item.item_id)
return updated
def _send_item(self, item_type, item_content, item_uuid, version, last_known_version, first_sync_attempt,
prev_lock_id):
"""
Sends an item to the shared folder. Does the following:
1. Check for an existing lock, raise LockException if one found.
2. Check if the item already exists on remote. If so, check if the latest version is available locally, raise
ConflictException if the remote version is not known locally. If the latest version is known, create a lock
file and move the existing file to the history folder. If the item does not exists already, just create a
lock file.
3. Place file with item in folder.
4. Delete lock file.
:param item_type: The type of the item
:param item_content: The content to save and syncronize
:param item_uuid: The uuid of the item
:param version: The new version of the item
:param last_known_version: The last known version of the item
:param first_sync_attempt: If the item has been attempted synchronized before,
this is the timestamp of the first sync attempt.
:param prev_lock_id: If the item has been attempted synchronized before, this is the id of the lock that
prevented the synchronization.
:return: The new version.
"""
if item_type == SyncItemType.Song:
folder_path = self.song_folder_path
history_folder_path = self.song_history_folder_path
else:
folder_path = self.custom_folder_path
history_folder_path = self.custom_history_folder_path
# Check for lock file. Will raise exception on lock
self._check_for_lock_file(item_type, folder_path, item_uuid, first_sync_attempt, prev_lock_id)
# Check if item already exists
existing_item_files = self._get_file_list(folder_path, item_uuid + '*.xml')
counter = -1
if existing_item_files:
# Handle case with multiple files returned, which indicates a conflict!
if len(existing_item_files) > 1:
raise ConflictException(item_type, item_uuid, ConflictReason.MultipleRemoteEntries)
existing_file = existing_item_files[0].name
filename_elements = existing_file.split('=')
counter = int(filename_elements[1])
if last_known_version:
current_local_counter = int(last_known_version.split('=')[0])
# Check if we do have the latest version locally, if not we flag a conflict
if current_local_counter != counter:
raise ConflictException(item_type, item_uuid, ConflictReason.VersionMismatch)
counter += 1
# Create lock file
lock_filename = '{path}.lock={pcid}={counter}'.format(path=str(folder_path / item_uuid),
pcid=self.pc_id, counter=counter)
self._create_file(lock_filename, '')
# Move old file to history folder
self._move_file(folder_path / existing_file, history_folder_path)
else:
# TODO: Check for missing (deleted) file
counter += 1
lock_filename = '{path}.lock={pcid}={counter}'.format(path=str(folder_path / item_uuid),
pcid=self.pc_id, counter=counter)
# Create lock file
self._create_file(lock_filename, '')
basename = item_uuid + "=" + version + '.xml'
basename_tmp = basename + '-tmp'
new_filename = folder_path / basename
new_tmp_filename = folder_path / basename_tmp
self._create_file(new_tmp_filename, item_content)
self._move_file(new_tmp_filename, new_filename)
# Delete lock file
self._remove_lock_file(lock_filename)
return version
def _fetch_item(self, item_type, item_uuid):
"""
Fetch a specific item from the shared folder
:param item_type: type of the item
:param item_uuid: uuid of the item
:return: The song object
"""
if item_type == SyncItemType.Song:
folder_path = self.song_folder_path
else:
folder_path = self.custom_folder_path
# Check for lock file - is this actually needed? should we create a read lock?
if self._get_file_list(folder_path, item_uuid + '.lock'):
log.debug('Found a lock file! Ignoring it for now.')
existing_item_files = self._get_file_list(folder_path, item_uuid + '*')
if existing_item_files:
# Handle case with multiple files returned, which indicates a conflict!
if len(existing_item_files) > 1:
raise ConflictException(item_type, item_uuid, ConflictReason.MultipleRemoteEntries)
existing_file = existing_item_files[0].name
filename_elements = existing_file.split('=', 1)
version = filename_elements[1].replace('.xml', '')
item_content = self._read_file(existing_item_files[0])
return version, item_content
else:
return None, None
def _delete_item(self, item_type, item_uuid, first_del_attempt, prev_lock_id):
"""
Delete item from the remote location. Does the following:
1. Check for an existing lock, raise LockException if one found.
2. Create a lock file and move the existing file to the history folder.
3. Place a file in the deleted folder, named after the item uuid.
4. Delete lock file.
:param item_type:
:param item_uuid:
:type str:
:param first_del_attempt:
:type DateTime:
:param prev_lock_id:
:type str:
"""
if item_type == SyncItemType.Song:
folder_path = self.song_folder_path
history_folder_path = self.song_history_folder_path
deleted_folder_path = self.song_deleted_folder_path
else:
folder_path = self.custom_folder_path
history_folder_path = self.custom_history_folder_path
deleted_folder_path = self.custom_deleted_folder_path
# Check for lock file. Will raise exception on lock
self._check_for_lock_file(item_type, folder_path, item_uuid, first_del_attempt, prev_lock_id)
# Move the song xml file to the history folder
existing_item_files = self._get_file_list(folder_path, item_uuid + '*.xml')
if existing_item_files:
# Handle case with multiple files returned, which indicates a conflict!
if len(existing_item_files) > 1:
raise ConflictException(item_type, item_uuid, ConflictReason.MultipleRemoteEntries)
existing_file = existing_item_files[0].name
# Move old file to deleted folder
self._move_file(folder_path / existing_file, history_folder_path)
# Create a file in the deleted-folder
delete_filename = deleted_folder_path / item_uuid
self._create_file(delete_filename, '')

View File

@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
import fnmatch
from ftplib import FTP, FTP_TLS
from io import TextIOBase
from pathlib import Path
from openlp.core.common.enum import FtpType
from openlp.plugins.remotesync.lib.backends.foldersynchronizer import FolderSynchronizer
class FtpSynchronizer(FolderSynchronizer):
def __init__(self, manager, base_folder_path, pc_id, ftp_type, ftp_server, ftp_user, ftp_pwd):
super(FtpSynchronizer, self).__init__(manager, base_folder_path, pc_id)
self.ftp = None
self.ftp_type = ftp_type
self.ftp_server = ftp_server
self.ftp_user = ftp_user
self.ftp_pwd = ftp_pwd
def check_configuration(self):
return True
def check_connection(self):
self.connect()
base_folder_content = self._get_file_list(self.base_folder_path, '*')
self.disconnect()
if base_folder_content:
return True
return False
def initialize_remote(self):
self.connect()
self.ftp.mkd(self.song_history_folder_path)
self.ftp.mkd(self.custom_folder_path)
self.ftp.mkd(self.service_folder_path)
self.disconnect()
def connect(self):
if self.ftp_type == FtpType.Ftp:
self.ftp = FTP(self.ftp_server, self.ftp_user, self.ftp_pwd)
else:
self.ftp = FTP_TLS(self.ftp_server, self.ftp_user, self.ftp_pwd)
def disconnect(self):
self.ftp.close()
self.ftp = None
def _get_file_list(self, path, mask):
file_list = self.ftp.nlst(path)
filtered_list = fnmatch.filter(file_list, mask)
# The returned list must be a list of Path objects
path_filtered_list = [Path(f) for f in filtered_list]
return path_filtered_list
def _remove_lock_file(self, lock_filename):
self.ftp.remove(str(lock_filename))
def _move_file(self, src, dst):
self.ftp.move(src, dst)
def _create_file(self, filename, file_content):
text_stream = TextIOBase()
text_stream.write(file_content)
self.ftp.storbinary('STOR ' + str(filename), text_stream)
def _read_file(self, filename):
text_stream = TextIOBase()
self.ftp.retrbinary('RETR ' + str(filename), text_stream, 1024)
return text_stream.read()

View File

@ -0,0 +1,3 @@
from .api_config import *
from .models import *
from .services import *

View File

@ -0,0 +1,30 @@
import os
from typing import Optional, Union
from pydantic import BaseModel, Field
class APIConfig(BaseModel):
base_path: str = "http://localhost:8888/api/v1"
verify: Union[bool, str] = True
def get_access_token(self) -> Optional[str]:
try:
return os.environ["access_token"]
except KeyError:
return None
def set_access_token(self, value: str):
raise Exception(
"This client was generated with an environment variable for the access token. Please set the environment variable 'access_token' to the access token."
)
class HTTPException(Exception):
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f"{status_code} {message}")
def __str__(self):
return f"{self.status_code} {self.message}"

View File

@ -0,0 +1,16 @@
from typing import *
from pydantic import BaseModel, Field
class ApiResponse(BaseModel):
"""
None model
"""
code: Optional[int] = Field(alias="code", default=None)
type: Optional[str] = Field(alias="type", default=None)
message: Optional[str] = Field(alias="message", default=None)

View File

@ -0,0 +1,20 @@
from typing import *
from pydantic import BaseModel, Field
class ItemInfo(BaseModel):
"""
None model
"""
uuid: Optional[str] = Field(alias="uuid", default=None)
version: Optional[int] = Field(alias="version", default=None)
user: Optional[str] = Field(alias="user", default=None)
timestamp: Optional[str] = Field(alias="timestamp", default=None)
title: Optional[str] = Field(alias="title", default=None)

View File

@ -0,0 +1,14 @@
from typing import *
from pydantic import BaseModel, Field
from .ItemInfo import ItemInfo
class ItemList(BaseModel):
"""
None model
"""
list: Optional[List[Optional[ItemInfo]]] = Field(alias="list", default=None)

View File

@ -0,0 +1,22 @@
from typing import *
from pydantic import BaseModel, Field
class TextItem(BaseModel):
"""
None model
"""
uuid: Optional[str] = Field(alias="uuid", default=None)
version: Optional[int] = Field(alias="version", default=None)
user: Optional[str] = Field(alias="user", default=None)
timestamp: Optional[str] = Field(alias="timestamp", default=None)
title: Optional[str] = Field(alias="title", default=None)
itemxml: Optional[str] = Field(alias="itemxml", default=None)

View File

@ -0,0 +1,4 @@
from .ApiResponse import *
from .ItemInfo import *
from .ItemList import *
from .TextItem import *

View File

@ -0,0 +1,3 @@
The restclient client was created using the OpenAPI python generator
See https://marcomuellner.github.io/openapi-python-generator/

View File

@ -0,0 +1,165 @@
import json
from typing import *
import requests
from ..api_config import APIConfig, HTTPException
from ..models import *
def getCustomslideList(api_config_override: Optional[APIConfig] = None) -> ItemList:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/custom-list"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"get",
f"{base_path}{path}",
headers=headers,
params=query_params,
verify=api_config.verify,
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return ItemList(**response.json()) if response.json() is not None else ItemList()
def getCustomslide(uuid: str, api_config_override: Optional[APIConfig] = None) -> TextItem:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/custom/{uuid}"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"get",
f"{base_path}{path}",
headers=headers,
params=query_params,
verify=api_config.verify,
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return TextItem(**response.json()) if response.json() is not None else TextItem()
def updateCustomslide(uuid: str, data: TextItem, api_config_override: Optional[APIConfig] = None) -> None:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/custom/{uuid}"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"put", f"{base_path}{path}", headers=headers, params=query_params, verify=api_config.verify, json=data.dict()
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return None
def deleteCustomslide(uuid: str, api_config_override: Optional[APIConfig] = None) -> None:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/custom/{uuid}"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"delete",
f"{base_path}{path}",
headers=headers,
params=query_params,
verify=api_config.verify,
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return None
def getCustomslideVersion(uuid: str, version: int, api_config_override: Optional[APIConfig] = None) -> TextItem:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/custom/{uuid}/{version}"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"get",
f"{base_path}{path}",
headers=headers,
params=query_params,
verify=api_config.verify,
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return TextItem(**response.json()) if response.json() is not None else TextItem()
def getCustomslideHistory(uuid: str, api_config_override: Optional[APIConfig] = None) -> TextItem:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/custom-history/{uuid}"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"get",
f"{base_path}{path}",
headers=headers,
params=query_params,
verify=api_config.verify,
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return TextItem(**response.json()) if response.json() is not None else TextItem()

View File

@ -0,0 +1,219 @@
import json
from typing import *
import requests
from ..api_config import APIConfig, HTTPException
from ..models import *
def getSongList(api_config_override: Optional[APIConfig] = None) -> ItemList:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/song-list"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"get",
f"{base_path}{path}",
headers=headers,
params=query_params,
verify=api_config.verify,
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return ItemList(**response.json()) if response.json() is not None else ItemList()
def getSongListChangesSince(sinceDateTime: str, api_config_override: Optional[APIConfig] = None) -> ItemList:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/song-list/changes/{sinceDateTime}"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"get",
f"{base_path}{path}",
headers=headers,
params=query_params,
verify=api_config.verify,
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return ItemList(**response.json()) if response.json() is not None else ItemList()
def getSong(uuid: str, api_config_override: Optional[APIConfig] = None) -> TextItem:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/song/{uuid}"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"get",
f"{base_path}{path}",
headers=headers,
params=query_params,
verify=api_config.verify,
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return TextItem(**response.json()) if response.json() is not None else TextItem()
def updateSong(uuid: str, data: TextItem, api_config_override: Optional[APIConfig] = None) -> None:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/song/{uuid}"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"put", f"{base_path}{path}", headers=headers, params=query_params, verify=api_config.verify, json=data.dict()
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return None
def deleteSong(uuid: str, api_config_override: Optional[APIConfig] = None) -> None:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/song/{uuid}"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"delete",
f"{base_path}{path}",
headers=headers,
params=query_params,
verify=api_config.verify,
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return None
def getSongVersion(uuid: str, version: int, api_config_override: Optional[APIConfig] = None) -> TextItem:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/song/{uuid}/{version}"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"get",
f"{base_path}{path}",
headers=headers,
params=query_params,
verify=api_config.verify,
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return TextItem(**response.json()) if response.json() is not None else TextItem()
def getSongHistory(uuid: str, api_config_override: Optional[APIConfig] = None) -> TextItem:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/song-history/{uuid}"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"get",
f"{base_path}{path}",
headers=headers,
params=query_params,
verify=api_config.verify,
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return TextItem(**response.json()) if response.json() is not None else TextItem()
def getCustomSlideListChangesSince(sinceDateTime: str, api_config_override: Optional[APIConfig] = None) -> ItemList:
api_config = api_config_override if api_config_override else APIConfig()
base_path = api_config.base_path
path = f"/custom-list/changes/{sinceDateTime}"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer { api_config.get_access_token() }",
}
query_params: Dict[str, Any] = {}
query_params = {key: value for (key, value) in query_params.items() if value is not None}
response = requests.request(
"get",
f"{base_path}{path}",
headers=headers,
params=query_params,
verify=api_config.verify,
)
if response.status_code != 200:
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
return ItemList(**response.json()) if response.json() is not None else ItemList()

View File

@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
from sqlalchemy.sql import and_
from openlp.core.common.registry import Registry
from openlp.plugins.remotesync.lib.db import RemoteSyncItem, ConflictItem
from openlp.plugins.songs.lib.openlyricsxml import OpenLyrics
class ConflictReason:
"""
Conflict reason type definitions
"""
MultipleRemoteEntries = 'MultipleRemoteEntries'
VersionMismatch = 'VersionMismatch'
class ConflictException(Exception):
"""
Exception thrown in case of conflicts
"""
def __init__(self, type, uuid, reason):
self.type = type
self.uuid = uuid
self.reason = reason
class LockException(Exception):
"""
Exception thrown in case of a locked item
"""
def __init__(self, type, uuid, lock_id, first_attempt):
self.type = type
self.uuid = uuid
self.lock_id = lock_id
self.first_attempt = first_attempt
class SyncItemType:
"""
Sync item type definitions
"""
Song = 'song'
Custom = 'custom'
class SyncItemAction:
"""
Sync item Action definitions
"""
Update = 'update'
Delete = 'delete'
class Synchronizer(object):
"""
The base class used for synchronization.
Any Synchronizer implementation must override the functions needed to actually synchronize songs, custom slides
and services.
"""
def __init__(self, manager):
self.manager = manager
self.song_manager = Registry().get('songs_manager')
self.open_lyrics = OpenLyrics(Registry().get('songs_manager'))
def connect(self):
pass
def disconnect(self):
pass
def get_sync_item(self, uuid, type):
item = self.manager.get_object_filtered(RemoteSyncItem, and_(RemoteSyncItem.uuid == uuid,
RemoteSyncItem.type == type))
if item:
return item
else:
return None
def mark_item_for_conflict(self, type, uuid, reason):
"""
Marks item as having a conflict
:param type: Type of the item
:param uuid: The uuid of the item
:param reason: The reason for the conflict
"""
# Check if it is already marked with a conflict
item = self.manager.get_object_filtered(ConflictItem, and_(ConflictItem.uuid == uuid,
ConflictItem.conflict_reason == reason))
if not item:
item = ConflictItem()
item.type = type
item.uuid = uuid
item.conflict_reason = reason
self.manager.save_object(item)
def check_configuration(self):
return False
def check_connection(self):
"""
Check that it is possible to connect to the remote server/folder.
"""
return False
def initialize_remote(self):
"""
Setup connection to the remote server and do remote initialization.
"""
pass
def get_remote_song_changes(self):
"""
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
remotely, they are also deleted locally.
:return: True if one or more songs was updated, otherwise False
"""
pass
def send_song(self, song, song_uuid, last_known_version, first_sync_attempt, prev_lock_id):
"""
Sends a song to the remote location
:param song: The song object to synchronize
:param song_uuid: The uuid of the song
:param last_known_version: The last known version of the song
:param first_sync_attempt: If the song has been attempted synchronized before,
this is the timestamp of the first sync attempt.
:param prev_lock_id: If the song has been attempted synchronized before, this is the id of the lock that
prevented the synchronization.
:return: The new version.
"""
pass
def fetch_song(self, song_uuid, song_id):
"""
Fetch a specific song from the remote location and saves it to the song db.
:param song_uuid: uuid of the song
:param song_id: song db id, None if song does not yet exists in the song db
:return: The song object
"""
pass
def delete_song(self, song_uuid, first_del_attempt, prev_lock_id):
"""
Delete song from the remote location
:param song_uuid:
:type str:
:param first_del_attempt:
:type DateTime:
:param prev_lock_id:
:type str:
"""
pass
def get_remote_custom_changes(self):
"""
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
remotely, they are also deleted locally.
:return: True if one or more songs was updated, otherwise False
"""
pass
def send_custom(self, custom, custom_uuid, last_known_version, first_sync_attempt, prev_lock_id):
"""
Sends a custom slide to the remote location.
:param custom: The custom object to synchronize
:param custom_uuid: The uuid of the custom slide
:param last_known_version: The last known version of the custom slide
:param first_sync_attempt: If the custom slide has been attempted synchronized before,
this is the timestamp of the first sync attempt.
:param prev_lock_id: If the custom slide has been attempted synchronized before, this is the id of the lock
that prevented the synchronization.
:return: The new version.
"""
pass
def fetch_custom(self, custom_uuid, custom_id):
"""
Fetch a specific custom slide from the remote location and stores it in the custom db
:param custom_uuid: uuid of the custom slide
:param custom_id: custom db id, None if the custom slide does not yet exists in the custom db
:return: The custom object
"""
pass
def delete_custom(self, custom_uuid, first_del_attempt, prev_lock_id):
"""
Delete custom slide from the remote location.
:param custom_uuid:
:type str:
:param first_del_attempt:
:type DateTime:
:param prev_lock_id:
:type str:
"""
pass
def send_service(self, service):
pass
def fetch_service(self):
pass

View File

@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
import requests
from openlp.core.common import Settings, registry
from openlp.core.lib.db import Manager
from openlp.plugins.remotesync.lib.backends.synchronizer import Synchronizer
from openlp.plugins.songs.lib.db import init_schema, Song
from openlp.plugins.remotesync.lib.backends.restclient.services import song_service, custom_service
class WebServiceSynchronizer(Synchronizer):
baseurl = 'http://localhost:8000/'
auth_token = 'afd9a4aa979534edf0015f7379cd6d61a52f9e10'
def __init__(self, *args, **kwargs):
port = kwargs['port']
address = kwargs['address']
self.auth_token = kwargs['auth_token']
self.base_url = '{}:{}/'.format(address, port)
self.manager = registry.Registry().get('songs_manager')
registry.Registry().register('remote_synchronizer', self)
def connect(self):
pass
def disconnect(self):
pass
def check_configuration(self):
return False
def check_connection(self):
"""
Check that it is possible to connect to the remote server/folder.
"""
return False
def initialize_remote(self):
"""
Setup connection to the remote server and do remote initialization.
"""
pass
def get_remote_song_changes(self):
"""
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
remotely, they are also deleted locally.
:return: True if one or more songs was updated, otherwise False
"""
# GET song/list
pass
def send_song(self, song, song_uuid, last_known_version, first_sync_attempt, prev_lock_id):
"""
Sends a song to the remote location
:param song: The song object to synchronize
:param song_uuid: The uuid of the song
:param last_known_version: The last known version of the song
:param first_sync_attempt: If the song has been attempted synchronized before,
this is the timestamp of the first sync attempt.
:param prev_lock_id: If the song has been attempted synchronized before, this is the id of the lock that
prevented the synchronization.
:return: The new version.
"""
# PUT song/<uuid>
pass
def fetch_song(self, song_uuid, song_id):
"""
Fetch a specific song from the remote location and saves it to the song db.
:param song_uuid: uuid of the song
:param song_id: song db id, None if song does not yet exists in the song db
:return: The song object
"""
# GET song/<uuid>
pass
def delete_song(self, song_uuid, first_del_attempt, prev_lock_id):
"""
Delete song from the remote location
:param song_uuid:
:type str:
:param first_del_attempt:
:type DateTime:
:param prev_lock_id:
:type str:
"""
# DELETE song/<uuid>
pass
def get_remote_custom_changes(self):
"""
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
remotely, they are also deleted locally.
:return: True if one or more songs was updated, otherwise False
"""
# GET custom/list
pass
def send_custom(self, custom, custom_uuid, last_known_version, first_sync_attempt, prev_lock_id):
"""
Sends a custom slide to the remote location.
:param custom: The custom object to synchronize
:param custom_uuid: The uuid of the custom slide
:param last_known_version: The last known version of the custom slide
:param first_sync_attempt: If the custom slide has been attempted synchronized before,
this is the timestamp of the first sync attempt.
:param prev_lock_id: If the custom slide has been attempted synchronized before, this is the id of the lock
that prevented the synchronization.
:return: The new version.
"""
# PUT custom/<uuid>
pass
def fetch_custom(self, custom_uuid, custom_id):
"""
Fetch a specific custom slide from the remote location and stores it in the custom db
:param custom_uuid: uuid of the custom slide
:param custom_id: custom db id, None if the custom slide does not yet exists in the custom db
:return: The custom object
"""
# GET custom/<uuid>
pass
def delete_custom(self, custom_uuid, first_del_attempt, prev_lock_id):
"""
Delete custom slide from the remote location.
:param custom_uuid:
:type str:
:param first_del_attempt:
:type DateTime:
:param prev_lock_id:
:type str:
"""
# DELETE custom/<uuid>
pass
def send_service(self, service):
pass
def fetch_service(self):
pass
@staticmethod
def _handle(response, expected_status_code=200):
if response.status_code != expected_status_code:
print('whoops got {} expected {}'.format(response.status_code, expected_status_code))
return response
def _get(self, url):
return self._handle(requests.get(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)}),
200)
def _post(self, url, data, return_code):
return self._handle(requests.post(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)},
json=data), return_code)
def _put(self, url, data):
return self._handle(requests.put(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)},
data=data))
def _delete(self, url):
return self._handle(requests.delete(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)}))
def check_connection(self):
return False
def send_song(self, song):
self._post('http://localhost:8000/songs/', song, 201)
def receive_songs(self):
self._get('http://localhost:8000/songs/')
def send_all_songs(self):
for song in self.manager.get_all_objects(Song):
self._post('http://localhost:8000/songs/', self.open_lyrics.song_to_xml(song), 201)

View File

@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
The :mod:`db` module provides the database and schema that is the backend for
the Custom plugin
"""
from sqlalchemy import Column
from sqlalchemy.orm import declarative_base
from sqlalchemy.types import DateTime, Integer, Unicode
from openlp.core.db.helpers import init_db
Base = declarative_base()
class RemoteSyncItem(Base):
"""
RemoteSyncItem model
"""
__tablename__ = 'remote_sync_map'
item_id = Column(Integer, primary_key=True)
type = Column(Unicode(64), primary_key=True)
uuid = Column(Unicode(36), nullable=False)
version = Column(Unicode(64), nullable=False)
class SyncQueueItem(Base):
"""
SyncQueueItem model
"""
__tablename__ = 'sync_queue_table'
item_id = Column(Integer, primary_key=True)
type = Column(Unicode(64), primary_key=True)
action = Column(Unicode(32))
lock_id = Column(Unicode(128))
first_attempt = Column(DateTime())
class ConflictItem(Base):
"""
ConflictItem model
"""
__tablename__ = 'conflicts_table'
type = Column(Unicode(64), primary_key=True)
uuid = Column(Unicode(36), nullable=False)
conflict_reason = Column(Unicode(64), nullable=False)
def init_schema(url):
"""
Setup the custom database connection and initialise the database schema
:param url: The database to setup
"""
session, metadata = init_db(url, base=Base)
metadata.create_all(bind=metadata.bind, checkfirst=True)
return session

View File

@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
from PyQt5 import QtCore, QtGui, QtWidgets
from openlp.core.common.enum import FtpType, SyncType
from openlp.core.common.registry import Registry
from openlp.core.common.i18n import translate
from openlp.core.lib.settingstab import SettingsTab
from openlp.core.widgets.edits import PathEdit
from openlp.core.widgets.enums import PathEditType
class RemoteSyncTab(SettingsTab):
"""
RemoteSyncTab is the RemoteSync settings tab in the settings dialog.
"""
def setup_ui(self):
self.setObjectName('RemoteSyncTab')
super(RemoteSyncTab, self).setup_ui()
# sync type setup
self.sync_type_group_box = QtWidgets.QGroupBox(self.left_column)
self.sync_type_group_box.setObjectName('sync_type_group_box')
self.sync_type_group_box_layout = QtWidgets.QFormLayout(self.sync_type_group_box)
self.sync_type_group_box_layout.setObjectName('sync_type_group_box_layout')
self.sync_type_label = QtWidgets.QLabel(self.sync_type_group_box)
self.sync_type_label.setObjectName('sync_type_label')
self.sync_type_radio_group = QtWidgets.QButtonGroup(self)
self.disabled_sync_radio = QtWidgets.QRadioButton('', self)
self.sync_type_radio_group.addButton(self.disabled_sync_radio, SyncType.Disabled)
self.sync_type_group_box_layout.setWidget(0, QtWidgets.QFormLayout.SpanningRole, self.disabled_sync_radio)
self.folder_sync_radio = QtWidgets.QRadioButton('', self)
self.sync_type_radio_group.addButton(self.folder_sync_radio, SyncType.Folder)
self.sync_type_group_box_layout.setWidget(1, QtWidgets.QFormLayout.SpanningRole, self.folder_sync_radio)
self.ftp_sync_radio = QtWidgets.QRadioButton('', self)
self.sync_type_radio_group.addButton(self.ftp_sync_radio, SyncType.Ftp)
self.sync_type_group_box_layout.setWidget(2, QtWidgets.QFormLayout.SpanningRole, self.ftp_sync_radio)
self.left_layout.addWidget(self.sync_type_group_box)
# Folder sync settings
self.folder_settings_group_box = QtWidgets.QGroupBox(self.left_column)
self.folder_settings_group_box.setObjectName('folder_settings_group_box')
self.folder_settings_layout = QtWidgets.QFormLayout(self.folder_settings_group_box)
self.folder_settings_layout.setObjectName('folder_settings_layout')
# Sync folder path
self.folder_label = QtWidgets.QLabel(self.folder_settings_group_box)
self.folder_label.setObjectName('folder_label')
self.folder_path_edit = PathEdit(self.folder_settings_group_box, path_type=PathEditType.Directories,
show_revert=False)
self.folder_settings_layout.addRow(self.folder_label, self.folder_path_edit)
self.left_layout.addWidget(self.folder_settings_group_box)
# FTP server settings
self.ftp_settings_group_box = QtWidgets.QGroupBox(self.left_column)
self.ftp_settings_group_box.setObjectName('ftp_settings_group_box')
self.ftp_settings_layout = QtWidgets.QFormLayout(self.ftp_settings_group_box)
self.ftp_settings_layout.setObjectName('ftp_settings_layout')
self.ftp_type_label = QtWidgets.QLabel(self.ftp_settings_group_box)
self.ftp_type_label.setObjectName('ftp_type_label')
# FTP type
self.ftp_type_radio_group = QtWidgets.QButtonGroup(self)
self.ftp_unsecure_radio = QtWidgets.QRadioButton('', self)
self.ftp_type_radio_group.addButton(self.ftp_unsecure_radio, FtpType.Ftp)
self.ftp_settings_layout.setWidget(0, QtWidgets.QFormLayout.SpanningRole, self.ftp_unsecure_radio)
self.ftp_secure_radio = QtWidgets.QRadioButton('', self)
self.ftp_type_radio_group.addButton(self.ftp_secure_radio, FtpType.FtpTls)
self.ftp_settings_layout.setWidget(1, QtWidgets.QFormLayout.SpanningRole, self.ftp_secure_radio)
# FTP server address
self.ftp_address_label = QtWidgets.QLabel(self.ftp_settings_group_box)
self.ftp_address_label.setObjectName('address_label')
self.ftp_server_edit = QtWidgets.QLineEdit(self.ftp_settings_group_box)
self.ftp_server_edit.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
self.ftp_server_edit.setObjectName('address_edit')
self.ftp_settings_layout.addRow(self.ftp_address_label, self.ftp_server_edit)
# FTP server username
self.ftp_username_label = QtWidgets.QLabel(self.ftp_settings_group_box)
self.ftp_username_label.setObjectName('ftp_username_label')
self.ftp_username_edit = QtWidgets.QLineEdit(self.ftp_settings_group_box)
self.ftp_username_edit.setObjectName('ftp_username_edit')
self.ftp_settings_layout.addRow(self.ftp_username_label, self.ftp_username_edit)
# FTP server password
self.ftp_pswd_label = QtWidgets.QLabel(self.ftp_settings_group_box)
self.ftp_pswd_label.setObjectName('ftp_pswd_label')
self.ftp_pswd_edit = QtWidgets.QLineEdit(self.ftp_settings_group_box)
self.ftp_pswd_edit.setObjectName('ftp_pswd_edit')
self.ftp_settings_layout.addRow(self.ftp_pswd_label, self.ftp_pswd_edit)
# FTP server data folder
self.ftp_folder_label = QtWidgets.QLabel(self.ftp_settings_group_box)
self.ftp_folder_label.setObjectName('ftp_folder_label')
self.ftp_folder_edit = QtWidgets.QLineEdit(self.ftp_settings_group_box)
self.ftp_folder_edit.setObjectName('ftp_folder_edit')
self.ftp_settings_layout.addRow(self.ftp_folder_label, self.ftp_folder_edit)
self.left_layout.addWidget(self.ftp_settings_group_box)
"""
# Manual trigger actions
self.actions_group_box = QtWidgets.QGroupBox(self.left_column)
self.actions_group_box.setObjectName('actions_group_box')
self.actions_layout = QtWidgets.QFormLayout(self.actions_group_box)
self.actions_layout.setObjectName('actions_layout')
# send songs
self.send_songs_btn = QtWidgets.QPushButton(self.actions_group_box)
self.send_songs_btn.setObjectName('send_songs_btn')
self.send_songs_btn.clicked.connect(self.on_send_songs_clicked)
# receive songs
self.receive_songs_btn = QtWidgets.QPushButton(self.actions_group_box)
self.receive_songs_btn.setObjectName('receive_songs_btn')
self.receive_songs_btn.clicked.connect(self.on_receive_songs_clicked)
self.actions_layout.addRow(self.send_songs_btn, self.receive_songs_btn)
self.left_layout.addWidget(self.actions_group_box)
"""
# statistics
self.remote_statistics_group_box = QtWidgets.QGroupBox(self.left_column)
self.remote_statistics_group_box.setObjectName('remote_statistics_group_box')
self.remote_statistics_layout = QtWidgets.QFormLayout(self.remote_statistics_group_box)
self.remote_statistics_layout.setObjectName('remote_statistics_layout')
self.update_policy_label = QtWidgets.QLabel(self.remote_statistics_group_box)
self.update_policy_label.setObjectName('update_policy_label')
self.update_policy = QtWidgets.QLabel(self.remote_statistics_group_box)
self.update_policy.setObjectName('update_policy')
self.remote_statistics_layout.addRow(self.update_policy_label, self.update_policy)
self.last_sync_label = QtWidgets.QLabel(self.remote_statistics_group_box)
self.last_sync_label.setObjectName('last_sync_label')
self.last_sync = QtWidgets.QLabel(self.remote_statistics_group_box)
self.last_sync.setObjectName('last_sync')
self.remote_statistics_layout.addRow(self.last_sync_label, self.last_sync)
self.left_layout.addWidget(self.remote_statistics_group_box)
self.left_layout.addStretch()
self.right_column.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
self.right_layout.addStretch()
# Set up the connections and things
self.sync_type_radio_group.buttonToggled.connect(self.on_sync_type_radio_group_button_toggled)
def retranslate_ui(self):
self.sync_type_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Synchronization Type'))
self.disabled_sync_radio.setText(translate('RemotePlugin.RemoteSyncTab', 'Disabled'))
self.folder_sync_radio.setText(translate('RemotePlugin.RemoteSyncTab', 'Folder'))
self.folder_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Synchronization Folder'))
self.ftp_sync_radio.setText(translate('RemotePlugin.RemoteSyncTab', 'FTP'))
self.folder_settings_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Folder Settings'))
self.ftp_settings_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'FTP Settings'))
self.ftp_unsecure_radio.setText(translate('RemotePlugin.RemoteSyncTab', 'FTP (unencrypted)'))
self.ftp_secure_radio.setText(translate('RemotePlugin.RemoteSyncTab', 'FTPS (encrypted)'))
#self.ftp_type_label.setText(translate('RemotePlugin.RemoteSyncTab', 'FTP Type:'))
self.ftp_address_label.setText(translate('RemotePlugin.RemoteSyncTab', 'FTP server address:'))
self.ftp_username_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Username:'))
self.ftp_pswd_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Password:'))
self.ftp_folder_label.setText(translate('RemotePlugin.RemoteSyncTab', 'FTP data folder:'))
#self.actions_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Actions'))
#self.receive_songs_btn.setText(translate('RemotePlugin.RemoteSyncTab', 'Receive Songs'))
#self.send_songs_btn.setText(translate('RemotePlugin.RemoteSyncTab', 'Send Songs'))
self.remote_statistics_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Remote Statistics'))
self.update_policy_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Update Policy:'))
self.last_sync_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Last Sync:'))
def load(self):
"""
Load the configuration and update the server configuration if necessary
"""
checked_radio_sync_type = self.sync_type_radio_group.button(self.settings.value('remotesync/type'))
checked_radio_sync_type.setChecked(True)
self.folder_path_edit.path = self.settings.value('remotesync/folder path')
checked_radio_ftp_type = self.ftp_type_radio_group.button(self.settings.value('remotesync/ftp type'))
checked_radio_ftp_type.setChecked(True)
self.ftp_server_edit.setText(self.settings.value('remotesync/ftp server'))
self.ftp_username_edit.setText(self.settings.value('remotesync/ftp username'))
self.ftp_pswd_edit.setText(self.settings.value('remotesync/ftp password'))
self.ftp_folder_edit.setText(self.settings.value('remotesync/ftp data folder'))
#self.port_spin_box.setValue(Settings().value(self.settings_section + '/port'))
#self.address_edit.setText(Settings().value(self.settings_section + '/ip address'))
#self.auth_token.setText(Settings().value(self.settings_section + '/auth token'))
def save(self):
"""
Save the configuration and update the server configuration if necessary
"""
self.settings.setValue('remotesync/type', self.sync_type_radio_group.checkedId())
self.settings.setValue('remotesync/folder path', self.folder_path_edit.path)
self.settings.setValue('remotesync/ftp type', self.ftp_type_radio_group.checkedId())
self.settings.setValue('remotesync/ftp server', self.ftp_server_edit.text())
self.settings.setValue('remotesync/ftp username', self.ftp_username_edit.text())
self.settings.setValue('remotesync/ftp password', self.ftp_pswd_edit.text())
#Settings().setValue(self.settings_section + '/port', self.port_spin_box.value())
#Settings().setValue(self.settings_section + '/ip address', self.address_edit.text())
#Settings().setValue(self.settings_section + '/auth token', self.auth_token.text())
self.generate_icon()
def on_sync_type_radio_group_button_toggled(self, button, checked):
"""
Handles the toggled signal on the radio buttons. The signal is emitted twice if a radio butting being toggled on
causes another radio button in the group to be toggled off.
En/Disables the Sync type settings groups depending on the currently selected radio button
:param QtWidgets.QRadioButton button: The button that has toggled
:param bool checked: The buttons new state
"""
group_id = self.sync_type_radio_group.id(button) # The work around (see above comment)
self.folder_settings_group_box.setEnabled(group_id == SyncType.Folder)
self.ftp_settings_group_box.setEnabled(group_id == SyncType.Ftp)
def on_send_songs_clicked(self):
Registry().execute('synchronize_to_remote')
#self.remote_synchronizer.send_all_songs()
def on_receive_songs_clicked(self):
Registry().execute('synchronize_from_remote')
#self.remote_synchronizer.receive_songs()
def generate_icon(self):
"""
Generate icon for main window
"""
self.remote_sync_icon.hide()
icon = QtGui.QImage(':/remote/network_server.png')
icon = icon.scaled(80, 80, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
self.remote_sync_icon.setPixmap(QtGui.QPixmap.fromImage(icon))
self.remote_sync_icon.show()

View File

@ -0,0 +1,547 @@
openapi: 3.1.0
info:
title: OpenLP Remote Sync Api - OpenAPI 3.1
description: |-
This is the description for the OpenLP remote sync API.
Some useful links:
- [The OpenLP remotesync MR](https://gitlab.com/openlp/openlp/-/merge_requests/9)
contact:
email: dev@openlp.io
license:
name: Apache 2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
version: 0.9.1
externalDocs:
description: Find out more about Swagger
url: http://swagger.io
servers:
- url: http://localhost:8888/api/v1
tags:
- name: song
description: Operations about songs
- name: custom
description: Operations about custom slides
paths:
/song-list:
get:
tags:
- song
summary: Get list of songs
description: ''
operationId: getSongList
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ItemList'
application/xml:
schema:
$ref: '#/components/schemas/ItemList'
'400':
description: Invalid uuid supplied
'404':
description: Song not found
security:
- openlp_auth:
- write:song
- read:song
/song-list/changes/{sinceDateTime}:
get:
tags:
- song
summary: Get list of songs changed since given timestamp
description: ''
operationId: getSongListChangesSince
parameters:
- name: sinceDateTime
in: path
description: The timestamp to look for changes after
required: true
schema:
type: string
format: date-time
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ItemList'
application/xml:
schema:
$ref: '#/components/schemas/ItemList'
'400':
description: Invalid uuid supplied
'404':
description: Song not found
security:
- openlp_auth:
- write:song
- read:song
/song/{uuid}:
get:
tags:
- song
summary: Get song by uuid
description: Get song by uuid
operationId: getSong
parameters:
- name: uuid
in: path
description: The uuid of the song that needs to be fetched.
required: true
schema:
type: string
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/TextItem'
application/xml:
schema:
$ref: '#/components/schemas/TextItem'
'400':
description: Invalid uuid supplied
'404':
description: Song not found
security:
- openlp_auth:
- write:song
- read:song
put:
tags:
- song
summary: Update or create song
description: This can only be done by the logged in user.
operationId: updateSong
parameters:
- name: uuid
in: path
description: uuid of song that need to be deleted
required: true
schema:
type: string
requestBody:
description: Update an existent song in the db
content:
application/json:
schema:
$ref: '#/components/schemas/TextItem'
application/xml:
schema:
$ref: '#/components/schemas/TextItem'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/TextItem'
responses:
'200':
description: successful operation
'409':
description: A conflict occured. The version requested to be saved is not the newest.
security:
- openlp_auth:
- write:song
- read:song
delete:
tags:
- song
summary: Delete song
description: This can only be done by the logged in user.
operationId: deleteSong
parameters:
- name: uuid
in: path
description: The uuid of the song to delete
required: true
schema:
type: string
responses:
'400':
description: Invalid uuid supplied
'404':
description: Song not found
security:
- openlp_auth:
- write:song
- read:song
/song/{uuid}/{version}:
get:
tags:
- song
summary: Get song by uuid and version
description: Get song by uuid and version
operationId: getSongVersion
parameters:
- name: uuid
in: path
description: The uuid of the song that needs to be fetched.
required: true
schema:
type: string
- name: version
in: path
description: The version of the song that needs to be fetched.
required: true
schema:
type: integer
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/TextItem'
application/xml:
schema:
$ref: '#/components/schemas/TextItem'
'400':
description: Invalid uuid supplied
'404':
description: Song not found
security:
- openlp_auth:
- write:song
- read:song
/song-history/{uuid}:
get:
tags:
- song
summary: Get history of song by uuid
description: fwef
operationId: getSongHistory
parameters:
- name: uuid
in: path
description: The uuid of the song that needs to be fetched.
required: true
schema:
type: string
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/TextItem'
application/xml:
schema:
$ref: '#/components/schemas/TextItem'
'400':
description: Invalid uuid supplied
'404':
description: Song not found
security:
- openlp_auth:
- write:song
- read:song
/custom-list:
get:
tags:
- custom
summary: Get list of custom slides
description: ''
operationId: getCustomslideList
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ItemList'
application/xml:
schema:
$ref: '#/components/schemas/ItemList'
'400':
description: Invalid uuid supplied
'404':
description: Custom slide not found
security:
- openlp_auth:
- write:custom
- read:custom
/custom-list/changes/{sinceDateTime}:
get:
tags:
- song
summary: Get list of custom slides changed since given timestamp
description: ''
operationId: getCustomSlideListChangesSince
parameters:
- name: sinceDateTime
in: path
description: The timestamp to look for changes after
required: true
schema:
type: string
format: date-time
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ItemList'
application/xml:
schema:
$ref: '#/components/schemas/ItemList'
'400':
description: Invalid uuid supplied
'404':
description: Song not found
security:
- openlp_auth:
- write:song
- read:song
/custom/{uuid}:
get:
tags:
- custom
summary: Get custom slide by uuid
description: fwef
operationId: getCustomslide
parameters:
- name: uuid
in: path
description: The uuid of the custom slide that needs to be fetched.
required: true
schema:
type: string
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/TextItem'
application/xml:
schema:
$ref: '#/components/schemas/TextItem'
'400':
description: Invalid uuid supplied
'404':
description: Custom slide not found
security:
- openlp_auth:
- write:custom
- read:custom
put:
tags:
- custom
summary: Update or create custom slide
description: This can only be done by the logged in user.
operationId: updateCustomslide
parameters:
- name: uuid
in: path
description: uuid of custom slide that need to be deleted
required: true
schema:
type: string
requestBody:
description: Update an existent custom slide in the db
content:
application/json:
schema:
$ref: '#/components/schemas/TextItem'
application/xml:
schema:
$ref: '#/components/schemas/TextItem'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/TextItem'
responses:
'200':
description: successful operation
'409':
description: A conflict occured. The version requested to be saved is not the newest.
security:
- openlp_auth:
- write:custom
- read:custom
delete:
tags:
- custom
summary: Delete custom slide
description: This can only be done by the logged in user.
operationId: deleteCustomslide
parameters:
- name: uuid
in: path
description: The uuid of the custom slide to delete
required: true
schema:
type: string
responses:
'400':
description: Invalid uuid supplied
'404':
description: Custom slide not found
security:
- openlp_auth:
- write:custom
- read:custom
/custom/{uuid}/{version}:
get:
tags:
- custom
summary: Get custom slide by uuid and version
description: fwef
operationId: getCustomslideVersion
parameters:
- name: uuid
in: path
description: The uuid of the custom slide that needs to be fetched.
required: true
schema:
type: string
- name: version
in: path
description: The version of the custom slide that needs to be fetched.
required: true
schema:
type: integer
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/TextItem'
application/xml:
schema:
$ref: '#/components/schemas/TextItem'
'400':
description: Invalid uuid supplied
'404':
description: Custom slide not found
security:
- openlp_auth:
- write:custom
- read:custom
/custom-history/{uuid}:
get:
tags:
- custom
summary: Get history of custom slide by uuid
description: Get history of custom slide by uuid
operationId: getCustomslideHistory
parameters:
- name: uuid
in: path
description: The uuid of the custom slide that needs to be fetched.
required: true
schema:
type: string
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/TextItem'
application/xml:
schema:
$ref: '#/components/schemas/TextItem'
'400':
description: Invalid uuid supplied
'404':
description: Custom slide not found
security:
- openlp_auth:
- write:custom
- read:custom
components:
schemas:
TextItem:
type: object
properties:
uuid:
type: string
examples: ['43f74822-c620-40c7-8723-0136878bab23']
version:
type: integer
format: int32
examples: [2]
user:
type: string
examples: ['user1', '354364']
timestamp:
type: string
format: date-time
title:
type: string
examples: ['Text item title']
itemxml:
type: string
description: Text item xml
examples: ['<xml>dsf</xml>']
xml:
name: textitem
ItemList:
type: object
properties:
list:
type: array
items:
$ref: '#/components/schemas/ItemInfo'
ItemInfo:
type: object
properties:
uuid:
type: string
examples: ['43f74822-c620-40c7-8723-0136878bab23']
version:
type: integer
format: int32
examples: [2]
user:
type: string
examples: ['user1', '354364']
timestamp:
type: string
format: date-time
title:
type: string
examples: ['Text item title']
ApiResponse:
type: object
properties:
code:
type: integer
format: int32
type:
type: string
message:
type: string
xml:
name: '##default'
requestBodies:
Song:
description: Song object that needs to be added to the db
content:
application/json:
schema:
$ref: '#/components/schemas/TextItem'
application/xml:
schema:
$ref: '#/components/schemas/TextItem'
securitySchemes:
openlp_auth:
type: oauth2
flows:
implicit:
authorizationUrl: http://localhost:8888/oauth/authorize
scopes:
write:songs: modify songs in your account
read:songs: read your songs
write:customs: modify custom slides in your account
read:customs: read your custom slides
api_key:
type: apiKey
name: api_key
in: header

View File

@ -0,0 +1,356 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
The RemoteSync plugin makes it possible to synchronize songs, custom slides and service files.
There is currently 2 different Synchronizer backends: FolderSynchronizer and FtpSynchronizer.
When synchronizing there is 3 things to do:
1. Pull updates from the remote.
2. Push updates to the remote.
3. Handle conflicts.
"""
import logging
import uuid
import getpass
import socket
from sqlalchemy.sql import and_
from PyQt5 import QtWidgets, QtCore
from openlp.core.common.settings import Settings
from openlp.core.common.registry import Registry
from openlp.core.common.i18n import translate
from openlp.core.lib.plugin import Plugin, StringContent
from openlp.core.ui.icons import UiIcons
from openlp.core.db.manager import DBManager
from openlp.core.common.enum import SyncType
from openlp.plugins.remotesync.lib.backends.synchronizer import SyncItemType, SyncItemAction, ConflictException, \
LockException
from openlp.core.state import State
from openlp.plugins.custom.lib.db import CustomSlide
from openlp.plugins.songs.lib.db import Song
from openlp.plugins.remotesync.lib import RemoteSyncTab
from openlp.plugins.remotesync.lib.backends.foldersynchronizer import FolderSynchronizer
from openlp.plugins.remotesync.lib.backends.ftpsynchronizer import FtpSynchronizer
from openlp.plugins.remotesync.lib.db import init_schema, SyncQueueItem, RemoteSyncItem
log = logging.getLogger(__name__)
class RemoteSyncPlugin(Plugin):
log.info('RemoteSync Plugin loaded')
def __init__(self):
"""
remotes constructor
"""
super(RemoteSyncPlugin, self).__init__('remotesync', None, RemoteSyncTab)
self.weight = -1
self.manager = DBManager('remotesync', init_schema)
self.icon = UiIcons().network_stream
self.icon_path = self.icon
self.synchronizer = None
self.sync_timer = QtCore.QTimer()
self.sync_timer_disabled = False
State().add_service('remote_sync', self.weight, is_plugin=True)
State().update_pre_conditions('remote_sync', self.check_pre_conditions())
def check_pre_conditions(self):
"""
Check the plugin can run.
"""
return self.manager.session is not None
def initialise(self):
"""
Initialise the remotesync plugin
"""
log.debug('initialise')
super(RemoteSyncPlugin, self).initialise()
if not hasattr(self, 'remote_sync_icon'):
self.remote_sync_icon = QtWidgets.QLabel(self.main_window.status_bar)
size_policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
size_policy.setHorizontalStretch(0)
size_policy.setVerticalStretch(0)
size_policy.setHeightForWidth(self.remote_sync_icon.sizePolicy().hasHeightForWidth())
self.remote_sync_icon.setSizePolicy(size_policy)
self.remote_sync_icon.setFrameShadow(QtWidgets.QFrame.Plain)
self.remote_sync_icon.setLineWidth(1)
self.remote_sync_icon.setScaledContents(True)
self.remote_sync_icon.setFixedSize(20, 20)
self.remote_sync_icon.setObjectName('remote_sync_icon')
self.main_window.status_bar.insertPermanentWidget(2, self.remote_sync_icon)
self.settings_tab.remote_sync_icon = self.remote_sync_icon
# Generate a pc id if not already done
if not Settings().value('remotesync/folder pc id'):
username = getpass.getuser()
hostname = socket.gethostname()
Settings().setValue('remotesync/folder pc id', '{u}-{h}'.format(u=username, h=hostname))
self.settings_tab.generate_icon()
sync_type = Settings().value('remotesync/type')
if sync_type == SyncType.Folder:
self.synchronizer = FolderSynchronizer(self.manager, Settings().value('remotesync/folder path'),
Settings().value('remotesync/folder pc id'))
elif sync_type == SyncType.Ftp:
self.synchronizer = FtpSynchronizer(self.manager, Settings().value('remotesync/ftp data folder'),
Settings().value('remotesync/folder pc id'),
Settings().value('remotesync/ftp type'),
Settings().value('remotesync/ftp server'),
Settings().value('remotesync/ftp username'),
Settings().value('remotesync/ftp password'))
elif sync_type == SyncType.WebService:
self.synchronizer = WebServiceSynchronizer()
else:
self.synchronizer = None
if self.synchronizer and not self.synchronizer.check_connection():
self.synchronizer.initialize_remote()
# TODO: register delete functions
Registry().register_function('song_changed', self.queue_song_for_sync)
Registry().register_function('custom_changed', self.queue_custom_for_sync)
Registry().register_function('service_changed', self.save_service)
Registry().register_function('synchronize_to_remote', self.push_to_remote)
Registry().register_function('synchronize_from_remote', self.pull_from_remote)
Registry().register_function('song_deleted', self.queue_song_for_deletion)
Registry().register_function('custom_deleted', self.queue_custom_for_deletion) # TODO: implement executing
# prevent sync timer activation during startup check
self.sync_timer_disabled = True
self.startup_check()
self.sync_timer_disabled = False
self.sync_timer.timeout.connect(self.synchronize)
self.sync_timer.setSingleShot(True)
if self.synchronizer:
# Set a timer to start the processing of the queue in 2 seconds
self.sync_timer.start(2000)
log.debug('remotesync init done')
def finalise(self):
log.debug('finalise')
super(RemoteSyncPlugin, self).finalise()
@staticmethod
def about():
"""
Information about this plugin
"""
about_text = translate('RemoteSyncPlugin', '<strong>RemoteSync Plugin</strong>'
'<br />The remotesync plugin provides the ability to synchronize '
'songs, custom slides and service-files between multiple OpenLP '
'instances.')
return about_text
def set_plugin_text_strings(self):
"""
Called to define all translatable texts of the plugin
"""
# Name PluginList
self.text_strings[StringContent.Name] = {
'singular': translate('RemoteSyncPlugin', 'RemoteSync', 'name singular'),
'plural': translate('RemoteSyncPlugin', 'RemotesSync', 'name plural')
}
# Name for MediaDockManager, SettingsManager
self.text_strings[StringContent.VisibleName] = {
'title': translate('RemoteSyncPlugin', 'RemoteSync', 'container title')
}
def startup_check(self):
"""
Run through all songs and custom slides to see if they have been synchronized. Queue them if they have not
"""
song_manager = Registry().get('songs_manager')
all_songs = song_manager.get_all_objects(Song)
for song in all_songs:
# TODO: Check that songs actually exists remotely - should we delete if not?
synced_songs = self.manager.get_object_filtered(RemoteSyncItem,
and_(RemoteSyncItem.type == SyncItemType.Song,
RemoteSyncItem.item_id == song.id))
if not synced_songs:
self.queue_song_for_sync(song.id)
custom_manager = Registry().get('custom_manager')
all_custom_slides = custom_manager.get_all_objects(CustomSlide)
for custom in all_custom_slides:
# TODO: Check that custom slide actually exists remotely - should we delete if not?
synced_custom = self.manager.get_object_filtered(RemoteSyncItem,
and_(RemoteSyncItem.type == SyncItemType.Song,
RemoteSyncItem.item_id == custom.id))
if not synced_custom:
self.queue_custom_for_sync(custom.id)
def synchronize(self):
"""
Synchronize by first pulling data from remote and then pushing local changes to the remote
"""
if self.synchronizer:
self.synchronizer.connect()
self.pull_from_remote()
self.push_to_remote()
self.synchronizer.disconnect()
# Set a timer to start the synchronization again in 1 minutes.
self.sync_timer.start(6000)
def push_to_remote(self):
"""
Run through the queue and push songs and custom slides to remote
"""
queue_items = self.manager.get_all_objects(SyncQueueItem)
song_manager = Registry().get('songs_manager')
custom_manager = Registry().get('custom_manager')
for queue_item in queue_items:
sync_item = self.manager.get_object_filtered(RemoteSyncItem,
and_(RemoteSyncItem.type == queue_item.type,
RemoteSyncItem.item_id == queue_item.item_id))
if queue_item.action == SyncItemAction.Update:
if queue_item.type == SyncItemType.Song:
item = song_manager.get_object(Song, queue_item.item_id)
item_type = SyncItemType.Song
else:
item = custom_manager.get_object(CustomSlide, queue_item.item_id)
item_type = SyncItemType.Custom
# If item has not been sync'ed before we generate a uuid
if not sync_item:
sync_item = RemoteSyncItem()
sync_item.type = item_type
sync_item.item_id = item.id
sync_item.uuid = str(uuid.uuid4())
# Synchronize the item
try:
if queue_item.type == SyncItemType.Song:
version = self.synchronizer.send_song(item, sync_item.uuid, sync_item.version,
queue_item.first_attempt, queue_item.lock_id)
else:
version = self.synchronizer.send_custom(item, sync_item.uuid, sync_item.version,
queue_item.first_attempt, queue_item.lock_id)
except ConflictException:
log.debug('Conflict detected for item %d / %s' % (sync_item.item_id, sync_item.uuid))
# TODO: Store the conflict in the DB and turn on the conflict icon
continue
except LockException as le:
# Store the lock time in the DB and keep it in the queue
log.debug('Lock detected for item %d / %s' % (sync_item.item_id, sync_item.uuid))
queue_item.first_attempt = le.first_attempt
queue_item.lock_id = le.lock_id
self.manager.save_object(queue_item)
continue
sync_item.version = version
# Save the RemoteSyncItem so we know which version we have locally
self.manager.save_object(sync_item, True)
elif queue_item.action == SyncItemAction.Delete:
# Delete the item
try:
if queue_item.type == SyncItemType.Song:
version = self.synchronizer.delete_song(sync_item.uuid,
queue_item.first_attempt, queue_item.lock_id)
else:
version = self.synchronizer.delete_custom(sync_item.uuid,
queue_item.first_attempt, queue_item.lock_id)
except ConflictException:
log.debug('Conflict detected for item %d / %s' % (item.item_id, item.uuid))
# TODO: Store the conflict in the DB and turn on the conflict icon
continue
except LockException as le:
# Store the lock time in the DB and keep it in the queue
log.debug('Lock detected for item %d / %s' % (item.item_id, item.uuid))
queue_item.first_attempt = le.first_attempt
queue_item.lock_id = le.lock_id
self.manager.save_object(queue_item)
continue
# Delete the SyncQueueItem from the queue since the synchronization is now complete
self.manager.delete_all_objects(SyncQueueItem, and_(SyncQueueItem.item_id == queue_item.item_id,
SyncQueueItem.type == queue_item.type))
def start_short_sync_timer(self):
if not self.sync_timer_disabled:
if self.sync_timer.isActive():
self.sync_timer.stop()
self.sync_timer.start(2000)
def queue_song_for_sync(self, song_id):
"""
Put song in queue to be sync'ed
:param song_id:
"""
# First check that the song isn't already in the queue
queue_item = self.manager.get_object_filtered(SyncQueueItem, and_(SyncQueueItem.item_id == song_id,
SyncQueueItem.type == SyncItemType.Song))
if not queue_item:
queue_item = SyncQueueItem()
queue_item.item_id = song_id
queue_item.type = SyncItemType.Song
queue_item.action = SyncItemAction.Update
self.manager.save_object(queue_item, True)
self.start_short_sync_timer()
def queue_custom_for_sync(self, custom_id):
"""
Put custom slide in queue to be sync'ed
:param custom_id:
"""
# First check that the custom slide isn't already in the queue
queue_item = self.manager.get_object_filtered(SyncQueueItem, and_(SyncQueueItem.item_id == custom_id,
SyncQueueItem.type == SyncItemType.Custom))
if not queue_item:
queue_item = SyncQueueItem()
queue_item.item_id = custom_id
queue_item.type = SyncItemType.Custom
queue_item.action = SyncItemAction.Update
self.manager.save_object(queue_item, True)
self.start_short_sync_timer()
def queue_song_for_deletion(self, song_id):
"""
Put song in queue to be deleted
:param song_id:
"""
queue_item = SyncQueueItem()
queue_item.item_id = song_id
queue_item.type = SyncItemType.Song
queue_item.action = SyncItemAction.Delete
self.manager.save_object(queue_item, True)
self.start_short_sync_timer()
def queue_custom_for_deletion(self, custom_id):
"""
Put custom slide in queue to be deleted
:param custom_id:
"""
queue_item = SyncQueueItem()
queue_item.item_id = custom_id
queue_item.type = SyncItemType.Song
queue_item.action = SyncItemAction.Delete
self.manager.save_object(queue_item, True)
self.start_short_sync_timer()
def pull_from_remote(self):
songs_updated = self.synchronizer.get_remote_song_changes()
if songs_updated:
Registry().execute('songs_load_list')
customs_updated = self.synchronizer.get_remote_custom_changes()
if customs_updated:
Registry().execute('custom_load_list')
def save_service(self, service_item):
pass
def handle_conflicts(self):
# (Re)use the duplicate song UI to let the user manually handle the conflicts. Also allow for batch
# processing, where either local or remote always wins.
pass