diff --git a/openlp/core/__init__.py b/openlp/core/__init__.py
index 50a1ab9b6..db135ef10 100644
--- a/openlp/core/__init__.py
+++ b/openlp/core/__init__.py
@@ -27,26 +27,26 @@ All the core functions of the OpenLP application including the GUI, settings,
logging and a plugin framework are contained within the openlp.core module.
"""
-import os
-import sys
-import logging
import argparse
-from traceback import format_exception
+import logging
+import os
import shutil
+import sys
import time
+from traceback import format_exception
+
from PyQt5 import QtCore, QtGui, QtWidgets
-from openlp.core.common import Registry, OpenLPMixin, AppLocation, Settings, UiStrings, check_directory_exists, \
- is_macosx, is_win, translate
+from openlp.core.common import Registry, OpenLPMixin, AppLocation, LanguageManager, Settings, UiStrings, \
+ check_directory_exists, is_macosx, is_win, translate
+from openlp.core.common.versionchecker import VersionThread, get_application_version
from openlp.core.lib import ScreenList
from openlp.core.resources import qInitResources
-from openlp.core.ui.mainwindow import MainWindow
-from openlp.core.ui.firsttimelanguageform import FirstTimeLanguageForm
-from openlp.core.ui.firsttimeform import FirstTimeForm
-from openlp.core.ui.exceptionform import ExceptionForm
from openlp.core.ui import SplashScreen
-from openlp.core.utils import LanguageManager, VersionThread, get_application_version
-
+from openlp.core.ui.exceptionform import ExceptionForm
+from openlp.core.ui.firsttimeform import FirstTimeForm
+from openlp.core.ui.firsttimelanguageform import FirstTimeLanguageForm
+from openlp.core.ui.mainwindow import MainWindow
__all__ = ['OpenLP', 'main']
diff --git a/openlp/core/common/__init__.py b/openlp/core/common/__init__.py
index b0926dccd..b8a1a4d2e 100644
--- a/openlp/core/common/__init__.py
+++ b/openlp/core/common/__init__.py
@@ -24,15 +24,15 @@ The :mod:`common` module contains most of the components and libraries that make
OpenLP work.
"""
import hashlib
-import re
-import os
import logging
+import os
+import re
import sys
import traceback
from ipaddress import IPv4Address, IPv6Address, AddressValueError
-from codecs import decode, encode
+from shutil import which
-from PyQt5 import QtCore
+from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QCryptographicHash as QHash
log = logging.getLogger(__name__ + '.__init__')
@@ -40,6 +40,9 @@ log = logging.getLogger(__name__ + '.__init__')
FIRST_CAMEL_REGEX = re.compile('(.)([A-Z][a-z]+)')
SECOND_CAMEL_REGEX = re.compile('([a-z0-9])([A-Z])')
+CONTROL_CHARS = re.compile(r'[\x00-\x1F\x7F-\x9F]', re.UNICODE)
+INVALID_FILE_CHARS = re.compile(r'[\\/:\*\?"<>\|\+\[\]%]', re.UNICODE)
+IMAGES_FILTER = None
def trace_error_handler(logger):
@@ -241,4 +244,130 @@ from .registryproperties import RegistryProperties
from .uistrings import UiStrings
from .settings import Settings
from .applocation import AppLocation
-from .historycombobox import HistoryComboBox
+from .actions import ActionList
+from .languagemanager import LanguageManager
+
+
+def add_actions(target, actions):
+ """
+ Adds multiple actions to a menu or toolbar in one command.
+
+ :param target: The menu or toolbar to add actions to
+ :param actions: The actions to be added. An action consisting of the keyword ``None``
+ will result in a separator being inserted into the target.
+ """
+ for action in actions:
+ if action is None:
+ target.addSeparator()
+ else:
+ target.addAction(action)
+
+
+def get_uno_command(connection_type='pipe'):
+ """
+ Returns the UNO command to launch an libreoffice.org instance.
+ """
+ for command in ['libreoffice', 'soffice']:
+ if which(command):
+ break
+ else:
+ raise FileNotFoundError('Command not found')
+
+ OPTIONS = '--nologo --norestore --minimized --nodefault --nofirststartwizard'
+ if connection_type == 'pipe':
+ CONNECTION = '"--accept=pipe,name=openlp_pipe;urp;"'
+ else:
+ CONNECTION = '"--accept=socket,host=localhost,port=2002;urp;"'
+ return '%s %s %s' % (command, OPTIONS, CONNECTION)
+
+
+def get_uno_instance(resolver, connection_type='pipe'):
+ """
+ Returns a running libreoffice.org instance.
+
+ :param resolver: The UNO resolver to use to find a running instance.
+ """
+ log.debug('get UNO Desktop Openoffice - resolve')
+ if connection_type == 'pipe':
+ return resolver.resolve('uno:pipe,name=openlp_pipe;urp;StarOffice.ComponentContext')
+ else:
+ return resolver.resolve('uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext')
+
+
+def get_filesystem_encoding():
+ """
+ Returns the name of the encoding used to convert Unicode filenames into system file names.
+ """
+ encoding = sys.getfilesystemencoding()
+ if encoding is None:
+ encoding = sys.getdefaultencoding()
+ return encoding
+
+
+def split_filename(path):
+ """
+ Return a list of the parts in a given path.
+ """
+ path = os.path.abspath(path)
+ if not os.path.isfile(path):
+ return path, ''
+ else:
+ return os.path.split(path)
+
+
+def delete_file(file_path_name):
+ """
+ Deletes a file from the system.
+
+ :param file_path_name: The file, including path, to delete.
+ """
+ if not file_path_name:
+ return False
+ try:
+ if os.path.exists(file_path_name):
+ os.remove(file_path_name)
+ return True
+ except (IOError, OSError):
+ log.exception("Unable to delete file %s" % file_path_name)
+ return False
+
+
+def get_images_filter():
+ """
+ Returns a filter string for a file dialog containing all the supported image formats.
+ """
+ global IMAGES_FILTER
+ if not IMAGES_FILTER:
+ log.debug('Generating images filter.')
+ formats = list(map(bytes.decode, list(map(bytes, QtGui.QImageReader.supportedImageFormats()))))
+ visible_formats = '(*.%s)' % '; *.'.join(formats)
+ actual_formats = '(*.%s)' % ' *.'.join(formats)
+ IMAGES_FILTER = '%s %s %s' % (translate('OpenLP', 'Image Files'), visible_formats, actual_formats)
+ return IMAGES_FILTER
+
+
+def is_not_image_file(file_name):
+ """
+ Validate that the file is not an image file.
+
+ :param file_name: File name to be checked.
+ """
+ if not file_name:
+ return True
+ else:
+ formats = [bytes(fmt).decode().lower() for fmt in QtGui.QImageReader.supportedImageFormats()]
+ file_part, file_extension = os.path.splitext(str(file_name))
+ if file_extension[1:].lower() in formats and os.path.exists(file_name):
+ return False
+ return True
+
+
+def clean_filename(filename):
+ """
+ Removes invalid characters from the given ``filename``.
+
+ :param filename: The "dirty" file name to clean.
+ """
+ if not isinstance(filename, str):
+ filename = str(filename, 'utf-8')
+ return INVALID_FILE_CHARS.sub('_', CONTROL_CHARS.sub('', filename))
diff --git a/openlp/core/utils/actions.py b/openlp/core/common/actions.py
similarity index 100%
rename from openlp/core/utils/actions.py
rename to openlp/core/common/actions.py
diff --git a/openlp/core/utils/db.py b/openlp/core/common/db.py
similarity index 100%
rename from openlp/core/utils/db.py
rename to openlp/core/common/db.py
diff --git a/openlp/core/utils/languagemanager.py b/openlp/core/common/languagemanager.py
similarity index 75%
rename from openlp/core/utils/languagemanager.py
rename to openlp/core/common/languagemanager.py
index 873b64c57..52e9e9f13 100644
--- a/openlp/core/utils/languagemanager.py
+++ b/openlp/core/common/languagemanager.py
@@ -22,9 +22,9 @@
"""
The :mod:`languagemanager` module provides all the translation settings and language file loading for OpenLP.
"""
+import locale
import logging
import re
-import sys
from PyQt5 import QtCore, QtWidgets
@@ -33,6 +33,9 @@ from openlp.core.common import AppLocation, Settings, translate, is_win, is_maco
log = logging.getLogger(__name__)
+ICU_COLLATOR = None
+DIGITS_OR_NONDIGITS = re.compile(r'\d+|\D+', re.UNICODE)
+
class LanguageManager(object):
"""
@@ -144,3 +147,60 @@ class LanguageManager(object):
if not LanguageManager.__qm_list__:
LanguageManager.init_qm_list()
return LanguageManager.__qm_list__
+
+
+def format_time(text, local_time):
+ """
+ Workaround for Python built-in time formatting function time.strftime().
+
+ time.strftime() accepts only ascii characters. This function accepts
+ unicode string and passes individual % placeholders to time.strftime().
+ This ensures only ascii characters are passed to time.strftime().
+
+ :param text: The text to be processed.
+ :param local_time: The time to be used to add to the string. This is a time object
+ """
+
+ def match_formatting(match):
+ """
+ Format the match
+ """
+ return local_time.strftime(match.group())
+
+ return re.sub('\%[a-zA-Z]', match_formatting, text)
+
+
+def get_locale_key(string):
+ """
+ Creates a key for case insensitive, locale aware string sorting.
+
+ :param string: The corresponding string.
+ """
+ string = string.lower()
+ # ICU is the prefered way to handle locale sort key, we fallback to locale.strxfrm which will work in most cases.
+ global ICU_COLLATOR
+ try:
+ if ICU_COLLATOR is None:
+ import icu
+ language = LanguageManager.get_language()
+ icu_locale = icu.Locale(language)
+ ICU_COLLATOR = icu.Collator.createInstance(icu_locale)
+ return ICU_COLLATOR.getSortKey(string)
+ except:
+ return locale.strxfrm(string).encode()
+
+
+def get_natural_key(string):
+ """
+ Generate a key for locale aware natural string sorting.
+
+ :param string: string to be sorted by
+ Returns a list of string compare keys and integers.
+ """
+ key = DIGITS_OR_NONDIGITS.findall(string)
+ key = [int(part) if part.isdigit() else get_locale_key(part) for part in key]
+ # Python 3 does not support comparison of different types anymore. So make sure, that we do not compare str
+ # and int.
+ if string and string[0].isdigit():
+ return [b''] + key
+ return key
diff --git a/openlp/core/common/settings.py b/openlp/core/common/settings.py
index 4cdf3d712..4bba580b2 100644
--- a/openlp/core/common/settings.py
+++ b/openlp/core/common/settings.py
@@ -119,6 +119,7 @@ class Settings(QtCore.QSettings):
'advanced/double click live': False,
'advanced/enable exit confirmation': True,
'advanced/expand service item': False,
+ 'advanced/slide max height': 0,
'advanced/hide mouse': True,
'advanced/is portable': False,
'advanced/max recent files': 20,
diff --git a/openlp/core/common/versionchecker.py b/openlp/core/common/versionchecker.py
new file mode 100644
index 000000000..136405607
--- /dev/null
+++ b/openlp/core/common/versionchecker.py
@@ -0,0 +1,170 @@
+import logging
+import os
+import platform
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from datetime import datetime
+from distutils.version import LooseVersion
+from subprocess import Popen, PIPE
+
+from openlp.core.common import AppLocation, Settings
+
+from PyQt5 import QtCore
+
+log = logging.getLogger(__name__)
+
+APPLICATION_VERSION = {}
+CONNECTION_TIMEOUT = 30
+CONNECTION_RETRIES = 2
+
+
+class VersionThread(QtCore.QThread):
+ """
+ A special Qt thread class to fetch the version of OpenLP from the website.
+ This is threaded so that it doesn't affect the loading time of OpenLP.
+ """
+ def __init__(self, main_window):
+ """
+ Constructor for the thread class.
+
+ :param main_window: The main window Object.
+ """
+ log.debug("VersionThread - Initialise")
+ super(VersionThread, self).__init__(None)
+ self.main_window = main_window
+
+ def run(self):
+ """
+ Run the thread.
+ """
+ self.sleep(1)
+ log.debug('Version thread - run')
+ app_version = get_application_version()
+ version = check_latest_version(app_version)
+ log.debug("Versions %s and %s " % (LooseVersion(str(version)), LooseVersion(str(app_version['full']))))
+ if LooseVersion(str(version)) > LooseVersion(str(app_version['full'])):
+ self.main_window.openlp_version_check.emit('%s' % version)
+
+
+def get_application_version():
+ """
+ Returns the application version of the running instance of OpenLP::
+
+ {'full': '1.9.4-bzr1249', 'version': '1.9.4', 'build': 'bzr1249'}
+ """
+ global APPLICATION_VERSION
+ if APPLICATION_VERSION:
+ return APPLICATION_VERSION
+ if '--dev-version' in sys.argv or '-d' in sys.argv:
+ # NOTE: The following code is a duplicate of the code in setup.py. Any fix applied here should also be applied
+ # there.
+
+ # Get the revision of this tree.
+ bzr = Popen(('bzr', 'revno'), stdout=PIPE)
+ tree_revision, error = bzr.communicate()
+ tree_revision = tree_revision.decode()
+ code = bzr.wait()
+ if code != 0:
+ raise Exception('Error running bzr log')
+
+ # Get all tags.
+ bzr = Popen(('bzr', 'tags'), stdout=PIPE)
+ output, error = bzr.communicate()
+ code = bzr.wait()
+ if code != 0:
+ raise Exception('Error running bzr tags')
+ tags = list(map(bytes.decode, output.splitlines()))
+ if not tags:
+ tag_version = '0.0.0'
+ tag_revision = '0'
+ else:
+ # Remove any tag that has "?" as revision number. A "?" as revision number indicates, that this tag is from
+ # another series.
+ tags = [tag for tag in tags if tag.split()[-1].strip() != '?']
+ # Get the last tag and split it in a revision and tag name.
+ tag_version, tag_revision = tags[-1].split()
+ # If they are equal, then this tree is tarball with the source for the release. We do not want the revision
+ # number in the full version.
+ if tree_revision == tag_revision:
+ full_version = tag_version.strip()
+ else:
+ full_version = '%s-bzr%s' % (tag_version.strip(), tree_revision.strip())
+ else:
+ # We're not running the development version, let's use the file.
+ file_path = AppLocation.get_directory(AppLocation.VersionDir)
+ file_path = os.path.join(file_path, '.version')
+ version_file = None
+ try:
+ version_file = open(file_path, 'r')
+ full_version = str(version_file.read()).rstrip()
+ except IOError:
+ log.exception('Error in version file.')
+ full_version = '0.0.0-bzr000'
+ finally:
+ if version_file:
+ version_file.close()
+ bits = full_version.split('-')
+ APPLICATION_VERSION = {
+ 'full': full_version,
+ 'version': bits[0],
+ 'build': bits[1] if len(bits) > 1 else None
+ }
+ if APPLICATION_VERSION['build']:
+ log.info('Openlp version %s build %s', APPLICATION_VERSION['version'], APPLICATION_VERSION['build'])
+ else:
+ log.info('Openlp version %s' % APPLICATION_VERSION['version'])
+ return APPLICATION_VERSION
+
+
+def check_latest_version(current_version):
+ """
+ Check the latest version of OpenLP against the version file on the OpenLP
+ site.
+
+ **Rules around versions and version files:**
+
+ * If a version number has a build (i.e. -bzr1234), then it is a nightly.
+ * If a version number's minor version is an odd number, it is a development release.
+ * If a version number's minor version is an even number, it is a stable release.
+
+ :param current_version: The current version of OpenLP.
+ """
+ version_string = current_version['full']
+ # set to prod in the distribution config file.
+ settings = Settings()
+ settings.beginGroup('core')
+ last_test = settings.value('last version test')
+ this_test = str(datetime.now().date())
+ settings.setValue('last version test', this_test)
+ settings.endGroup()
+ if last_test != this_test:
+ if current_version['build']:
+ req = urllib.request.Request('http://www.openlp.org/files/nightly_version.txt')
+ else:
+ version_parts = current_version['version'].split('.')
+ if int(version_parts[1]) % 2 != 0:
+ req = urllib.request.Request('http://www.openlp.org/files/dev_version.txt')
+ else:
+ req = urllib.request.Request('http://www.openlp.org/files/version.txt')
+ req.add_header('User-Agent', 'OpenLP/%s %s/%s; ' % (current_version['full'], platform.system(),
+ platform.release()))
+ remote_version = None
+ retries = 0
+ while True:
+ try:
+ remote_version = str(urllib.request.urlopen(req, None,
+ timeout=CONNECTION_TIMEOUT).read().decode()).strip()
+ except (urllib.error.URLError, ConnectionError):
+ if retries > CONNECTION_RETRIES:
+ log.exception('Failed to download the latest OpenLP version file')
+ else:
+ retries += 1
+ time.sleep(0.1)
+ continue
+ break
+ if remote_version:
+ version_string = remote_version
+ return version_string
diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py
index d63dee23c..7ae3cdc6f 100644
--- a/openlp/core/lib/db.py
+++ b/openlp/core/lib/db.py
@@ -34,9 +34,8 @@ from sqlalchemy.pool import NullPool
from alembic.migration import MigrationContext
from alembic.operations import Operations
-from openlp.core.common import AppLocation, Settings, translate
+from openlp.core.common import AppLocation, Settings, translate, delete_file
from openlp.core.lib.ui import critical_error_message_box
-from openlp.core.utils import delete_file
log = logging.getLogger(__name__)
diff --git a/openlp/core/lib/plugin.py b/openlp/core/lib/plugin.py
index 6c19ac1dd..b9c8ca5f0 100644
--- a/openlp/core/lib/plugin.py
+++ b/openlp/core/lib/plugin.py
@@ -24,11 +24,10 @@ Provide the generic plugin functionality for OpenLP plugins.
"""
import logging
-
from PyQt5 import QtCore
from openlp.core.common import Registry, RegistryProperties, Settings, UiStrings
-from openlp.core.utils import get_application_version
+from openlp.core.common.versionchecker import get_application_version
log = logging.getLogger(__name__)
diff --git a/openlp/core/lib/projector/pjlink1.py b/openlp/core/lib/projector/pjlink1.py
index c5c765d62..4cdd31269 100644
--- a/openlp/core/lib/projector/pjlink1.py
+++ b/openlp/core/lib/projector/pjlink1.py
@@ -513,17 +513,13 @@ class PJLink1(QTcpSocket):
log.debug('(%s) _send_string(): Sending "%s"' % (self.ip, out.strip()))
log.debug('(%s) _send_string(): Queue = %s' % (self.ip, self.send_queue))
self.socket_timer.start()
- try:
- self.projectorNetwork.emit(S_NETWORK_SENDING)
- sent = self.write(out.encode('ascii'))
- self.waitForBytesWritten(2000) # 2 seconds should be enough
- if sent == -1:
- # Network error?
- self.change_status(E_NETWORK,
- translate('OpenLP.PJLink1', 'Error while sending data to projector'))
- except SocketError as e:
- self.disconnect_from_host(abort=True)
- self.changeStatus(E_NETWORK, '%s : %s' % (e.error(), e.errorString()))
+ self.projectorNetwork.emit(S_NETWORK_SENDING)
+ sent = self.write(out.encode('ascii'))
+ self.waitForBytesWritten(2000) # 2 seconds should be enough
+ if sent == -1:
+ # Network error?
+ self.change_status(E_NETWORK,
+ translate('OpenLP.PJLink1', 'Error while sending data to projector'))
def process_command(self, cmd, data):
"""
diff --git a/openlp/core/lib/ui.py b/openlp/core/lib/ui.py
index 402e4ad34..ab47a65e1 100644
--- a/openlp/core/lib/ui.py
+++ b/openlp/core/lib/ui.py
@@ -27,8 +27,8 @@ import logging
from PyQt5 import QtCore, QtGui, QtWidgets
from openlp.core.common import Registry, UiStrings, translate, is_macosx
+from openlp.core.common.actions import ActionList
from openlp.core.lib import build_icon
-from openlp.core.utils.actions import ActionList
log = logging.getLogger(__name__)
diff --git a/openlp/core/lib/webpagereader.py b/openlp/core/lib/webpagereader.py
new file mode 100644
index 000000000..653717c93
--- /dev/null
+++ b/openlp/core/lib/webpagereader.py
@@ -0,0 +1,183 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2016 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+The :mod:`openlp.core.utils` module provides the utility libraries for OpenLP.
+"""
+import logging
+import socket
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from http.client import HTTPException
+from random import randint
+
+from openlp.core.common import Registry
+
+log = logging.getLogger(__name__ + '.__init__')
+
+USER_AGENTS = {
+ 'win32': [
+ 'Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36',
+ 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36',
+ 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.71 Safari/537.36'
+ ],
+ 'darwin': [
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_3) AppleWebKit/537.31 (KHTML, like Gecko) '
+ 'Chrome/26.0.1410.43 Safari/537.31',
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_3) AppleWebKit/536.11 (KHTML, like Gecko) '
+ 'Chrome/20.0.1132.57 Safari/536.11',
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/536.11 (KHTML, like Gecko) '
+ 'Chrome/20.0.1132.47 Safari/536.11',
+ ],
+ 'linux2': [
+ 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.22 (KHTML, like Gecko) Ubuntu Chromium/25.0.1364.160 '
+ 'Chrome/25.0.1364.160 Safari/537.22',
+ 'Mozilla/5.0 (X11; CrOS armv7l 2913.260.0) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.99 '
+ 'Safari/537.11',
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.27 (KHTML, like Gecko) Chrome/26.0.1389.0 Safari/537.27'
+ ],
+ 'default': [
+ 'Mozilla/5.0 (X11; NetBSD amd64; rv:18.0) Gecko/20130120 Firefox/18.0'
+ ]
+}
+CONNECTION_TIMEOUT = 30
+CONNECTION_RETRIES = 2
+
+
+class HTTPRedirectHandlerFixed(urllib.request.HTTPRedirectHandler):
+ """
+ Special HTTPRedirectHandler used to work around http://bugs.python.org/issue22248
+ (Redirecting to urls with special chars)
+ """
+ def redirect_request(self, req, fp, code, msg, headers, new_url):
+ #
+ """
+ Test if the new_url can be decoded to ascii
+
+ :param req:
+ :param fp:
+ :param code:
+ :param msg:
+ :param headers:
+ :param new_url:
+ :return:
+ """
+ try:
+ new_url.encode('latin1').decode('ascii')
+ fixed_url = new_url
+ except Exception:
+ # The url could not be decoded to ascii, so we do some url encoding
+ fixed_url = urllib.parse.quote(new_url.encode('latin1').decode('utf-8', 'replace'), safe='/:')
+ return super(HTTPRedirectHandlerFixed, self).redirect_request(req, fp, code, msg, headers, fixed_url)
+
+
+def _get_user_agent():
+ """
+ Return a user agent customised for the platform the user is on.
+ """
+ browser_list = USER_AGENTS.get(sys.platform, None)
+ if not browser_list:
+ browser_list = USER_AGENTS['default']
+ random_index = randint(0, len(browser_list) - 1)
+ return browser_list[random_index]
+
+
+def get_web_page(url, header=None, update_openlp=False):
+ """
+ Attempts to download the webpage at url and returns that page or None.
+
+ :param url: The URL to be downloaded.
+ :param header: An optional HTTP header to pass in the request to the web server.
+ :param update_openlp: Tells OpenLP to update itself if the page is successfully downloaded.
+ Defaults to False.
+ """
+ # TODO: Add proxy usage. Get proxy info from OpenLP settings, add to a
+ # proxy_handler, build into an opener and install the opener into urllib2.
+ # http://docs.python.org/library/urllib2.html
+ if not url:
+ return None
+ # This is needed to work around http://bugs.python.org/issue22248 and https://bugs.launchpad.net/openlp/+bug/1251437
+ opener = urllib.request.build_opener(HTTPRedirectHandlerFixed())
+ urllib.request.install_opener(opener)
+ req = urllib.request.Request(url)
+ if not header or header[0].lower() != 'user-agent':
+ user_agent = _get_user_agent()
+ req.add_header('User-Agent', user_agent)
+ if header:
+ req.add_header(header[0], header[1])
+ log.debug('Downloading URL = %s' % url)
+ retries = 0
+ while retries <= CONNECTION_RETRIES:
+ retries += 1
+ time.sleep(0.1)
+ try:
+ page = urllib.request.urlopen(req, timeout=CONNECTION_TIMEOUT)
+ log.debug('Downloaded page {}'.format(page.geturl()))
+ break
+ except urllib.error.URLError as err:
+ log.exception('URLError on {}'.format(url))
+ log.exception('URLError: {}'.format(err.reason))
+ page = None
+ if retries > CONNECTION_RETRIES:
+ raise
+ except socket.timeout:
+ log.exception('Socket timeout: {}'.format(url))
+ page = None
+ if retries > CONNECTION_RETRIES:
+ raise
+ except socket.gaierror:
+ log.exception('Socket gaierror: {}'.format(url))
+ page = None
+ if retries > CONNECTION_RETRIES:
+ raise
+ except ConnectionRefusedError:
+ log.exception('ConnectionRefused: {}'.format(url))
+ page = None
+ if retries > CONNECTION_RETRIES:
+ raise
+ break
+ except ConnectionError:
+ log.exception('Connection error: {}'.format(url))
+ page = None
+ if retries > CONNECTION_RETRIES:
+ raise
+ except HTTPException:
+ log.exception('HTTPException error: {}'.format(url))
+ page = None
+ if retries > CONNECTION_RETRIES:
+ raise
+ except:
+ # Don't know what's happening, so reraise the original
+ raise
+ if update_openlp:
+ Registry().get('application').process_events()
+ if not page:
+ log.exception('{} could not be downloaded'.format(url))
+ return None
+ log.debug(page)
+ return page
+
+
+__all__ = ['get_application_version', 'check_latest_version',
+ 'get_web_page']
diff --git a/openlp/core/ui/aboutform.py b/openlp/core/ui/aboutform.py
index b376d4646..fc29f968c 100644
--- a/openlp/core/ui/aboutform.py
+++ b/openlp/core/ui/aboutform.py
@@ -26,8 +26,8 @@ import webbrowser
from PyQt5 import QtCore, QtWidgets
+from openlp.core.common.versionchecker import get_application_version
from openlp.core.lib import translate
-from openlp.core.utils import get_application_version
from .aboutdialog import UiAboutDialog
diff --git a/openlp/core/ui/advancedtab.py b/openlp/core/ui/advancedtab.py
index 4a25c40a3..0e36ae352 100644
--- a/openlp/core/ui/advancedtab.py
+++ b/openlp/core/ui/advancedtab.py
@@ -29,9 +29,15 @@ import sys
from PyQt5 import QtCore, QtGui, QtWidgets
+<<<<<<< TREE
from openlp.core.common import AppLocation, Settings, SlideLimits, UiStrings, translate
from openlp.core.lib import SettingsTab, build_icon
from openlp.core.utils import format_time
+=======
+from openlp.core.common import AppLocation, Settings, SlideLimits, UiStrings, translate, get_images_filter
+from openlp.core.lib import ColorButton, SettingsTab, build_icon
+from openlp.core.common.languagemanager import format_time
+>>>>>>> MERGE-SOURCE
log = logging.getLogger(__name__)
@@ -81,6 +87,13 @@ class AdvancedTab(SettingsTab):
self.expand_service_item_check_box = QtWidgets.QCheckBox(self.ui_group_box)
self.expand_service_item_check_box.setObjectName('expand_service_item_check_box')
self.ui_layout.addRow(self.expand_service_item_check_box)
+ self.slide_max_height_label = QtWidgets.QLabel(self.ui_group_box)
+ self.slide_max_height_label.setObjectName('slide_max_height_label')
+ self.slide_max_height_spin_box = QtWidgets.QSpinBox(self.ui_group_box)
+ self.slide_max_height_spin_box.setObjectName('slide_max_height_spin_box')
+ self.slide_max_height_spin_box.setRange(0, 1000)
+ self.slide_max_height_spin_box.setSingleStep(20)
+ self.ui_layout.addRow(self.slide_max_height_label, self.slide_max_height_spin_box)
self.search_as_type_check_box = QtWidgets.QCheckBox(self.ui_group_box)
self.search_as_type_check_box.setObjectName('SearchAsType_check_box')
self.ui_layout.addRow(self.search_as_type_check_box)
@@ -245,6 +258,9 @@ class AdvancedTab(SettingsTab):
'Preview items when clicked in Service Manager'))
self.expand_service_item_check_box.setText(translate('OpenLP.AdvancedTab',
'Expand new service items on creation'))
+ self.slide_max_height_label.setText(translate('OpenLP.AdvancedTab',
+ 'Max height for non-text slides\nin slide controller:'))
+ self.slide_max_height_spin_box.setSpecialValueText(translate('OpenLP.AdvancedTab', 'Disabled'))
self.enable_auto_close_check_box.setText(translate('OpenLP.AdvancedTab',
'Enable application exit confirmation'))
self.service_name_group_box.setTitle(translate('OpenLP.AdvancedTab', 'Default Service Name'))
@@ -309,6 +325,7 @@ class AdvancedTab(SettingsTab):
self.single_click_preview_check_box.setChecked(settings.value('single click preview'))
self.single_click_service_preview_check_box.setChecked(settings.value('single click service preview'))
self.expand_service_item_check_box.setChecked(settings.value('expand service item'))
+ self.slide_max_height_spin_box.setValue(settings.value('slide max height'))
self.enable_auto_close_check_box.setChecked(settings.value('enable exit confirmation'))
self.hide_mouse_check_box.setChecked(settings.value('hide mouse'))
self.service_name_day.setCurrentIndex(settings.value('default service day'))
@@ -388,6 +405,7 @@ class AdvancedTab(SettingsTab):
settings.setValue('single click preview', self.single_click_preview_check_box.isChecked())
settings.setValue('single click service preview', self.single_click_service_preview_check_box.isChecked())
settings.setValue('expand service item', self.expand_service_item_check_box.isChecked())
+ settings.setValue('slide max height', self.slide_max_height_spin_box.value())
settings.setValue('enable exit confirmation', self.enable_auto_close_check_box.isChecked())
settings.setValue('hide mouse', self.hide_mouse_check_box.isChecked())
settings.setValue('alternate rows', self.alternate_rows_check_box.isChecked())
diff --git a/openlp/core/ui/exceptionform.py b/openlp/core/ui/exceptionform.py
index 2e4661579..68dd9705f 100644
--- a/openlp/core/ui/exceptionform.py
+++ b/openlp/core/ui/exceptionform.py
@@ -23,18 +23,17 @@
The actual exception dialog form.
"""
import logging
-import re
import os
import platform
+import re
import bs4
import sqlalchemy
+from PyQt5 import Qt, QtCore, QtGui, QtWebKit, QtWidgets
from lxml import etree
from openlp.core.common import RegistryProperties, is_linux
-from PyQt5 import Qt, QtCore, QtGui, QtWebKit, QtWidgets
-
try:
import migrate
MIGRATE_VERSION = getattr(migrate, '__version__', '< 0.7')
@@ -74,7 +73,7 @@ except ImportError:
VLC_VERSION = '-'
from openlp.core.common import Settings, UiStrings, translate
-from openlp.core.utils import get_application_version
+from openlp.core.common.versionchecker import get_application_version
from .exceptiondialog import Ui_ExceptionDialog
diff --git a/openlp/core/ui/firsttimeform.py b/openlp/core/ui/firsttimeform.py
index 0d7c129bf..f2be3b29c 100644
--- a/openlp/core/ui/firsttimeform.py
+++ b/openlp/core/ui/firsttimeform.py
@@ -39,7 +39,7 @@ from openlp.core.common import Registry, RegistryProperties, AppLocation, Settin
translate, clean_button_text, trace_error_handler
from openlp.core.lib import PluginStatus, build_icon
from openlp.core.lib.ui import critical_error_message_box
-from openlp.core.utils import get_web_page, CONNECTION_RETRIES, CONNECTION_TIMEOUT
+from openlp.core.lib.webpagereader import get_web_page, CONNECTION_RETRIES, CONNECTION_TIMEOUT
from .firsttimewizard import UiFirstTimeWizard, FirstTimePage
log = logging.getLogger(__name__)
diff --git a/openlp/core/ui/firsttimelanguageform.py b/openlp/core/ui/firsttimelanguageform.py
index a55713bf0..4058612fb 100644
--- a/openlp/core/ui/firsttimelanguageform.py
+++ b/openlp/core/ui/firsttimelanguageform.py
@@ -25,7 +25,7 @@ The language selection dialog.
from PyQt5 import QtCore, QtWidgets
from openlp.core.lib.ui import create_action
-from openlp.core.utils import LanguageManager
+from openlp.core.common import LanguageManager
from .firsttimelanguagedialog import Ui_FirstTimeLanguageDialog
diff --git a/tests/functional/openlp_core_utils/__init__.py b/openlp/core/ui/lib/__init__.py
similarity index 100%
rename from tests/functional/openlp_core_utils/__init__.py
rename to openlp/core/ui/lib/__init__.py
diff --git a/openlp/core/common/historycombobox.py b/openlp/core/ui/lib/historycombobox.py
similarity index 98%
rename from openlp/core/common/historycombobox.py
rename to openlp/core/ui/lib/historycombobox.py
index f0ec7c2ad..23e05e76e 100644
--- a/openlp/core/common/historycombobox.py
+++ b/openlp/core/ui/lib/historycombobox.py
@@ -20,7 +20,7 @@
# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
###############################################################################
"""
-The :mod:`~openlp.core.common.historycombobox` module contains the HistoryComboBox widget
+The :mod:`~openlp.core.ui.lib.historycombobox` module contains the HistoryComboBox widget
"""
from PyQt5 import QtCore, QtWidgets
diff --git a/openlp/core/ui/listpreviewwidget.py b/openlp/core/ui/listpreviewwidget.py
index fb6481e56..68c983d42 100644
--- a/openlp/core/ui/listpreviewwidget.py
+++ b/openlp/core/ui/listpreviewwidget.py
@@ -26,7 +26,7 @@ It is based on a QTableWidget but represents its contents in list form.
from PyQt5 import QtCore, QtGui, QtWidgets
-from openlp.core.common import RegistryProperties
+from openlp.core.common import RegistryProperties, Settings
from openlp.core.lib import ImageSource, ServiceItem
@@ -63,6 +63,8 @@ class ListPreviewWidget(QtWidgets.QTableWidget, RegistryProperties):
# Initialize variables.
self.service_item = ServiceItem()
self.screen_ratio = screen_ratio
+ # Connect signals
+ self.verticalHeader().sectionResized.connect(self.row_resized)
def resizeEvent(self, event):
"""
@@ -80,12 +82,30 @@ class ListPreviewWidget(QtWidgets.QTableWidget, RegistryProperties):
# Sort out songs, bibles, etc.
if self.service_item.is_text():
self.resizeRowsToContents()
+ # Sort out image heights.
else:
- # Sort out image heights.
+ height = self.viewport().width() // self.screen_ratio
+ max_img_row_height = Settings().value('advanced/slide max height')
+ # Adjust for row height cap if in use.
+ if max_img_row_height > 0 and height > max_img_row_height:
+ height = max_img_row_height
+ # Apply new height to slides
for frame_number in range(len(self.service_item.get_frames())):
- height = self.viewport().width() // self.screen_ratio
self.setRowHeight(frame_number, height)
+ def row_resized(self, row, old_height, new_height):
+ """
+ Will scale non-image slides.
+ """
+ # Only for non-text slides when row height cap in use
+ if self.service_item.is_text() or Settings().value('advanced/slide max height') <= 0:
+ return
+ # Get and validate label widget containing slide & adjust max width
+ try:
+ self.cellWidget(row, 0).children()[1].setMaximumWidth(new_height * self.screen_ratio)
+ except:
+ return
+
def screen_size_changed(self, screen_ratio):
"""
This method is called whenever the live screen size changes, which then makes a layout recalculation necessary
@@ -139,8 +159,26 @@ class ListPreviewWidget(QtWidgets.QTableWidget, RegistryProperties):
pixmap = QtGui.QPixmap.fromImage(image)
pixmap.setDevicePixelRatio(label.devicePixelRatio())
label.setPixmap(pixmap)
- self.setCellWidget(frame_number, 0, label)
slide_height = width // self.screen_ratio
+ # Setup row height cap if in use.
+ max_img_row_height = Settings().value('advanced/slide max height')
+ if max_img_row_height > 0:
+ if slide_height > max_img_row_height:
+ slide_height = max_img_row_height
+ label.setMaximumWidth(max_img_row_height * self.screen_ratio)
+ label.resize(max_img_row_height * self.screen_ratio, max_img_row_height)
+ # Build widget with stretch padding
+ container = QtWidgets.QWidget()
+ hbox = QtWidgets.QHBoxLayout()
+ hbox.setContentsMargins(0, 0, 0, 0)
+ hbox.addWidget(label, stretch=1)
+ hbox.addStretch(0)
+ container.setLayout(hbox)
+ # Add to table
+ self.setCellWidget(frame_number, 0, container)
+ else:
+ # Add to table
+ self.setCellWidget(frame_number, 0, label)
row += 1
text.append(str(row))
self.setItem(frame_number, 0, item)
diff --git a/openlp/core/ui/mainwindow.py b/openlp/core/ui/mainwindow.py
index 4427de655..228969ad1 100644
--- a/openlp/core/ui/mainwindow.py
+++ b/openlp/core/ui/mainwindow.py
@@ -24,30 +24,29 @@ This is the main window, where all the action happens.
"""
import logging
import os
-import sys
import shutil
+import sys
+import time
+from datetime import datetime
from distutils import dir_util
from distutils.errors import DistutilsFileError
from tempfile import gettempdir
-import time
-from datetime import datetime
from PyQt5 import QtCore, QtGui, QtWidgets
-from openlp.core.common import Registry, RegistryProperties, AppLocation, Settings, check_directory_exists, translate, \
- is_win, is_macosx
+from openlp.core.common import Registry, RegistryProperties, AppLocation, LanguageManager, Settings, \
+ check_directory_exists, translate, is_win, is_macosx, add_actions
+from openlp.core.common.actions import ActionList, CategoryOrder
+from openlp.core.common.versionchecker import get_application_version
from openlp.core.lib import Renderer, OpenLPDockWidget, PluginManager, ImageManager, PluginStatus, ScreenList, \
build_icon
from openlp.core.lib.ui import UiStrings, create_action
from openlp.core.ui import AboutForm, SettingsForm, ServiceManager, ThemeManager, LiveController, PluginForm, \
MediaDockManager, ShortcutListForm, FormattingTagForm, PreviewController
-
-from openlp.core.ui.media import MediaController
-from openlp.core.utils import LanguageManager, add_actions, get_application_version
-from openlp.core.utils.actions import ActionList, CategoryOrder
from openlp.core.ui.firsttimeform import FirstTimeForm
-from openlp.core.ui.projector.manager import ProjectorManager
+from openlp.core.ui.media import MediaController
from openlp.core.ui.printserviceform import PrintServiceForm
+from openlp.core.ui.projector.manager import ProjectorManager
log = logging.getLogger(__name__)
diff --git a/openlp/core/ui/servicemanager.py b/openlp/core/ui/servicemanager.py
index fdae5c069..66cbdf1b7 100644
--- a/openlp/core/ui/servicemanager.py
+++ b/openlp/core/ui/servicemanager.py
@@ -23,23 +23,22 @@
The service manager sets up, loads, saves and manages services.
"""
import html
+import json
import os
import shutil
import zipfile
-import json
-from tempfile import mkstemp
from datetime import datetime, timedelta
+from tempfile import mkstemp
from PyQt5 import QtCore, QtGui, QtWidgets
from openlp.core.common import Registry, RegistryProperties, AppLocation, Settings, ThemeLevel, OpenLPMixin, \
- RegistryMixin, check_directory_exists, UiStrings, translate
+ RegistryMixin, check_directory_exists, UiStrings, translate, split_filename, delete_file
+from openlp.core.common.actions import ActionList, CategoryOrder
from openlp.core.lib import OpenLPToolbar, ServiceItem, ItemCapabilities, PluginStatus, build_icon
from openlp.core.lib.ui import critical_error_message_box, create_widget_action, find_and_set_in_combo_box
from openlp.core.ui import ServiceNoteForm, ServiceItemEditForm, StartTimeForm
-from openlp.core.ui.printserviceform import PrintServiceForm
-from openlp.core.utils import delete_file, split_filename, format_time
-from openlp.core.utils.actions import ActionList, CategoryOrder
+from openlp.core.common.languagemanager import format_time
class ServiceManagerList(QtWidgets.QTreeWidget):
diff --git a/openlp/core/ui/shortcutlistform.py b/openlp/core/ui/shortcutlistform.py
index 574392a7a..e0d72d9e1 100644
--- a/openlp/core/ui/shortcutlistform.py
+++ b/openlp/core/ui/shortcutlistform.py
@@ -27,7 +27,7 @@ import re
from PyQt5 import QtCore, QtGui, QtWidgets
from openlp.core.common import RegistryProperties, Settings, translate
-from openlp.core.utils.actions import ActionList
+from openlp.core.common.actions import ActionList
from .shortcutlistdialog import Ui_ShortcutListDialog
REMOVE_AMPERSAND = re.compile(r'&{1}')
diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py
index d2e2fe4ae..fd88e67ee 100644
--- a/openlp/core/ui/slidecontroller.py
+++ b/openlp/core/ui/slidecontroller.py
@@ -23,20 +23,20 @@
The :mod:`slidecontroller` module contains the most important part of OpenLP - the slide controller
"""
-import os
import copy
+import os
from collections import deque
from threading import Lock
from PyQt5 import QtCore, QtGui, QtWidgets
from openlp.core.common import Registry, RegistryProperties, Settings, SlideLimits, UiStrings, translate, \
- RegistryMixin, OpenLPMixin, is_win
+ RegistryMixin, OpenLPMixin
+from openlp.core.common.actions import ActionList, CategoryOrder
from openlp.core.lib import OpenLPToolbar, ItemCapabilities, ServiceItem, ImageSource, ServiceItemAction, \
ScreenList, build_icon, build_html
-from openlp.core.ui import HideMode, MainDisplay, Display, DisplayControllerType
from openlp.core.lib.ui import create_action
-from openlp.core.utils.actions import ActionList, CategoryOrder
+from openlp.core.ui import HideMode, MainDisplay, Display, DisplayControllerType
from openlp.core.ui.listpreviewwidget import ListPreviewWidget
# Threshold which has to be trespassed to toggle.
diff --git a/openlp/core/ui/themeform.py b/openlp/core/ui/themeform.py
index cd81ec800..d620a0f79 100644
--- a/openlp/core/ui/themeform.py
+++ b/openlp/core/ui/themeform.py
@@ -27,11 +27,10 @@ import os
from PyQt5 import QtCore, QtGui, QtWidgets
-from openlp.core.common import Registry, RegistryProperties, UiStrings, translate
+from openlp.core.common import Registry, RegistryProperties, UiStrings, translate, get_images_filter, is_not_image_file
from openlp.core.lib.theme import BackgroundType, BackgroundGradientType
from openlp.core.lib.ui import critical_error_message_box
from openlp.core.ui import ThemeLayoutForm
-from openlp.core.utils import get_images_filter, is_not_image_file
from .themewizard import Ui_ThemeWizard
log = logging.getLogger(__name__)
diff --git a/openlp/core/ui/thememanager.py b/openlp/core/ui/thememanager.py
index fdf87e528..a80640150 100644
--- a/openlp/core/ui/thememanager.py
+++ b/openlp/core/ui/thememanager.py
@@ -30,13 +30,13 @@ from xml.etree.ElementTree import ElementTree, XML
from PyQt5 import QtCore, QtGui, QtWidgets
from openlp.core.common import Registry, RegistryProperties, AppLocation, Settings, OpenLPMixin, RegistryMixin, \
- check_directory_exists, UiStrings, translate, is_win
+ check_directory_exists, UiStrings, translate, is_win, get_filesystem_encoding, delete_file
from openlp.core.lib import FileDialog, ImageSource, OpenLPToolbar, ValidationError, get_text_file_string, build_icon, \
check_item_selected, create_thumb, validate_thumb
from openlp.core.lib.theme import ThemeXML, BackgroundType
from openlp.core.lib.ui import critical_error_message_box, create_widget_action
from openlp.core.ui import FileRenameForm, ThemeForm
-from openlp.core.utils import delete_file, get_locale_key, get_filesystem_encoding
+from openlp.core.common.languagemanager import get_locale_key
class Ui_ThemeManager(object):
diff --git a/openlp/core/utils/__init__.py b/openlp/core/utils/__init__.py
deleted file mode 100644
index 086c69c79..000000000
--- a/openlp/core/utils/__init__.py
+++ /dev/null
@@ -1,543 +0,0 @@
-# -*- coding: utf-8 -*-
-# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
-
-###############################################################################
-# OpenLP - Open Source Lyrics Projection #
-# --------------------------------------------------------------------------- #
-# Copyright (c) 2008-2016 OpenLP Developers #
-# --------------------------------------------------------------------------- #
-# This program is free software; you can redistribute it and/or modify it #
-# under the terms of the GNU General Public License as published by the Free #
-# Software Foundation; version 2 of the License. #
-# #
-# This program is distributed in the hope that it will be useful, but WITHOUT #
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
-# more details. #
-# #
-# You should have received a copy of the GNU General Public License along #
-# with this program; if not, write to the Free Software Foundation, Inc., 59 #
-# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
-###############################################################################
-"""
-The :mod:`openlp.core.utils` module provides the utility libraries for OpenLP.
-"""
-from datetime import datetime
-from distutils.version import LooseVersion
-from http.client import HTTPException
-import logging
-import locale
-import os
-import platform
-import re
-import socket
-import time
-from shutil import which
-from subprocess import Popen, PIPE
-import sys
-import urllib.request
-import urllib.error
-import urllib.parse
-from random import randint
-
-from PyQt5 import QtGui, QtCore
-
-from openlp.core.common import Registry, AppLocation, Settings, is_win, is_macosx
-
-
-if not is_win() and not is_macosx():
- try:
- from xdg import BaseDirectory
- XDG_BASE_AVAILABLE = True
- except ImportError:
- BaseDirectory = None
- XDG_BASE_AVAILABLE = False
-
-from openlp.core.common import translate
-
-log = logging.getLogger(__name__ + '.__init__')
-
-APPLICATION_VERSION = {}
-IMAGES_FILTER = None
-ICU_COLLATOR = None
-CONTROL_CHARS = re.compile(r'[\x00-\x1F\x7F-\x9F]', re.UNICODE)
-INVALID_FILE_CHARS = re.compile(r'[\\/:\*\?"<>\|\+\[\]%]', re.UNICODE)
-DIGITS_OR_NONDIGITS = re.compile(r'\d+|\D+', re.UNICODE)
-USER_AGENTS = {
- 'win32': [
- 'Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36',
- 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36',
- 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.71 Safari/537.36'
- ],
- 'darwin': [
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_3) AppleWebKit/537.31 (KHTML, like Gecko) '
- 'Chrome/26.0.1410.43 Safari/537.31',
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_3) AppleWebKit/536.11 (KHTML, like Gecko) '
- 'Chrome/20.0.1132.57 Safari/536.11',
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/536.11 (KHTML, like Gecko) '
- 'Chrome/20.0.1132.47 Safari/536.11',
- ],
- 'linux2': [
- 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.22 (KHTML, like Gecko) Ubuntu Chromium/25.0.1364.160 '
- 'Chrome/25.0.1364.160 Safari/537.22',
- 'Mozilla/5.0 (X11; CrOS armv7l 2913.260.0) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.99 '
- 'Safari/537.11',
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.27 (KHTML, like Gecko) Chrome/26.0.1389.0 Safari/537.27'
- ],
- 'default': [
- 'Mozilla/5.0 (X11; NetBSD amd64; rv:18.0) Gecko/20130120 Firefox/18.0'
- ]
-}
-CONNECTION_TIMEOUT = 30
-CONNECTION_RETRIES = 2
-
-
-class VersionThread(QtCore.QThread):
- """
- A special Qt thread class to fetch the version of OpenLP from the website.
- This is threaded so that it doesn't affect the loading time of OpenLP.
- """
- def __init__(self, main_window):
- """
- Constructor for the thread class.
-
- :param main_window: The main window Object.
- """
- log.debug("VersionThread - Initialise")
- super(VersionThread, self).__init__(None)
- self.main_window = main_window
-
- def run(self):
- """
- Run the thread.
- """
- self.sleep(1)
- log.debug('Version thread - run')
- app_version = get_application_version()
- version = check_latest_version(app_version)
- log.debug("Versions %s and %s " % (LooseVersion(str(version)), LooseVersion(str(app_version['full']))))
- if LooseVersion(str(version)) > LooseVersion(str(app_version['full'])):
- self.main_window.openlp_version_check.emit('%s' % version)
-
-
-class HTTPRedirectHandlerFixed(urllib.request.HTTPRedirectHandler):
- """
- Special HTTPRedirectHandler used to work around http://bugs.python.org/issue22248
- (Redirecting to urls with special chars)
- """
- def redirect_request(self, req, fp, code, msg, headers, new_url):
- #
- """
- Test if the new_url can be decoded to ascii
-
- :param req:
- :param fp:
- :param code:
- :param msg:
- :param headers:
- :param new_url:
- :return:
- """
- try:
- new_url.encode('latin1').decode('ascii')
- fixed_url = new_url
- except Exception:
- # The url could not be decoded to ascii, so we do some url encoding
- fixed_url = urllib.parse.quote(new_url.encode('latin1').decode('utf-8', 'replace'), safe='/:')
- return super(HTTPRedirectHandlerFixed, self).redirect_request(req, fp, code, msg, headers, fixed_url)
-
-
-def get_application_version():
- """
- Returns the application version of the running instance of OpenLP::
-
- {'full': '1.9.4-bzr1249', 'version': '1.9.4', 'build': 'bzr1249'}
- """
- global APPLICATION_VERSION
- if APPLICATION_VERSION:
- return APPLICATION_VERSION
- if '--dev-version' in sys.argv or '-d' in sys.argv:
- # NOTE: The following code is a duplicate of the code in setup.py. Any fix applied here should also be applied
- # there.
-
- # Get the revision of this tree.
- bzr = Popen(('bzr', 'revno'), stdout=PIPE)
- tree_revision, error = bzr.communicate()
- tree_revision = tree_revision.decode()
- code = bzr.wait()
- if code != 0:
- raise Exception('Error running bzr log')
-
- # Get all tags.
- bzr = Popen(('bzr', 'tags'), stdout=PIPE)
- output, error = bzr.communicate()
- code = bzr.wait()
- if code != 0:
- raise Exception('Error running bzr tags')
- tags = list(map(bytes.decode, output.splitlines()))
- if not tags:
- tag_version = '0.0.0'
- tag_revision = '0'
- else:
- # Remove any tag that has "?" as revision number. A "?" as revision number indicates, that this tag is from
- # another series.
- tags = [tag for tag in tags if tag.split()[-1].strip() != '?']
- # Get the last tag and split it in a revision and tag name.
- tag_version, tag_revision = tags[-1].split()
- # If they are equal, then this tree is tarball with the source for the release. We do not want the revision
- # number in the full version.
- if tree_revision == tag_revision:
- full_version = tag_version.decode('utf-8')
- else:
- full_version = '%s-bzr%s' % (tag_version.decode('utf-8'), tree_revision.decode('utf-8'))
- else:
- # We're not running the development version, let's use the file.
- file_path = AppLocation.get_directory(AppLocation.VersionDir)
- file_path = os.path.join(file_path, '.version')
- version_file = None
- try:
- version_file = open(file_path, 'r')
- full_version = str(version_file.read()).rstrip()
- except IOError:
- log.exception('Error in version file.')
- full_version = '0.0.0-bzr000'
- finally:
- if version_file:
- version_file.close()
- bits = full_version.split('-')
- APPLICATION_VERSION = {
- 'full': full_version,
- 'version': bits[0],
- 'build': bits[1] if len(bits) > 1 else None
- }
- if APPLICATION_VERSION['build']:
- log.info('Openlp version %s build %s', APPLICATION_VERSION['version'], APPLICATION_VERSION['build'])
- else:
- log.info('Openlp version %s' % APPLICATION_VERSION['version'])
- return APPLICATION_VERSION
-
-
-def check_latest_version(current_version):
- """
- Check the latest version of OpenLP against the version file on the OpenLP
- site.
-
- **Rules around versions and version files:**
-
- * If a version number has a build (i.e. -bzr1234), then it is a nightly.
- * If a version number's minor version is an odd number, it is a development release.
- * If a version number's minor version is an even number, it is a stable release.
-
- :param current_version: The current version of OpenLP.
- """
- version_string = current_version['full']
- # set to prod in the distribution config file.
- settings = Settings()
- settings.beginGroup('core')
- last_test = settings.value('last version test')
- this_test = str(datetime.now().date())
- settings.setValue('last version test', this_test)
- settings.endGroup()
- if last_test != this_test:
- if current_version['build']:
- req = urllib.request.Request('http://www.openlp.org/files/nightly_version.txt')
- else:
- version_parts = current_version['version'].split('.')
- if int(version_parts[1]) % 2 != 0:
- req = urllib.request.Request('http://www.openlp.org/files/dev_version.txt')
- else:
- req = urllib.request.Request('http://www.openlp.org/files/version.txt')
- req.add_header('User-Agent', 'OpenLP/%s %s/%s; ' % (current_version['full'], platform.system(),
- platform.release()))
- remote_version = None
- retries = 0
- while True:
- try:
- remote_version = str(urllib.request.urlopen(req, None,
- timeout=CONNECTION_TIMEOUT).read().decode()).strip()
- except (urllib.error.URLError, ConnectionError):
- if retries > CONNECTION_RETRIES:
- log.exception('Failed to download the latest OpenLP version file')
- else:
- retries += 1
- time.sleep(0.1)
- continue
- break
- if remote_version:
- version_string = remote_version
- return version_string
-
-
-def add_actions(target, actions):
- """
- Adds multiple actions to a menu or toolbar in one command.
-
- :param target: The menu or toolbar to add actions to
- :param actions: The actions to be added. An action consisting of the keyword ``None``
- will result in a separator being inserted into the target.
- """
- for action in actions:
- if action is None:
- target.addSeparator()
- else:
- target.addAction(action)
-
-
-def get_filesystem_encoding():
- """
- Returns the name of the encoding used to convert Unicode filenames into system file names.
- """
- encoding = sys.getfilesystemencoding()
- if encoding is None:
- encoding = sys.getdefaultencoding()
- return encoding
-
-
-def get_images_filter():
- """
- Returns a filter string for a file dialog containing all the supported image formats.
- """
- global IMAGES_FILTER
- if not IMAGES_FILTER:
- log.debug('Generating images filter.')
- formats = list(map(bytes.decode, list(map(bytes, QtGui.QImageReader.supportedImageFormats()))))
- visible_formats = '(*.%s)' % '; *.'.join(formats)
- actual_formats = '(*.%s)' % ' *.'.join(formats)
- IMAGES_FILTER = '%s %s %s' % (translate('OpenLP', 'Image Files'), visible_formats, actual_formats)
- return IMAGES_FILTER
-
-
-def is_not_image_file(file_name):
- """
- Validate that the file is not an image file.
-
- :param file_name: File name to be checked.
- """
- if not file_name:
- return True
- else:
- formats = [bytes(fmt).decode().lower() for fmt in QtGui.QImageReader.supportedImageFormats()]
- file_part, file_extension = os.path.splitext(str(file_name))
- if file_extension[1:].lower() in formats and os.path.exists(file_name):
- return False
- return True
-
-
-def split_filename(path):
- """
- Return a list of the parts in a given path.
- """
- path = os.path.abspath(path)
- if not os.path.isfile(path):
- return path, ''
- else:
- return os.path.split(path)
-
-
-def clean_filename(filename):
- """
- Removes invalid characters from the given ``filename``.
-
- :param filename: The "dirty" file name to clean.
- """
- if not isinstance(filename, str):
- filename = str(filename, 'utf-8')
- return INVALID_FILE_CHARS.sub('_', CONTROL_CHARS.sub('', filename))
-
-
-def delete_file(file_path_name):
- """
- Deletes a file from the system.
-
- :param file_path_name: The file, including path, to delete.
- """
- if not file_path_name:
- return False
- try:
- if os.path.exists(file_path_name):
- os.remove(file_path_name)
- return True
- except (IOError, OSError):
- log.exception("Unable to delete file %s" % file_path_name)
- return False
-
-
-def _get_user_agent():
- """
- Return a user agent customised for the platform the user is on.
- """
- browser_list = USER_AGENTS.get(sys.platform, None)
- if not browser_list:
- browser_list = USER_AGENTS['default']
- random_index = randint(0, len(browser_list) - 1)
- return browser_list[random_index]
-
-
-def get_web_page(url, header=None, update_openlp=False):
- """
- Attempts to download the webpage at url and returns that page or None.
-
- :param url: The URL to be downloaded.
- :param header: An optional HTTP header to pass in the request to the web server.
- :param update_openlp: Tells OpenLP to update itself if the page is successfully downloaded.
- Defaults to False.
- """
- # TODO: Add proxy usage. Get proxy info from OpenLP settings, add to a
- # proxy_handler, build into an opener and install the opener into urllib2.
- # http://docs.python.org/library/urllib2.html
- if not url:
- return None
- # This is needed to work around http://bugs.python.org/issue22248 and https://bugs.launchpad.net/openlp/+bug/1251437
- opener = urllib.request.build_opener(HTTPRedirectHandlerFixed())
- urllib.request.install_opener(opener)
- req = urllib.request.Request(url)
- if not header or header[0].lower() != 'user-agent':
- user_agent = _get_user_agent()
- req.add_header('User-Agent', user_agent)
- if header:
- req.add_header(header[0], header[1])
- log.debug('Downloading URL = %s' % url)
- retries = 0
- while retries <= CONNECTION_RETRIES:
- retries += 1
- time.sleep(0.1)
- try:
- page = urllib.request.urlopen(req, timeout=CONNECTION_TIMEOUT)
- log.debug('Downloaded page {}'.format(page.geturl()))
- break
- except urllib.error.URLError as err:
- log.exception('URLError on {}'.format(url))
- log.exception('URLError: {}'.format(err.reason))
- page = None
- if retries > CONNECTION_RETRIES:
- raise
- except socket.timeout:
- log.exception('Socket timeout: {}'.format(url))
- page = None
- if retries > CONNECTION_RETRIES:
- raise
- except socket.gaierror:
- log.exception('Socket gaierror: {}'.format(url))
- page = None
- if retries > CONNECTION_RETRIES:
- raise
- except ConnectionRefusedError:
- log.exception('ConnectionRefused: {}'.format(url))
- page = None
- if retries > CONNECTION_RETRIES:
- raise
- break
- except ConnectionError:
- log.exception('Connection error: {}'.format(url))
- page = None
- if retries > CONNECTION_RETRIES:
- raise
- except HTTPException:
- log.exception('HTTPException error: {}'.format(url))
- page = None
- if retries > CONNECTION_RETRIES:
- raise
- except:
- # Don't know what's happening, so reraise the original
- raise
- if update_openlp:
- Registry().get('application').process_events()
- if not page:
- log.exception('{} could not be downloaded'.format(url))
- return None
- log.debug(page)
- return page
-
-
-def get_uno_command(connection_type='pipe'):
- """
- Returns the UNO command to launch an openoffice.org instance.
- """
- for command in ['libreoffice', 'soffice']:
- if which(command):
- break
- else:
- raise FileNotFoundError('Command not found')
-
- OPTIONS = '--nologo --norestore --minimized --nodefault --nofirststartwizard'
- if connection_type == 'pipe':
- CONNECTION = '"--accept=pipe,name=openlp_pipe;urp;"'
- else:
- CONNECTION = '"--accept=socket,host=localhost,port=2002;urp;"'
- return '%s %s %s' % (command, OPTIONS, CONNECTION)
-
-
-def get_uno_instance(resolver, connection_type='pipe'):
- """
- Returns a running openoffice.org instance.
-
- :param resolver: The UNO resolver to use to find a running instance.
- """
- log.debug('get UNO Desktop Openoffice - resolve')
- if connection_type == 'pipe':
- return resolver.resolve('uno:pipe,name=openlp_pipe;urp;StarOffice.ComponentContext')
- else:
- return resolver.resolve('uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext')
-
-
-def format_time(text, local_time):
- """
- Workaround for Python built-in time formatting function time.strftime().
-
- time.strftime() accepts only ascii characters. This function accepts
- unicode string and passes individual % placeholders to time.strftime().
- This ensures only ascii characters are passed to time.strftime().
-
- :param text: The text to be processed.
- :param local_time: The time to be used to add to the string. This is a time object
- """
- def match_formatting(match):
- """
- Format the match
- """
- return local_time.strftime(match.group())
- return re.sub('\%[a-zA-Z]', match_formatting, text)
-
-
-def get_locale_key(string):
- """
- Creates a key for case insensitive, locale aware string sorting.
-
- :param string: The corresponding string.
- """
- string = string.lower()
- # ICU is the prefered way to handle locale sort key, we fallback to locale.strxfrm which will work in most cases.
- global ICU_COLLATOR
- try:
- if ICU_COLLATOR is None:
- import icu
- from .languagemanager import LanguageManager
- language = LanguageManager.get_language()
- icu_locale = icu.Locale(language)
- ICU_COLLATOR = icu.Collator.createInstance(icu_locale)
- return ICU_COLLATOR.getSortKey(string)
- except:
- return locale.strxfrm(string).encode()
-
-
-def get_natural_key(string):
- """
- Generate a key for locale aware natural string sorting.
- Returns a list of string compare keys and integers.
- """
- key = DIGITS_OR_NONDIGITS.findall(string)
- key = [int(part) if part.isdigit() else get_locale_key(part) for part in key]
- # Python 3 does not support comparison of different types anymore. So make sure, that we do not compare str
- # and int.
- if string[0].isdigit():
- return [b''] + key
- return key
-
-
-from .languagemanager import LanguageManager
-from .actions import ActionList
-
-
-__all__ = ['ActionList', 'LanguageManager', 'get_application_version', 'check_latest_version',
- 'add_actions', 'get_filesystem_encoding', 'get_web_page', 'get_uno_command', 'get_uno_instance',
- 'delete_file', 'clean_filename', 'format_time', 'get_locale_key', 'get_natural_key']
diff --git a/openlp/plugins/alerts/alertsplugin.py b/openlp/plugins/alerts/alertsplugin.py
index 14b1f7805..61640262b 100644
--- a/openlp/plugins/alerts/alertsplugin.py
+++ b/openlp/plugins/alerts/alertsplugin.py
@@ -24,17 +24,16 @@ import logging
from PyQt5 import QtGui
-
from openlp.core.common import Settings, translate
+from openlp.core.common.actions import ActionList
from openlp.core.lib import Plugin, StringContent, build_icon
from openlp.core.lib.db import Manager
-from openlp.core.lib.ui import create_action, UiStrings
from openlp.core.lib.theme import VerticalType
+from openlp.core.lib.ui import create_action, UiStrings
from openlp.core.ui import AlertLocation
-from openlp.core.utils.actions import ActionList
+from openlp.plugins.alerts.forms import AlertForm
from openlp.plugins.alerts.lib import AlertsManager, AlertsTab
from openlp.plugins.alerts.lib.db import init_schema
-from openlp.plugins.alerts.forms import AlertForm
log = logging.getLogger(__name__)
diff --git a/openlp/plugins/bibles/bibleplugin.py b/openlp/plugins/bibles/bibleplugin.py
index 289f8cc32..ccc61ba56 100644
--- a/openlp/plugins/bibles/bibleplugin.py
+++ b/openlp/plugins/bibles/bibleplugin.py
@@ -24,13 +24,13 @@ import logging
from PyQt5 import QtWidgets
+from openlp.core.common.actions import ActionList
from openlp.core.lib import Plugin, StringContent, build_icon, translate
from openlp.core.lib.ui import UiStrings, create_action
-from openlp.core.utils.actions import ActionList
+from openlp.plugins.bibles.forms import BibleUpgradeForm
from openlp.plugins.bibles.lib import BibleManager, BiblesTab, BibleMediaItem, LayoutStyle, DisplayStyle, \
LanguageSelection
from openlp.plugins.bibles.lib.mediaitem import BibleSearch
-from openlp.plugins.bibles.forms import BibleUpgradeForm
log = logging.getLogger(__name__)
diff --git a/openlp/plugins/bibles/forms/bibleimportform.py b/openlp/plugins/bibles/forms/bibleimportform.py
index 52417f2ef..27dbea963 100644
--- a/openlp/plugins/bibles/forms/bibleimportform.py
+++ b/openlp/plugins/bibles/forms/bibleimportform.py
@@ -28,11 +28,11 @@ import urllib.error
from PyQt5 import QtWidgets
-from openlp.core.common import AppLocation, Settings, UiStrings, translate
+from openlp.core.common import AppLocation, Settings, UiStrings, translate, clean_filename
from openlp.core.lib.db import delete_database
from openlp.core.lib.ui import critical_error_message_box
from openlp.core.ui.wizard import OpenLPWizard, WizardStrings
-from openlp.core.utils import get_locale_key
+from openlp.core.common.languagemanager import get_locale_key
from openlp.plugins.bibles.lib.manager import BibleFormat
from openlp.plugins.bibles.lib.db import BiblesResourcesDB, clean_filename
from openlp.plugins.bibles.lib.http import CWExtract, BGExtract, BSExtract
diff --git a/openlp/plugins/bibles/forms/bibleupgradeform.py b/openlp/plugins/bibles/forms/bibleupgradeform.py
index 4adfeaf3b..611e6ead3 100644
--- a/openlp/plugins/bibles/forms/bibleupgradeform.py
+++ b/openlp/plugins/bibles/forms/bibleupgradeform.py
@@ -29,10 +29,10 @@ from tempfile import gettempdir
from PyQt5 import QtCore, QtWidgets
-from openlp.core.common import Registry, AppLocation, UiStrings, Settings, check_directory_exists, translate
+from openlp.core.common import Registry, AppLocation, UiStrings, Settings, check_directory_exists, translate, \
+ delete_file
from openlp.core.lib.ui import critical_error_message_box
from openlp.core.ui.wizard import OpenLPWizard, WizardStrings
-from openlp.core.utils import delete_file
from openlp.plugins.bibles.lib.db import BibleDB, BibleMeta, OldBibleDB, BiblesResourcesDB
from openlp.plugins.bibles.lib.http import BSExtract, BGExtract, CWExtract
diff --git a/openlp/plugins/bibles/lib/db.py b/openlp/plugins/bibles/lib/db.py
index b77117e21..8dcf8c042 100644
--- a/openlp/plugins/bibles/lib/db.py
+++ b/openlp/plugins/bibles/lib/db.py
@@ -33,10 +33,9 @@ from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import class_mapper, mapper, relation
from sqlalchemy.orm.exc import UnmappedClassError
-from openlp.core.common import Registry, RegistryProperties, AppLocation, translate
+from openlp.core.common import Registry, RegistryProperties, AppLocation, translate, clean_filename
from openlp.core.lib.db import BaseModel, init_db, Manager
from openlp.core.lib.ui import critical_error_message_box
-from openlp.core.utils import clean_filename
from openlp.plugins.bibles.lib import upgrade
log = logging.getLogger(__name__)
diff --git a/openlp/plugins/bibles/lib/http.py b/openlp/plugins/bibles/lib/http.py
index 35b1f4bcb..c81e65575 100644
--- a/openlp/plugins/bibles/lib/http.py
+++ b/openlp/plugins/bibles/lib/http.py
@@ -32,7 +32,7 @@ from bs4 import BeautifulSoup, NavigableString, Tag
from openlp.core.common import Registry, RegistryProperties, translate
from openlp.core.lib.ui import critical_error_message_box
-from openlp.core.utils import get_web_page
+from openlp.core.lib.webpagereader import get_web_page
from openlp.plugins.bibles.lib import SearchResults
from openlp.plugins.bibles.lib.db import BibleDB, BiblesResourcesDB, Book
diff --git a/openlp/plugins/bibles/lib/manager.py b/openlp/plugins/bibles/lib/manager.py
index 8cecbe0af..b8b7ee56f 100644
--- a/openlp/plugins/bibles/lib/manager.py
+++ b/openlp/plugins/bibles/lib/manager.py
@@ -23,8 +23,7 @@
import logging
import os
-from openlp.core.common import RegistryProperties, AppLocation, Settings, translate
-from openlp.core.utils import delete_file
+from openlp.core.common import RegistryProperties, AppLocation, Settings, translate, delete_file
from openlp.plugins.bibles.lib import parse_reference, get_reference_separator, LanguageSelection
from openlp.plugins.bibles.lib.db import BibleDB, BibleMeta
from .csvbible import CSVBible
diff --git a/openlp/plugins/bibles/lib/mediaitem.py b/openlp/plugins/bibles/lib/mediaitem.py
index 94937e61b..cd728a68b 100644
--- a/openlp/plugins/bibles/lib/mediaitem.py
+++ b/openlp/plugins/bibles/lib/mediaitem.py
@@ -29,7 +29,7 @@ from openlp.core.lib import MediaManagerItem, ItemCapabilities, ServiceItemConte
from openlp.core.lib.searchedit import SearchEdit
from openlp.core.lib.ui import set_case_insensitive_completer, create_horizontal_adjusting_combo_box, \
critical_error_message_box, find_and_set_in_combo_box, build_icon
-from openlp.core.utils import get_locale_key
+from openlp.core.common.languagemanager import get_locale_key
from openlp.plugins.bibles.forms.bibleimportform import BibleImportForm
from openlp.plugins.bibles.forms.editbibleform import EditBibleForm
from openlp.plugins.bibles.lib import LayoutStyle, DisplayStyle, VerseReferenceList, get_reference_separator, \
diff --git a/openlp/plugins/custom/lib/db.py b/openlp/plugins/custom/lib/db.py
index 743822072..62ec1f408 100644
--- a/openlp/plugins/custom/lib/db.py
+++ b/openlp/plugins/custom/lib/db.py
@@ -28,7 +28,7 @@ from sqlalchemy import Column, Table, types
from sqlalchemy.orm import mapper
from openlp.core.lib.db import BaseModel, init_db
-from openlp.core.utils import get_locale_key
+from openlp.core.common.languagemanager import get_locale_key
class CustomSlide(BaseModel):
diff --git a/openlp/plugins/images/lib/mediaitem.py b/openlp/plugins/images/lib/mediaitem.py
index 248da94ea..f35fd48c7 100644
--- a/openlp/plugins/images/lib/mediaitem.py
+++ b/openlp/plugins/images/lib/mediaitem.py
@@ -25,11 +25,12 @@ import os
from PyQt5 import QtCore, QtGui, QtWidgets
-from openlp.core.common import Registry, AppLocation, Settings, UiStrings, check_directory_exists, translate
+from openlp.core.common import Registry, AppLocation, Settings, UiStrings, check_directory_exists, translate, \
+ delete_file, get_images_filter
from openlp.core.lib import ItemCapabilities, MediaManagerItem, ServiceItemContext, StringContent, TreeWidgetWithDnD,\
build_icon, check_item_selected, create_thumb, validate_thumb
from openlp.core.lib.ui import create_widget_action, critical_error_message_box
-from openlp.core.utils import delete_file, get_locale_key, get_images_filter
+from openlp.core.common.languagemanager import get_locale_key
from openlp.plugins.images.forms import AddGroupForm, ChooseGroupForm
from openlp.plugins.images.lib.db import ImageFilenames, ImageGroups
diff --git a/openlp/plugins/media/lib/mediaitem.py b/openlp/plugins/media/lib/mediaitem.py
index 09b32b308..dfe6f1fa4 100644
--- a/openlp/plugins/media/lib/mediaitem.py
+++ b/openlp/plugins/media/lib/mediaitem.py
@@ -32,7 +32,7 @@ from openlp.core.lib import ItemCapabilities, MediaManagerItem, MediaType, Servi
from openlp.core.lib.ui import critical_error_message_box, create_horizontal_adjusting_combo_box
from openlp.core.ui import DisplayController, Display, DisplayControllerType
from openlp.core.ui.media import get_media_players, set_media_players, parse_optical_path, format_milliseconds
-from openlp.core.utils import get_locale_key
+from openlp.core.common.languagemanager import get_locale_key
from openlp.core.ui.media.vlcplayer import get_vlc
if get_vlc() is not None:
diff --git a/openlp/plugins/presentations/lib/impresscontroller.py b/openlp/plugins/presentations/lib/impresscontroller.py
index 183a05ac5..29af3a375 100644
--- a/openlp/plugins/presentations/lib/impresscontroller.py
+++ b/openlp/plugins/presentations/lib/impresscontroller.py
@@ -35,7 +35,7 @@ import logging
import os
import time
-from openlp.core.common import is_win, Registry
+from openlp.core.common import is_win, Registry, get_uno_command, get_uno_instance, delete_file
if is_win():
from win32com.client import Dispatch
@@ -57,7 +57,7 @@ else:
from PyQt5 import QtCore
from openlp.core.lib import ScreenList
-from openlp.core.utils import delete_file, get_uno_command, get_uno_instance
+from openlp.core.common import get_uno_command, get_uno_instance
from .presentationcontroller import PresentationController, PresentationDocument, TextType
diff --git a/openlp/plugins/presentations/lib/mediaitem.py b/openlp/plugins/presentations/lib/mediaitem.py
index b2ece3e67..b64c552b8 100644
--- a/openlp/plugins/presentations/lib/mediaitem.py
+++ b/openlp/plugins/presentations/lib/mediaitem.py
@@ -29,7 +29,7 @@ from openlp.core.common import Registry, Settings, UiStrings, translate
from openlp.core.lib import MediaManagerItem, ItemCapabilities, ServiceItemContext,\
build_icon, check_item_selected, create_thumb, validate_thumb
from openlp.core.lib.ui import critical_error_message_box, create_horizontal_adjusting_combo_box
-from openlp.core.utils import get_locale_key
+from openlp.core.common.languagemanager import get_locale_key
from openlp.plugins.presentations.lib import MessageListener
from openlp.plugins.presentations.lib.pdfcontroller import PDF_CONTROLLER_FILETYPES
diff --git a/openlp/plugins/presentations/lib/pdfcontroller.py b/openlp/plugins/presentations/lib/pdfcontroller.py
index 64e197eba..dbea84327 100644
--- a/openlp/plugins/presentations/lib/pdfcontroller.py
+++ b/openlp/plugins/presentations/lib/pdfcontroller.py
@@ -27,7 +27,7 @@ import re
from shutil import which
from subprocess import check_output, CalledProcessError, STDOUT
-from openlp.core.utils import AppLocation
+from openlp.core.common import AppLocation
from openlp.core.common import Settings, is_win, trace_error_handler
from openlp.core.lib import ScreenList
from .presentationcontroller import PresentationController, PresentationDocument
diff --git a/openlp/plugins/presentations/lib/pptviewcontroller.py b/openlp/plugins/presentations/lib/pptviewcontroller.py
index aba0aa88e..c5e1b351f 100644
--- a/openlp/plugins/presentations/lib/pptviewcontroller.py
+++ b/openlp/plugins/presentations/lib/pptviewcontroller.py
@@ -20,7 +20,6 @@
# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
###############################################################################
-import logging
import os
import logging
import zipfile
@@ -34,7 +33,7 @@ if is_win():
from ctypes import cdll
from ctypes.wintypes import RECT
-from openlp.core.utils import AppLocation
+from openlp.core.common import AppLocation
from openlp.core.lib import ScreenList
from .presentationcontroller import PresentationController, PresentationDocument
diff --git a/openlp/plugins/songs/forms/songselectdialog.py b/openlp/plugins/songs/forms/songselectdialog.py
index 68d91e1ae..833ee39ec 100644
--- a/openlp/plugins/songs/forms/songselectdialog.py
+++ b/openlp/plugins/songs/forms/songselectdialog.py
@@ -25,7 +25,7 @@ The :mod:`~openlp.plugins.songs.forms.songselectdialog` module contains the user
from PyQt5 import QtCore, QtWidgets
-from openlp.core.common import HistoryComboBox
+from openlp.core.ui.lib.historycombobox import HistoryComboBox
from openlp.core.lib import translate, build_icon
from openlp.core.ui import SingleColumnTableWidget
diff --git a/openlp/plugins/songs/lib/__init__.py b/openlp/plugins/songs/lib/__init__.py
index ce80c4b1e..1d45f52b2 100644
--- a/openlp/plugins/songs/lib/__init__.py
+++ b/openlp/plugins/songs/lib/__init__.py
@@ -29,9 +29,8 @@ import re
from PyQt5 import QtWidgets
-from openlp.core.common import AppLocation
+from openlp.core.common import AppLocation, CONTROL_CHARS
from openlp.core.lib import translate
-from openlp.core.utils import CONTROL_CHARS
from openlp.plugins.songs.lib.db import MediaFile, Song
from .db import Author
from .ui import SongStrings
diff --git a/openlp/plugins/songs/lib/db.py b/openlp/plugins/songs/lib/db.py
index 8fc6e1a4a..5ea35d6b6 100644
--- a/openlp/plugins/songs/lib/db.py
+++ b/openlp/plugins/songs/lib/db.py
@@ -29,7 +29,7 @@ from sqlalchemy.orm import mapper, relation, reconstructor
from sqlalchemy.sql.expression import func, text
from openlp.core.lib.db import BaseModel, init_db
-from openlp.core.utils import get_natural_key
+from openlp.core.common.languagemanager import get_natural_key
from openlp.core.lib import translate
diff --git a/openlp/plugins/songs/lib/importer.py b/openlp/plugins/songs/lib/importer.py
index a09bf5ea6..47f6edb46 100644
--- a/openlp/plugins/songs/lib/importer.py
+++ b/openlp/plugins/songs/lib/importer.py
@@ -313,9 +313,9 @@ class SongFormat(object):
},
ProPresenter: {
'class': ProPresenterImport,
- 'name': 'ProPresenter 4',
+ 'name': 'ProPresenter 4, 5 and 6',
'prefix': 'proPresenter',
- 'filter': '%s (*.pro4)' % translate('SongsPlugin.ImportWizardForm', 'ProPresenter 4 Song Files')
+ 'filter': '%s (*.pro4 *.pro5 *.pro6)' % translate('SongsPlugin.ImportWizardForm', 'ProPresenter Song Files')
},
SongBeamer: {
'class': SongBeamerImport,
diff --git a/openlp/plugins/songs/lib/importers/openoffice.py b/openlp/plugins/songs/lib/importers/openoffice.py
index 5f94d6e77..b21f3a7d3 100644
--- a/openlp/plugins/songs/lib/importers/openoffice.py
+++ b/openlp/plugins/songs/lib/importers/openoffice.py
@@ -25,8 +25,7 @@ import time
from PyQt5 import QtCore
-from openlp.core.common import is_win
-from openlp.core.utils import get_uno_command, get_uno_instance
+from openlp.core.common import is_win, get_uno_command, get_uno_instance
from openlp.core.lib import translate
from .songimport import SongImport
diff --git a/openlp/plugins/songs/lib/importers/propresenter.py b/openlp/plugins/songs/lib/importers/propresenter.py
index fb47eb1cd..cddf0e52b 100644
--- a/openlp/plugins/songs/lib/importers/propresenter.py
+++ b/openlp/plugins/songs/lib/importers/propresenter.py
@@ -39,7 +39,7 @@ log = logging.getLogger(__name__)
class ProPresenterImport(SongImport):
"""
The :class:`ProPresenterImport` class provides OpenLP with the
- ability to import ProPresenter 4 song files.
+ ability to import ProPresenter 4-6 song files.
"""
def do_import(self):
self.import_wizard.progress_bar.setMaximum(len(self.import_source))
@@ -52,23 +52,82 @@ class ProPresenterImport(SongImport):
def process_song(self, root, filename):
self.set_defaults()
- self.title = os.path.basename(filename).rstrip('.pro4')
- self.copyright = root.get('CCLICopyrightInfo')
+
+ # Extract ProPresenter versionNumber
+ try:
+ self.version = int(root.get('versionNumber'))
+ except ValueError:
+ log.debug('ProPresenter versionNumber invalid or missing')
+ return
+
+ # Title
+ self.title = root.get('CCLISongTitle')
+ if not self.title or self.title == '':
+ self.title = os.path.basename(filename)
+ if self.title[-5:-1] == '.pro':
+ self.title = self.title[:-5]
+ # Notes
self.comments = root.get('notes')
- self.ccli_number = root.get('CCLILicenseNumber')
- for author_key in ['author', 'artist', 'CCLIArtistCredits']:
+ # Author
+ for author_key in ['author', 'CCLIAuthor', 'artist', 'CCLIArtistCredits']:
author = root.get(author_key)
- if len(author) > 0:
+ if author and len(author) > 0:
self.parse_author(author)
- count = 0
- for slide in root.slides.RVDisplaySlide:
- count += 1
- if not hasattr(slide.displayElements, 'RVTextElement'):
- log.debug('No text found, may be an image slide')
- continue
- RTFData = slide.displayElements.RVTextElement.get('RTFData')
- rtf = base64.standard_b64decode(RTFData)
- words, encoding = strip_rtf(rtf.decode())
- self.add_verse(words, "v%d" % count)
+
+ # ProPresenter 4
+ if(self.version >= 400 and self.version < 500):
+ self.copyright = root.get('CCLICopyrightInfo')
+ self.ccli_number = root.get('CCLILicenseNumber')
+ count = 0
+ for slide in root.slides.RVDisplaySlide:
+ count += 1
+ if not hasattr(slide.displayElements, 'RVTextElement'):
+ log.debug('No text found, may be an image slide')
+ continue
+ RTFData = slide.displayElements.RVTextElement.get('RTFData')
+ rtf = base64.standard_b64decode(RTFData)
+ words, encoding = strip_rtf(rtf.decode())
+ self.add_verse(words, "v%d" % count)
+
+ # ProPresenter 5
+ elif(self.version >= 500 and self.version < 600):
+ self.copyright = root.get('CCLICopyrightInfo')
+ self.ccli_number = root.get('CCLILicenseNumber')
+ count = 0
+ for group in root.groups.RVSlideGrouping:
+ for slide in group.slides.RVDisplaySlide:
+ count += 1
+ if not hasattr(slide.displayElements, 'RVTextElement'):
+ log.debug('No text found, may be an image slide')
+ continue
+ RTFData = slide.displayElements.RVTextElement.get('RTFData')
+ rtf = base64.standard_b64decode(RTFData)
+ words, encoding = strip_rtf(rtf.decode())
+ self.add_verse(words, "v%d" % count)
+
+ # ProPresenter 6
+ elif(self.version >= 600 and self.version < 700):
+ self.copyright = root.get('CCLICopyrightYear')
+ self.ccli_number = root.get('CCLISongNumber')
+ count = 0
+ for group in root.array.RVSlideGrouping:
+ for slide in group.array.RVDisplaySlide:
+ count += 1
+ for item in slide.array:
+ if not (item.get('rvXMLIvarName') == "displayElements"):
+ continue
+ if not hasattr(item, 'RVTextElement'):
+ log.debug('No text found, may be an image slide')
+ continue
+ for contents in item.RVTextElement.NSString:
+ b64Data = contents.text
+ data = base64.standard_b64decode(b64Data)
+ words = None
+ if(contents.get('rvXMLIvarName') == "RTFData"):
+ words, encoding = strip_rtf(data.decode())
+ break
+ if words:
+ self.add_verse(words, "v%d" % count)
+
if not self.finish():
self.log_error(self.import_source)
diff --git a/openlp/plugins/songs/lib/mediaitem.py b/openlp/plugins/songs/lib/mediaitem.py
index 8772f0771..d724bfaf2 100644
--- a/openlp/plugins/songs/lib/mediaitem.py
+++ b/openlp/plugins/songs/lib/mediaitem.py
@@ -32,6 +32,7 @@ from openlp.core.common import Registry, AppLocation, Settings, check_directory_
from openlp.core.lib import MediaManagerItem, ItemCapabilities, PluginStatus, ServiceItemContext, \
check_item_selected, create_separated_list
from openlp.core.lib.ui import create_widget_action
+from openlp.core.common.languagemanager import get_natural_key
from openlp.plugins.songs.forms.editsongform import EditSongForm
from openlp.plugins.songs.forms.songmaintenanceform import SongMaintenanceForm
from openlp.plugins.songs.forms.songimportform import SongImportForm
@@ -203,7 +204,13 @@ class SongMediaItem(MediaManagerItem):
self.display_results_topic(search_results)
elif search_type == SongSearch.Books:
log.debug('Songbook Search')
- self.display_results_book(search_keywords)
+ search_keywords = search_keywords.rpartition(' ')
+ search_book = search_keywords[0] + '%'
+ search_entry = search_keywords[2] + '%'
+ search_results = (self.plugin.manager.session.query(SongBookEntry)
+ .join(Book)
+ .filter(Book.name.like(search_book), SongBookEntry.entry.like(search_entry)).all())
+ self.display_results_book(search_results)
elif search_type == SongSearch.Themes:
log.debug('Theme Search')
search_string = '%' + search_keywords + '%'
@@ -278,8 +285,10 @@ class SongMediaItem(MediaManagerItem):
"""
log.debug('display results Author')
self.list_view.clear()
+ search_results = sorted(search_results, key=lambda author: get_natural_key(author.display_name))
for author in search_results:
- for song in author.songs:
+ songs = sorted(author.songs, key=lambda song: song.sort_key)
+ for song in songs:
# Do not display temporary songs
if song.temporary:
continue
@@ -288,32 +297,20 @@ class SongMediaItem(MediaManagerItem):
song_name.setData(QtCore.Qt.UserRole, song.id)
self.list_view.addItem(song_name)
- def display_results_book(self, search_keywords):
+ def display_results_book(self, search_results):
"""
- Display the song search results in the media manager list, grouped by book
+ Display the song search results in the media manager list, grouped by book and entry
- :param search_keywords: A list of search keywords - book first, then number
+ :param search_results: A list of db SongBookEntry objects
:return: None
"""
-
log.debug('display results Book')
self.list_view.clear()
-
- search_keywords = search_keywords.rpartition(' ')
- search_book = search_keywords[0]
- search_entry = re.sub(r'[^0-9]', '', search_keywords[2])
-
- songbook_entries = (self.plugin.manager.session.query(SongBookEntry)
- .join(Book)
- .order_by(Book.name)
- .order_by(SongBookEntry.entry))
- for songbook_entry in songbook_entries:
+ search_results = sorted(search_results, key=lambda songbook_entry:
+ (get_natural_key(songbook_entry.songbook.name), get_natural_key(songbook_entry.entry)))
+ for songbook_entry in search_results:
if songbook_entry.song.temporary:
continue
- if search_book.lower() not in songbook_entry.songbook.name.lower():
- continue
- if search_entry not in songbook_entry.entry:
- continue
song_detail = '%s #%s: %s' % (songbook_entry.songbook.name, songbook_entry.entry, songbook_entry.song.title)
song_name = QtWidgets.QListWidgetItem(song_detail)
song_name.setData(QtCore.Qt.UserRole, songbook_entry.song.id)
@@ -328,7 +325,7 @@ class SongMediaItem(MediaManagerItem):
"""
log.debug('display results Topic')
self.list_view.clear()
- search_results = sorted(search_results, key=lambda topic: self._natural_sort_key(topic.name))
+ search_results = sorted(search_results, key=lambda topic: get_natural_key(topic.name))
for topic in search_results:
songs = sorted(topic.songs, key=lambda song: song.sort_key)
for song in songs:
@@ -349,6 +346,8 @@ class SongMediaItem(MediaManagerItem):
"""
log.debug('display results Themes')
self.list_view.clear()
+ search_results = sorted(search_results, key=lambda song: (get_natural_key(song.theme_name),
+ song.sort_key))
for song in search_results:
# Do not display temporary songs
if song.temporary:
@@ -367,7 +366,8 @@ class SongMediaItem(MediaManagerItem):
"""
log.debug('display results CCLI number')
self.list_view.clear()
- songs = sorted(search_results, key=lambda song: self._natural_sort_key(song.ccli_number))
+ songs = sorted(search_results, key=lambda song: (get_natural_key(song.ccli_number),
+ song.sort_key))
for song in songs:
# Do not display temporary songs
if song.temporary:
@@ -694,14 +694,6 @@ class SongMediaItem(MediaManagerItem):
# List must be empty at the end
return not author_list
- def _natural_sort_key(self, s):
- """
- Return a tuple by which s is sorted.
- :param s: A string value from the list we want to sort.
- """
- return [int(text) if text.isdecimal() else text.lower()
- for text in re.split('(\d+)', s)]
-
def search(self, string, show_error):
"""
Search for some songs
diff --git a/openlp/plugins/songs/lib/openlyricsexport.py b/openlp/plugins/songs/lib/openlyricsexport.py
index a8cffb418..d5ca31e18 100644
--- a/openlp/plugins/songs/lib/openlyricsexport.py
+++ b/openlp/plugins/songs/lib/openlyricsexport.py
@@ -28,8 +28,7 @@ import os
from lxml import etree
-from openlp.core.common import RegistryProperties, check_directory_exists, translate
-from openlp.core.utils import clean_filename
+from openlp.core.common import RegistryProperties, check_directory_exists, translate, clean_filename
from openlp.plugins.songs.lib.openlyricsxml import OpenLyrics
log = logging.getLogger(__name__)
diff --git a/openlp/plugins/songs/lib/openlyricsxml.py b/openlp/plugins/songs/lib/openlyricsxml.py
index bba60baa2..806df438d 100644
--- a/openlp/plugins/songs/lib/openlyricsxml.py
+++ b/openlp/plugins/songs/lib/openlyricsxml.py
@@ -62,10 +62,10 @@ import re
from lxml import etree, objectify
from openlp.core.common import translate
+from openlp.core.common.versionchecker import get_application_version
from openlp.core.lib import FormattingTags
from openlp.plugins.songs.lib import VerseType, clean_song
from openlp.plugins.songs.lib.db import Author, AuthorType, Book, Song, Topic
-from openlp.core.utils import get_application_version
log = logging.getLogger(__name__)
diff --git a/openlp/plugins/songs/lib/upgrade.py b/openlp/plugins/songs/lib/upgrade.py
index 09f7ce92a..19a67caa4 100644
--- a/openlp/plugins/songs/lib/upgrade.py
+++ b/openlp/plugins/songs/lib/upgrade.py
@@ -28,8 +28,8 @@ import logging
from sqlalchemy import Table, Column, ForeignKey, types
from sqlalchemy.sql.expression import func, false, null, text
+from openlp.core.common.db import drop_columns
from openlp.core.lib.db import get_upgrade_op
-from openlp.core.utils.db import drop_columns
log = logging.getLogger(__name__)
__version__ = 5
diff --git a/openlp/plugins/songs/songsplugin.py b/openlp/plugins/songs/songsplugin.py
index b7f6a36bb..b2218f701 100644
--- a/openlp/plugins/songs/songsplugin.py
+++ b/openlp/plugins/songs/songsplugin.py
@@ -26,27 +26,26 @@ for the Songs plugin.
import logging
import os
-from tempfile import gettempdir
import sqlite3
+from tempfile import gettempdir
from PyQt5 import QtCore, QtWidgets
from openlp.core.common import UiStrings, Registry, translate
+from openlp.core.common.actions import ActionList
from openlp.core.lib import Plugin, StringContent, build_icon
from openlp.core.lib.db import Manager
from openlp.core.lib.ui import create_action
-from openlp.core.utils.actions import ActionList
from openlp.plugins.songs.forms.duplicatesongremovalform import DuplicateSongRemovalForm
from openlp.plugins.songs.forms.songselectform import SongSelectForm
from openlp.plugins.songs.lib import clean_song, upgrade
from openlp.plugins.songs.lib.db import init_schema, Song
-from openlp.plugins.songs.lib.mediaitem import SongSearch
from openlp.plugins.songs.lib.importer import SongFormat
from openlp.plugins.songs.lib.importers.openlp import OpenLPSongImport
from openlp.plugins.songs.lib.mediaitem import SongMediaItem
+from openlp.plugins.songs.lib.mediaitem import SongSearch
from openlp.plugins.songs.lib.songstab import SongsTab
-
log = logging.getLogger(__name__)
__default_settings__ = {
'songs/db type': 'sqlite',
diff --git a/openlp/plugins/songusage/songusageplugin.py b/openlp/plugins/songusage/songusageplugin.py
index 4cb3153cf..9fca21b75 100644
--- a/openlp/plugins/songusage/songusageplugin.py
+++ b/openlp/plugins/songusage/songusageplugin.py
@@ -26,10 +26,10 @@ from datetime import datetime
from PyQt5 import QtCore, QtWidgets
from openlp.core.common import Registry, Settings, translate
+from openlp.core.common.actions import ActionList
from openlp.core.lib import Plugin, StringContent, build_icon
from openlp.core.lib.db import Manager
from openlp.core.lib.ui import create_action
-from openlp.core.utils.actions import ActionList
from openlp.plugins.songusage.forms import SongUsageDetailForm, SongUsageDeleteForm
from openlp.plugins.songusage.lib import upgrade
from openlp.plugins.songusage.lib.db import init_schema, SongUsageItem
diff --git a/setup.py b/setup.py
index ba7d13e27..78f2692b1 100755
--- a/setup.py
+++ b/setup.py
@@ -65,8 +65,8 @@ def natural_sort(seq):
return temp
-# NOTE: The following code is a duplicate of the code in openlp/core/utils/__init__.py. Any fix applied here should also
-# be applied there.
+# NOTE: The following code is a duplicate of the code in openlp/core/common/checkversion.py.
+# Any fix applied here should also be applied there.
ver_file = None
try:
# Get the revision of this tree.
diff --git a/tests/functional/openlp_core/__init__.py b/tests/functional/openlp_core/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/functional/openlp_core/test_init.py b/tests/functional/openlp_core/test_init.py
index 703122c18..86f2c1515 100644
--- a/tests/functional/openlp_core/test_init.py
+++ b/tests/functional/openlp_core/test_init.py
@@ -37,7 +37,7 @@ class TestInitFunctions(TestMixin, TestCase):
# GIVEN: a a set of system arguments.
sys.argv[1:] = []
# WHEN: We we parse them to expand to options
- args = parse_options()
+ args = parse_options(None)
# THEN: the following fields will have been extracted.
self.assertFalse(args.dev_version, 'The dev_version flag should be False')
self.assertEquals(args.loglevel, 'warning', 'The log level should be set to warning')
@@ -54,7 +54,7 @@ class TestInitFunctions(TestMixin, TestCase):
# GIVEN: a a set of system arguments.
sys.argv[1:] = ['-l debug']
# WHEN: We we parse them to expand to options
- args = parse_options()
+ args = parse_options(None)
# THEN: the following fields will have been extracted.
self.assertFalse(args.dev_version, 'The dev_version flag should be False')
self.assertEquals(args.loglevel, ' debug', 'The log level should be set to debug')
@@ -71,7 +71,7 @@ class TestInitFunctions(TestMixin, TestCase):
# GIVEN: a a set of system arguments.
sys.argv[1:] = ['--portable']
# WHEN: We we parse them to expand to options
- args = parse_options()
+ args = parse_options(None)
# THEN: the following fields will have been extracted.
self.assertFalse(args.dev_version, 'The dev_version flag should be False')
self.assertEquals(args.loglevel, 'warning', 'The log level should be set to warning')
@@ -88,7 +88,7 @@ class TestInitFunctions(TestMixin, TestCase):
# GIVEN: a a set of system arguments.
sys.argv[1:] = ['-l debug', '-d']
# WHEN: We we parse them to expand to options
- args = parse_options()
+ args = parse_options(None)
# THEN: the following fields will have been extracted.
self.assertTrue(args.dev_version, 'The dev_version flag should be True')
self.assertEquals(args.loglevel, ' debug', 'The log level should be set to debug')
@@ -105,7 +105,7 @@ class TestInitFunctions(TestMixin, TestCase):
# GIVEN: a a set of system arguments.
sys.argv[1:] = ['dummy_temp']
# WHEN: We we parse them to expand to options
- args = parse_options()
+ args = parse_options(None)
# THEN: the following fields will have been extracted.
self.assertFalse(args.dev_version, 'The dev_version flag should be False')
self.assertEquals(args.loglevel, 'warning', 'The log level should be set to warning')
@@ -122,7 +122,7 @@ class TestInitFunctions(TestMixin, TestCase):
# GIVEN: a a set of system arguments.
sys.argv[1:] = ['-l debug', 'dummy_temp']
# WHEN: We we parse them to expand to options
- args = parse_options()
+ args = parse_options(None)
# THEN: the following fields will have been extracted.
self.assertFalse(args.dev_version, 'The dev_version flag should be False')
self.assertEquals(args.loglevel, ' debug', 'The log level should be set to debug')
@@ -130,15 +130,3 @@ class TestInitFunctions(TestMixin, TestCase):
self.assertFalse(args.portable, 'The portable flag should be set to false')
self.assertEquals(args.style, None, 'There are no style flags to be processed')
self.assertEquals(args.rargs, 'dummy_temp', 'The service file should not be blank')
-
- def parse_options_two_files_test(self):
- """
- Test the parse options process works with a file
-
- """
- # GIVEN: a a set of system arguments.
- sys.argv[1:] = ['dummy_temp', 'dummy_temp2']
- # WHEN: We we parse them to expand to options
- args = parse_options()
- # THEN: the following fields will have been extracted.
- self.assertEquals(args, None, 'The args should be None')
diff --git a/tests/functional/openlp_core_utils/test_actions.py b/tests/functional/openlp_core_common/test_actions.py
similarity index 98%
rename from tests/functional/openlp_core_utils/test_actions.py
rename to tests/functional/openlp_core_common/test_actions.py
index 1fdb01aec..2b2d735bb 100644
--- a/tests/functional/openlp_core_utils/test_actions.py
+++ b/tests/functional/openlp_core_common/test_actions.py
@@ -20,15 +20,14 @@
# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
###############################################################################
"""
-Package to test the openlp.core.utils.actions package.
+Package to test the openlp.core.common.actions package.
"""
from unittest import TestCase
from PyQt5 import QtGui, QtCore, QtWidgets
from openlp.core.common import Settings
-from openlp.core.utils import ActionList
-from openlp.core.utils.actions import CategoryActionList
+from openlp.core.common.actions import CategoryActionList, ActionList
from tests.functional import MagicMock
from tests.helpers.testmixin import TestMixin
diff --git a/tests/functional/openlp_core_common/test_applocation.py b/tests/functional/openlp_core_common/test_applocation.py
index 6bb218046..0d42867e2 100644
--- a/tests/functional/openlp_core_common/test_applocation.py
+++ b/tests/functional/openlp_core_common/test_applocation.py
@@ -171,7 +171,7 @@ class TestAppLocation(TestCase):
"""
Test the _get_frozen_path() function when the application is not frozen (compiled by PyInstaller)
"""
- with patch('openlp.core.utils.sys') as mocked_sys:
+ with patch('openlp.core.common.sys') as mocked_sys:
# GIVEN: The sys module "without" a "frozen" attribute
mocked_sys.frozen = None
diff --git a/tests/functional/openlp_core_utils/test_db.py b/tests/functional/openlp_core_common/test_db.py
similarity index 97%
rename from tests/functional/openlp_core_utils/test_db.py
rename to tests/functional/openlp_core_common/test_db.py
index f2c3d264a..029efadc3 100644
--- a/tests/functional/openlp_core_utils/test_db.py
+++ b/tests/functional/openlp_core_common/test_db.py
@@ -20,19 +20,19 @@
# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
###############################################################################
"""
-Package to test the openlp.core.utils.db package.
+Package to test the openlp.core.common.db package.
"""
-from tempfile import mkdtemp
-from unittest import TestCase
import gc
import os
import shutil
-import sqlalchemy
import time
+from tempfile import mkdtemp
+from unittest import TestCase
-from openlp.core.utils.db import drop_column, drop_columns
+import sqlalchemy
+
+from openlp.core.common.db import drop_column, drop_columns
from openlp.core.lib.db import init_db, get_upgrade_op
-
from tests.utils.constants import TEST_RESOURCES_PATH
diff --git a/tests/functional/openlp_core_common/test_init.py b/tests/functional/openlp_core_common/test_init.py
new file mode 100644
index 000000000..2032e883e
--- /dev/null
+++ b/tests/functional/openlp_core_common/test_init.py
@@ -0,0 +1,342 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2016 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+Functional tests to test the AppLocation class and related methods.
+"""
+import os
+from unittest import TestCase
+
+from openlp.core.common import add_actions, get_uno_instance, get_uno_command, delete_file, get_filesystem_encoding, \
+ split_filename, clean_filename
+from tests.functional import MagicMock, patch
+from tests.helpers.testmixin import TestMixin
+
+
+class TestInit(TestCase, TestMixin):
+ """
+ A test suite to test out various methods around the common __init__ class.
+ """
+
+ def setUp(self):
+ """
+ Create an instance and a few example actions.
+ """
+ self.build_settings()
+
+ def tearDown(self):
+ """
+ Clean up
+ """
+ self.destroy_settings()
+
+ def add_actions_empty_list_test(self):
+ """
+ Test that no actions are added when the list is empty
+ """
+ # GIVEN: a mocked action list, and an empty list
+ mocked_target = MagicMock()
+ empty_list = []
+
+ # WHEN: The empty list is added to the mocked target
+ add_actions(mocked_target, empty_list)
+
+ # THEN: The add method on the mocked target is never called
+ self.assertEqual(0, mocked_target.addSeparator.call_count, 'addSeparator method should not have been called')
+ self.assertEqual(0, mocked_target.addAction.call_count, 'addAction method should not have been called')
+
+ def add_actions_none_action_test(self):
+ """
+ Test that a separator is added when a None action is in the list
+ """
+ # GIVEN: a mocked action list, and a list with None in it
+ mocked_target = MagicMock()
+ separator_list = [None]
+
+ # WHEN: The list is added to the mocked target
+ add_actions(mocked_target, separator_list)
+
+ # THEN: The addSeparator method is called, but the addAction method is never called
+ mocked_target.addSeparator.assert_called_with()
+ self.assertEqual(0, mocked_target.addAction.call_count, 'addAction method should not have been called')
+
+ def add_actions_add_action_test(self):
+ """
+ Test that an action is added when a valid action is in the list
+ """
+ # GIVEN: a mocked action list, and a list with an action in it
+ mocked_target = MagicMock()
+ action_list = ['action']
+
+ # WHEN: The list is added to the mocked target
+ add_actions(mocked_target, action_list)
+
+ # THEN: The addSeparator method is not called, and the addAction method is called
+ self.assertEqual(0, mocked_target.addSeparator.call_count, 'addSeparator method should not have been called')
+ mocked_target.addAction.assert_called_with('action')
+
+ def add_actions_action_and_none_test(self):
+ """
+ Test that an action and a separator are added when a valid action and None are in the list
+ """
+ # GIVEN: a mocked action list, and a list with an action and None in it
+ mocked_target = MagicMock()
+ action_list = ['action', None]
+
+ # WHEN: The list is added to the mocked target
+ add_actions(mocked_target, action_list)
+
+ # THEN: The addSeparator method is called, and the addAction method is called
+ mocked_target.addSeparator.assert_called_with()
+ mocked_target.addAction.assert_called_with('action')
+
+ def get_uno_instance_pipe_test(self):
+ """
+ Test that when the UNO connection type is "pipe" the resolver is given the "pipe" URI
+ """
+ # GIVEN: A mock resolver object and UNO_CONNECTION_TYPE is "pipe"
+ mock_resolver = MagicMock()
+
+ # WHEN: get_uno_instance() is called
+ get_uno_instance(mock_resolver)
+
+ # THEN: the resolve method is called with the correct argument
+ mock_resolver.resolve.assert_called_with('uno:pipe,name=openlp_pipe;urp;StarOffice.ComponentContext')
+
+ def get_uno_instance_socket_test(self):
+ """
+ Test that when the UNO connection type is other than "pipe" the resolver is given the "socket" URI
+ """
+ # GIVEN: A mock resolver object and UNO_CONNECTION_TYPE is "socket"
+ mock_resolver = MagicMock()
+
+ # WHEN: get_uno_instance() is called
+ get_uno_instance(mock_resolver, 'socket')
+
+ # THEN: the resolve method is called with the correct argument
+ mock_resolver.resolve.assert_called_with('uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext')
+
+ def get_uno_command_libreoffice_command_exists_test(self):
+ """
+ Test the ``get_uno_command`` function uses the libreoffice command when available.
+ :return:
+ """
+
+ # GIVEN: A patched 'which' method which returns a path when called with 'libreoffice'
+ with patch('openlp.core.common.which',
+ **{'side_effect': lambda command: {'libreoffice': '/usr/bin/libreoffice'}[command]}):
+ # WHEN: Calling get_uno_command
+ result = get_uno_command()
+
+ # THEN: The command 'libreoffice' should be called with the appropriate parameters
+ self.assertEquals(result,
+ 'libreoffice --nologo --norestore --minimized --nodefault --nofirststartwizard'
+ ' "--accept=pipe,name=openlp_pipe;urp;"')
+
+ def get_uno_command_only_soffice_command_exists_test(self):
+ """
+ Test the ``get_uno_command`` function uses the soffice command when the libreoffice command is not available.
+ :return:
+ """
+
+ # GIVEN: A patched 'which' method which returns None when called with 'libreoffice' and a path when called with
+ # 'soffice'
+ with patch('openlp.core.common.which',
+ **{'side_effect': lambda command: {'libreoffice': None, 'soffice': '/usr/bin/soffice'}[
+ command]}):
+ # WHEN: Calling get_uno_command
+ result = get_uno_command()
+
+ # THEN: The command 'soffice' should be called with the appropriate parameters
+ self.assertEquals(result, 'soffice --nologo --norestore --minimized --nodefault --nofirststartwizard'
+ ' "--accept=pipe,name=openlp_pipe;urp;"')
+
+ def get_uno_command_when_no_command_exists_test(self):
+ """
+ Test the ``get_uno_command`` function raises an FileNotFoundError when neither the libreoffice or soffice
+ commands are available.
+ :return:
+ """
+
+ # GIVEN: A patched 'which' method which returns None
+ with patch('openlp.core.common.which', **{'return_value': None}):
+ # WHEN: Calling get_uno_command
+
+ # THEN: a FileNotFoundError exception should be raised
+ self.assertRaises(FileNotFoundError, get_uno_command)
+
+ def get_uno_command_connection_type_test(self):
+ """
+ Test the ``get_uno_command`` function when the connection type is anything other than pipe.
+ :return:
+ """
+
+ # GIVEN: A patched 'which' method which returns 'libreoffice'
+ with patch('openlp.core.common.which', **{'return_value': 'libreoffice'}):
+ # WHEN: Calling get_uno_command with a connection type other than pipe
+ result = get_uno_command('socket')
+
+ # THEN: The connection parameters should be set for socket
+ self.assertEqual(result, 'libreoffice --nologo --norestore --minimized --nodefault --nofirststartwizard'
+ ' "--accept=socket,host=localhost,port=2002;urp;"')
+
+ def get_filesystem_encoding_sys_function_not_called_test(self):
+ """
+ Test the get_filesystem_encoding() function does not call the sys.getdefaultencoding() function
+ """
+ # GIVEN: sys.getfilesystemencoding returns "cp1252"
+ with patch('openlp.core.common.sys.getfilesystemencoding') as mocked_getfilesystemencoding, \
+ patch('openlp.core.common.sys.getdefaultencoding') as mocked_getdefaultencoding:
+ mocked_getfilesystemencoding.return_value = 'cp1252'
+
+ # WHEN: get_filesystem_encoding() is called
+ result = get_filesystem_encoding()
+
+ # THEN: getdefaultencoding should have been called
+ mocked_getfilesystemencoding.assert_called_with()
+ self.assertEqual(0, mocked_getdefaultencoding.called, 'getdefaultencoding should not have been called')
+ self.assertEqual('cp1252', result, 'The result should be "cp1252"')
+
+ def get_filesystem_encoding_sys_function_is_called_test(self):
+ """
+ Test the get_filesystem_encoding() function calls the sys.getdefaultencoding() function
+ """
+ # GIVEN: sys.getfilesystemencoding returns None and sys.getdefaultencoding returns "utf-8"
+ with patch('openlp.core.common.sys.getfilesystemencoding') as mocked_getfilesystemencoding, \
+ patch('openlp.core.common.sys.getdefaultencoding') as mocked_getdefaultencoding:
+ mocked_getfilesystemencoding.return_value = None
+ mocked_getdefaultencoding.return_value = 'utf-8'
+
+ # WHEN: get_filesystem_encoding() is called
+ result = get_filesystem_encoding()
+
+ # THEN: getdefaultencoding should have been called
+ mocked_getfilesystemencoding.assert_called_with()
+ mocked_getdefaultencoding.assert_called_with()
+ self.assertEqual('utf-8', result, 'The result should be "utf-8"')
+
+ def split_filename_with_file_path_test(self):
+ """
+ Test the split_filename() function with a path to a file
+ """
+ # GIVEN: A path to a file.
+ if os.name == 'nt':
+ file_path = 'C:\\home\\user\\myfile.txt'
+ wanted_result = ('C:\\home\\user', 'myfile.txt')
+ else:
+ file_path = '/home/user/myfile.txt'
+ wanted_result = ('/home/user', 'myfile.txt')
+ with patch('openlp.core.common.os.path.isfile') as mocked_is_file:
+ mocked_is_file.return_value = True
+
+ # WHEN: Split the file name.
+ result = split_filename(file_path)
+
+ # THEN: A tuple should be returned.
+ self.assertEqual(wanted_result, result, 'A tuple with the dir and file name should have been returned')
+
+ def split_filename_with_dir_path_test(self):
+ """
+ Test the split_filename() function with a path to a directory
+ """
+ # GIVEN: A path to a dir.
+ if os.name == 'nt':
+ file_path = 'C:\\home\\user\\mydir'
+ wanted_result = ('C:\\home\\user\\mydir', '')
+ else:
+ file_path = '/home/user/mydir'
+ wanted_result = ('/home/user/mydir', '')
+ with patch('openlp.core.common.os.path.isfile') as mocked_is_file:
+ mocked_is_file.return_value = False
+
+ # WHEN: Split the file name.
+ result = split_filename(file_path)
+
+ # THEN: A tuple should be returned.
+ self.assertEqual(wanted_result, result,
+ 'A two-entry tuple with the directory and file name (empty) should have been returned.')
+
+ def clean_filename_test(self):
+ """
+ Test the clean_filename() function
+ """
+ # GIVEN: A invalid file name and the valid file name.
+ invalid_name = 'A_file_with_invalid_characters_[\\/:\*\?"<>\|\+\[\]%].py'
+ wanted_name = 'A_file_with_invalid_characters______________________.py'
+
+ # WHEN: Clean the name.
+ result = clean_filename(invalid_name)
+
+ # THEN: The file name should be cleaned.
+ self.assertEqual(wanted_name, result, 'The file name should not contain any special characters.')
+
+ def delete_file_no_path_test(self):
+ """
+ Test the delete_file function when called with out a valid path
+ """
+ # GIVEN: A blank path
+ # WEHN: Calling delete_file
+ result = delete_file('')
+
+ # THEN: delete_file should return False
+ self.assertFalse(result, "delete_file should return False when called with ''")
+
+ def delete_file_path_success_test(self):
+ """
+ Test the delete_file function when it successfully deletes a file
+ """
+ # GIVEN: A mocked os which returns True when os.path.exists is called
+ with patch('openlp.core.common.os', **{'path.exists.return_value': False}):
+
+ # WHEN: Calling delete_file with a file path
+ result = delete_file('path/file.ext')
+
+ # THEN: delete_file should return True
+ self.assertTrue(result, 'delete_file should return True when it successfully deletes a file')
+
+ def delete_file_path_no_file_exists_test(self):
+ """
+ Test the delete_file function when the file to remove does not exist
+ """
+ # GIVEN: A mocked os which returns False when os.path.exists is called
+ with patch('openlp.core.common.os', **{'path.exists.return_value': False}):
+
+ # WHEN: Calling delete_file with a file path
+ result = delete_file('path/file.ext')
+
+ # THEN: delete_file should return True
+ self.assertTrue(result, 'delete_file should return True when the file doesnt exist')
+
+ def delete_file_path_exception_test(self):
+ """
+ Test the delete_file function when os.remove raises an exception
+ """
+ # GIVEN: A mocked os which returns True when os.path.exists is called and raises an OSError when os.remove is
+ # called.
+ with patch('openlp.core.common.os', **{'path.exists.return_value': True, 'path.exists.side_effect': OSError}), \
+ patch('openlp.core.common.log') as mocked_log:
+
+ # WHEN: Calling delete_file with a file path
+ result = delete_file('path/file.ext')
+
+ # THEN: delete_file should log and exception and return False
+ self.assertEqual(mocked_log.exception.call_count, 1)
+ self.assertFalse(result, 'delete_file should return False when os.remove raises an OSError')
diff --git a/tests/functional/openlp_core_common/test_languagemanager.py b/tests/functional/openlp_core_common/test_languagemanager.py
new file mode 100644
index 000000000..8fe7d543c
--- /dev/null
+++ b/tests/functional/openlp_core_common/test_languagemanager.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2016 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+Functional tests to test the AppLocation class and related methods.
+"""
+from unittest import TestCase
+
+from tests.functional import patch
+from openlp.core.common.languagemanager import get_locale_key, get_natural_key
+
+
+class TestLanguageManager(TestCase):
+ """
+ A test suite to test out various methods around the common __init__ class.
+ """
+
+ def get_locale_key_test(self):
+ """
+ Test the get_locale_key(string) function
+ """
+ with patch('openlp.core.common.languagemanager.LanguageManager.get_language') as mocked_get_language:
+ # GIVEN: The language is German
+ # 0x00C3 (A with diaresis) should be sorted as "A". 0x00DF (sharp s) should be sorted as "ss".
+ mocked_get_language.return_value = 'de'
+ unsorted_list = ['Auszug', 'Aushang', '\u00C4u\u00DFerung']
+
+ # WHEN: We sort the list and use get_locale_key() to generate the sorting keys
+ sorted_list = sorted(unsorted_list, key=get_locale_key)
+
+ # THEN: We get a properly sorted list
+ self.assertEqual(['Aushang', '\u00C4u\u00DFerung', 'Auszug'], sorted_list,
+ 'Strings should be sorted properly')
+
+ def get_natural_key_test(self):
+ """
+ Test the get_natural_key(string) function
+ """
+ with patch('openlp.core.common.languagemanager.LanguageManager.get_language') as mocked_get_language:
+ # GIVEN: The language is English (a language, which sorts digits before letters)
+ mocked_get_language.return_value = 'en'
+ unsorted_list = ['item 10a', 'item 3b', '1st item']
+
+ # WHEN: We sort the list and use get_natural_key() to generate the sorting keys
+ sorted_list = sorted(unsorted_list, key=get_natural_key)
+
+ # THEN: We get a properly sorted list
+ self.assertEqual(['1st item', 'item 3b', 'item 10a'], sorted_list, 'Numbers should be sorted naturally')
diff --git a/tests/functional/openlp_core_common/test_versionchecker.py b/tests/functional/openlp_core_common/test_versionchecker.py
new file mode 100644
index 000000000..d14bfa679
--- /dev/null
+++ b/tests/functional/openlp_core_common/test_versionchecker.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2016 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+Package to test the openlp.core.common.versionchecker package.
+"""
+from unittest import TestCase
+
+from openlp.core.common.settings import Settings
+from openlp.core.common.versionchecker import VersionThread
+from tests.functional import MagicMock, patch
+from tests.helpers.testmixin import TestMixin
+
+
+class TestVersionchecker(TestMixin, TestCase):
+
+ def setUp(self):
+ """
+ Create an instance and a few example actions.
+ """
+ self.build_settings()
+
+ def tearDown(self):
+ """
+ Clean up
+ """
+ self.destroy_settings()
+
+ def version_thread_triggered_test(self):
+ """
+ Test the version thread call does not trigger UI
+ :return:
+ """
+ # GIVEN: a equal version setup and the data is not today.
+ mocked_main_window = MagicMock()
+ Settings().setValue('core/last version test', '1950-04-01')
+ # WHEN: We check to see if the version is different .
+ with patch('PyQt5.QtCore.QThread'),\
+ patch('openlp.core.common.versionchecker.get_application_version') as mocked_get_application_version:
+ mocked_get_application_version.return_value = {'version': '1.0.0', 'build': '', 'full': '2.0.4'}
+ version_thread = VersionThread(mocked_main_window)
+ version_thread.run()
+ # THEN: If the version has changed the main window is notified
+ self.assertTrue(mocked_main_window.openlp_version_check.emit.called,
+ 'The main windows should have been notified')
diff --git a/tests/functional/openlp_core_lib/test_projector_pjlink1.py b/tests/functional/openlp_core_lib/test_projector_pjlink1.py
index 7e19ff065..a3d99e884 100644
--- a/tests/functional/openlp_core_lib/test_projector_pjlink1.py
+++ b/tests/functional/openlp_core_lib/test_projector_pjlink1.py
@@ -92,3 +92,18 @@ class TestPJLink(TestCase):
mock_change_status.called_with(E_PARAMETER,
'change_status should have been called with "{}"'.format(
ERROR_STRING[E_PARAMETER]))
+
+ @patch.object(pjlink_test, 'process_inpt')
+ def projector_return_ok_test(self, mock_process_inpt):
+ """
+ Test projector calls process_inpt command when process_command is called with INPT option
+ """
+ # GIVEN: Test object
+ pjlink = pjlink_test
+
+ # WHEN: process_command is called with INST command and 31 input:
+ pjlink.process_command('INPT', '31')
+
+ # THEN: process_inpt method should have been called with 31
+ mock_process_inpt.called_with('31',
+ "process_inpt should have been called with 31")
diff --git a/tests/functional/openlp_core_lib/test_webpagereader.py b/tests/functional/openlp_core_lib/test_webpagereader.py
new file mode 100644
index 000000000..80b2c1f9a
--- /dev/null
+++ b/tests/functional/openlp_core_lib/test_webpagereader.py
@@ -0,0 +1,229 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2016 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+Functional tests to test the AppLocation class and related methods.
+"""
+from unittest import TestCase
+
+from openlp.core.lib.webpagereader import _get_user_agent, get_web_page
+
+from tests.functional import MagicMock, patch
+
+
+class TestUtils(TestCase):
+ """
+ A test suite to test out various methods around the AppLocation class.
+ """
+ def get_user_agent_linux_test(self):
+ """
+ Test that getting a user agent on Linux returns a user agent suitable for Linux
+ """
+ with patch('openlp.core.lib.webpagereader.sys') as mocked_sys:
+
+ # GIVEN: The system is Linux
+ mocked_sys.platform = 'linux2'
+
+ # WHEN: We call _get_user_agent()
+ user_agent = _get_user_agent()
+
+ # THEN: The user agent is a Linux (or ChromeOS) user agent
+ result = 'Linux' in user_agent or 'CrOS' in user_agent
+ self.assertTrue(result, 'The user agent should be a valid Linux user agent')
+
+ def get_user_agent_windows_test(self):
+ """
+ Test that getting a user agent on Windows returns a user agent suitable for Windows
+ """
+ with patch('openlp.core.lib.webpagereader.sys') as mocked_sys:
+
+ # GIVEN: The system is Linux
+ mocked_sys.platform = 'win32'
+
+ # WHEN: We call _get_user_agent()
+ user_agent = _get_user_agent()
+
+ # THEN: The user agent is a Linux (or ChromeOS) user agent
+ self.assertIn('Windows', user_agent, 'The user agent should be a valid Windows user agent')
+
+ def get_user_agent_macos_test(self):
+ """
+ Test that getting a user agent on OS X returns a user agent suitable for OS X
+ """
+ with patch('openlp.core.lib.webpagereader.sys') as mocked_sys:
+
+ # GIVEN: The system is Linux
+ mocked_sys.platform = 'darwin'
+
+ # WHEN: We call _get_user_agent()
+ user_agent = _get_user_agent()
+
+ # THEN: The user agent is a Linux (or ChromeOS) user agent
+ self.assertIn('Mac OS X', user_agent, 'The user agent should be a valid OS X user agent')
+
+ def get_user_agent_default_test(self):
+ """
+ Test that getting a user agent on a non-Linux/Windows/OS X platform returns the default user agent
+ """
+ with patch('openlp.core.lib.webpagereader.sys') as mocked_sys:
+
+ # GIVEN: The system is Linux
+ mocked_sys.platform = 'freebsd'
+
+ # WHEN: We call _get_user_agent()
+ user_agent = _get_user_agent()
+
+ # THEN: The user agent is a Linux (or ChromeOS) user agent
+ self.assertIn('NetBSD', user_agent, 'The user agent should be the default user agent')
+
+ def get_web_page_no_url_test(self):
+ """
+ Test that sending a URL of None to the get_web_page method returns None
+ """
+ # GIVEN: A None url
+ test_url = None
+
+ # WHEN: We try to get the test URL
+ result = get_web_page(test_url)
+
+ # THEN: None should be returned
+ self.assertIsNone(result, 'The return value of get_web_page should be None')
+
+ def get_web_page_test(self):
+ """
+ Test that the get_web_page method works correctly
+ """
+ with patch('openlp.core.lib.webpagereader.urllib.request.Request') as MockRequest, \
+ patch('openlp.core.lib.webpagereader.urllib.request.urlopen') as mock_urlopen, \
+ patch('openlp.core.lib.webpagereader._get_user_agent') as mock_get_user_agent, \
+ patch('openlp.core.common.Registry') as MockRegistry:
+ # GIVEN: Mocked out objects and a fake URL
+ mocked_request_object = MagicMock()
+ MockRequest.return_value = mocked_request_object
+ mocked_page_object = MagicMock()
+ mock_urlopen.return_value = mocked_page_object
+ mock_get_user_agent.return_value = 'user_agent'
+ fake_url = 'this://is.a.fake/url'
+
+ # WHEN: The get_web_page() method is called
+ returned_page = get_web_page(fake_url)
+
+ # THEN: The correct methods are called with the correct arguments and a web page is returned
+ MockRequest.assert_called_with(fake_url)
+ mocked_request_object.add_header.assert_called_with('User-Agent', 'user_agent')
+ self.assertEqual(1, mocked_request_object.add_header.call_count,
+ 'There should only be 1 call to add_header')
+ mock_get_user_agent.assert_called_with()
+ mock_urlopen.assert_called_with(mocked_request_object, timeout=30)
+ mocked_page_object.geturl.assert_called_with()
+ self.assertEqual(0, MockRegistry.call_count, 'The Registry() object should have never been called')
+ self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object')
+
+ def get_web_page_with_header_test(self):
+ """
+ Test that adding a header to the call to get_web_page() adds the header to the request
+ """
+ with patch('openlp.core.lib.webpagereader.urllib.request.Request') as MockRequest, \
+ patch('openlp.core.lib.webpagereader.urllib.request.urlopen') as mock_urlopen, \
+ patch('openlp.core.lib.webpagereader._get_user_agent') as mock_get_user_agent:
+ # GIVEN: Mocked out objects, a fake URL and a fake header
+ mocked_request_object = MagicMock()
+ MockRequest.return_value = mocked_request_object
+ mocked_page_object = MagicMock()
+ mock_urlopen.return_value = mocked_page_object
+ mock_get_user_agent.return_value = 'user_agent'
+ fake_url = 'this://is.a.fake/url'
+ fake_header = ('Fake-Header', 'fake value')
+
+ # WHEN: The get_web_page() method is called
+ returned_page = get_web_page(fake_url, header=fake_header)
+
+ # THEN: The correct methods are called with the correct arguments and a web page is returned
+ MockRequest.assert_called_with(fake_url)
+ mocked_request_object.add_header.assert_called_with(fake_header[0], fake_header[1])
+ self.assertEqual(2, mocked_request_object.add_header.call_count,
+ 'There should only be 2 calls to add_header')
+ mock_get_user_agent.assert_called_with()
+ mock_urlopen.assert_called_with(mocked_request_object, timeout=30)
+ mocked_page_object.geturl.assert_called_with()
+ self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object')
+
+ def get_web_page_with_user_agent_in_headers_test(self):
+ """
+ Test that adding a user agent in the header when calling get_web_page() adds that user agent to the request
+ """
+ with patch('openlp.core.lib.webpagereader.urllib.request.Request') as MockRequest, \
+ patch('openlp.core.lib.webpagereader.urllib.request.urlopen') as mock_urlopen, \
+ patch('openlp.core.lib.webpagereader._get_user_agent') as mock_get_user_agent:
+ # GIVEN: Mocked out objects, a fake URL and a fake header
+ mocked_request_object = MagicMock()
+ MockRequest.return_value = mocked_request_object
+ mocked_page_object = MagicMock()
+ mock_urlopen.return_value = mocked_page_object
+ fake_url = 'this://is.a.fake/url'
+ user_agent_header = ('User-Agent', 'OpenLP/2.2.0')
+
+ # WHEN: The get_web_page() method is called
+ returned_page = get_web_page(fake_url, header=user_agent_header)
+
+ # THEN: The correct methods are called with the correct arguments and a web page is returned
+ MockRequest.assert_called_with(fake_url)
+ mocked_request_object.add_header.assert_called_with(user_agent_header[0], user_agent_header[1])
+ self.assertEqual(1, mocked_request_object.add_header.call_count,
+ 'There should only be 1 call to add_header')
+ self.assertEqual(0, mock_get_user_agent.call_count, '_get_user_agent should not have been called')
+ mock_urlopen.assert_called_with(mocked_request_object, timeout=30)
+ mocked_page_object.geturl.assert_called_with()
+ self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object')
+
+ def get_web_page_update_openlp_test(self):
+ """
+ Test that passing "update_openlp" as true to get_web_page calls Registry().get('app').process_events()
+ """
+ with patch('openlp.core.lib.webpagereader.urllib.request.Request') as MockRequest, \
+ patch('openlp.core.lib.webpagereader.urllib.request.urlopen') as mock_urlopen, \
+ patch('openlp.core.lib.webpagereader._get_user_agent') as mock_get_user_agent, \
+ patch('openlp.core.lib.webpagereader.Registry') as MockRegistry:
+ # GIVEN: Mocked out objects, a fake URL
+ mocked_request_object = MagicMock()
+ MockRequest.return_value = mocked_request_object
+ mocked_page_object = MagicMock()
+ mock_urlopen.return_value = mocked_page_object
+ mock_get_user_agent.return_value = 'user_agent'
+ mocked_registry_object = MagicMock()
+ mocked_application_object = MagicMock()
+ mocked_registry_object.get.return_value = mocked_application_object
+ MockRegistry.return_value = mocked_registry_object
+ fake_url = 'this://is.a.fake/url'
+
+ # WHEN: The get_web_page() method is called
+ returned_page = get_web_page(fake_url, update_openlp=True)
+
+ # THEN: The correct methods are called with the correct arguments and a web page is returned
+ MockRequest.assert_called_with(fake_url)
+ mocked_request_object.add_header.assert_called_with('User-Agent', 'user_agent')
+ self.assertEqual(1, mocked_request_object.add_header.call_count,
+ 'There should only be 1 call to add_header')
+ mock_urlopen.assert_called_with(mocked_request_object, timeout=30)
+ mocked_page_object.geturl.assert_called_with()
+ mocked_registry_object.get.assert_called_with('application')
+ mocked_application_object.process_events.assert_called_with()
+ self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object')
diff --git a/tests/functional/openlp_core_utils/test_first_time.py b/tests/functional/openlp_core_ui/test_first_time.py
similarity index 95%
rename from tests/functional/openlp_core_utils/test_first_time.py
rename to tests/functional/openlp_core_ui/test_first_time.py
index b9a7622a2..43c8c0bc1 100644
--- a/tests/functional/openlp_core_utils/test_first_time.py
+++ b/tests/functional/openlp_core_ui/test_first_time.py
@@ -28,10 +28,10 @@ import urllib.request
import urllib.error
import urllib.parse
-from tests.functional import MagicMock, patch
+from tests.functional import patch
from tests.helpers.testmixin import TestMixin
-from openlp.core.utils import CONNECTION_TIMEOUT, CONNECTION_RETRIES, get_web_page
+from openlp.core.lib.webpagereader import CONNECTION_RETRIES, get_web_page
class TestFirstTimeWizard(TestMixin, TestCase):
diff --git a/tests/functional/openlp_core_ui/test_listpreviewwidget.py b/tests/functional/openlp_core_ui/test_listpreviewwidget.py
index 6f27fbde3..a222189e6 100644
--- a/tests/functional/openlp_core_ui/test_listpreviewwidget.py
+++ b/tests/functional/openlp_core_ui/test_listpreviewwidget.py
@@ -23,9 +23,12 @@
Package to test the openlp.core.ui.listpreviewwidget package.
"""
from unittest import TestCase
-from openlp.core.ui.listpreviewwidget import ListPreviewWidget
-from tests.functional import patch
+from openlp.core.common import Settings
+from openlp.core.ui.listpreviewwidget import ListPreviewWidget
+from openlp.core.lib import ServiceItem
+
+from tests.functional import MagicMock, patch, call
class TestListPreviewWidget(TestCase):
@@ -34,9 +37,27 @@ class TestListPreviewWidget(TestCase):
"""
Mock out stuff for all the tests
"""
- self.setup_patcher = patch('openlp.core.ui.listpreviewwidget.ListPreviewWidget._setup')
- self.mocked_setup = self.setup_patcher.start()
- self.addCleanup(self.setup_patcher.stop)
+ # Mock self.parent().width()
+ self.parent_patcher = patch('openlp.core.ui.listpreviewwidget.ListPreviewWidget.parent')
+ self.mocked_parent = self.parent_patcher.start()
+ self.mocked_parent.width.return_value = 100
+ self.addCleanup(self.parent_patcher.stop)
+
+ # Mock Settings().value()
+ self.Settings_patcher = patch('openlp.core.ui.listpreviewwidget.Settings')
+ self.mocked_Settings = self.Settings_patcher.start()
+ self.mocked_Settings_obj = MagicMock()
+ self.mocked_Settings_obj.value.return_value = None
+ self.mocked_Settings.return_value = self.mocked_Settings_obj
+ self.addCleanup(self.Settings_patcher.stop)
+
+ # Mock self.viewport().width()
+ self.viewport_patcher = patch('openlp.core.ui.listpreviewwidget.ListPreviewWidget.viewport')
+ self.mocked_viewport = self.viewport_patcher.start()
+ self.mocked_viewport_obj = MagicMock()
+ self.mocked_viewport_obj.width.return_value = 200
+ self.mocked_viewport.return_value = self.mocked_viewport_obj
+ self.addCleanup(self.viewport_patcher.stop)
def new_list_preview_widget_test(self):
"""
@@ -49,4 +70,206 @@ class TestListPreviewWidget(TestCase):
# THEN: The object is not None, and the _setup() method was called.
self.assertIsNotNone(list_preview_widget, 'The ListPreviewWidget object should not be None')
- self.mocked_setup.assert_called_with(1)
+ self.assertEquals(list_preview_widget.screen_ratio, 1, 'Should not be called')
+
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.resizeRowsToContents')
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.setRowHeight')
+ def replace_recalculate_layout_test_text(self, mocked_setRowHeight, mocked_resizeRowsToContents):
+ """
+ Test if "Max height for non-text slides..." enabled, txt slides unchanged in replace_service_item & __recalc...
+ """
+ # GIVEN: A setting to adjust "Max height for non-text slides in slide controller",
+ # a text ServiceItem and a ListPreviewWidget.
+
+ # Mock Settings().value('advanced/slide max height')
+ self.mocked_Settings_obj.value.return_value = 100
+ # Mock self.viewport().width()
+ self.mocked_viewport_obj.width.return_value = 200
+ # Mock text service item
+ service_item = MagicMock()
+ service_item.is_text.return_value = True
+ service_item.get_frames.return_value = [{'title': None, 'text': None, 'verseTag': None},
+ {'title': None, 'text': None, 'verseTag': None}]
+ # init ListPreviewWidget and load service item
+ list_preview_widget = ListPreviewWidget(None, 1)
+ list_preview_widget.replace_service_item(service_item, 200, 0)
+ # Change viewport width before forcing a resize
+ self.mocked_viewport_obj.width.return_value = 400
+
+ # WHEN: __recalculate_layout() is called (via resizeEvent)
+ list_preview_widget.resizeEvent(None)
+
+ # THEN: setRowHeight() should not be called, while resizeRowsToContents() should be called twice
+ # (once each in __recalculate_layout and replace_service_item)
+ self.assertEquals(mocked_resizeRowsToContents.call_count, 2, 'Should be called')
+ self.assertEquals(mocked_setRowHeight.call_count, 0, 'Should not be called')
+
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.resizeRowsToContents')
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.setRowHeight')
+ def replace_recalculate_layout_test_img(self, mocked_setRowHeight, mocked_resizeRowsToContents):
+ """
+ Test if "Max height for non-text slides..." disabled, img slides unchanged in replace_service_item & __recalc...
+ """
+ # GIVEN: A setting to adjust "Max height for non-text slides in slide controller",
+ # an image ServiceItem and a ListPreviewWidget.
+
+ # Mock Settings().value('advanced/slide max height')
+ self.mocked_Settings_obj.value.return_value = 0
+ # Mock self.viewport().width()
+ self.mocked_viewport_obj.width.return_value = 200
+ # Mock image service item
+ service_item = MagicMock()
+ service_item.is_text.return_value = False
+ service_item.get_frames.return_value = [{'title': None, 'path': None, 'image': None},
+ {'title': None, 'path': None, 'image': None}]
+ # init ListPreviewWidget and load service item
+ list_preview_widget = ListPreviewWidget(None, 1)
+ list_preview_widget.replace_service_item(service_item, 200, 0)
+ # Change viewport width before forcing a resize
+ self.mocked_viewport_obj.width.return_value = 400
+
+ # WHEN: __recalculate_layout() is called (via resizeEvent)
+ list_preview_widget.resizeEvent(None)
+
+ # THEN: resizeRowsToContents() should not be called, while setRowHeight() should be called
+ # twice for each slide.
+ self.assertEquals(mocked_resizeRowsToContents.call_count, 0, 'Should not be called')
+ self.assertEquals(mocked_setRowHeight.call_count, 4, 'Should be called twice for each slide')
+ calls = [call(0, 200), call(1, 200), call(0, 400), call(1, 400)]
+ mocked_setRowHeight.assert_has_calls(calls)
+
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.resizeRowsToContents')
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.setRowHeight')
+ def replace_recalculate_layout_test_img_max(self, mocked_setRowHeight, mocked_resizeRowsToContents):
+ """
+ Test if "Max height for non-text slides..." enabled, img slides resized in replace_service_item & __recalc...
+ """
+ # GIVEN: A setting to adjust "Max height for non-text slides in slide controller",
+ # an image ServiceItem and a ListPreviewWidget.
+
+ # Mock Settings().value('advanced/slide max height')
+ self.mocked_Settings_obj.value.return_value = 100
+ # Mock self.viewport().width()
+ self.mocked_viewport_obj.width.return_value = 200
+ # Mock image service item
+ service_item = MagicMock()
+ service_item.is_text.return_value = False
+ service_item.get_frames.return_value = [{'title': None, 'path': None, 'image': None},
+ {'title': None, 'path': None, 'image': None}]
+ # init ListPreviewWidget and load service item
+ list_preview_widget = ListPreviewWidget(None, 1)
+ list_preview_widget.replace_service_item(service_item, 200, 0)
+ # Change viewport width before forcing a resize
+ self.mocked_viewport_obj.width.return_value = 400
+
+ # WHEN: __recalculate_layout() is called (via resizeEvent)
+ list_preview_widget.resizeEvent(None)
+
+ # THEN: resizeRowsToContents() should not be called, while setRowHeight() should be called
+ # twice for each slide.
+ self.assertEquals(mocked_resizeRowsToContents.call_count, 0, 'Should not be called')
+ self.assertEquals(mocked_setRowHeight.call_count, 4, 'Should be called twice for each slide')
+ calls = [call(0, 100), call(1, 100), call(0, 100), call(1, 100)]
+ mocked_setRowHeight.assert_has_calls(calls)
+
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.resizeRowsToContents')
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.setRowHeight')
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.cellWidget')
+ def row_resized_test_text(self, mocked_cellWidget, mocked_setRowHeight, mocked_resizeRowsToContents):
+ """
+ Test if "Max height for non-text slides..." enabled, text-based slides not affected in row_resized.
+ """
+ # GIVEN: A setting to adjust "Max height for non-text slides in slide controller",
+ # a text ServiceItem and a ListPreviewWidget.
+
+ # Mock Settings().value('advanced/slide max height')
+ self.mocked_Settings_obj.value.return_value = 100
+ # Mock self.viewport().width()
+ self.mocked_viewport_obj.width.return_value = 200
+ # Mock text service item
+ service_item = MagicMock()
+ service_item.is_text.return_value = True
+ service_item.get_frames.return_value = [{'title': None, 'text': None, 'verseTag': None},
+ {'title': None, 'text': None, 'verseTag': None}]
+ # Mock self.cellWidget().children().setMaximumWidth()
+ mocked_cellWidget_child = MagicMock()
+ mocked_cellWidget_obj = MagicMock()
+ mocked_cellWidget_obj.children.return_value = [None, mocked_cellWidget_child]
+ mocked_cellWidget.return_value = mocked_cellWidget_obj
+ # init ListPreviewWidget and load service item
+ list_preview_widget = ListPreviewWidget(None, 1)
+ list_preview_widget.replace_service_item(service_item, 200, 0)
+
+ # WHEN: row_resized() is called
+ list_preview_widget.row_resized(0, 100, 150)
+
+ # THEN: self.cellWidget(row, 0).children()[1].setMaximumWidth() should not be called
+ self.assertEquals(mocked_cellWidget_child.setMaximumWidth.call_count, 0, 'Should not be called')
+
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.resizeRowsToContents')
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.setRowHeight')
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.cellWidget')
+ def row_resized_test_img(self, mocked_cellWidget, mocked_setRowHeight, mocked_resizeRowsToContents):
+ """
+ Test if "Max height for non-text slides..." disabled, image-based slides not affected in row_resized.
+ """
+ # GIVEN: A setting to adjust "Max height for non-text slides in slide controller",
+ # an image ServiceItem and a ListPreviewWidget.
+
+ # Mock Settings().value('advanced/slide max height')
+ self.mocked_Settings_obj.value.return_value = 0
+ # Mock self.viewport().width()
+ self.mocked_viewport_obj.width.return_value = 200
+ # Mock image service item
+ service_item = MagicMock()
+ service_item.is_text.return_value = False
+ service_item.get_frames.return_value = [{'title': None, 'path': None, 'image': None},
+ {'title': None, 'path': None, 'image': None}]
+ # Mock self.cellWidget().children().setMaximumWidth()
+ mocked_cellWidget_child = MagicMock()
+ mocked_cellWidget_obj = MagicMock()
+ mocked_cellWidget_obj.children.return_value = [None, mocked_cellWidget_child]
+ mocked_cellWidget.return_value = mocked_cellWidget_obj
+ # init ListPreviewWidget and load service item
+ list_preview_widget = ListPreviewWidget(None, 1)
+ list_preview_widget.replace_service_item(service_item, 200, 0)
+
+ # WHEN: row_resized() is called
+ list_preview_widget.row_resized(0, 100, 150)
+
+ # THEN: self.cellWidget(row, 0).children()[1].setMaximumWidth() should not be called
+ self.assertEquals(mocked_cellWidget_child.setMaximumWidth.call_count, 0, 'Should not be called')
+
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.resizeRowsToContents')
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.setRowHeight')
+ @patch(u'openlp.core.ui.listpreviewwidget.ListPreviewWidget.cellWidget')
+ def row_resized_test_img_max(self, mocked_cellWidget, mocked_setRowHeight, mocked_resizeRowsToContents):
+ """
+ Test if "Max height for non-text slides..." enabled, image-based slides are scaled in row_resized.
+ """
+ # GIVEN: A setting to adjust "Max height for non-text slides in slide controller",
+ # an image ServiceItem and a ListPreviewWidget.
+
+ # Mock Settings().value('advanced/slide max height')
+ self.mocked_Settings_obj.value.return_value = 100
+ # Mock self.viewport().width()
+ self.mocked_viewport_obj.width.return_value = 200
+ # Mock image service item
+ service_item = MagicMock()
+ service_item.is_text.return_value = False
+ service_item.get_frames.return_value = [{'title': None, 'path': None, 'image': None},
+ {'title': None, 'path': None, 'image': None}]
+ # Mock self.cellWidget().children().setMaximumWidth()
+ mocked_cellWidget_child = MagicMock()
+ mocked_cellWidget_obj = MagicMock()
+ mocked_cellWidget_obj.children.return_value = [None, mocked_cellWidget_child]
+ mocked_cellWidget.return_value = mocked_cellWidget_obj
+ # init ListPreviewWidget and load service item
+ list_preview_widget = ListPreviewWidget(None, 1)
+ list_preview_widget.replace_service_item(service_item, 200, 0)
+
+ # WHEN: row_resized() is called
+ list_preview_widget.row_resized(0, 100, 150)
+
+ # THEN: self.cellWidget(row, 0).children()[1].setMaximumWidth() should be called
+ mocked_cellWidget_child.setMaximumWidth.assert_called_once_with(150)
diff --git a/tests/functional/openlp_core_utils/test_init.py b/tests/functional/openlp_core_utils/test_init.py
deleted file mode 100644
index 6a62f3a7f..000000000
--- a/tests/functional/openlp_core_utils/test_init.py
+++ /dev/null
@@ -1,129 +0,0 @@
-# -*- coding: utf-8 -*-
-# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
-
-###############################################################################
-# OpenLP - Open Source Lyrics Projection #
-# --------------------------------------------------------------------------- #
-# Copyright (c) 2008-2016 OpenLP Developers #
-# --------------------------------------------------------------------------- #
-# This program is free software; you can redistribute it and/or modify it #
-# under the terms of the GNU General Public License as published by the Free #
-# Software Foundation; version 2 of the License. #
-# #
-# This program is distributed in the hope that it will be useful, but WITHOUT #
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
-# more details. #
-# #
-# You should have received a copy of the GNU General Public License along #
-# with this program; if not, write to the Free Software Foundation, Inc., 59 #
-# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
-###############################################################################
-"""
-Package to test the openlp.core.utils.actions package.
-"""
-from unittest import TestCase
-
-from openlp.core.common.settings import Settings
-from openlp.core.utils import VersionThread, get_uno_command
-from tests.functional import MagicMock, patch
-from tests.helpers.testmixin import TestMixin
-
-
-class TestInitFunctions(TestMixin, TestCase):
-
- def setUp(self):
- """
- Create an instance and a few example actions.
- """
- self.build_settings()
-
- def tearDown(self):
- """
- Clean up
- """
- self.destroy_settings()
-
- def version_thread_triggered_test(self):
- """
- Test the version thread call does not trigger UI
- :return:
- """
- # GIVEN: a equal version setup and the data is not today.
- mocked_main_window = MagicMock()
- Settings().setValue('core/last version test', '1950-04-01')
- # WHEN: We check to see if the version is different .
- with patch('PyQt5.QtCore.QThread'),\
- patch('openlp.core.utils.get_application_version') as mocked_get_application_version:
- mocked_get_application_version.return_value = {'version': '1.0.0', 'build': '', 'full': '2.0.4'}
- version_thread = VersionThread(mocked_main_window)
- version_thread.run()
- # THEN: If the version has changed the main window is notified
- self.assertTrue(mocked_main_window.openlp_version_check.emit.called,
- 'The main windows should have been notified')
-
- def get_uno_command_libreoffice_command_exists_test(self):
- """
- Test the ``get_uno_command`` function uses the libreoffice command when available.
- :return:
- """
-
- # GIVEN: A patched 'which' method which returns a path when called with 'libreoffice'
- with patch('openlp.core.utils.which',
- **{'side_effect': lambda command: {'libreoffice': '/usr/bin/libreoffice'}[command]}):
-
- # WHEN: Calling get_uno_command
- result = get_uno_command()
-
- # THEN: The command 'libreoffice' should be called with the appropriate parameters
- self.assertEquals(result, 'libreoffice --nologo --norestore --minimized --nodefault --nofirststartwizard'
- ' "--accept=pipe,name=openlp_pipe;urp;"')
-
- def get_uno_command_only_soffice_command_exists_test(self):
- """
- Test the ``get_uno_command`` function uses the soffice command when the libreoffice command is not available.
- :return:
- """
-
- # GIVEN: A patched 'which' method which returns None when called with 'libreoffice' and a path when called with
- # 'soffice'
- with patch('openlp.core.utils.which',
- **{'side_effect': lambda command: {'libreoffice': None, 'soffice': '/usr/bin/soffice'}[command]}):
-
- # WHEN: Calling get_uno_command
- result = get_uno_command()
-
- # THEN: The command 'soffice' should be called with the appropriate parameters
- self.assertEquals(result, 'soffice --nologo --norestore --minimized --nodefault --nofirststartwizard'
- ' "--accept=pipe,name=openlp_pipe;urp;"')
-
- def get_uno_command_when_no_command_exists_test(self):
- """
- Test the ``get_uno_command`` function raises an FileNotFoundError when neither the libreoffice or soffice
- commands are available.
- :return:
- """
-
- # GIVEN: A patched 'which' method which returns None
- with patch('openlp.core.utils.which', **{'return_value': None}):
-
- # WHEN: Calling get_uno_command
-
- # THEN: a FileNotFoundError exception should be raised
- self.assertRaises(FileNotFoundError, get_uno_command)
-
- def get_uno_command_connection_type_test(self):
- """
- Test the ``get_uno_command`` function when the connection type is anything other than pipe.
- :return:
- """
-
- # GIVEN: A patched 'which' method which returns 'libreoffice'
- with patch('openlp.core.utils.which', **{'return_value': 'libreoffice'}):
-
- # WHEN: Calling get_uno_command with a connection type other than pipe
- result = get_uno_command('socket')
-
- # THEN: The connection parameters should be set for socket
- self.assertEqual(result, 'libreoffice --nologo --norestore --minimized --nodefault --nofirststartwizard'
- ' "--accept=socket,host=localhost,port=2002;urp;"')
diff --git a/tests/functional/openlp_core_utils/test_utils.py b/tests/functional/openlp_core_utils/test_utils.py
deleted file mode 100644
index d63d11771..000000000
--- a/tests/functional/openlp_core_utils/test_utils.py
+++ /dev/null
@@ -1,491 +0,0 @@
-# -*- coding: utf-8 -*-
-# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
-
-###############################################################################
-# OpenLP - Open Source Lyrics Projection #
-# --------------------------------------------------------------------------- #
-# Copyright (c) 2008-2016 OpenLP Developers #
-# --------------------------------------------------------------------------- #
-# This program is free software; you can redistribute it and/or modify it #
-# under the terms of the GNU General Public License as published by the Free #
-# Software Foundation; version 2 of the License. #
-# #
-# This program is distributed in the hope that it will be useful, but WITHOUT #
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
-# more details. #
-# #
-# You should have received a copy of the GNU General Public License along #
-# with this program; if not, write to the Free Software Foundation, Inc., 59 #
-# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
-###############################################################################
-"""
-Functional tests to test the AppLocation class and related methods.
-"""
-import os
-from unittest import TestCase
-
-from openlp.core.utils import clean_filename, delete_file, get_filesystem_encoding, get_locale_key, \
- get_natural_key, split_filename, _get_user_agent, get_web_page, get_uno_instance, add_actions
-from tests.functional import MagicMock, patch
-
-
-class TestUtils(TestCase):
- """
- A test suite to test out various methods around the AppLocation class.
- """
- def add_actions_empty_list_test(self):
- """
- Test that no actions are added when the list is empty
- """
- # GIVEN: a mocked action list, and an empty list
- mocked_target = MagicMock()
- empty_list = []
-
- # WHEN: The empty list is added to the mocked target
- add_actions(mocked_target, empty_list)
-
- # THEN: The add method on the mocked target is never called
- self.assertEqual(0, mocked_target.addSeparator.call_count, 'addSeparator method should not have been called')
- self.assertEqual(0, mocked_target.addAction.call_count, 'addAction method should not have been called')
-
- def add_actions_none_action_test(self):
- """
- Test that a separator is added when a None action is in the list
- """
- # GIVEN: a mocked action list, and a list with None in it
- mocked_target = MagicMock()
- separator_list = [None]
-
- # WHEN: The list is added to the mocked target
- add_actions(mocked_target, separator_list)
-
- # THEN: The addSeparator method is called, but the addAction method is never called
- mocked_target.addSeparator.assert_called_with()
- self.assertEqual(0, mocked_target.addAction.call_count, 'addAction method should not have been called')
-
- def add_actions_add_action_test(self):
- """
- Test that an action is added when a valid action is in the list
- """
- # GIVEN: a mocked action list, and a list with an action in it
- mocked_target = MagicMock()
- action_list = ['action']
-
- # WHEN: The list is added to the mocked target
- add_actions(mocked_target, action_list)
-
- # THEN: The addSeparator method is not called, and the addAction method is called
- self.assertEqual(0, mocked_target.addSeparator.call_count, 'addSeparator method should not have been called')
- mocked_target.addAction.assert_called_with('action')
-
- def add_actions_action_and_none_test(self):
- """
- Test that an action and a separator are added when a valid action and None are in the list
- """
- # GIVEN: a mocked action list, and a list with an action and None in it
- mocked_target = MagicMock()
- action_list = ['action', None]
-
- # WHEN: The list is added to the mocked target
- add_actions(mocked_target, action_list)
-
- # THEN: The addSeparator method is called, and the addAction method is called
- mocked_target.addSeparator.assert_called_with()
- mocked_target.addAction.assert_called_with('action')
-
- def get_filesystem_encoding_sys_function_not_called_test(self):
- """
- Test the get_filesystem_encoding() function does not call the sys.getdefaultencoding() function
- """
- # GIVEN: sys.getfilesystemencoding returns "cp1252"
- with patch('openlp.core.utils.sys.getfilesystemencoding') as mocked_getfilesystemencoding, \
- patch('openlp.core.utils.sys.getdefaultencoding') as mocked_getdefaultencoding:
- mocked_getfilesystemencoding.return_value = 'cp1252'
-
- # WHEN: get_filesystem_encoding() is called
- result = get_filesystem_encoding()
-
- # THEN: getdefaultencoding should have been called
- mocked_getfilesystemencoding.assert_called_with()
- self.assertEqual(0, mocked_getdefaultencoding.called, 'getdefaultencoding should not have been called')
- self.assertEqual('cp1252', result, 'The result should be "cp1252"')
-
- def get_filesystem_encoding_sys_function_is_called_test(self):
- """
- Test the get_filesystem_encoding() function calls the sys.getdefaultencoding() function
- """
- # GIVEN: sys.getfilesystemencoding returns None and sys.getdefaultencoding returns "utf-8"
- with patch('openlp.core.utils.sys.getfilesystemencoding') as mocked_getfilesystemencoding, \
- patch('openlp.core.utils.sys.getdefaultencoding') as mocked_getdefaultencoding:
- mocked_getfilesystemencoding.return_value = None
- mocked_getdefaultencoding.return_value = 'utf-8'
-
- # WHEN: get_filesystem_encoding() is called
- result = get_filesystem_encoding()
-
- # THEN: getdefaultencoding should have been called
- mocked_getfilesystemencoding.assert_called_with()
- mocked_getdefaultencoding.assert_called_with()
- self.assertEqual('utf-8', result, 'The result should be "utf-8"')
-
- def split_filename_with_file_path_test(self):
- """
- Test the split_filename() function with a path to a file
- """
- # GIVEN: A path to a file.
- if os.name == 'nt':
- file_path = 'C:\\home\\user\\myfile.txt'
- wanted_result = ('C:\\home\\user', 'myfile.txt')
- else:
- file_path = '/home/user/myfile.txt'
- wanted_result = ('/home/user', 'myfile.txt')
- with patch('openlp.core.utils.os.path.isfile') as mocked_is_file:
- mocked_is_file.return_value = True
-
- # WHEN: Split the file name.
- result = split_filename(file_path)
-
- # THEN: A tuple should be returned.
- self.assertEqual(wanted_result, result, 'A tuple with the dir and file name should have been returned')
-
- def split_filename_with_dir_path_test(self):
- """
- Test the split_filename() function with a path to a directory
- """
- # GIVEN: A path to a dir.
- if os.name == 'nt':
- file_path = 'C:\\home\\user\\mydir'
- wanted_result = ('C:\\home\\user\\mydir', '')
- else:
- file_path = '/home/user/mydir'
- wanted_result = ('/home/user/mydir', '')
- with patch('openlp.core.utils.os.path.isfile') as mocked_is_file:
- mocked_is_file.return_value = False
-
- # WHEN: Split the file name.
- result = split_filename(file_path)
-
- # THEN: A tuple should be returned.
- self.assertEqual(wanted_result, result,
- 'A two-entry tuple with the directory and file name (empty) should have been returned.')
-
- def clean_filename_test(self):
- """
- Test the clean_filename() function
- """
- # GIVEN: A invalid file name and the valid file name.
- invalid_name = 'A_file_with_invalid_characters_[\\/:\*\?"<>\|\+\[\]%].py'
- wanted_name = 'A_file_with_invalid_characters______________________.py'
-
- # WHEN: Clean the name.
- result = clean_filename(invalid_name)
-
- # THEN: The file name should be cleaned.
- self.assertEqual(wanted_name, result, 'The file name should not contain any special characters.')
-
- def delete_file_no_path_test(self):
- """
- Test the delete_file function when called with out a valid path
- """
- # GIVEN: A blank path
- # WEHN: Calling delete_file
- result = delete_file('')
-
- # THEN: delete_file should return False
- self.assertFalse(result, "delete_file should return False when called with ''")
-
- def delete_file_path_success_test(self):
- """
- Test the delete_file function when it successfully deletes a file
- """
- # GIVEN: A mocked os which returns True when os.path.exists is called
- with patch('openlp.core.utils.os', **{'path.exists.return_value': False}):
-
- # WHEN: Calling delete_file with a file path
- result = delete_file('path/file.ext')
-
- # THEN: delete_file should return True
- self.assertTrue(result, 'delete_file should return True when it successfully deletes a file')
-
- def delete_file_path_no_file_exists_test(self):
- """
- Test the delete_file function when the file to remove does not exist
- """
- # GIVEN: A mocked os which returns False when os.path.exists is called
- with patch('openlp.core.utils.os', **{'path.exists.return_value': False}):
-
- # WHEN: Calling delete_file with a file path
- result = delete_file('path/file.ext')
-
- # THEN: delete_file should return True
- self.assertTrue(result, 'delete_file should return True when the file doesnt exist')
-
- def delete_file_path_exception_test(self):
- """
- Test the delete_file function when os.remove raises an exception
- """
- # GIVEN: A mocked os which returns True when os.path.exists is called and raises an OSError when os.remove is
- # called.
- with patch('openlp.core.utils.os', **{'path.exists.return_value': True, 'path.exists.side_effect': OSError}), \
- patch('openlp.core.utils.log') as mocked_log:
-
- # WHEN: Calling delete_file with a file path
- result = delete_file('path/file.ext')
-
- # THEN: delete_file should log and exception and return False
- self.assertEqual(mocked_log.exception.call_count, 1)
- self.assertFalse(result, 'delete_file should return False when os.remove raises an OSError')
-
- def get_locale_key_test(self):
- """
- Test the get_locale_key(string) function
- """
- with patch('openlp.core.utils.languagemanager.LanguageManager.get_language') as mocked_get_language:
- # GIVEN: The language is German
- # 0x00C3 (A with diaresis) should be sorted as "A". 0x00DF (sharp s) should be sorted as "ss".
- mocked_get_language.return_value = 'de'
- unsorted_list = ['Auszug', 'Aushang', '\u00C4u\u00DFerung']
-
- # WHEN: We sort the list and use get_locale_key() to generate the sorting keys
- sorted_list = sorted(unsorted_list, key=get_locale_key)
-
- # THEN: We get a properly sorted list
- self.assertEqual(['Aushang', '\u00C4u\u00DFerung', 'Auszug'], sorted_list,
- 'Strings should be sorted properly')
-
- def get_natural_key_test(self):
- """
- Test the get_natural_key(string) function
- """
- with patch('openlp.core.utils.languagemanager.LanguageManager.get_language') as mocked_get_language:
- # GIVEN: The language is English (a language, which sorts digits before letters)
- mocked_get_language.return_value = 'en'
- unsorted_list = ['item 10a', 'item 3b', '1st item']
-
- # WHEN: We sort the list and use get_natural_key() to generate the sorting keys
- sorted_list = sorted(unsorted_list, key=get_natural_key)
-
- # THEN: We get a properly sorted list
- self.assertEqual(['1st item', 'item 3b', 'item 10a'], sorted_list, 'Numbers should be sorted naturally')
-
- def get_uno_instance_pipe_test(self):
- """
- Test that when the UNO connection type is "pipe" the resolver is given the "pipe" URI
- """
- # GIVEN: A mock resolver object and UNO_CONNECTION_TYPE is "pipe"
- mock_resolver = MagicMock()
-
- # WHEN: get_uno_instance() is called
- get_uno_instance(mock_resolver)
-
- # THEN: the resolve method is called with the correct argument
- mock_resolver.resolve.assert_called_with('uno:pipe,name=openlp_pipe;urp;StarOffice.ComponentContext')
-
- def get_uno_instance_socket_test(self):
- """
- Test that when the UNO connection type is other than "pipe" the resolver is given the "socket" URI
- """
- # GIVEN: A mock resolver object and UNO_CONNECTION_TYPE is "socket"
- mock_resolver = MagicMock()
-
- # WHEN: get_uno_instance() is called
- get_uno_instance(mock_resolver, 'socket')
-
- # THEN: the resolve method is called with the correct argument
- mock_resolver.resolve.assert_called_with('uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext')
-
- def get_user_agent_linux_test(self):
- """
- Test that getting a user agent on Linux returns a user agent suitable for Linux
- """
- with patch('openlp.core.utils.sys') as mocked_sys:
-
- # GIVEN: The system is Linux
- mocked_sys.platform = 'linux2'
-
- # WHEN: We call _get_user_agent()
- user_agent = _get_user_agent()
-
- # THEN: The user agent is a Linux (or ChromeOS) user agent
- result = 'Linux' in user_agent or 'CrOS' in user_agent
- self.assertTrue(result, 'The user agent should be a valid Linux user agent')
-
- def get_user_agent_windows_test(self):
- """
- Test that getting a user agent on Windows returns a user agent suitable for Windows
- """
- with patch('openlp.core.utils.sys') as mocked_sys:
-
- # GIVEN: The system is Linux
- mocked_sys.platform = 'win32'
-
- # WHEN: We call _get_user_agent()
- user_agent = _get_user_agent()
-
- # THEN: The user agent is a Linux (or ChromeOS) user agent
- self.assertIn('Windows', user_agent, 'The user agent should be a valid Windows user agent')
-
- def get_user_agent_macos_test(self):
- """
- Test that getting a user agent on OS X returns a user agent suitable for OS X
- """
- with patch('openlp.core.utils.sys') as mocked_sys:
-
- # GIVEN: The system is Linux
- mocked_sys.platform = 'darwin'
-
- # WHEN: We call _get_user_agent()
- user_agent = _get_user_agent()
-
- # THEN: The user agent is a Linux (or ChromeOS) user agent
- self.assertIn('Mac OS X', user_agent, 'The user agent should be a valid OS X user agent')
-
- def get_user_agent_default_test(self):
- """
- Test that getting a user agent on a non-Linux/Windows/OS X platform returns the default user agent
- """
- with patch('openlp.core.utils.sys') as mocked_sys:
-
- # GIVEN: The system is Linux
- mocked_sys.platform = 'freebsd'
-
- # WHEN: We call _get_user_agent()
- user_agent = _get_user_agent()
-
- # THEN: The user agent is a Linux (or ChromeOS) user agent
- self.assertIn('NetBSD', user_agent, 'The user agent should be the default user agent')
-
- def get_web_page_no_url_test(self):
- """
- Test that sending a URL of None to the get_web_page method returns None
- """
- # GIVEN: A None url
- test_url = None
-
- # WHEN: We try to get the test URL
- result = get_web_page(test_url)
-
- # THEN: None should be returned
- self.assertIsNone(result, 'The return value of get_web_page should be None')
-
- def get_web_page_test(self):
- """
- Test that the get_web_page method works correctly
- """
- with patch('openlp.core.utils.urllib.request.Request') as MockRequest, \
- patch('openlp.core.utils.urllib.request.urlopen') as mock_urlopen, \
- patch('openlp.core.utils._get_user_agent') as mock_get_user_agent, \
- patch('openlp.core.utils.Registry') as MockRegistry:
- # GIVEN: Mocked out objects and a fake URL
- mocked_request_object = MagicMock()
- MockRequest.return_value = mocked_request_object
- mocked_page_object = MagicMock()
- mock_urlopen.return_value = mocked_page_object
- mock_get_user_agent.return_value = 'user_agent'
- fake_url = 'this://is.a.fake/url'
-
- # WHEN: The get_web_page() method is called
- returned_page = get_web_page(fake_url)
-
- # THEN: The correct methods are called with the correct arguments and a web page is returned
- MockRequest.assert_called_with(fake_url)
- mocked_request_object.add_header.assert_called_with('User-Agent', 'user_agent')
- self.assertEqual(1, mocked_request_object.add_header.call_count,
- 'There should only be 1 call to add_header')
- mock_get_user_agent.assert_called_with()
- mock_urlopen.assert_called_with(mocked_request_object, timeout=30)
- mocked_page_object.geturl.assert_called_with()
- self.assertEqual(0, MockRegistry.call_count, 'The Registry() object should have never been called')
- self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object')
-
- def get_web_page_with_header_test(self):
- """
- Test that adding a header to the call to get_web_page() adds the header to the request
- """
- with patch('openlp.core.utils.urllib.request.Request') as MockRequest, \
- patch('openlp.core.utils.urllib.request.urlopen') as mock_urlopen, \
- patch('openlp.core.utils._get_user_agent') as mock_get_user_agent:
- # GIVEN: Mocked out objects, a fake URL and a fake header
- mocked_request_object = MagicMock()
- MockRequest.return_value = mocked_request_object
- mocked_page_object = MagicMock()
- mock_urlopen.return_value = mocked_page_object
- mock_get_user_agent.return_value = 'user_agent'
- fake_url = 'this://is.a.fake/url'
- fake_header = ('Fake-Header', 'fake value')
-
- # WHEN: The get_web_page() method is called
- returned_page = get_web_page(fake_url, header=fake_header)
-
- # THEN: The correct methods are called with the correct arguments and a web page is returned
- MockRequest.assert_called_with(fake_url)
- mocked_request_object.add_header.assert_called_with(fake_header[0], fake_header[1])
- self.assertEqual(2, mocked_request_object.add_header.call_count,
- 'There should only be 2 calls to add_header')
- mock_get_user_agent.assert_called_with()
- mock_urlopen.assert_called_with(mocked_request_object, timeout=30)
- mocked_page_object.geturl.assert_called_with()
- self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object')
-
- def get_web_page_with_user_agent_in_headers_test(self):
- """
- Test that adding a user agent in the header when calling get_web_page() adds that user agent to the request
- """
- with patch('openlp.core.utils.urllib.request.Request') as MockRequest, \
- patch('openlp.core.utils.urllib.request.urlopen') as mock_urlopen, \
- patch('openlp.core.utils._get_user_agent') as mock_get_user_agent:
- # GIVEN: Mocked out objects, a fake URL and a fake header
- mocked_request_object = MagicMock()
- MockRequest.return_value = mocked_request_object
- mocked_page_object = MagicMock()
- mock_urlopen.return_value = mocked_page_object
- fake_url = 'this://is.a.fake/url'
- user_agent_header = ('User-Agent', 'OpenLP/2.2.0')
-
- # WHEN: The get_web_page() method is called
- returned_page = get_web_page(fake_url, header=user_agent_header)
-
- # THEN: The correct methods are called with the correct arguments and a web page is returned
- MockRequest.assert_called_with(fake_url)
- mocked_request_object.add_header.assert_called_with(user_agent_header[0], user_agent_header[1])
- self.assertEqual(1, mocked_request_object.add_header.call_count,
- 'There should only be 1 call to add_header')
- self.assertEqual(0, mock_get_user_agent.call_count, '_get_user_agent should not have been called')
- mock_urlopen.assert_called_with(mocked_request_object, timeout=30)
- mocked_page_object.geturl.assert_called_with()
- self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object')
-
- def get_web_page_update_openlp_test(self):
- """
- Test that passing "update_openlp" as true to get_web_page calls Registry().get('app').process_events()
- """
- with patch('openlp.core.utils.urllib.request.Request') as MockRequest, \
- patch('openlp.core.utils.urllib.request.urlopen') as mock_urlopen, \
- patch('openlp.core.utils._get_user_agent') as mock_get_user_agent, \
- patch('openlp.core.utils.Registry') as MockRegistry:
- # GIVEN: Mocked out objects, a fake URL
- mocked_request_object = MagicMock()
- MockRequest.return_value = mocked_request_object
- mocked_page_object = MagicMock()
- mock_urlopen.return_value = mocked_page_object
- mock_get_user_agent.return_value = 'user_agent'
- mocked_registry_object = MagicMock()
- mocked_application_object = MagicMock()
- mocked_registry_object.get.return_value = mocked_application_object
- MockRegistry.return_value = mocked_registry_object
- fake_url = 'this://is.a.fake/url'
-
- # WHEN: The get_web_page() method is called
- returned_page = get_web_page(fake_url, update_openlp=True)
-
- # THEN: The correct methods are called with the correct arguments and a web page is returned
- MockRequest.assert_called_with(fake_url)
- mocked_request_object.add_header.assert_called_with('User-Agent', 'user_agent')
- self.assertEqual(1, mocked_request_object.add_header.call_count,
- 'There should only be 1 call to add_header')
- mock_urlopen.assert_called_with(mocked_request_object, timeout=30)
- mocked_page_object.geturl.assert_called_with()
- mocked_registry_object.get.assert_called_with('application')
- mocked_application_object.process_events.assert_called_with()
- self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object')
diff --git a/tests/functional/openlp_plugins/remotes/test_remotetab.py b/tests/functional/openlp_plugins/remotes/test_remotetab.py
index d541dc6e3..24375740f 100644
--- a/tests/functional/openlp_plugins/remotes/test_remotetab.py
+++ b/tests/functional/openlp_plugins/remotes/test_remotetab.py
@@ -99,7 +99,7 @@ class TestRemoteTab(TestCase, TestMixin):
"""
# GIVEN: A mocked location
with patch('openlp.core.common.Settings') as mocked_class, \
- patch('openlp.core.utils.AppLocation.get_directory') as mocked_get_directory, \
+ patch('openlp.core.common.applocation.AppLocation.get_directory') as mocked_get_directory, \
patch('openlp.core.common.check_directory_exists') as mocked_check_directory_exists, \
patch('openlp.core.common.applocation.os') as mocked_os:
# GIVEN: A mocked out Settings class and a mocked out AppLocation.get_directory()
@@ -127,7 +127,7 @@ class TestRemoteTab(TestCase, TestMixin):
"""
# GIVEN: A mocked location
with patch('openlp.core.common.Settings') as mocked_class, \
- patch('openlp.core.utils.AppLocation.get_directory') as mocked_get_directory, \
+ patch('openlp.core.common.applocation.AppLocation.get_directory') as mocked_get_directory, \
patch('openlp.core.common.check_directory_exists') as mocked_check_directory_exists, \
patch('openlp.core.common.applocation.os') as mocked_os:
# GIVEN: A mocked out Settings class and a mocked out AppLocation.get_directory()
diff --git a/tests/functional/openlp_plugins/songs/test_mediaitem.py b/tests/functional/openlp_plugins/songs/test_mediaitem.py
index 4ae15909b..3cd5f97ba 100644
--- a/tests/functional/openlp_plugins/songs/test_mediaitem.py
+++ b/tests/functional/openlp_plugins/songs/test_mediaitem.py
@@ -127,6 +127,38 @@ class TestMediaItem(TestCase, TestMixin):
mock_qlist_widget.setData.assert_called_with(MockedUserRole, mock_song.id)
self.media_item.list_view.addItem.assert_called_with(mock_qlist_widget)
+ def display_results_book_test(self):
+ """
+ Test displaying song search results grouped by book and entry with basic song
+ """
+ # GIVEN: Search results grouped by book and entry, plus a mocked QtListWidgetItem
+ with patch('openlp.core.lib.QtWidgets.QListWidgetItem') as MockedQListWidgetItem, \
+ patch('openlp.core.lib.QtCore.Qt.UserRole') as MockedUserRole:
+ mock_search_results = []
+ mock_songbook_entry = MagicMock()
+ mock_songbook = MagicMock()
+ mock_song = MagicMock()
+ mock_songbook_entry.entry = '1'
+ mock_songbook.name = 'My Book'
+ mock_song.id = 1
+ mock_song.title = 'My Song'
+ mock_song.sort_key = 'My Song'
+ mock_song.temporary = False
+ mock_songbook_entry.song = mock_song
+ mock_songbook_entry.songbook = mock_songbook
+ mock_search_results.append(mock_songbook_entry)
+ mock_qlist_widget = MagicMock()
+ MockedQListWidgetItem.return_value = mock_qlist_widget
+
+ # WHEN: I display song search results grouped by book
+ self.media_item.display_results_book(mock_search_results)
+
+ # THEN: The current list view is cleared, the widget is created, and the relevant attributes set
+ self.media_item.list_view.clear.assert_called_with()
+ MockedQListWidgetItem.assert_called_with('My Book #1: My Song')
+ mock_qlist_widget.setData.assert_called_with(MockedUserRole, mock_songbook_entry.song.id)
+ self.media_item.list_view.addItem.assert_called_with(mock_qlist_widget)
+
def display_results_topic_test(self):
"""
Test displaying song search results grouped by topic with basic song
@@ -416,19 +448,6 @@ class TestMediaItem(TestCase, TestMixin):
# THEN: They should not match
self.assertFalse(result, "Authors should not match")
- def natural_sort_key_test(self):
- """
- Test the _natural_sort_key function
- """
- # GIVEN: A string to be converted into a sort key
- string_sort_key = 'A1B12C'
-
- # WHEN: We attempt to create a sort key
- sort_key_result = self.media_item._natural_sort_key(string_sort_key)
-
- # THEN: We should get back a tuple split on integers
- self.assertEqual(sort_key_result, ['a', 1, 'b', 12, 'c'])
-
def build_remote_search_test(self):
"""
Test results for the remote search api
diff --git a/tests/functional/openlp_plugins/songs/test_propresenterimport.py b/tests/functional/openlp_plugins/songs/test_propresenterimport.py
index bb6cb2bf9..79735cdfe 100644
--- a/tests/functional/openlp_plugins/songs/test_propresenterimport.py
+++ b/tests/functional/openlp_plugins/songs/test_propresenterimport.py
@@ -39,9 +39,23 @@ class TestProPresenterFileImport(SongImportTestHelper):
self.importer_module_name = 'propresenter'
super(TestProPresenterFileImport, self).__init__(*args, **kwargs)
- def test_song_import(self):
+ def test_pro4_song_import(self):
"""
- Test that loading a ProPresenter file works correctly
+ Test that loading a ProPresenter 4 file works correctly
"""
self.file_import([os.path.join(TEST_PATH, 'Amazing Grace.pro4')],
self.load_external_result_data(os.path.join(TEST_PATH, 'Amazing Grace.json')))
+
+ def test_pro5_song_import(self):
+ """
+ Test that loading a ProPresenter 5 file works correctly
+ """
+ self.file_import([os.path.join(TEST_PATH, 'Amazing Grace.pro5')],
+ self.load_external_result_data(os.path.join(TEST_PATH, 'Amazing Grace.json')))
+
+ def test_pro6_song_import(self):
+ """
+ Test that loading a ProPresenter 6 file works correctly
+ """
+ self.file_import([os.path.join(TEST_PATH, 'Amazing Grace.pro6')],
+ self.load_external_result_data(os.path.join(TEST_PATH, 'Amazing Grace.json')))
diff --git a/tests/interfaces/openlp_core_utils/test_utils.py b/tests/interfaces/openlp_core_common/test_utils.py
similarity index 98%
rename from tests/interfaces/openlp_core_utils/test_utils.py
rename to tests/interfaces/openlp_core_common/test_utils.py
index 1124abbb7..2c0d9a572 100644
--- a/tests/interfaces/openlp_core_utils/test_utils.py
+++ b/tests/interfaces/openlp_core_common/test_utils.py
@@ -25,7 +25,7 @@ Functional tests to test the AppLocation class and related methods.
import os
from unittest import TestCase
-from openlp.core.utils import is_not_image_file
+from openlp.core.common import is_not_image_file
from tests.utils.constants import TEST_RESOURCES_PATH
from tests.helpers.testmixin import TestMixin
diff --git a/tests/interfaces/openlp_core_utils/__init__.py b/tests/interfaces/openlp_core_ui_lib/__init__.py
similarity index 100%
rename from tests/interfaces/openlp_core_utils/__init__.py
rename to tests/interfaces/openlp_core_ui_lib/__init__.py
diff --git a/tests/interfaces/openlp_core_common/test_historycombobox.py b/tests/interfaces/openlp_core_ui_lib/test_historycombobox.py
similarity index 96%
rename from tests/interfaces/openlp_core_common/test_historycombobox.py
rename to tests/interfaces/openlp_core_ui_lib/test_historycombobox.py
index 1bc7a2f0d..7aa460c8f 100644
--- a/tests/interfaces/openlp_core_common/test_historycombobox.py
+++ b/tests/interfaces/openlp_core_ui_lib/test_historycombobox.py
@@ -28,9 +28,8 @@ from unittest import TestCase
from PyQt5 import QtWidgets
from openlp.core.common import Registry
-from openlp.core.common import HistoryComboBox
+from openlp.core.ui.lib.historycombobox import HistoryComboBox
from tests.helpers.testmixin import TestMixin
-from tests.interfaces import MagicMock, patch
class TestHistoryComboBox(TestCase, TestMixin):
diff --git a/tests/resources/propresentersongs/Amazing Grace.pro5 b/tests/resources/propresentersongs/Amazing Grace.pro5
new file mode 100644
index 000000000..a19b51df9
--- /dev/null
+++ b/tests/resources/propresentersongs/Amazing Grace.pro5
@@ -0,0 +1,520 @@
+
+
+
+
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+ <_-RVRect3D-_position x="10" y="10" z="0" width="1004" height="748" />
+ <_-D-_serializedShadow containerClass="NSMutableDictionary">
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
+
+
+
+
+
+
+
+
+
+ <_-RVProTransitionObject-_transitionObject transitionType="-1" transitionDuration="1" motionEnabled="0" motionSpeed="0" />
+
\ No newline at end of file
diff --git a/tests/resources/propresentersongs/Amazing Grace.pro6 b/tests/resources/propresentersongs/Amazing Grace.pro6
new file mode 100644
index 000000000..d6eb39cbf
--- /dev/null
+++ b/tests/resources/propresentersongs/Amazing Grace.pro6
@@ -0,0 +1,490 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ QW1hemluZyBncmFjZSEgSG93IHN3ZWV0IHRoZSBzb3VuZA==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBBbWF6aW5nIGdyYWNlISBIb3cgc3dlZXQgdGhlIHNvdW5kfVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5BbWF6aW5nIGdyYWNlISBIb3cgc3dlZXQgdGhlIHNvdW5kPC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ VGhhdCBzYXZlZCBhIHdyZXRjaCBsaWtlIG1lIQ==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBUaGF0IHNhdmVkIGEgd3JldGNoIGxpa2UgbWUhfVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5UaGF0IHNhdmVkIGEgd3JldGNoIGxpa2UgbWUhPC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ SSBvbmNlIHdhcyBsb3N0LCBidXQgbm93IGFtIGZvdW5kOw==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBJIG9uY2Ugd2FzIGxvc3QsIGJ1dCBub3cgYW0gZm91bmQ7fVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5JIG9uY2Ugd2FzIGxvc3QsIGJ1dCBub3cgYW0gZm91bmQ7PC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ V2FzIGJsaW5kLCBidXQgbm93IEkgc2VlLg==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBXYXMgYmxpbmQsIGJ1dCBub3cgSSBzZWUufVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5XYXMgYmxpbmQsIGJ1dCBub3cgSSBzZWUuPC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ J1R3YXMgZ3JhY2UgdGhhdCB0YXVnaHQgbXkgaGVhcnQgdG8gZmVhciw=
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCAnVHdhcyBncmFjZSB0aGF0IHRhdWdodCBteSBoZWFydCB0byBmZWFyLH1cbGkwXHNhMFxzYjBcZmkwXHFjXHBhcn0NCn0NCn0=
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj4nVHdhcyBncmFjZSB0aGF0IHRhdWdodCBteSBoZWFydCB0byBmZWFyLDwvUnVuPjwvU3Bhbj48L1BhcmFncmFwaD48L0Zsb3dEb2N1bWVudD4=
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ QW5kIGdyYWNlIG15IGZlYXJzIHJlbGlldmVkOw==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBBbmQgZ3JhY2UgbXkgZmVhcnMgcmVsaWV2ZWQ7fVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5BbmQgZ3JhY2UgbXkgZmVhcnMgcmVsaWV2ZWQ7PC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ SG93IHByZWNpb3VzIGRpZCB0aGF0IGdyYWNlIGFwcGVhcg==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBIb3cgcHJlY2lvdXMgZGlkIHRoYXQgZ3JhY2UgYXBwZWFyfVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5Ib3cgcHJlY2lvdXMgZGlkIHRoYXQgZ3JhY2UgYXBwZWFyPC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ VGhlIGhvdXIgSSBmaXJzdCBiZWxpZXZlZC4=
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBUaGUgaG91ciBJIGZpcnN0IGJlbGlldmVkLn1cbGkwXHNhMFxzYjBcZmkwXHFjXHBhcn0NCn0NCn0=
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5UaGUgaG91ciBJIGZpcnN0IGJlbGlldmVkLjwvUnVuPjwvU3Bhbj48L1BhcmFncmFwaD48L0Zsb3dEb2N1bWVudD4=
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ VGhyb3VnaCBtYW55IGRhbmdlcnMsIHRvaWxzIGFuZCBzbmFyZXMs
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBUaHJvdWdoIG1hbnkgZGFuZ2VycywgdG9pbHMgYW5kIHNuYXJlcyx9XGxpMFxzYTBcc2IwXGZpMFxxY1xwYXJ9DQp9DQp9
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5UaHJvdWdoIG1hbnkgZGFuZ2VycywgdG9pbHMgYW5kIHNuYXJlcyw8L1J1bj48L1NwYW4+PC9QYXJhZ3JhcGg+PC9GbG93RG9jdW1lbnQ+
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ SSBoYXZlIGFscmVhZHkgY29tZTs=
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBJIGhhdmUgYWxyZWFkeSBjb21lO31cbGkwXHNhMFxzYjBcZmkwXHFjXHBhcn0NCn0NCn0=
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5JIGhhdmUgYWxyZWFkeSBjb21lOzwvUnVuPjwvU3Bhbj48L1BhcmFncmFwaD48L0Zsb3dEb2N1bWVudD4=
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ J1RpcyBncmFjZSBoYXRoIGJyb3VnaHQgbWUgc2FmZSB0aHVzIGZhciw=
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCAnVGlzIGdyYWNlIGhhdGggYnJvdWdodCBtZSBzYWZlIHRodXMgZmFyLH1cbGkwXHNhMFxzYjBcZmkwXHFjXHBhcn0NCn0NCn0=
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj4nVGlzIGdyYWNlIGhhdGggYnJvdWdodCBtZSBzYWZlIHRodXMgZmFyLDwvUnVuPjwvU3Bhbj48L1BhcmFncmFwaD48L0Zsb3dEb2N1bWVudD4=
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ QW5kIGdyYWNlIHdpbGwgbGVhZCBtZSBob21lLg==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBBbmQgZ3JhY2Ugd2lsbCBsZWFkIG1lIGhvbWUufVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5BbmQgZ3JhY2Ugd2lsbCBsZWFkIG1lIGhvbWUuPC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ VGhlIExvcmQgaGFzIHByb21pc2VkIGdvb2QgdG8gbWUs
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBUaGUgTG9yZCBoYXMgcHJvbWlzZWQgZ29vZCB0byBtZSx9XGxpMFxzYTBcc2IwXGZpMFxxY1xwYXJ9DQp9DQp9
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5UaGUgTG9yZCBoYXMgcHJvbWlzZWQgZ29vZCB0byBtZSw8L1J1bj48L1NwYW4+PC9QYXJhZ3JhcGg+PC9GbG93RG9jdW1lbnQ+
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ SGlzIFdvcmQgbXkgaG9wZSBzZWN1cmVzOw==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBIaXMgV29yZCBteSBob3BlIHNlY3VyZXM7fVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5IaXMgV29yZCBteSBob3BlIHNlY3VyZXM7PC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ SGUgd2lsbCBteSBTaGllbGQgYW5kIFBvcnRpb24gYmUs
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBIZSB3aWxsIG15IFNoaWVsZCBhbmQgUG9ydGlvbiBiZSx9XGxpMFxzYTBcc2IwXGZpMFxxY1xwYXJ9DQp9DQp9
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5IZSB3aWxsIG15IFNoaWVsZCBhbmQgUG9ydGlvbiBiZSw8L1J1bj48L1NwYW4+PC9QYXJhZ3JhcGg+PC9GbG93RG9jdW1lbnQ+
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ QXMgbG9uZyBhcyBsaWZlIGVuZHVyZXMu
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBBcyBsb25nIGFzIGxpZmUgZW5kdXJlcy59XGxpMFxzYTBcc2IwXGZpMFxxY1xwYXJ9DQp9DQp9
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5BcyBsb25nIGFzIGxpZmUgZW5kdXJlcy48L1J1bj48L1NwYW4+PC9QYXJhZ3JhcGg+PC9GbG93RG9jdW1lbnQ+
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ WWVhLCB3aGVuIHRoaXMgZmxlc2ggYW5kIGhlYXJ0IHNoYWxsIGZhaWws
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBZZWEsIHdoZW4gdGhpcyBmbGVzaCBhbmQgaGVhcnQgc2hhbGwgZmFpbCx9XGxpMFxzYTBcc2IwXGZpMFxxY1xwYXJ9DQp9DQp9
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5ZZWEsIHdoZW4gdGhpcyBmbGVzaCBhbmQgaGVhcnQgc2hhbGwgZmFpbCw8L1J1bj48L1NwYW4+PC9QYXJhZ3JhcGg+PC9GbG93RG9jdW1lbnQ+
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ QW5kIG1vcnRhbCBsaWZlIHNoYWxsIGNlYXNlLA==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBBbmQgbW9ydGFsIGxpZmUgc2hhbGwgY2Vhc2UsfVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5BbmQgbW9ydGFsIGxpZmUgc2hhbGwgY2Vhc2UsPC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ SSBzaGFsbCBwb3NzZXNzLCB3aXRoaW4gdGhlIHZlaWws
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBJIHNoYWxsIHBvc3Nlc3MsIHdpdGhpbiB0aGUgdmVpbCx9XGxpMFxzYTBcc2IwXGZpMFxxY1xwYXJ9DQp9DQp9
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5JIHNoYWxsIHBvc3Nlc3MsIHdpdGhpbiB0aGUgdmVpbCw8L1J1bj48L1NwYW4+PC9QYXJhZ3JhcGg+PC9GbG93RG9jdW1lbnQ+
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ QSBsaWZlIG9mIGpveSBhbmQgcGVhY2Uu
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBBIGxpZmUgb2Ygam95IGFuZCBwZWFjZS59XGxpMFxzYTBcc2IwXGZpMFxxY1xwYXJ9DQp9DQp9
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5BIGxpZmUgb2Ygam95IGFuZCBwZWFjZS48L1J1bj48L1NwYW4+PC9QYXJhZ3JhcGg+PC9GbG93RG9jdW1lbnQ+
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ VGhlIGVhcnRoIHNoYWxsIHNvb24gZGlzc29sdmUgbGlrZSBzbm93LA==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBUaGUgZWFydGggc2hhbGwgc29vbiBkaXNzb2x2ZSBsaWtlIHNub3csfVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5UaGUgZWFydGggc2hhbGwgc29vbiBkaXNzb2x2ZSBsaWtlIHNub3csPC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ VGhlIHN1biBmb3JiZWFyIHRvIHNoaW5lOw==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBUaGUgc3VuIGZvcmJlYXIgdG8gc2hpbmU7fVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5UaGUgc3VuIGZvcmJlYXIgdG8gc2hpbmU7PC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ QnV0IEdvZCwgV2hvIGNhbGxlZCBtZSBoZXJlIGJlbG93LA==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBCdXQgR29kLCBXaG8gY2FsbGVkIG1lIGhlcmUgYmVsb3csfVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5CdXQgR29kLCBXaG8gY2FsbGVkIG1lIGhlcmUgYmVsb3csPC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ U2hhbGwgYmUgZm9yZXZlciBtaW5lLg==
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBTaGFsbCBiZSBmb3JldmVyIG1pbmUufVxsaTBcc2EwXHNiMFxmaTBccWNccGFyfQ0KfQ0KfQ==
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5TaGFsbCBiZSBmb3JldmVyIG1pbmUuPC9SdW4+PC9TcGFuPjwvUGFyYWdyYXBoPjwvRmxvd0RvY3VtZW50Pg==
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ V2hlbiB3ZSd2ZSBiZWVuIHRoZXJlIHRlbiB0aG91c2FuZCB5ZWFycyw=
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBXaGVuIHdlJ3ZlIGJlZW4gdGhlcmUgdGVuIHRob3VzYW5kIHllYXJzLH1cbGkwXHNhMFxzYjBcZmkwXHFjXHBhcn0NCn0NCn0=
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5XaGVuIHdlJ3ZlIGJlZW4gdGhlcmUgdGVuIHRob3VzYW5kIHllYXJzLDwvUnVuPjwvU3Bhbj48L1BhcmFncmFwaD48L0Zsb3dEb2N1bWVudD4=
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ QnJpZ2h0IHNoaW5pbmcgYXMgdGhlIHN1biw=
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBCcmlnaHQgc2hpbmluZyBhcyB0aGUgc3VuLH1cbGkwXHNhMFxzYjBcZmkwXHFjXHBhcn0NCn0NCn0=
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5CcmlnaHQgc2hpbmluZyBhcyB0aGUgc3VuLDwvUnVuPjwvU3Bhbj48L1BhcmFncmFwaD48L0Zsb3dEb2N1bWVudD4=
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ V2UndmUgbm8gbGVzcyBkYXlzIHRvIHNpbmcgR29kJ3MgcHJhaXNl
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBXZSd2ZSBubyBsZXNzIGRheXMgdG8gc2luZyBHb2QncyBwcmFpc2V9XGxpMFxzYTBcc2IwXGZpMFxxY1xwYXJ9DQp9DQp9
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5XZSd2ZSBubyBsZXNzIGRheXMgdG8gc2luZyBHb2QncyBwcmFpc2U8L1J1bj48L1NwYW4+PC9QYXJhZ3JhcGg+PC9GbG93RG9jdW1lbnQ+
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+ {10 10 0 1004 748}
+ 0|0 0 0 0|{0, 0}
+
+ 0 0 0 0
+ 0
+
+ VGhhbiB3aGVuIHdlJ2QgZmlyc3QgYmVndW4u
+ e1xydGYxXHByb3J0ZjFcYW5zaVxhbnNpY3BnMTI1Mlx1YzFcaHRtYXV0c3BcZGVmZjJ7XGZvbnR0Ymx7XGYwXGZjaGFyc2V0MCBUaW1lcyBOZXcgUm9tYW47fXtcZjJcZmNoYXJzZXQwIEdlb3JnaWE7fXtcZjNcZmNoYXJzZXQwIEhlbHZldGljYTt9fXtcY29sb3J0Ymw7XHJlZDBcZ3JlZW4wXGJsdWUwO1xyZWQyNTVcZ3JlZW4yNTVcYmx1ZTI1NTt9XGxvY2hcaGljaFxkYmNoXHBhcmRcc2xsZWFkaW5nMFxwbGFpblxsdHJwYXJcaXRhcDB7XGxhbmcxMDMzXGZzMTIwXGYzXGNmMSBcY2YxXHFse1xmMyB7XGNmMlxsdHJjaCBUaGFuIHdoZW4gd2UnZCBmaXJzdCBiZWd1bi59XGxpMFxzYTBcc2IwXGZpMFxxY1xwYXJ9DQp9DQp9
+ PEZsb3dEb2N1bWVudCBUZXh0QWxpZ25tZW50PSJMZWZ0IiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwvcHJlc2VudGF0aW9uIj48UGFyYWdyYXBoIE1hcmdpbj0iMCwwLDAsMCIgVGV4dEFsaWdubWVudD0iQ2VudGVyIiBGb250RmFtaWx5PSJIZWx2ZXRpY2EiIEZvbnRTaXplPSI2MCI+PFNwYW4gRm9yZWdyb3VuZD0iI0ZGRkZGRkZGIiB4bWw6bGFuZz0iZW4tdXMiPjxSdW4gQmxvY2suVGV4dEFsaWdubWVudD0iQ2VudGVyIj5UaGFuIHdoZW4gd2UnZCBmaXJzdCBiZWd1bi48L1J1bj48L1NwYW4+PC9QYXJhZ3JhcGg+PC9GbG93RG9jdW1lbnQ+
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+PFJWRm9udCB4bWxuczppPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9Qcm9QcmVzZW50ZXIuQ29tbW9uIj48S2VybmluZz4wPC9LZXJuaW5nPjxMaW5lU3BhY2luZz4wPC9MaW5lU3BhY2luZz48T3V0bGluZUNvbG9yIHhtbG5zOmQycDE9Imh0dHA6Ly9zY2hlbWFzLmRhdGFjb250cmFjdC5vcmcvMjAwNC8wNy9TeXN0ZW0uV2luZG93cy5NZWRpYSI+PGQycDE6QT4wPC9kMnAxOkE+PGQycDE6Qj4wPC9kMnAxOkI+PGQycDE6Rz4wPC9kMnAxOkc+PGQycDE6Uj4wPC9kMnAxOlI+PGQycDE6U2NBPjA8L2QycDE6U2NBPjxkMnAxOlNjQj4wPC9kMnAxOlNjQj48ZDJwMTpTY0c+MDwvZDJwMTpTY0c+PGQycDE6U2NSPjA8L2QycDE6U2NSPjwvT3V0bGluZUNvbG9yPjxPdXRsaW5lV2lkdGg+MDwvT3V0bGluZVdpZHRoPjxWYXJpYW50cz5Ob3JtYWw8L1ZhcmlhbnRzPjwvUlZGb250Pg==
+
+
+
+
+
+
+
+
\ No newline at end of file