WereSync-1.0.9/0000755000175000017500000000000013315166025013774 5ustar danieldaniel00000000000000WereSync-1.0.9/PKG-INFO0000644000175000017500000001557413315166025015105 0ustar danieldaniel00000000000000Metadata-Version: 1.0 Name: WereSync Version: 1.0.9 Summary: Incrementally clones Linux drives Home-page: https://github.com/DonyorM/weresync Author: Daniel Manila Author-email: dmv@springwater7.org License: Apache 2.0 Description: ######## WereSync ######## `Installation <#installation>`__ | `Basic Usage <#basic-usage>`__ | `Documentation `__ | `Contributing <#contributing-and-bug-reports>`__ .. image:: https://github.com/DonyorM/weresync/raw/master/docs/source/img/weresync-logo.png :align: center :alt: WereSync Logo A lone hard drive stands atop a data heap, staring at the full moon. Suddenly, it transforms...into a bootable clone of your drive, whirring hungrily at the digital moon. WereSync takes a Linux hard drive and effectively clones it, but works incrementally so you don't have to spend so long backing up each time. Additionally, WereSync can clone to a smaller drive, if your data will fit on the smaller drive. Because WereSync uses rsync to copy, it can copy a running drive, though certain parts of state may not be preserved. Why Use WereSync? ================= Hopefully, you think this project looks amazing and you want to try it right away. However, you may be skeptical about the usefulness of WereSync. You may be thinking, I can do this exact same thing using gparted or ddrescue. Hear me out! There are a few reasons to use WereSync over the other tools. - **WereSync is accessible to less-technical users.** It comes with a simple interface and clone a drive with a single command while your computer is running. No booting to a live disk or pushing through a long initiation process. Unlike `dd` or CloneZilla, WereSync requires a low level of technical skill and has an easy learning curve - WereSync can run while the your main drive is being used, instead of blocking your computer up for hours at a time - WereSync will incrementally update clones, making subsequent clones much faster. - WereSync works quickly, a single command copies your entire drive, no booting to live CDs or managing MBRs. - WereSync can copy to a smaller drive, provided your drive's data will fit. - WereSync creates new UUIDs for the new partitions, allowing you to use the old and new drives alongside each other. Full documentation may be found `here `__. Installation ============ WereSync can be installed using the `setup.py` file. .. code-block:: bash $ ./setup.py install If you have `pip `__ installed, you can easily install WereSync with the following command:: $ pip install weresync For more in-depth instructions, see the `installation documentation `__. Basic Usage =========== **Note:** WereSync requires root capabilities to run because it has to access block devices. The gui can be launched with the command:: $ sudo weresync-gui Which generates the following GUI, though generally the advanced options are unneeded: .. image:: https://github.com/DonyorM/weresync/raw/master/docs/source/img/gui-example.png :align: left :alt: Picture of WereSync GUI To see the options for the terminal command use:: $ weresync -h To copy from /dev/sda to /dev/sdb (the two drives must have the same partition scheme) use:: $ sudo weresync /dev/sda /dev/sdb For more information, including how to copy the partition table from drive to another, see the `Basic Usage `__ documentation page. Documentation ============= Documentation can be found on the `Read the Docs `__. Contributing and Bug Reports ============================ First, take a look at our `contribution guidelines `__. To contribute simply fork this repository, make your changes, and submit a pull request. Bugs can be reported on the `issue tracker `__ WereSync currently has huge need of people testing the program on complex drive setups. In order to do this please: 1. Install WereSync from pip:: pip install weresync #. Run it on your system:: sudo weresync -C source_drive target_drive #. Report any errors to the `issue tracker `__. Please be sure to post the contents of ``/var/log/weresync/weresync.log`` and ``fdisk -l``. All contributions will be greatly appreciated! Distributions Capability for Drive Copying ------------------------------------------ |ubuntu| |debian| |arch| |centos| |fedora| |opensuse| .. |ubuntu| image:: https://img.shields.io/badge/ubuntu-stable-brightgreen.svg .. |arch| image:: https://img.shields.io/badge/Arch%20Linux-stable-brightgreen.svg .. |centos| image:: https://img.shields.io/badge/CentOS-not%20tested-red.svg .. |fedora| image:: https://img.shields.io/badge/Fedora-not%20tested-red.svg .. |opensuse| image:: https://img.shields.io/badge/openSUSE-not%20tested-red.svg .. |debian| image:: https://img.shields.io/badge/Debian-stable-brightgreen.svg If you are able to test any of these systems, please report your exprience at the `issue tracker `__. Any help will be much appreciated. Licensing ========= This project is licensed under the `Apache 2.0 License `__. Licensing is in the **LICENSE.txt** file in this directory. Acknowledgments =============== Huge thanks to the creators of: * `rsync `__, whose software allowed this project to be possible. * `GNU Parted `__ * And `GPT fdisk `__ Keywords: clone,linux,backup,smaller drive Platform: UNKNOWN WereSync-1.0.9/README.rst0000644000175000017500000001276613120341354015471 0ustar danieldaniel00000000000000######## WereSync ######## `Installation <#installation>`__ | `Basic Usage <#basic-usage>`__ | `Documentation `__ | `Contributing <#contributing-and-bug-reports>`__ .. image:: https://github.com/DonyorM/weresync/raw/master/docs/source/img/weresync-logo.png :align: center :alt: WereSync Logo A lone hard drive stands atop a data heap, staring at the full moon. Suddenly, it transforms...into a bootable clone of your drive, whirring hungrily at the digital moon. WereSync takes a Linux hard drive and effectively clones it, but works incrementally so you don't have to spend so long backing up each time. Additionally, WereSync can clone to a smaller drive, if your data will fit on the smaller drive. Because WereSync uses rsync to copy, it can copy a running drive, though certain parts of state may not be preserved. Why Use WereSync? ================= Hopefully, you think this project looks amazing and you want to try it right away. However, you may be skeptical about the usefulness of WereSync. You may be thinking, I can do this exact same thing using gparted or ddrescue. Hear me out! There are a few reasons to use WereSync over the other tools. - **WereSync is accessible to less-technical users.** It comes with a simple interface and clone a drive with a single command while your computer is running. No booting to a live disk or pushing through a long initiation process. Unlike `dd` or CloneZilla, WereSync requires a low level of technical skill and has an easy learning curve - WereSync can run while the your main drive is being used, instead of blocking your computer up for hours at a time - WereSync will incrementally update clones, making subsequent clones much faster. - WereSync works quickly, a single command copies your entire drive, no booting to live CDs or managing MBRs. - WereSync can copy to a smaller drive, provided your drive's data will fit. - WereSync creates new UUIDs for the new partitions, allowing you to use the old and new drives alongside each other. Full documentation may be found `here `__. Installation ============ WereSync can be installed using the `setup.py` file. .. code-block:: bash $ ./setup.py install If you have `pip `__ installed, you can easily install WereSync with the following command:: $ pip install weresync For more in-depth instructions, see the `installation documentation `__. Basic Usage =========== **Note:** WereSync requires root capabilities to run because it has to access block devices. The gui can be launched with the command:: $ sudo weresync-gui Which generates the following GUI, though generally the advanced options are unneeded: .. image:: https://github.com/DonyorM/weresync/raw/master/docs/source/img/gui-example.png :align: left :alt: Picture of WereSync GUI To see the options for the terminal command use:: $ weresync -h To copy from /dev/sda to /dev/sdb (the two drives must have the same partition scheme) use:: $ sudo weresync /dev/sda /dev/sdb For more information, including how to copy the partition table from drive to another, see the `Basic Usage `__ documentation page. Documentation ============= Documentation can be found on the `Read the Docs `__. Contributing and Bug Reports ============================ First, take a look at our `contribution guidelines `__. To contribute simply fork this repository, make your changes, and submit a pull request. Bugs can be reported on the `issue tracker `__ WereSync currently has huge need of people testing the program on complex drive setups. In order to do this please: 1. Install WereSync from pip:: pip install weresync #. Run it on your system:: sudo weresync -C source_drive target_drive #. Report any errors to the `issue tracker `__. Please be sure to post the contents of ``/var/log/weresync/weresync.log`` and ``fdisk -l``. All contributions will be greatly appreciated! Distributions Capability for Drive Copying ------------------------------------------ |ubuntu| |debian| |arch| |centos| |fedora| |opensuse| .. |ubuntu| image:: https://img.shields.io/badge/ubuntu-stable-brightgreen.svg .. |arch| image:: https://img.shields.io/badge/Arch%20Linux-stable-brightgreen.svg .. |centos| image:: https://img.shields.io/badge/CentOS-not%20tested-red.svg .. |fedora| image:: https://img.shields.io/badge/Fedora-not%20tested-red.svg .. |opensuse| image:: https://img.shields.io/badge/openSUSE-not%20tested-red.svg .. |debian| image:: https://img.shields.io/badge/Debian-stable-brightgreen.svg If you are able to test any of these systems, please report your exprience at the `issue tracker `__. Any help will be much appreciated. Licensing ========= This project is licensed under the `Apache 2.0 License `__. Licensing is in the **LICENSE.txt** file in this directory. Acknowledgments =============== Huge thanks to the creators of: * `rsync `__, whose software allowed this project to be possible. * `GNU Parted `__ * And `GPT fdisk `__ WereSync-1.0.9/setup.cfg0000644000175000017500000000044713315166025015622 0ustar danieldaniel00000000000000[tool:pytest] testpaths = tests [metadata] license_file = LICENSE.txt [build_sphinx] source-dir = docs/source build-dir = docs/build all_files = 1 [upload_sphinx] upload-dir = docs/build/html [flake8] ignore = W503, builtins = _ [egg_info] tag_build = tag_date = 0 tag_svn_revision = 0 WereSync-1.0.9/LICENSE.txt0000644000175000017500000002170713006623417015626 0ustar danieldaniel00000000000000Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS WereSync-1.0.9/MANIFEST.in0000644000175000017500000000067113131127717015537 0ustar danieldaniel00000000000000include README.rst include LICENSE.txt include COPYING include AUTHORS include docs/Makefile recursive-include src *.py recursive-include tests *.py recursive-include docs *.rst recursive-include docs *.py recursive-include docs *.inc recursive-include docs/source *.html recursive-include docs/source *.png recursive-include src *.svg recursive-include src *.png recursive-include src *.po include src/weresync/resources/locale/weresync.potWereSync-1.0.9/setup.py0000775000175000017500000000472113314722051015513 0ustar danieldaniel00000000000000#! /usr/bin/env python3 import os from setuptools import setup, find_packages import subprocess import shutil class InvalidSetupError(Exception): pass def create_mo_files(): """Converts .po templates to readble .mo files using msgfmt.""" # Avoids this code running on read the docs, since gettext is not installed # there if os.environ.get("READTHEDOCS") == "True": return [] if shutil.which("msgfmt") is None: # If gettext isn't installed, skip this raise InvalidSetupError("gettext not installed but is required.") localedir = 'src/weresync/resources/locale' po_dirs = [] langs = next(os.walk(localedir))[1] po_dirs = [localedir + '/' + l + '/LC_MESSAGES/' for l in langs] for d in po_dirs: po_files = [f for f in next(os.walk(d))[2] if os.path.splitext(f)[1] == '.po'] for po_file in po_files: filename, extension = os.path.splitext(po_file) mo_file = filename + '.mo' msgfmt_cmd = 'msgfmt {} -o {}'.format(d + po_file, d + mo_file) subprocess.call(msgfmt_cmd, shell=True) return ["locale/" + l + "/LC_MESSAGES/*.mo" for l in langs] def read(fname): with open(os.path.join(os.path.dirname(__file__), fname)) as file: return file.read() target_icon_loc = "share/icons/hicolor/scalable/apps" if os.getuid() == 0: # Install is running as root target_icon_loc = "/usr/" + target_icon_loc if __name__ == "__main__": setup( name="WereSync", version="1.0.9", package_dir={"": "src"}, packages=find_packages("src"), install_requires=["parse>=1.6.6", "yapsy>=1.11.223"], entry_points={ 'console_scripts': [ "weresync = weresync.interface:main" ], 'gui_scripts': [ "weresync-gui = weresync.gui:start_gui" ] }, package_data={ "weresync.resources": ["*.svg", "*.png"] + create_mo_files() }, data_files=[(target_icon_loc, ["src/weresync/resources/weresync.svg"])], # Metadata author="Daniel Manila", author_email="dmv@springwater7.org", description="Incrementally clones Linux drives", long_description=read("README.rst"), license="Apache 2.0", keywords="clone, linux, backup, smaller drive", url="https://github.com/DonyorM/weresync", ) WereSync-1.0.9/tests/0000755000175000017500000000000013315166025015136 5ustar danieldaniel00000000000000WereSync-1.0.9/tests/test_device.py0000644000175000017500000006155513216442734020026 0ustar danieldaniel00000000000000# Copyright 2016 Daniel Manila # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # flake8: noqa import sys import os myPath = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, myPath + "/../src/") import pytest import unittest.mock as mock import weresync.device as device from weresync.exception import DeviceError, UnsupportedDeviceError def generateStandardMock(monkeypatch, return_value_output, return_value_error, return_code, type="gpt"): """Generates a mock for the Popen class that allows easy testing of device methods that use Popen.""" mock_popen = mock.MagicMock() if return_value_error != None: return_value_output += return_value_error # Simulates combining the stdout and stderr mock_popen.communicate.return_value = (return_value_output, None) mock_popen.returncode = return_code def popen_constructor(*args, **kargs): return mock_popen def mock_table_type(*args, **kargs): return type monkeypatch.setattr("subprocess.Popen", popen_constructor) if type != None: monkeypatch.setattr( "weresync.device.DeviceManager.get_partition_table_type", mock_table_type) def test_get_partitions_valid(monkeypatch): generateStandardMock(monkeypatch, b"""Model: Unknown (unknown) Disk /dev/nbd0: 8590MB Sector size (logical/physical): 512B/512B Partition Table: gpt Number Start End Size File system Name Flags 4 1049kB 500MB 499MB bios_grub 1 500MB 6000MB 5500MB ext4 2 6000MB 7400MB 1400MB ext4 3 7400MB 8589MB 1189MB linux-swap(v1)\n """, None, 0) # standard return from sgdisk -p manager = device.DeviceManager("/dev/sdd") result = manager.get_partitions() assert result == [4, 1, 2, 3] def test_get_partitions_none_zero_returncode(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 1) manager = device.DeviceManager("/dev/sda") with pytest.raises(DeviceError) as execinfo: manager.get_partitions() assert "Error." in str(execinfo.value) def test_get_partitions_no_partitions(monkeypatch): generateStandardMock(monkeypatch, b"Nope\nvery\nvery\nbad\ndata", None, 0) manager = device.DeviceManager("/dev/sda") result = manager.get_partitions() assert result == [] def test_mount_point_normal(monkeypatch): generateStandardMock(monkeypatch, b"""TARGET SOURCE FSTYPE OPTIONS /mnt /dev/sda11 fuseblk rw,nosuid,nodev,relatime,user_id=0,group_id=0,def """, None, 0) manager = device.DeviceManager("/dev/sda") result = manager.mount_point(3) assert "/mnt" == result def test_mount_point_non_zero_return_code(monkeypatch): generateStandardMock(monkeypatch, b"""TARGET SOURCE FSTYPE OPTIONS\n /mnt /dev/sda11 fuseblk rw,nosuid,nodev,relatime,user_id=0,group_id=0,def\n """, b"Error.", 2) manager = device.DeviceManager("/dev/sda") with pytest.raises(DeviceError) as execinfo: result = manager.mount_point(5) assert "Error." in str(execinfo.value) def test_mount_point_no_mount_point(monkeypatch): generateStandardMock(monkeypatch, b"", None, 1) # findmnt returns 1 when there is no mount point manager = device.DeviceManager("/dev/sda") result = manager.mount_point(5) assert result == None def test_mount_partition(monkeypatch): generateStandardMock(monkeypatch, b"", None, 0) manager = device.DeviceManager("/dev/sda") manager.mount_partition(3, "/mnt") def test_mount_partition_non_zero_return_code(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 1) manager = device.DeviceManager("/dev/sda") with pytest.raises(DeviceError) as execinfo: manager.mount_partition(3, "/mnt") assert "Error." in str(execinfo.value) def test_unmount_partition(monkeypatch): generateStandardMock(monkeypatch, b"", b"", 0) manager = device.DeviceManager("/dev/sda") manager.unmount_partition(5) def test_unmount_partition_non_zero(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 1) manager = device.DeviceManager("/dev/sda") with pytest.raises(DeviceError) as execinfo: manager.unmount_partition(5) assert "Error." in str(execinfo.value) def test_get_partition_table_type_gpt(monkeypatch): generateStandardMock(monkeypatch, b"""/dev/sda: gpt partitions 1 2 3 4 5 11 8 9 10 6 7""", b"", 0, None) manager = device.DeviceManager("/dev/sda") result = manager.get_partition_table_type() assert "gpt" == result def test_get_partition_table_type_non_zero_return_code(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 1, None) manager = device.DeviceManager("/dev/sda") with pytest.raises(DeviceError) as execinfo: manager.get_partition_table_type() assert "Error." in str(execinfo.value) def test_get_partition_table_type_mbr(monkeypatch): generateStandardMock(monkeypatch, b"""/dev/sda: msdos partitions 1 2 3 4 5 11 8 9 10 6 7""", b"", 0, None) manager = device.DeviceManager("mbr.img") result = manager.get_partition_table_type() assert result == "msdos" def test_get_partition_table_type_unsupported(monkeypatch): generateStandardMock(monkeypatch, b""" dddd """, b"", 0, None) manager = device.DeviceManager("/dev/sda") with pytest.raises(UnsupportedDeviceError) as execinfo: manager.get_partition_table_type() assert "Partition table type of /dev/sda not supported" in str(execinfo) def test_get_drive_size(monkeypatch): generateStandardMock(monkeypatch, b"192", b"", 0) manager = device.DeviceManager("/dev/sda") result = manager.get_drive_size() assert 192 == result def test_get_drive_size_non_zero_return_code(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 1) manager = device.DeviceManager("/dev/sda") with pytest.raises(DeviceError) as execinfo: manager.get_drive_size() assert "Error." in str(execinfo.value) def test_get_drive_size_bytes(monkeypatch): generateStandardMock(monkeypatch, b"190", b"", 0) manager = device.DeviceManager("/dev/sda") result = manager.get_drive_size_bytes() assert 190 == result def test_get_drive_size_bytes_non_zero_return_code(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 1) manager = device.DeviceManager("/dev/sda") with pytest.raises(DeviceError) as execinfo: manager.get_drive_size() assert "Error." in str(execinfo.value) def test_get_partition_used(monkeypatch): generateStandardMock( monkeypatch, b"/dev/sda11 676276220 179697120 496579100 27% /media/Data", b"", 0) manager = device.DeviceManager("/dev/sda") result = manager.get_partition_used(5) assert 179697120 == result def test_get_partition_used_non_zero_return(monkeypatch): generateStandardMock(monkeypatch, b" ", b"Error.", 1) manager = device.DeviceManager("/dev/sda") with pytest.raises(DeviceError) as execinfo: manager.get_partition_used(4) assert "Error." in str(execinfo.value) def test_get_drive_empty_space(monkeypatch): generateStandardMock(monkeypatch, b"""Disk gpt.img: 1024000 sectors, 500.0 MiB Logical sector size: 512 bytes Disk identifier (GUID): 6FCE9962-D7B0-4BF3-B7BC-5E5CE8A5B0B0 Partition table holds up to 128 entries First usable sector is 34, last usable sector is 1023966 Partitions will be aligned on 2-sector boundaries Total free space is 647 sectors (323.5 KiB) Number Start (sector) End (sector) Size Code Name 1 34 97656 47.7 MiB 8300 test 2 98304 145407 23.0 MiB 8300 cool 3 145408 391167 120.0 MiB 8300 nice 4 391168 684031 143.0 MiB 8300 great 5 684032 976895 143.0 MiB 8300 sweet 6 976896 1023966 23.0 MiB 8300 """, b"", 0) manager = device.DeviceManager("gpt.img") result = manager.get_empty_space() assert result == 34 def test_get_empty_space_non_zero_return(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 2) manager = device.DeviceManager("gpt.img") with pytest.raises(DeviceError) as execinfo: manager.get_empty_space() assert "Error." in str(execinfo.value) def test_get_empty_space_mbr(monkeypatch): generateStandardMock(monkeypatch, b"""Disk mbr.img: 524 MB, 524288000 bytes 63 heads, 37 sectors/track, 439 cylinders, total 1024000 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disk identifier: 0xd9e3a78c Device Boot Start End Blocks Id System mbr.img1 2050 3942 946+ 83 Linux """, b"", 0, "msdos") manager = device.DeviceManager("mbr.img") result = manager.get_empty_space() assert result == 1020058 def test_get_empty_space_mbr_boot(monkeypatch): generateStandardMock(monkeypatch, b"""Disk mbr.img: 524 MB, 524288000 bytes 63 heads, 37 sectors/track, 439 cylinders, total 1024000 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disk identifier: 0xd9e3a78c Device Boot Start End Blocks Id System mbr.img1 2050 3942 946+ 83 Linux Disk mbr.img: 524 MB, 524288000 bytes 255 heads, 63 sectors/track, 63 cylinders, total 1024000 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disk identifier: 0xd9e3a78c Device Boot Start End Blocks Id System mbr.img1 2050 3942 946+ 83 Linux mbr.img2 2048 2049 1 83 Linux mbr.img3 3943 255846 125952 5 Extended mbr.img5 5991 104244 49127 83 Linux mbr.img6 * 106293 255846 74777 83 Linux """, b"", 0, "msdos") manager = device.DeviceManager("mbr.img") result = manager.get_empty_space() def test_get_partition_size(monkeypatch): generateStandardMock(monkeypatch, b"""Disk /dev/loop0: 1024000 sectors, 500.0 MiB Logical sector size: 512 bytes Disk identifier (GUID): 4EB07926-DFE2-4D18-A2F4-75FB23616F71 Partition table holds up to 128 entries First usable sector is 34, last usable sector is 1023966 Partitions will be aligned on 2048-sector boundaries Total free space is 2014 sectors (1007.0 KiB) Number Start (sector) End (sector) Size Code Name 1 2048 309247 150.0 MiB 8300 Linux filesystem 2 309248 821247 250.0 MiB 8300 Linux filesystem 3 821248 972799 74.0 MiB 8300 Linux filesystem 4 972800 1019903 23.0 MiB 8300 Linux filesystem 5 1019904 1023966 2.0 MiB 8300 Linux filesystem """, None, 0) monkeypatch.setattr( "weresync.device.DeviceManager.get_partition_table_type", lambda x: "gpt") manager = device.DeviceManager("gpt.img") result = manager.get_partition_size(5) assert 4062 == result def test_get_partition_size_non_zero_return_code(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 2) manager = device.DeviceManager("gpt.img") with pytest.raises(DeviceError) as execinfo: manager.get_partition_size(1) assert "Error." in str(execinfo) def test_get_partition_size_mbr_non_zero_return_code(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 2, "msdos") manager = device.DeviceManager("mbr.img") with pytest.raises(DeviceError) as execinfo: manager.get_partition_size(3) assert "Error." in str(execinfo) def test_get_partition_size_mbr(monkeypatch): generateStandardMock(monkeypatch, b"204800", b"", 0, "msdos") manager = device.DeviceManager("mbr.img") result = manager.get_partition_size(4) assert result == 204800 def test_get_partition_size_unknown_table_type(monkeypatch): generateStandardMock(monkeypatch, b"", b"", 0, "blah") manager = device.DeviceManager("blah.img") with pytest.raises(ValueError) as execinfo: manager.get_partition_size(5) assert "Unsupported" in str(execinfo) def test_get_sector_alignment_number(monkeypatch): generateStandardMock(monkeypatch, b"""Disk /dev/loop1: 512000 sectors, 250.0 MiB Logical sector size: 512 bytes Disk identifier (GUID): 4EB07926-DFE2-4D18-A2F4-75FB23616F71 Partition table holds up to 128 entries First usable sector is 34, last usable sector is 511966 Partitions will be aligned on 2048-sector boundaries Total free space is 5558 sectors (2.7 MiB) Number Start (sector) End (sector) Size Code Name 1 2048 309247 150.0 MiB 8300 Linux filesystem 2 309248 508422 97.3 MiB 8300 Linux filesystem """, b"", 0) manager = device.DeviceManager("gpt.img") result = manager.get_partition_alignment() assert result == 2048 def test_get_sector_alignment_number_non_zero_return(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 1) manager = device.DeviceManager("gpt.img") with pytest.raises(DeviceError) as execinfo: result = manager.get_partition_alignment() assert "Error." in str(execinfo.value) def test_get_sector_alignment_number_invalid_return(monkeypatch): generateStandardMock(monkeypatch, b"No alignment", b"", 0) manager = device.DeviceManager("gpt.img") with pytest.raises(DeviceError) as execinfo: result = manager.get_partition_alignment() def test_get_partition_alignment_msdos(monkeypatch): generateStandardMock(monkeypatch, b"""Disk /dev/loop0: 7516 MB, 7516192768 bytes 255 heads, 63 sectors/track, 913 cylinders, total 14680064 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disk identifier: 0x524f0bf8 Device Boot Start End Blocks Id System """, b"", 0, "msdos") manager = device.DeviceManager("msdos.img") result = manager.get_partition_alignment() # Basically, this is testing if the physical partition is different then the logical partition. If so, then sectors will need to be aligned properly. That isn't the case here. assert result == 1 def test_get_partition_alignment_msdos_non_zero_return_code(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error msdos", 1, "msdos") manager = device.DeviceManager("msdos.img") with pytest.raises(DeviceError) as exceinfo: result = manager.get_partition_alignment() def test_get_partition_file_system(monkeypatch): generateStandardMock(monkeypatch, b"ext4", b"", 0) manager = device.DeviceManager("gpt.img") result = manager.get_partition_file_system(4) assert result == "ext4" def test_get_partition_file_system_empty_return(monkeypatch): generateStandardMock(monkeypatch, b"", b"", 0) manager = device.DeviceManager("gpt.img") result = manager.get_partition_file_system(4) assert result == None def test_get_partition_file_system_unsupported_type(monkeypatch): generateStandardMock(monkeypatch, b"completelyimpossiblefilesystemtype", b"", 0) manager = device.DeviceManager("gpt.img") result = manager.get_partition_file_system(4) assert result == None def test_get_partition_file_system_non_zero_return(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 1) manager = device.DeviceManager("gpt.img") with pytest.raises(DeviceError) as execinfo: manager.get_partition_file_system(3) assert "Error." in str(execinfo.value) def test_set_partition_file_system_non_zero_return(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 1) manager = device.DeviceManager("gpt.img") with pytest.raises(DeviceError) as execinfo: manager.set_partition_file_system(3, "ext4") assert "new file system" in str(execinfo.value) assert "Error." in str(execinfo.value) def test_partition_code(monkeypatch): generateStandardMock(monkeypatch, b"""Disk /dev/nbd0: 16777216 sectors, 8.0 GiB Logical sector size: 512 bytes Disk identifier (GUID): 13E1C95B-5AC6-412B-930B-8F119760B86E Partition table holds up to 128 entries First usable sector is 34, last usable sector is 16777182 Partitions will be aligned on 2048-sector boundaries Total free space is 4029 sectors (2.0 MiB) Number Start (sector) End (sector) Size Code Name 1 976896 11718655 5.1 GiB 8300 2 11718656 14452735 1.3 GiB 8300 3 14452736 16775167 1.1 GiB 8200 4 2048 976895 476.0 MiB EF02 """, b"", 0) manager = device.DeviceManager("gpt.img") result = manager.get_partition_code(3) assert "8200" == result def test_get_partition_code_non_zero_return_code(monkeypatch): generateStandardMock(monkeypatch, b"", b"Error.", 2, "gpt") with pytest.raises(DeviceError) as execinfo: manager = device.DeviceManager("gpt.img") manager.get_partition_code(3) assert "Error." in str(execinfo) def test_get_partition_code_newer_format(monkeypatch): generateStandardMock(monkeypatch, b"""Disk /dev/sdb: 5 GiB, 5368709120 bytes, 10485760 sectors Units: sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disklabel type: dos Disk identifier: 0x0ee18f9a Device Boot Start End Sectors Size Id Type /dev/sdb1 * 2048 350300 348253 170M 83 Linux /dev/sdb2 352347 10485759 10133413 4,9G 5 Extended /dev/sdb5 352349 10485759 10133411 4,9G 8e Linux LVM""", b"", 0, "msdos") manager = device.DeviceManager("/dev/sdb") result = manager.get_partition_code(5) assert "8e" == result def test_get_partition_code_mbr(monkeypatch): generateStandardMock(monkeypatch, b"""Disk /dev/loop0: 524 MB, 524288000 bytes 255 heads, 63 sectors/track, 63 cylinders, total 1024000 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disk identifier: 0x01517e72 Device Boot Start End Blocks Id System /dev/loop0p1 * 2048 411647 204800 83 Linux /dev/loop0p2 411648 718847 153600 83 Linux /dev/loop0p3 * 718848 819199 50176 83 Linux /dev/loop0p4 819200 1023999 102400 83 Linux """, b"", 0, "msdos") manager = device.DeviceManager("/dev/loop0", partition_mask="{0}p{1}") result = manager.get_partition_code(2) assert result == "83" def test_get_partition_code_mbr_bootable(monkeypatch): generateStandardMock(monkeypatch, b"""Disk /dev/loop0: 524 MB, 524288000 bytes 255 heads, 63 sectors/track, 63 cylinders, total 1024000 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disk identifier: 0x01517e72 Device Boot Start End Blocks Id System /dev/loop0p1 * 2048 411647 204800 83 Linux /dev/loop0p2 411648 718847 153600 83 Linux /dev/loop0p3 * 718848 819199 50176 83 Linux /dev/loop0p4 819200 1023999 102400 83 Linux """, b"", 0, "msdos") manager = device.DeviceManager("/dev/loop0", partition_mask="{0}p{1}") result = manager.get_partition_code(3) assert result == "83" def test_get_partition_code_mbr_invalid_value_passed(monkeypatch): generateStandardMock(monkeypatch, b"""Disk /dev/loop0: 524 MB, 524288000 bytes 255 heads, 63 sectors/track, 63 cylinders, total 1024000 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disk identifier: 0x01517e72 Device Boot Start End Blocks Id System /dev/loop0p1 * 2048 411647 204800 83 Linux /dev/loop0p2 411648 718847 153600 83 Linux /dev/loop0p3 * 718848 819199 50176 83 Linux /dev/loop0p4 819200 1023999 102400 83 Linux """, b"", 0, "msdos") with pytest.raises(ValueError) as execinfo: manager = device.DeviceManager("/dev/loop0", "{0}p{1}") manager.get_partition_code(5) def test_get_partition_code_mbr_non_zero_return_code(monkeypatch): generateStandardMock(monkeypatch, b"", b"Test error.", 2, "msdos") manager = device.DeviceManager("mbr.img") with pytest.raises(DeviceError) as execinfo: manager.get_partition_code(4) assert "Test error." in str(execinfo) def test_lvm_get_partitions_standard(monkeypatch): generateStandardMock( monkeypatch, b"""LV:VG:Attr:LSize:Pool:Origin:Data%:Meta%:Move:Log:Cpy%Sync:Convert backup:fileserver:-wi-a-----:5,00g:::::::: media:fileserver:-wi-a-----:1,00g:::::::: share:fileserver:-wi-a-----:50,00g:::::::: root:ubuntu-vg:-wi-ao----:6,52g:::::::: swap_1:ubuntu-vg:-wi-ao----:1,00g:::::::: """, b"", 0, "lvm") manager = device.LVMDeviceManager("/dev/fileserver") parts = manager.get_partitions() assert ["backup", "media", "share"] == parts def test_lvm_get_partitions_bad_return_code(monkeypatch): generateStandardMock(monkeypatch, b"Test error.", b"", 1, "lvm") manager = device.LVMDeviceManager("/dev/fileserver") with pytest.raises(DeviceError) as execinfo: manager.get_partitions() assert "Test error." in str(execinfo) def test_lvm_get_drive_size_standard(monkeypatch): generateStandardMock(monkeypatch, b" 12566528S", b"", 0, "lvm") manager = device.LVMDeviceManager("/dev/fileserver") result = manager.get_drive_size() assert 12566528 == result def test_lvm_get_drive_size_bytes_standard(monkeypatch): generateStandardMock(monkeypatch, b" 6434062336B", b"", 0, "lvm") manager = device.LVMDeviceManager("/dev/fileserver") result = manager.get_drive_size_bytes() assert 6434062336 == result def test_lvm_get_drive_size_bytes_bad_return_code(monkeypatch): generateStandardMock(monkeypatch, b"Test error", b"", 1, "lvm") manager = device.LVMDeviceManager("/dev/fileserver") with pytest.raises(DeviceError) as execinfo: manager.get_drive_size_bytes() assert "Test error" in str(execinfo) def test_lvm_get_partition_size(monkeypatch): generateStandardMock( monkeypatch, b"/dev/fileserver/media:fileserver:3:1:-1:0:2097152:256:-1:0:-1:252:3", b"", 0, "lvm") manager = device.LVMDeviceManager("/dev/fileserver") result = manager.get_partition_size("media") assert result == 2097152 def test_lvm_get_partition_size_bad_return_code(monkeypatch): generateStandardMock(monkeypatch, b"Didn't work.", b"", 1, "lvm") manager = device.LVMDeviceManager("/dev/fileserver") with pytest.raises(DeviceError) as execinfo: manager.get_partition_size("media") assert "Didn't work." in str(execinfo) def test_lvm_get_partition_code_unsupported(): manager = device.LVMDeviceManager("/dev/fileserver") with pytest.raises(UnsupportedDeviceError) as execinfo: manager.get_partition_code("media") def test_lvm_get_partition_alignment_unssported(): manager = device.LVMDeviceManager("/dev/fileserver") with pytest.raises(UnsupportedDeviceError) as execinfo: manager.get_partition_alignment() def test_get_empty_space(monkeypatch): generateStandardMock(monkeypatch, b" 70921486336B", b"", 0, "lvm") manager = device.LVMDeviceManager("/dev/fileserver") result = manager.get_empty_space() assert result == 70921486336 def test_get_empty_space_non_zero_return_code(monkeypatch): generateStandardMock(monkeypatch, b"Error.", b"", 1, "lvm") manager = device.LVMDeviceManager("/dev/fileserver") with pytest.raises(DeviceError) as execinfo: manager.get_empty_space() assert "Error." in str(execinfo) WereSync-1.0.9/tests/test_interface.py0000644000175000017500000000024113013034506020475 0ustar danieldaniel00000000000000import os import sys myPath = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, myPath + "/../src/") import pytest import weresync.interface pass WereSync-1.0.9/docs/0000755000175000017500000000000013315166025014724 5ustar danieldaniel00000000000000WereSync-1.0.9/docs/Makefile0000644000175000017500000001670713006633324016375 0ustar danieldaniel00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " applehelp to make an Apple Help Book" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " epub3 to make an epub3" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" @echo " coverage to run coverage check of the documentation (if enabled)" @echo " dummy to check syntax errors of document sources" .PHONY: clean clean: rm -rf $(BUILDDIR)/* .PHONY: html html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." .PHONY: dirhtml dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." .PHONY: singlehtml singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." .PHONY: pickle pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." .PHONY: json json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." .PHONY: htmlhelp htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." .PHONY: qthelp qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/WereSync.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/WereSync.qhc" .PHONY: applehelp applehelp: $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp @echo @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." @echo "N.B. You won't be able to view it unless you put it in" \ "~/Library/Documentation/Help or install it in your application" \ "bundle." .PHONY: devhelp devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/WereSync" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/WereSync" @echo "# devhelp" .PHONY: epub epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." .PHONY: epub3 epub3: $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 @echo @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." .PHONY: latex latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." .PHONY: latexpdf latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." .PHONY: latexpdfja latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." .PHONY: text text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." .PHONY: man man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." .PHONY: texinfo texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." .PHONY: info info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." .PHONY: gettext gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." .PHONY: changes changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." .PHONY: linkcheck linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." .PHONY: doctest doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." .PHONY: coverage coverage: $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage @echo "Testing of coverage in the sources finished, look at the " \ "results in $(BUILDDIR)/coverage/python.txt." .PHONY: xml xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." .PHONY: pseudoxml pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." .PHONY: dummy dummy: $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy @echo @echo "Build finished. Dummy builder generates no files." WereSync-1.0.9/docs/weresync.1.rst0000644000175000017500000001342613134020603017450 0ustar danieldaniel00000000000000.. Manpage documentation for WereSync. This should be converted to the groff format using rst2man.py ======== weresync ======== -------------------------------- clone linux drives incrementally -------------------------------- :Author: Daniel Manila :Date: June 30th, 2017 :Version: 1.0 :Manual Section: 1 :Manual group: admin weresync-gui - GUI interface for the weresync program. SYNOPSIS -------- weresync [ options ] [**-g** *ROOT_PARTITION*] [**-B** *BOOT_PARTITION*] [**-E** *EFI_PARTITION*] [**-L** *BOOTLOADER*] [**-l** *LVM_SOURCE* [*LVM_TARGET*]] source target **weresync-gui** DESCRIPTION ----------- WereSync clones linux drives incrementally producing a bootable clone. Clones produced by WereSync will have different UUIDs than the original drive, but WereSync will update the fstab and bootloader to allow the clone to properly boot. Clones can be created with one command or one button click, using *weresync* or *weresync-gui* respectively. OPTIONS ------- The *weresync-gui* command takes no arguments. These arguments apply to the *weresync* command. --h, --help Displays help message. -C, --check-and-partition Checks if all partitions are large enough and formatted correctly to allow drive to be copied. If the partitions are not valid, the target drive will be re-partitioned and reformatted. If unset, no checking occurs. -s, --source-mask *MASK* A string to be passed to format() that will produce a partition identifier (/dev/sda1 or such) of the source drive when passed two arguments: the identifier ("/dev/sda") and a partition number in that order. Defaults to "{0}{1}" -t, --target-mask *MASK* A string to be passed to format() that will produce a partition identifier (/dev/sda1 or such) of the source drive when passed two arguments: the identifier ("/dev/sda") and a partition number in that order. Defaults to "{0}{1}". -e, --excluded-partitions *LIST* A list of comma separated partition numbers that should not be searched or copied at any time. These partitions will still be formatted if -C is passed. Defaults to empty. -b, --break-on-error If passed the program will halt if there are any errors copying. This flag is not recommended because it will halt even if encountering a normal issue, like a swap partition. -g, --root-partition *PART_NUM* The partition mounted on /. It is recommended to pass this always, but WereSync will attempt to find the main partition even if it is not passed. -B, --boot-partition *PART_NUM* The partition that should be mounted on /boot of the grub_partition. If you have a separate boot partition, you must use this flag. -E, --efi-partition *PART_NUM* The partition that should be mounted on /boot/efi of the grub_partition. If passed this will create the /boot/efi folder if it does not exist and pass it to grub. Required if you have an EFI partition. -m, --source-mount *DIR* The directory to mount partitions from the source drive on. Cannot be the same as --target-mount. If unset, WereSync generates a randomly named directory in the /tmp dir. -M, --target-mount *DIR* The directory to mount partitions from the target drive on. Cannot be the same as --source-mount. If unset, WereSync generates a randomly named directory in the /tmp dir. -r, --rsync-args *RSYNC_ARGS* The arguments to be passed to the rsync instance used to copy files. Defaults to "-aAXxH --delete" -l, --lvm *SOURCE* [*TARGET*] This argument expects either one or two arguments specifying the logical volume groups to copy from and to, respectively. If no target VG is passed, WereSync will use the VG SOURCE-copy. If the target does not exist, WereSync will create it. -L, --bootloader *BOOTLOADER* The plugin to use to install the bootloader. Such plugins can be found at the bottom of the help message. Defaults to using the "uuid_copy" plugin. -v, --verbose Makes WereSync increase output and include more minor details. -d, --debug Causes a huge amount of output, useful for debugging the program. Usually not needed for casual use. COPYRIGHT --------- Copyright 2016 Daniel Manila Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at | | ``_ | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. EXAMPLES -------- To copy /dev/sda to /dev/sdb on a UEFI system use:: sudo weresync -C -L grub2 -E 1 -g 2 /dev/sda /dev/sdb This example uses grub2 as the bootloader (for most other UEFI bootloaders use ``-L uuid_copy`` or omit the ``-L`` option all together) with the efi partition on /dev/sda1, and the root partition on /dev/sda2. Subsequent copies should omit the ``-C`` option, since it could cause weresync to repartition /dev/sdb again, thus deleting existing data and making weresync's incremental abilities useless. To copy /dev/sda to /dev/sdb on a BIOS/Legacy system use:: sudo weresync -C -L grub2 -g 1 /dev/sda /dev/sdb This example uses grub2 as the bootloader (other bootloaders, such as syslinux, may be passed to the ``-L`` option) and has the root partition on /dev/sda1. The omittance of the ``-E`` option signals to WereSync that this is not a a UEFI drive. As above, subsequent copies should be initiated without the ``-C`` option. The gui can simply be run with:: sudo weresync-gui SEE ALSO -------- Full documentation can be found at WereSync's documentation on Read The Docs: ``_ WereSync-1.0.9/docs/source/0000755000175000017500000000000013315166025016224 5ustar danieldaniel00000000000000WereSync-1.0.9/docs/source/weresync.rst0000644000175000017500000001776513126624003020630 0ustar danieldaniel00000000000000.. WereSync command documentation. ###################### Command Line Interface ###################### For help using the weresync command when you are on the command line, use the help flag on the weresync command:: $ weresync -h Basic Usage =========== Requirements ------------ .. IMPORTANT:: WereSync requires root permissions to run, because it has to access block devices. Standard linux permissions restrict access to block devices to ordinary users. WereSync will copy GPT, MBR, and LVM drives. The source drive must have a valid disk label (such a disk label can be created with the gdisk or fdisk command). All `dependencies `_ must be installed. Commands -------- WereSync always requires a source drive and a target drive. The source drive comes first. WereSync requires root permissions in order to access hard drive data. So to copy from /dev/sda to /dev/sdb, use this command:: $ sudo weresync /dev/sda /dev/sdb This will simply copy data from one partition to the another, and if the partitions are different, you will encounter an error. To have WereSync fix your target drives partitions, use the ``-C`` flag:: $ sudo weresync -C /dev/sda /dev/sdb On subsequent backups, you may not want to include the -C flag, since this can sometimes trigger unnecessary repartitioning. LVM +++ WereSync supports the copying of LVM drives with the `-l` flag:: $ sudo weresync -C -l -B 1 volume-group /dev/sda /dev/sdb It is highly recommended to pass which partition of the drive your boot partition is stored on, if you have a boot partition seperate from the VG. If you have your /boot folder inside the VG, your bootloader installation mileage may vary. Bootloader Installation ----------------------- WereSync will attempt to update the target drive's system to it will boot up properly. By default this simply changes the UUIDs in the files of the /boot folder and EFI system partition, but specific bootloader installation plugins can also be specified. For this to work, it is highly recommended that you pass the root partition with the ``-g`` flag:: $ sudo weresync -g 1 /dev/sda /dev/sdb If this is not passed, WereSync will attempt to discover the root filesystem on its own, but this is unreliable. In order for a drive on an EFI system to be made bootable, the partition number of the EFI system partition to be passed to WereSync with the ``-E`` flag. In this case, the root filesystem should be installed on (``-g`` flag) should also be passed, especially if the efi partition comes before the grub partition on the partition list, as the efi partition can trigger the mechanisms used to find the grub partition. .. code-block:: bash $ sudo weresync -E 2 -g 3 /dev/sda /dev/sdb If you have your boot folder on a seperate partition, be sure to let WereSync know which partition that folder is on with the ``-B`` flag:: $ sudo weresync -E 1 -g 2 -B 3 /dev/sda /dev/sdb Obviously replace the numbers with the proper values for your system. Bootloader Plugins ++++++++++++++++++ Some bootloaders, especially those for MBR booting, require a more specific process. Bootloader plugins allow such a process to occur. All plugins available will be displayed at the end of the help message displayed with the ``-h`` flag. The specific plugin to use may be passed with the ``-L`` flag:: $ sudo weresync -L grub2 -E 1 -g 2 /dev/sda /dev/sdb For more information on installing and creating bootloader plugins see the `bootloader plugin page `_ Image Files ----------- WereSync supports image files normally. If either the target or the source ends in ".img" WereSync will automatically consider it an image file and mount it as such. Currently there is no way to mark files not ending in .img as image files. To create an image file on linux, use:: $ dd if=/dev/zero of=my_image.img bs=1M count= $ sgdisk my_image.img -o The second command creates a partition table on the command, which is currently needed by WereSync to start analyzing a drive. In-Depth Parameter Definitions ============================== Usage:: weresync [-h] [-C] [-s SOURCE_MASK] [-t TARGET_MASK] [-e EXCLUDED_PARTITIONS] [-b] [-g ROOT_PARTITION] [-B BOOT_PARTITION] [-E EFI_PARTITION] [-m SOURCE_MOUNT] [-M TARGET_MOUNT] [-r RSYNC_ARGS] [-l] [-L BOOTLOADER] [-v] [-d] source target .. list-table:: Parameters :widths: 15 10 30 10 :header-rows: 1 * - Long Option - Short Option - Description - Default * - --help - -h - Displays the help message - N/A * - --check-and-partition - -C - Checks if all partitions are large enough and formatted correctly to allow drive to be copied. If the partitions are not valid, the target drive will be re-partitioned and reformatted. - If unset, no checking occurs. * - --source-mask MASK - -s MASK - A string to be passed to :py:func:`format` that will produce a partition identifier (/dev/sda1 or such) of the source drive when passed two arguments: the identifier ("/dev/sda") and a partition number in that order. - "{0}{1}" * - --target-mask MASK - -t MASK - Same as --source-mask, but applied to the target drive. - "{0}{1}" * - --excluded-partitions LIST - -e LIST - A list of comma separated partition numbers that should not be searched or copied at any time. These partitions will still be formatted if `-C` is passed. - [] * - --break-on-error - -b - If passed the program will halt if there are any errors copying. This flag is not recommended because it will halt even if encountering a normal issue, like a swap partition. - False * - --root-partition PART_NUM - -g PART_NUM - The partition mounted on /. It is recommended to pass this always, but WereSync will attempt to find the main partition even if it is not passed. - None, WereSync searches for the partition. * - --boot-partition PART_NUM - -B PART_NUM - The partition that should be mounted on /boot of the grub_partition. If you have a separate boot partition, you must use this flag. - None, no partition mounted. * - --efi-partition PART_NUM - -E PART_NUM - The partition that should be mounted on /boot/efi of the grub_partition. If passed this will create the /boot/efi folder if it does not exist and pass it to grub. Required if you have an EFI partition. - None * - --source-mount DIR - -m DIR - The directory to mount partitions from the source drive on. Cannot be the same as --target-mount. - None, randomly generated directory in the /tmp folder. * - --target-mount DIR - -M DIR - The directory to mount partitions from the target drive on. Cannot be the same as --source-mount. - None, randomly generated directory in the /tmp folder. * - --rsync-args RSYNC_ARGS - -r RSYNC_ARGS - The arguments to be passed to the rsync instance used to copy files. - -aAXxvH --delete * - --lvm SOURCE [TARGET] - -l - This argument expects either one or two arguments specifying the logical volume groups to copy from and to, respectively. If no target VG is passed, WereSync will use the VG SOURCE-copy. If the target does not exist, WereSync will create it. - No Volume Groups are copied * - --bootloader BOOTLOADER - -L BOOTLOADER - The plugin to use to install the bootloader. Such plugins can be found at the bottom of the help message. - The "uuid_copy" plugin. * - --verbose - -v - Makes WereSync increase output and include more minor details. - Only Warnings, more serious issues, and basic info are printed. * - --debug - -d - Causes a huge amount of output, useful for debugging the program. Usually not needed for casual use. - Only Warnings, more serious issues, and basic info are printed. WereSync-1.0.9/docs/source/issues.rst0000644000175000017500000000066513117327354020304 0ustar danieldaniel00000000000000.. Known Issues Page Known Issues ============ * Due to the complexity of boot loader installations, bootloading may not always install correctly depending on the nature of your setup * Occasionally, installing the boot loader can change the order of boot on the parent drive, especially for a dual-boot drive If you have found anymore issues, please report them to the `issue tracker `_. WereSync-1.0.9/docs/source/global.rst.inc0000644000175000017500000000011213117327354020764 0ustar danieldaniel00000000000000.. Global includes and various things .. |project_version| replace:: 0.3 WereSync-1.0.9/docs/source/bootloader.rst0000644000175000017500000001044613125115721021111 0ustar danieldaniel00000000000000.. Documentaion on bootloader plugins .. include:: global.rst.inc ================== Bootloader Plugins ================== Bootloader plugins allow WereSync to have special process to install specific bootloaders, allowing support for a wider variety of bootloaders. The default bootloader plugin, UUID Copy, simply changes the UUIDs in each file in the /boot folder. UUIDs in /etc/fstab are always updated, regardless of boot plugin. Installing ---------- Bootloader plugins can be installed to two different locations: ``/usr/local/weresync/plugins`` and the python site-packages directory. This means that plugins can be installed as pip packages or installed manually. Creating Bootloader Plugins --------------------------- Bootloader plugins are very simple files. They must be a single python file that fits the form "weresync_.py". Inside this file, a class must extend :py:class:`~weresync.plugins.IBootPlugin` and at least implement the method :py:func:`~weresync.plugins.IBootPlugin.install_bootloader`. No other files are necessary, but other files may be packaged with a plugin for it to call within its process. For an example plugin see the `Grub2 Plugin `_. Method Implementations ++++++++++++++++++++++ All plugins should extend :py:func:`~weresync.plugins.IBootPlugin`, as mentioned above (signature: ``class MyPlugin(IBootPlugin)``). They should all call ``super().__init__(name, prettyName)`` where ``name`` is the portion of the file name after the "weresync\_" prefix but before the ".py" extension (weresync_.py). ``prettyName`` can be anything, but should be human readable. Currently this is only displayed by the GUI. For any given bootloader plugin, the following methods are called in this order: * :py:func:`~weresync.plugins.IBootPlugin.activate` is called before bootloader installation. All files will be exactly the same as the source drive at this point. Implementing this method is not required. * :py:func:`~weresync.plugins.IBootPlugin.install_bootloader` is called to install the bootloader. This should do the majority of the work. Implementing this method is required. * :py:func:`~weresync.plugins.IBootPlugin.deactivate` is called after bootloader installation is complete. Implementing this method is not required. :py:class:`~weresync.plugins.IBootPlugin` contains one more method, :py:func:`~weresync.plugins.IBootPlugin.get_help`, this is an optional method that should return a string describing what the plugin accomplishes (i.e. what bootloader it installs). Helpful Functions +++++++++++++++++ Several important methods are available to plugin developers. The ``copier`` parameter of :py:func:`~weresync.plugins.IBootPlugin.install_bootloader` provides access to a :py:class:`~weresync.device.DeviceCopier` instance. This instance then provides access to :py:class:`~weresync.device.DeviceManager` instances through the ``copier.source`` and ``copier.target`` fields. These instances allow a plugin to mount and umnount partitions and get information about the drives in question. The method :py:func:`~weresync.device.DeviceCopier.get_uuid_dict` of the ``copier`` parameter returns a dictionary relating the UUIDs of the source drive with those of the target drive. This can be used in conjunction with :py:func:`weresync.device.multireplace` to update the UUIDs of any given string, for example one from a file. The function :py:func:`weresync.plugins.translate_uuid` makes use of the above two functions to step recursively through the passed folder and update the UUIDs of every text file. LVM +++ Your bootloader will be expected to support LVM systems as well. One can test if Logical Volume Groups are being copied by testing if the ``lvm_source`` field of the ``copier`` object is not ``None``:: if copier.lvm_source is not None: # Handle copying VG The ``lvm_source`` and ``lvm_target`` fields will be :py:class:`~weresync.device.LVMDeviceManager` objects, but can generally be treated like ordinary DeviceManager objects. Builtin Bootloaders ------------------- UUID Copy +++++++++ .. automodule:: weresync.plugins.weresync_uuid_copy Grub2 +++++ .. automodule:: weresync.plugins.weresync_grub2 Syslinux ++++++++ .. automodule:: weresync.plugins.weresync_syslinux WereSync-1.0.9/docs/source/translation.rst0000644000175000017500000000303213125115721021306 0ustar danieldaniel00000000000000.. Documentation on translating WereSync .. include:: global.rst.inc ==================== Translating WereSync ==================== WereSync supports translation via Pythons `gettext `_ module. For end users, if your language is supported, it will simply change based on your system locale and requires no further configuration. More languages are always welcome. If know another language besides English your contribution would be most welcome. You can follow these steps in order to translate WereSync. 1. Clone WereSync to your computer:: $ git clone https://github.com/DonyorM/weresync.git 2. Enter the WereSync source directory:: $ cd weresync/src 3. Generate the `weresync.pot` file with the `pygettext` command from within the "src" directory:: $ pygettext -d weresync weresync/*.py weresync/plugins/*.py 4. Translate the file. I recommend using a tool, such as `poedit `_ to edit the translation. Be sure to choose the UTF-8 encoding. 5. Place your translation files in the proper folder. `gettext` expects specific path for language files:: $ mkdir -p resources/locale//LC_MESSAGES/weresync.po You can find your language code `here `_. 6. Add your language code to the `LANGUAGES` list of the `interface` module. 7. Stage and commit your new language folder and the `interface.py` file. Then create and submit a pull request. WereSync-1.0.9/docs/source/_templates/0000755000175000017500000000000013315166025020361 5ustar danieldaniel00000000000000WereSync-1.0.9/docs/source/_templates/homepage.html0000644000175000017500000000017013117327354023036 0ustar danieldaniel00000000000000

Homepage

WereSync-1.0.9/docs/source/index.rst0000644000175000017500000000344113125115721020063 0ustar danieldaniel00000000000000.. WereSync documentation master file, created by sphinx-quickstart on Thu Nov 3 19:08:36 2016. .. include:: global.rst.inc ======== WereSync ======== .. image:: img/weresync-logo.png :align: center Making your backup drives into were-drives, transforming them into clones at will. WereSync incrementally backups up hard drives using rsync. Backups can be run while you use your computer. As icing on the cake, you can boot your clone just like your normal computer. Installation ------------ WereSync can easily be installed with pip. Simply use:: $ pip install weresync For more in depth information see the `installation guide `_. Basic Usage ----------- .. IMPORTANT:: WereSync requires root permissions to run, because it has to access block devices. Standard linux permissions restrict access to block devices to ordinary users. To start the gui use:: $ sudo weresync-gui For help on how the terminal command works, run:: $ weresync -h For a basic setup, you could use a version of the following command:: $ sudo weresync -C --efi-partition /dev/sda /dev/sdb ========= Contents: ========= .. toctree:: :maxdepth: 2 installation gui weresync api bootloader translation issues ============ Contributing ============ First, take a look at our `contribution guidelines `_. Then, if you would like to submit a new feature or fix a bug, please submit a Pull Request at the `project repository `_. You can submit a bug report on the `issue tracker `_. ================== Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` WereSync-1.0.9/docs/source/gui.rst0000644000175000017500000000372113125115721017541 0ustar danieldaniel00000000000000.. gui information ######################## Graphical User Interface ######################## Provides a simple user interface in order to produce clones. Each field provided by the GUI has a "What's This?" link which opens a dialog explaining the field. By default WereSync outputs all log files to ``/var/log/weresync``. If any errors or problems occur, please be sure to include the output of the most recent file in your report. To start the gui run the command:: $ sudo weresync-gui .. IMPORTANT:: WereSync requires root permissions to run, because it has to access block devices. Standard linux permissions restrict access to block devices to ordinary users. .. image:: img/gui-example.png :align: left LVM === WereSync supports cloning logical volume groups as well. Simply check the "Copy Logical Volume Groups" option and select your source group from the dropdown. If the target group name is left as "Default" then WereSync will simply use the name of the source group with "-copy" appended to it. WereSync will create the target volume group if it does not already exist. Bootloader Installation ======================= WereSync will attempt to install a bootloader onto your drive, based on your choice in the "Bootloader Plugin" entry. If you are unsure which to pick, use the default "UUID Copy" plugin. WereSync cannot properly install all bootloaders. You may have to manually adjust some settings after the clone finishes. If you have your /boot directory on another partition, be sure to pass that partition number to the "Boot Partition" field under advanced options. Dependencies ============ The WereSync GUI runs using GTK and requires the `PyGObject `_ bindings to be installed. On Ubuntu these can be installed with:: $ sudo apt install python3-gi API Reference ============= The GUI can be started from the ``gui`` module with the method ``start_gui()``:: >>> import weresync.gui >>> weresync.gui.start_gui() WereSync-1.0.9/docs/source/img/0000755000175000017500000000000013315166025017000 5ustar danieldaniel00000000000000WereSync-1.0.9/docs/source/img/weresync-logo.png0000644000175000017500000010465613117327354022323 0ustar danieldaniel00000000000000‰PNG  IHDRôj캨¹sBIT|dˆ pHYs Z ZÉžûtEXtSoftwarewww.inkscape.org›î<tEXtAuthorJakub Steineræû÷/!tEXtSourcehttp://jimmac.musichall.czifã^XtEXtCopyrightCC0 Public Domain Dedication http://creativecommons.org/publicdomain/zero/1.0/Æã½ù IDATxœì½yt\×}çù}KíU(ì@;.â P¢H‰”ijáª-ɲ©ÈÑÄ™9“Μ¤{¦§gŽÒ}:§sNgzb'™žÄ™DØÓsä-IŸØ–íHŠE²S”Q”Ìâ"ì@jïÎÔ}~(T½w«êÕÜÏ9u$VݺïW…z÷w¿û[pÖ'Nœ¨—$i€{>yô¨àÿä¿î ŠÇápª‡$€«®¸ à"€oã߸\I¡8…#TZNñƒA—¢(ÇAxÀöJËÄápjàÇ„].×K_ýêW—*-‡®Ðk˜³gÏ:—––~[„…»8‡ÃáXÅ!ä_oݺõ/_xáµÒÂpÌá ½FyüñÇ?Cùz*- ‡ÃYÓüŒòßüæ7ß©´ c¸B¯1ž~úéºD"ñׄc•–…Ãá¬Ò‚ ü›¯ýëPiA8¹á ½†8uêT ß°£Ò²p8œuÉnjjúÿìÏþ,UiA8«‘*-‡S§Nõˆ¢øSƒ•–…Ãá¬[vE£Ñ3gÎ|ëÕW_%•†³®Ðk€`0ØàŸtWZ‡³¾aljjªïÌ™3Ë•zuÁz•sôèQ‡,Ëß°¥Ò²p8Î'lššJ…B¡UZίàgèUÎéÓ§ÿO¿eÅ\^¯›6m‚×ë…ÇãÇã Xó $÷FÝèµrÏ™Ï{Ëq|ÞWŒ<¥¾F¥ÿVÅÌQÉÏ\nÙÒé4–––°´´„ééi$‰‚æÿUUÕ‡¾õ­oýc1“p¬ƒ+ô*& Þ§ªêkÄ|ßÛÜ܌ݻwcûöíðz½°ÙlÌï·JyTëõ*y}«¯UÙKqrlP*=g¥ç1Ût,,,àúõëxå•Wpþüy¨jÞéæWEQÜþÒK/-$ ÇR¸B¯RŽ=êp»Ý¿Ð—Ïû:;;ñØcaçΨ««ƒ(æ·¨„b]Oʼ×+¥ü¥š»ÒŠ®sVzž|Þ§ª*>úè#¼øâ‹¸|9ïʯ_úÆ7¾a‰‘Sü ½JÙ¾}ûo8Ã:^E<òÈ#xöÙg1<<\;½RŠUË\ÿùRëʼ”¬eYŠy+ý yŸ ðûý˜˜˜€¢(¸téR>oß5<<ü·.\ø8ï s,…+ô*$ z !ÿËxI’ð…/|<ðššš IæVBÈ %ZiE³k©®YŠÍÐzSæÕ&[9•¹I’022‚úúz¼÷Þ{¬oEQ윜üjQçMÞg³œÒCù ­¬ãÏœ9ƒññq4440-îú›žRQe^‰ë¯%e^ ªUa–ê·Ri%œ9G¥”9EEÜÿýxê©§ò¹ö§Ož<¹Í8Ãzõ!B~“uð½÷Þ‹C‡Áçó™ŽÕ/‚ T\ɬÅZŽ K¥ÿŽFXõùK¥È+­„3ç)+=5‚ àÁÄÁƒ™ß#Šâ¿°LNAp…^eœ:uj€ËØúúzœ9suuu¦cõ‹ÅzTækÕPé¿c¹°úX¡š¬r+ç±AðÄO ¡¡õ-g‚Á`K)eâÃzõñëÀ“'O¢­­Ít\æbÁ]쥿^9¬òjv?S*ìhÄZVÂVãt:ñÜsÌK’KQ”gK)ǮЫˆÇ{Ì-Âi–±ÍÍ͸ÿþû ÓÒ*}>žÉzq±×â5jeƒPMTãf¥ŒŒŒ`Ó¦MLcA8[Zi8FÈ•€ó+l6ÛÌýçŽ9'w|®…”‚d2 UU«Ná—‚jNo*Å\å¼F-YºÕ&+ë}úsÏ~øa- ÅÌM¶¼¼ EQX¿€Õê‚X«rçÃzøŒëˆD"Ìc>Ì:t÷©S§v$§`¸B¯!gYÇÒÊM,n²p8\¨HFÖºÛ½Úàß‘õ¤Ói¦øB™ªR~s;ǸB¯0'Nœ¨áËØ¾¾>4773)óx<®YçVSë‹j­Ë_‹ðZ‰F£†¯Ó5G–ef+]„§{ì1wÑÂq˜á ½ÂH’ôËØ“'O2Ïkvƒ®w¸eÍÆZþlk•Bþfñxªª®z>Û±Þøø8ë´~‡ÃñxÞÂp †+ô ÃênE‘9 EUUæ@ÎÚ„+âòQR@a^3š››1<<Ì:/w»—®Ð+ÈÉ“'GA˜`ûàƒÂét2ÍFkª÷v¶k”ë:ÕF5Ê´V©–ïÚf³ÁívÃï÷ÃåbrÖ•„|¼zyÇÝòäÉ‘‚âä ¯WA>É=gâÈ‘#Ìó.//¯øwµ,\Fd“Qÿ\µ—¨­†vë…Zþ®iÙU›Í»ÝI’´ßy<¯¨gÇÙívÓ±£££p:ˆÇã¦c%I: à_- Çn¡Wˆ`0( ‚ð ËØ¦¦&lܸ‘iÞd2‰T*U”lå„Õ/—ÕÎ)ëñïçp8àõzÑÐЀ––477Ãï÷ÃívC–eí;I§ÓX\\¬°´ìVºÃáȧWúçŸþùÜÅã9–Áz… „|ŠÒÁ2öĉ†]ÕôÔ’u^ˆl<Ÿ¾üðïˆ Y–áv»Q__ææf´µµ¡¡¡^¯‡’$å|ïââbUx‰DÖà¸lÜ{ï½Lã!m333#‡ îr¯„æ¾Á¬Q¥Ù[$I²,}­ZöZv¹–þ=•Qa³Ù4ºÝngº?h¹ÕÙÙYÌÌÌ ¿¿N§³*¼jÔýÇáv»M;6l@ À•+WLçEñ9ß²HTN¸B¯O>ùds2™<Î2vÛ¶mY±d#‹­Ø]Ó´öb7Õø™¬b-¶ZE–eØívM‰gv)Ë! ˜žžÆìì,æçç±´´¤ými‘–R¸Ú ù ‰¢AF™‚óAÀ¡C‡ð•¯|Åt,!äh0ì~饗®ç%'/¸B¯Édò3Ì#O;vŒyÞLw;uÓsQø÷¼6 Ö7µ¼õgÝFÄãqLMMazzóó󘟟׺™eÒÒÒ‚±±1,,,X-~ÁÐÍ:F:fÚ´lÛ¶ 6›Åà B>àßY!+';\¡W†³,ƒìv;FFØ2>Òé4‰„öoý$Š"ó¹Øz„+âõ‹ +"Ïm6›áY7%Nc~~ÓÓÓšgPw¹\Ø·oß k]/O%~‹Ô:îzö¢Ñ(êêÌ; ºÝnìÛ·¯½öšéXBȯ½ð ÿþ…^à‹Q‰à ½Ìœ¸Ýlm„s-P¹n>ñ^ý½>??_5±¬ØívLLLdõ0(ŠRñÎj¹ŽŒ²Ý ‰DB‹É0c÷îÝøú׿ÎrOy‰ÄÌëÆr˜á ½ ¨ªÊ܈…µƒ‘ªª¦ ºÖ³)KY– έ-E>º~0s–I’`³ÙðÃþ°jÎekEQ‡‡+-Šå‚€ûî»gÕk´¾{¥Ϲîßl'Zh†å,½±±[¶lÁ{ï½g:–ò¸B·Þ>µÄ=zÔàI–±CCChjjbš7‹1- z÷Yf>4kºrC7"úG¥E.— ?þñ«ªî6§zÙ²eKÎáp¸¢Õôµ²‘+ö#Ÿ#Žx€uèø'•39Áz‰ñx<ǬqÍÂÉ“'™ç5 †Ó£¿A³„(4ˆ©ZÜõÅbô9A€ÛíÆ›o¾‰?þ¸ŒRqj•7bóæÍY_‹ÇãI›Óc³Ùr¾fTXQ”½"ŒÊêȆ$Ig™r˜à ½Ä°æžË²Œ±1¦cv-ššñú†×,ù¼ë·ÛP(„k×®UZN àõz±wïÞ¬¯¥Ó鲟›gÞ×´r^®‡Yœ«•n·Û™­tBÈ3gÏžÍ]_–“\¡—ãÇop„eìC=»}uGÕL—3!„Ù:§ݨÔõ^ˆR_+Vz6\.®^½Š÷߿ҢpjI’°ÿþ¬p5œ›ëËÌfƒ¥to"‘`NÇg]Sš"‘È ¦I9¦Tç!êA–å§Á¸iz衇V=—¹ÐÇb±‚R½2;pQhªBÎöjÙº×÷Ñêfô»˜››Ã;ï¼Sa 9µÂÎ;Q__Ÿõµ¥¥¥Š¦‰J’d/Ã’E@A,cr§·µµ¡¿¿¿üå/Yæ}À5È1…+ôÒ!€1÷¼­­ +žË¥Ìãñ8!9NñÌÎÎâŸþéŸj¢8 §²455a÷îÝhhhÈúz4­hΦ9 !σÁµßº°Äp¯<öØcnŸa»k×.x<¦›Š¦¼„B!D"‘‚ƒÙ8ÙY^^æ…c89ihhÀÎ;ÑÒÒb:vqq±b^A`·ÛM×UU Š›‰Çãðù|EÑtÝÚ·o~ðƒ°LÛI9 à¿å-Gƒ+ôàp8NBêXÆ>úè£ÌóÒèv¯×ËS©8œ2át:±}ûvæ ‘H¤b^A˜ê®BŠªÇár¹LÇvuu¡«« }ô˼Ï+ô¢à.÷Àš{îv»100À4§¾ˆ øù9‡Sb2Ýë,$‰¼ÓJ­BE&eÜuµôÉZ G>|˜uÚG;ÖQ°P®Ð­& <È2öÑGenÄB BdY^Ïáp¬£¥¥GŽÁÎ;™Î¢UUE8F$ÑÒˉ(Šš›]ÿÈ%k±ågÓé4s*Þ¶mÛX Ùf³}®(ÁÖ9\¡[ !䳸›²fʘæTUuU JwwwÞ²q8cœN'ÆÇÇqøðᜩhzhnöââ"R©AКùØívH’dyøläӭЪn¬Vº×ëÕø50®ŸœÕp…n-«»½¿¿ÍÍÍL“f6b!„Àãñ ±‘©D<‡Ã1AEŒŽŽæå^O¥RX\\4l”D‹ºØl¶’–Y¦µÖÍÜèÉdÒ²È{Zƒ…ýû÷³N; ï/X¨uWèòÉ‘i58~ü8ó¼ú39§Ó‰††Ô××cdd$o9ÎJÚÛÛqôèQlÛ¶Ù½‰D‡™Ï¡õ–»Íf+‰ÕNÑz—ÓëI¥R–K¢Áq,œè2QUõ¹bäZÏp…n!¬¹ç²,cÛ6¶®™Xèÿ ‚€®®.¸Ýîüåp8ðx<¸ÿþûñÀ ®Î<)Eï^/ÆmMû'”ÊjO&“«Ræò9óÎÖ*x’$åÓVõñ§žzÊü¼ƒ³ ®Ð-âØ±c>A–±`ŽFÍŒ˜Õ´ˆ¢ˆM›6å'(‡³ÎE[¶lÁ#<Â\Êâ^ϽÕN›§X¥Ü“ɤ¶N(ŠR²ì©TŠy£°cÇÖ `W"‘xª(ÁÖ)\¡[„Ýn? €© p¶F,Ù „d <Ñç¸öõõ•%è†ÃY 477㡇ÂØØ“r)Ľž´›"m$Ë2dY¶äžN§ÓH$–Áå‚ÅíNûO°¶ˆp¶™Ö+\X!ä ,ãš››ÑÓÓÃ4g,˺ˆ¤R)Í¥æv»ÑÕÕ•‡¤ÎúÃívãþûïÇC=ÄLj•{Ýhþ\–>µÜ©b/Æj/Gƒ!#¯EæçÌÃí¾+ î,^ºõ¯NbÁ`°_UU¦¢ÅÇgÞ}¥…$ íü¼¯¯ׯ_gš“ÃYO466¢³³CCCLoÀÝ 3m‚d5ù¸ë©bòëˆVnTUE"‘€Óé\ñ|¶Ï:00€úúz,,,°Ìû€·­’s=À-t „| ¹“‚ `bb‚iÎt:mèÊÒ»ÝÛÛÛy;UwN»»»1>>ŽS§NáÈ‘#cRæÔ*D"Wæ™ÐÎi¥L}ˋܙÁq¹Þc³Ù˜ëox꓾F¸…^$Á`Pbn¿çž{àóù˜æ5+Ú@SThp]?Þzë-¦¹9œµ„ËåÂÆÑÙÙ‰æææ‚Ë" ‚—Ë—ËEQN§‘J¥J¥ŠRÆVv\E¢(BUU¨ªZ±nn™$“I¤Ói¦¸„={öàïþîïXd÷Ûíö €­q=Àz‘¤ÓéEQd:Ä~ä‘G˜æ$„0ÕƒŽÇãšBïííÅÏþ󒤦p8Õ† hllD[[êëë‘N§±°°€™™M¹Ð”ÏT*QÑÔÔ„––´´´˜f™H’I’´q4»„Fu³Xð¥T¶Õ¨Øãñ8<ó¸àææf # ±Lû¸Bg†+ô"Eñ,Ë8·Û¡¡!¦9õÅ!ŒPŠ¢h©/@¿üå/™®ÁáÔ2„ÌÎÎbvv–ù=ÓÓÓ¸pျV=Uî---¦e^i㽂§›†t:½ê~-µ‚¥óÓsvBHÅ{,cRè‚ ààÁƒ¬ }ÿÉ“'G¾õ­o1 ^ïp…^'Nœ¨pŠeì‘#G˜ƒròéÖ¤ß áâÅ‹U±[çpª™X,†ëׯkÁ¤N§š‚ojj2 ^¥ÍPìv;€• ^Ÿ…’/f÷®Yd<µØ+ªªH&“ÚwbÄèè(<ÓZ'Šâ³þ¥"®yØZ}q²266öy'XÆþæoþ&¼^¯é8EQ˜"@õãN§Öyzz‘H„ùýçnj8ÆÇŒË—/ãƒ>ÀÔÔ”íîr¹ Ê¡ÔEajE½ï¾û.Ë´}ccc599¹X´€kn¡ÀÁƒeŸg;>>—˵êùl»ñB¬sJ"‘Ð6 }}}8þ|É2p8œÕD"D"\½zÀêHúúúúœž:Aàt:a·Û‡-/7[Nk]Qæà¸±±18N–zðâ'õ>þ­"®Y¸…^;vì8 ày–±¿ñ¿±*%&ÛÍDÑÜy… ( ‡¶ó_^^ÆÜÜ\Áóq8œâH§ÓX\\ÄíÛ·qñâE\¾|sssˆÇã9-xªØEQ´T©g³ØKm­³t”´Ùl˜ŸŸÇµk×X¦ œ9sæË¯¾ú*OãÉ/ýZ¬¹ç---+±5dÈÕˆ%ôå`YsÞ9NyˆF£¸víÞzë-|ç;ßÁË/¿Œ¥¥¥¬c].™S]ȶæÐ¨üREÁÓà8öíÛÇ:mïû￸`¡Ö\¡çÉ“O>ÙL9Æ2öèÑ£+ε îöbvÍñx\{]]ÚÛÛ ž‹Ãá”–ÙÙY|ï{ßÃäädÖû^’$øý~¦óèlZqÎhŽB¯ÍÒVº»»ÑÑÑÁ:ïs ´Nà =O‰Ä“L‡DQÄ}÷ÝmÀfvSоŔbn"ý¹ùàà`Aóp8œòN§qîÜ9üð‡?ÌZ?Bx<4440רgQä™×(¶Mk6¨B7“GÅ|Úªžƒ-ÅK·6á =OA8Ë2nË–-ðù|L7V¶F,…*uý®¸££~¿¿ y8Nù˜žžÆ÷¾÷½œgɲ,£¾¾ÞÔZ/tÝ çêVBÓñXضmëñ‚]UÕgŠl ÃâòàäÉ“ÛAø=–±Ï=÷³Ë{nn.ç9W¾¨ª »Ý®í¸Ðßߦ¦&8ÄãqýÎáT!Š¢àÆ˜™™A[[Û*GÓ¼l6Ûª¼u+ܨ¥n5,ÁqN§wîÜÁÍ›7Y¦ì …BZ´`k®Ðó`llìC1ŸÏ‡³gÏ2µŒÇã†éj…îšõ)#6› ~¿F €ßï‡,ˈF£«ýÌápV‰DpùòeØív466®z]’$¸\®UGl« ÒX±IP.—‹is»Ýxýõ×Y¦mÞ¼yóË“““7ŠpÁ:#GuØl¶˜F¨9rÛ·ogšwqqÑ´åi>J]_°"×ûèBÑÝÝ‘‘tuuÁãñ@„‚JÏr8kQU·nÝÂÒÒÚÚÚVŸSk]’$ËsÖéüV)uI’LÏÿ !ðûýxóÍ7³Af1 ý]ÑéÈÐ IDAT­1¸BgdëÖ­ÇAx–e,k#UU™±°(uªÈéx ZŒ@ €þþ~LMM!‹1ÉÅápJÇââ"®]»†úúú¬kŠ,Ëp¹\Z+e+¡ÆA±<³†-tÓ Š"Eam«:¸cÇŽ?>þ|Â|èúÅ1š{˜±,//[Ö AÅ»àBÏÉ8ŸÏWÐû9޵D£Q¼òÊ+øÉO~’Õ§cêêê,l£ÑŠöŒÏ6w溶cÇÖëyâñøgŠl Â-t‚Á`;!äÿÃèÉ'ŸD `šw~~>ïÝo¶6S™ëŸ/$ÈE–e´µµáêÕ«LòmÛ¶ CCChll„ÏçƒÝn!Äô(Ãá°³¸¸ˆ7n ¡¡gÕëÔZ§ÝŠE¯l­–ÓÇå2PÜn7®]»†?þ˜E¾¶P(ô•¢[Cp…ÎÀððð'Â§ÍÆ9ŽœX2)´ °R©çRæ4e\HJŠÓéDKK ®]»fèp»Ý¸÷Þ{á÷ûÑÜÜŒŽŽ all CCCèììDSSêëëµ¾Òù´äp8wI&“¸rå âñ8ÚÚÚV)Zz„&‚åÙ,Åœ©§Ói¸Ýn¦9œN'~ö³Ÿ±ÈÓ9<<üí .˜kÿuoÎÂëÙùž={²6bÉF1Xh»UA ƒMTUÕ ÖÐ3u Â¢à[ZZ°wï^üô§?Í9fhhÈp÷n·Ûµæ™$“I,//caaKKKˆD"‡Ã‡Ãܺçp ¸xñ"nß¾½{÷¢­­mÕ뇃)è–º^H’TõOA"‘`JaD]]]β¸zDQ|À¿È[ 5 ·ÐM8}úô.ÿ+ËØçŸMMM¦ãhoåb Q®ù ª*EA*•Za!YðõõõPUÓÓÓ«^³Ùl˜˜˜(øŒ¦ß444 ½½ÝÝÝèïïÇÈÈ:::ÐÔÔ¤¹ñ»;}nÙs8wϦ¯]»UUÑÒÒ²êEÑk]?o1Ñ着ÇQdYF,Ã/ùK–i÷îÝû¥ŸÿüçÜ·ÐM!„<ËbͶ··£··—iÎbSÃA(ºiÍaM¥RZ4+’Ï´¸·mÛ†X,†+W®¬x~ppВ晈¢ŸÏŸÏ‡ 6¬xMUUÌÎÎâÖ­[¸}û6¸‚ç¬[TUÅ/~ñ ܸq«òÖiéØB¬u£V¯ùXêôþL¥RP…ÉØ³g¾ûÝï²Äð4†Ãá“þ_&aÖ8ÜB7àìÙ³Îd2ùWLýèÇŽÃÈÈÓ¼ E­dSºÅ@Ya½Ë²¼jþŽŽÌÌÌhG²,cß¾}Ìõ¥­‚.PíííèïïÇÀÀáp8H$x<κ$‘HàÊ•+P­­­9­uÀ<†%æ¦PKÕ³èñxðá‡bvv–eÚúP(ô_òdº}}}§˜Ö –$ _üâ™ÎÏS©ÓÙP.dY.:„’ëfL§Ó« Ó‚€ÎÎNܼy‰D}}}èîîÖæ)UF3dYÖªà ­¨‚·¼¼Ì«àqÖ „LOOãæÍ›hjjZµéKÇ&“IÃrÓ,Šº…®( S÷8*ë;ï¼Ã2m`llì«“““sy ³á ݀͛7ÿGýfã¶nÝŠC‡1)µH$Rpe'£ˆö|aé—¹“–$ ¸~ý:öìÙ£¥§…Ãa$ $“I­À…ªªPUµ$MŒÐWÁFkk+Ün7Òé4s;G§–‰ÇãÚñX¶³ušÞ¦ªªæ‚/ô>E1/¥N,ËLë˜ßïÇO~ò¯›@ ‡B¡ddÂzŽ?¾QÅ/0ý•þóŸgêçKÁüü|Ag¾ôܼXå˜O!UUW‘Ûívtwwky°™E#¨ë>N#N#™Lj®pš«(Ê «¾T _Ex½^Í=ß×׿ßI’‹Å,¯¬ÅáT „LMMajj mmm«6ç‚ ÀétjÖz1ä[MάrÅn·cqqqUìNÛÚÚþèêÕ«ëÚ%Çz6oÞüß ‚pØl\}}=žyæ¦g<G$)H{n^È&‚*¼ÌÏF•dbÂzv„ÑÑѯ0ÍA{ðÁ±cÇS%MYešR§ùã…’¯2×W¢(f½¾¾ß1ÝuÛív¦Ô2ýù›>(Ω·ì©ûÞJE/Ün·væ>55Å-vΚ„6z¹s玡µ^L£—\çé¹Ö}߉lÐ÷ù|>œ?‹‹‹,b¸C¡Ðe¸Vá = ?þø>ÿÒlœ øâ¿ˆºº:ÃqTaeæž³(uZ<¦«¾«<Û{hªI¦"¥éw„ܾ}---Ú9¿Ñ¹W¶ΔÌÏIY‘;ŸL&Ç5kÞŠÎ~¿½½½ˆD"E,r8ÕL4ÅÕ«Wáv»Q__¿êuj­Óô²|É'HNUÕ¬AÄ™ï§)rï½÷Ë´ýñÁV‚s À›³dAUU¦Êp}}}Y+ é¡?Ðln]–¿$Iy+s#…YÈ{!ˆÅb+Îɨ‹œ¾çÂ… xã7´fÙjMÓ¹Xä ½u®$“IÄb1,--aqqËËËH$Gµ»\.ìß¿`ŠÂåpj‘d2‰×_¯¼òJÖ6¥¢(¢¡¡ÁÔHÉF>›kEQVyr­ cccLæÈ²,žI€5 ·Ð3xì±Çܲ,ÿ%Ó_Ðã?ŽþþìAð™?N£F,FòµÎ‹q¯›¡OgÓ+ød2‰?ü ˆF£èêêÒ\j4¢•Zø¢(2GÔæ³Û§V|<G2™Ôd£×cÅç󡯯étssë> †³F‰D"¸|ù²–’‰ÍfƒÓéÔŽ¼ò!Ÿ5…*j£÷8LMMá£>2O„@(ú2³k ®Ð3زeËOšs¹\øÂ¾uç˜ùãL$ˆD"†óeS:Ù ¼äÂ*÷ºTqŠ¢¨ÕˆînVnÞ¼©ý2™DGG‡vöNëÇÛl6Øív888Níÿi~,Uþôa¤Œ< 4è.kgú¬„$Ièèè@[[fffV|Vg­@ÏÖçææÐÖÖ¶êL»Ò±ùä±+ŠÂÜûÂëõö”ÐÑ022òZ(ºÊ4ñƒ+ô 6oÞüŸ˜ö?˜˜À¾}û²žùf²´´ÄtCdra)«ZE®>w•rûöífgg133ƒÙÙYÌÎÎ"#‹iß¾­+µÖ©ò§Š_¯üN§öÐ+ý@oùg~>UU‘J¥´\yVëÝãñpk³æ ‡Ã¸víü~?|>ߊ×h»ÝÎl­ ‚`˜í¢Ç,8Žâóùðî»ïšFŸ ‡B¡o² \kT¦¼W•rúôéM.‚á{ù½ßû= iÿ6²oݺŬD©‚1«WnEnÄ;#;wîä5¯$IÚBár¹àt:áv»µÿw¹\°Ûíp»ÝÕ‹§çûtaÑŸÅëÿK7 f–ûÂÂÞxã ®Ø9kšîînìÚµ+§ç‘nÎÍ A«fH’„††Óq„¼òÊ+x饗LLj;ŽŽ¯}íkÅuÀªAxs„g†×®®®XŒ”^4Í»’’™k¸Ê<ŸñŒ)%+P±X ±XÌôý´+›Ëå‚Íf˪ø].Ün·ö½±¦ùé+ÛÕ¨¯¯ÇC=„‹/âç?ÿ9Oqã¬I®_¿ŽééiìÞ½+^uuup8XZZ2TجÅfhª*KíÛ·ãÛßþ6‹·Ó™H$žðǦ“®1¸Bÿ„^xA<þüY–±÷Þ{¯VöÔŒBŠ–ärW“U®˲c/EQ‰DLÝm’$Ááp¬Pö.—+ëszÅϪüEQÄàà Z[[ñæ›o²6ŽàpjŠX,†ýèGÀöíÛW݇ÍÍ͆Öz>¨ñx^¯×t\CC¶lÙÂZßý×±:w¹Âã?~òŠÙ8Y–ñGôGY#C3I¥Røøãó–…V…Ë'À$“R¿‡ÌÌÌÌà•WL¿¶ªBE¸Ýn­E«ÏçC]]¼^/<sþ‡~ˆsçÎqk³fñù|˜˜˜@SSö[ñxápx•5®?æ2C466æ¼ïôëÒää$þø™õôîo|ão±^ p ýTUeê{¾uëVæÍB¬s}JW¡}¾Ké^ «]¼xçÏŸÏë½Õ€ªªšÅûöíU¯Ûívøý~øý~x<x½^MñS×  Bww7Þ~ûmܸq£ÜƒÃ)9áp/¿ü2úúú°}ûv‚°¢ùÍzɦ¸YÝî„$ ¦úîýýýhhhXU¤+ÏXW [èŽ;æ³Ùl·d¯†¢ãw~çw°sçNÓ9iõ´|s8iÔ§^ɲº¯Jm•ÓJRKKKxë­·0==÷õjjÙ{½^Íš÷z½ðûýXXXÀ;ï¼Ã;ºqÖ,.— £££ðûý¦c麢/>e„,ËY«×éç¢|ç;ßÁßÿýß3HŒ%·ÛÝñ×ý×ë¦Y·ÐØl¶ ”ycc#FFF˜æŒÇãU-Ë–vEÿÍâ’b%Ÿ÷‚ ;ÿâ¿@(Z—nf½eŸ ÚçpjZ‚¦†Ò ‡Ãò†IÚ‘%8nçÎø‡ø–5¨nyy9à¯,±&à !„ÉÝ>11Áä s·ÆÑÙú–£ôßù’ï{è }íÚ5œ;w®äpµ Úçpªê]¢A¡úQýC¿)M§ÓÌ54r‘O}wÖถ– arr’eÚçÀúúáôéÓÃî3'<È´CU¥d®WªÔK­Ì%I‚ÓéD"‘À~ô#ܺu+ïëq8œÒ"<O^ŠšY–ÑÐЀååå¼ ½á‘O¯ôD"±*(5Ûš%î»ï>&….Â}Á`pô¥—^bÒþµÎºWè>Ë2ˆ¦+±P¨uÎ]i©³¼'_l6›ô–YŽÃáT²,cdd$gO‰b^¯‡‹‹‹¦nîBºBR2ƒãŒÖ­¡¡!ÔÕÕ1uGü¤ÙÖï,X ±®K¿ƒA‰ò"Ó°õ“'Ob``€iÞ¹¹¹‚”¨¾$j&¬íFYÞc!333xýõ×qãÆ‚»—q8œÒ£ïuÞÒÒ’³+Y¾F@&Ôc—­ô3°2;'óù|ëZ°tV³Ùl‡Ã¸|ù2Ëœƒ½½½_ºxñâšüY×íS !‡tšs»ÝصkÓœúf Va4W¶×ŠmŸ‰DxC§†˜™™ÁË/¿Œ‹/f}½eNEQKçÔVÌMɧûîÝ»™®-B³Ëåz¬XÙjõ®ÐϲŒÛµkWÎþÞ™èÝíÅ*uVŬgÅÙzoo/8€¶¶¶¼çâp8•!•Jág?û¾ÿýï#—ì:N§MMMp:LÍ©òÅ̘ ë]GGÓ>ZQŸ+Z°`ݺܟzê©EQ¾†8‚gŸ}–éü\UÕ¬Xw°´]h¡JÙʳuY–±aÃø|>ÌÍÍ­Ë45§‰F£¸|ù2A@ss³%4­üFk¯S·;Kžy¾C£¶ª™õ9$I¹sçX¦Ý422òb(Ê¿ñD ±nƒâ‰Ä¦9h7nDww7Óœ¹±°(ZQ v•gû·UgëíííhllD(â‘îNN§qîÜ9ܼyããã«Ú¢Rô%ZõŠè¿õ] ÂÜë´Œ5«Q@A2™\™ŸmÝÚ¼y3\.Kʨà,€ßc¢FY·úÈÈÈŸ‚`z~~ôèQlÞ¼™é‡<77gDf4‡Óé„¢(–G¯ë£–6mÚ„ÆÆFLMMñ¨w§F`±Ö£Ñ(–––H$L&‘J¥N§W)r³µ)×ÚB å³nèƒãŒÖ,»ÝŽÙÙY\¿~ÝtNAúÆÆÆ¾499i]€S•±.ÏЃÁàAö˜³Ûí¸÷Þ{™rÏé`D®¦(ŠL’ôó°*fýÙz!5Ûëêê´~äxä‘GJ–"Ãáp¬‡Zë?øÁV­Ó´´ÆÆÆ¬E­X¹’$åµ¾w×SÖ²±ããã¬2nTUõp^‚ÔëR¡«ªú ˸R4bÉö¥gç,;àB\òú6 ¢(ÂëõÂív¯’Éf³a÷îÝ8xð s  ‡Ã©<333øîw¿‹ÉÉÉUëÍfCss3Ün·öœUÑë…(tÀ<8ŽÒÝÝŽŽÖi×tpܺs¹?ÿüó¶x<þ"ÓƒgΜAww·é›Ãå[âp8 ˲¡âµ²ƒšQMxQQWWgÚ#ÜçóaÓ¦MH§Ó˜››ËK6‡STUÅÇŒ;wî µµuUÎ7­ãžL&óÎφ šaH$, ŽÓCã~ñ‹_°L;0<<üg.\X“ [Ö…>77w„bšÕÒÒ‚‘‘¦]j,Ë»‹ÞÚ¦.ýl»X+åXæ¤ÿVUKKKH&“¦×±Ùlعs':”3è†ÃáTfÖzcc#sß #dY^µÎ™¡_‹XëÈoÙ²…© » LÚZdÝYè###¿`Ôl܃>ˆíÛ·›þ !XXX(*­‹v1A )Gã•\ï!„ •JAUUØl6ÓMÇãA €ªªZ·Û »Ý›ÍY–‹jðÀáp¬‡Zë333hmm]U.$IB*•*8¿Ün·kÞ¾d2Yáâ¨].nݺŔ‰#BO(ú“¼©ÖU?ô`0Ø¢ªêG ;ˆ¢ˆßÿýßGOOá|„(Š‚Û·o%—ÇãÑωDÞQäV)ò\¥©r.†……\¿~×®]ËÚ~”•|š=p86l6î¹çôõõ­zMUU„ÃaÃ3í\kÏçÓÖ•ÅÅE¦µ-s®††&ëþÂ… øò—¿l:î“kÜ÷Ío~ó'Lƒkˆue¡ŒŒ|À£ ãpøðaMÉf¢ÿÁYQ&U¿‹Õ[é,”Ê’§nhN¨¢(e¹à@§Ó‰¶¶6 ¡££’$!æ½yÙ±cÆÇÇÑÔÔ—ËUÒÎvÎzAUUܼy3§µît:a³Ù²Zë¹Ö›Í¶bžx<^p‹\k±žºº:¼ûÊB(úÛ¼…©rÖ•BýÏÚÍÆ?~+ª¶QE–ùƒ,´‹›Í¦)tQ ÚŲOª[æçV‰D"ï»l¸Ýnttt`xx]]]E‘I¹û|>ìÝ»6› ~¿Àèè(Z[[áóù´¹¬(A¹oß>‚€ÅÅ5]\ŠÃшD"¸téRÖ¼uI’àr¹r6hÉ„´Ñ9b±S|O&ªª2çK’„d2‰>øÀt¬ ƒ;vìøãóçϯ©¦ëF¡ƒÁí„ÌÆù|><óÌ3p»Ý¦?¾x<^p«T=²,¯P’4Õ,¥VäfТ,gë,¸\.ttt`hhíííe‘H$kLÂîÝ»Q__¿êyšf×ÚÚŠ@ €‘‘‘ že!ÉÄï÷c÷îÝèîî†ËåÂ;w,m¸ÃáT+ôlýã?^ÕÁõl]Å)pÀÝ6…@YaôäÜ]¿úÓŸ²ËÙE¹699ùvABU)ëF¡ŒŒü.€ ³qسgiÚÀ~&dF¦KI-ˆL¯4­Žv/t¬¢(H¥RE‘é{bAx<ttt`pp~¿ªªjÖvCCvìØÁ´‰ÈTðƒƒƒhnnÖ¬ ýÖ­[ÑØØhllDcc#nݺÅÏï9ë†h4Š+W®Àáph÷E–e8¤Ó鬛oèK±âh,W~ír»Ý¸|ù2¦§§Y¦l…BQ”PUƺPèGuØl¶ÿÀm6ösŸûÚÛM½òPU VˆAVY(*rÞJ«<ÉdÒRk"Š"êëëÑÓÓƒÔÕÕ¡···àB6’$¡®®6l@?†††ÐÒÒ¢ya2ócN'ÆÇÇW,H>ŸÝÝݘššâgöœuƒÑÙº(Šp¹\š«[ÿ|¦uNìŒ0Z‹TU…Ãá0-eMÏûß~›ÉðîûÛÉÉÉ;,ƒku¡Ð·oß~w óÒÛÛ‹G}”)¢;‰X¶°BVœB Ëòªâ,J³ÔŠ<³¢ªª–­gC’$444XZ•N’$ø|>´··£¯¯ÃÃÃhmmÕ| ÈÚ:Ön·# ‰ðsuκ"‰àòåËY­u›Í¦¨*Š¢ÊÒ“J¥ŠN]ÕWœ3Z¿ü~?Þ|óMÖõ9599ù¢«"Ö…BùCfã>ýéOctt”)Eb~~>ïf*FèwŸú¼L+ݨÊ+Å(ó\ÏÓ–>h‹EÚ±‰æÙW#¢(®Pð---†cù¹:g=B­õ\gëN§sU<%Ý‚™Ç™Ýs²,cyy—.]b™v蓆-k¢ãÔšWè§NÚ Ÿ¤*žÃáÀÙ³g™*ž%“I,--iÿ¶BQɲ¼ê<šžd£³u«¬ò\Ïë{&ë{'§R)-¿ž¢Õ®/:/½~¥hllD[[n߾ͻÏqÖFܨB§™2tX^^6\XÖ&Bìv;“Áåõzñú믳ÌëTUu2 7´Xó¥_EQ| }ß·oßÎ܈%³0ŠVZ:Ö~üú‡Q kÓ•BÊÇìŠ<ÛëÙH§ÓˆÇãXZZÂòòrA.¸T*…x<Žh4Šh4ºb£P ŒæmiiÁ‘#GÐÜÜ\’ks8Õ íàöÃþpU7ஞúÒ ¾°snذ›6mb+Šâšiزæ-ô‘‘‘?Û‡ú Á`===LXŒš‘j5fž£Ó¹h^º^±d 1š·ŒÜëFï1z_æÙ;µÜéÑ…YÄ|f„:=’H§ÓZ€ÝäXáâO§ÓˆF£$)§U`³Ù@a¬åpÖ FÖ:Í‚I&“–ÅQ·»ÙšGדsçαLÛ;66öÕÉÉÉšï2µ¦ú©S§v ‚ð¯ÍƵµµáñÇg*^F‹Å Ç¢Hr¹“¨R§Ö,kµR»×Y_Ë”+Ûk„M)Swz6…œH$ wúÔkAçJ¥RZOåB|<_Ñ "WП hoo‡ÏçÃ;wxjg]a–·N+aæŠrÏw­bmÇê÷ûñÆo°4šTU„B¡ÌK*dM+ôÑÑÑ`·Ù¸C‡aëÖ­L9Õ¬X Qê‚ d-qH•ËuË©ÈéëùÎiôUÈÔÚî*w–´=ô˜¡PŸy=ÁkT‚²¾¾6lÀ;wx3κ#æ¬2g·Ûáñx´{°X¶ØívÌÏÏãÚµk¦cAØ¿ÿ—Þ~ûíšÞ¯Y… ]„šÝ’$ásŸûšššLçL¥Ry¥+å«ÔJJ’¤)£l°6[ÉF©Ýë…ΫWîFŸ]Yà^Á¹è³y¨µnTÓÞår¡··óóóE5¡ápjBHNk]Ex<CkUUsÇeÞÿÿüÏÿ̲vx£Ñè[¡Pȼnl³fƒâ!ÇøÍÆŽŽ25å[æ5ß`´\5’©’Ëuvd¤äÍ\ôåv¯ò^}d{. üSÉd±X ápËËˈÇãZÚ]¶k(Š‚ååeCy<ˆÑQÓ.½ΚdzzßùÎw²ö[÷xø`uØ~`}i¯*àøñã lÍÕZÚê*’„¤R©ÆO®ûuóæÍp¹\¦ÁÌd»Ý~Àï[%g¹Y“ú–-[þ‡ÍÆÝwß}رcS¯]«±lJШ»$IPU5¯hê\V°‘g¢R»å°vY¯AÏÕêÙ»\.ôõõiÖ ‡S Âá0–––°iÓ&¸\.Øíö²=‡vM›ÍQǵ|ôlÇÅÞǬÁqN§ü1nÞ¼É2í¦3gÎ|ùÕW_­I—ÚZ$I‚$Iðx<¦v¶çrÖQy*gF¡ÕîÊ5?!ËË˦Á:£££¸ÿþû™¼BN!,..â{ßû^Å  ‚€ºº:øýþœ÷¼U÷2=z4›¯§§ ¦— IDAT'kÃ¥lÔrpÜšSè‚ <Ë2ŽÕ:§ u©ÈõC4;³§rn·;çy{¶÷ÍG_§ÊµÓ[©ÝëÕ¬È3¡eiæìèèÀ‘#Gà÷›&ap8‘H$ðÊ+¯0å`—AàóùÐÚÚZÒÍ«‘7SÝnÇøø8ë´§‚Á`£ù°êcM¡ƒA/!ä/ö?u¹\øìg?›u©·Ri4s¶ZÅ¥ ›,F­\õ»¾|®qFs°È”9n-¸×K”7:Ww8¼+§¤BpãÆ ‚€ÖÖÖŠÈ ÷$Èkù¤Õ´YQ0B¼^/Þxã 7½L¹ …Þ°LÐ2±¦,tBÈ)^³qÛ·o‡×ë]±ØfZjôÿËy•©`hu3#hrÇc©ÂÍö^½å¾žÝëFsÒG:ÆÒÒ’áßO–eÜ{ï½ü\SRΟ?7ß|³b%‰õÖz1¹ç¹0«O¯×ÒÒ‚‘‘Öi¿PœT•a­)ô³,ãöîÝ»Âݞ릪jÙƒK2•YÛA`¥R§î÷RxÑ¿7S±ÌkF-)òlŸŸ>¯ÿÿH$bºàŒŽŽâ`ê%ÀáÂ¥K—ðÚk¯U´$±ÍfCKK sWKVR©TÖÍJ¶û=à¸-§N2-^m¬—û‰'úDQü?þµ:::pìØ1x½w y£^߈¥’”™ëø•|6›-g™Ôb ¼² `ýΪ) ­Ð÷½N£ßJÆz½^twwczzÚÒŒ ‡‰DpëÖ-tvv®:צmc±˜ö ž8Z©‘¦Êf{¤R)ÌÎÎbyyÙðA³A¬ÜX«S}sÍ_WW‡wÞy‡õSC¡Ð³@̲±fúØØØo8h6îСCؼy3S¡ƒùùùç-•Rêô<–¥yŒ(аÙl«nšR[ì¬äŠY(…ÌŸÏ{XǦÓiÓ|u»ÝŽ@ €h4Š……f8Vâñ8®_¿ŽÖÖÖ^JºÙŒÇã „Àãñhîq–Íì‚ÖÁèaeot=ªªÂáphÞÃ\ȲŒX,†‹/šÎ)ÂðØØØ—'''­?ü/kÂåþ /ˆ,îvY–±k×.&×f*•ZÄQÉò‘HÄôúôÇL› Л´«Ûêèuº@Ð<›ÛÚ ÌæÌt™"G¾2+Šbz®.IÆÇDZ{÷n¦:N¾Äb1üà?ÀG}´ây§Ó ¿ßŸÏǬ̻ǂVÝ*ÚƒEÞíÛ·³FÞ{ !Á¢…+#kÂBoll<(Âo™Û²e öïßϤÐÃá°aTf¹­uhEw¡,zSf‹ê,•ÅÎò9JM5ZýzÌêÀ@cc#ÚÚÚpûöíŠ/–œµ!ׯ__Ok\ÐͶ‰D‚¹«`)6환Mw·.]º„™™–Ë4‡B¡ÿ'é*Ú0XsÏ3ƒárÁ’{^ k=•Ji9Î,Öº Ü­(§ÿÌ¥²ØY(‡¢­• ºX,fðØÒÒ‚O}êSL­}9œBÈOSaiUÊ\šÉQ-$“I¦ûS„|rÒï ƒ5Ó6±æú‰'ê'Š"|>ߊsø\ÔB•·Z¶úõ󫪊p8lº>|˜ycZ ‡p:8pàK~MNeYXXÀË/¿ŒùùùÏÓsuÊââb^Ußʹ6Ò*,ìØ±ƒÕÕäñxŽ%X™¨iÚéÓ§‡üÙ¸:tÇtN}ßB)WÀ\2™„ÃáÈzÖe„Ífƒ,ËYóÕk᜼–ç7º-aÖŠ5`nn®dEAÀý÷߆†íßp¹\¸sçNE³=8¥%NãÊ•+¨¯¯_Q†® ‹‹‹rÐ:ðv»=çoG’$ôööB„‚ÎÕÇÇÇóNO«Ö`¹{î¹@@û·ÏçC__$IÂÌÌLÕÈIñûýèëëƒÝnGcc#z{{1<<¬µÅbߌÐðmmmš·S-ã¢Ç/ùþÝôÁqFïõù|xÿý÷‡MçEÑ999ùR^‚”‘šT胃ƒÏ8n6îðáÃ6t½Ð2³K©)¥µžH$ ËrÁEA!e6› „æ2ÅPëó³\£XÏÕ[[[ÑÐЀ[·n1wöõõaóæÍ9_§^‚\×¥ejoÞ¼Yñ`¹þþ~lÛ¶mÕó¢(¢µµ===XZZª*7|kk+6nܸâ9QáõzÑÑÑááaø|>B*f½§Ói\½z ðù|Úó‡’$­PêÕ°i¢Áq¹ 2Š¢ˆt: .°LÛ?<<üg.\(o_mFjR¡ŽŽþ)€N£16› ŸùÌgÐÒÒ’µ0‚žT*U±†¥Rì‰D’$i®Ú|T6ª@h—$«å­E[ªù­º.‡@šüÔÕÕ¡«« SSS¦–Scc#î»ï¾œÊZQ,,, ^×ãñ`ãÆ –kkkþ}û £‡@^¯ÓÓÓ߀@gg§©w„FœŒŒŒ ©© 6› ñx¼¬Ö»ªª¸~ý:ÇŠx»Ý‡ÃQ2/g¡÷’(ŠY ›Ìù¼^/~ö³Ÿ±H¢(NMNNþ´ JLÍ)ôÓ§OoðïÌÆmß¾ããã«rϳý0–––´¤JQŠk'“I¨ªjèªeAEmžÙþ°šSÝ*1¿•×4*oi–¯NW8ιYu88tèPN+†V£oZ+;WùdÚþuaaÉ}i%>Ÿ<ðs%½††ôõõAQÌÍÍ•X:czzzòª×/Š"êêêÐÙÙ‰ááatwwÃívC„²Yï·nÝB2™\/Ë2\.âñxIZ¤‚ÑïUÛíÆÍ›7qçΖi{B¡ÐŸ-\ ¨9…>::ú;ö™;~ü8zzzLÏP!˜››[傯V_Ÿåü•š²’ë|`“-)òR]Ël^ŒdôwEÝÝÝ$ SSS+^û÷ï׊Çd#‰hé<ú†Š¢äÜH’„žž¨ªZ’ùlØív<øàƒÚy. ò4C’$ttt ­­ ³³³ó, ®paç‹ÓéDKK ÑØØ¨YË¥ X›Åººº²FÀ[åý(ö¨Šuís8xçwX¦mûÁäääõ‚+5¥ÐŸþy[<ÿ+†¡¸ÍÍÍxì±ÇÐÐРÈE,Ëšz³–¬uýùk±Í6èù:¦g¹iéç©u÷z©ËÁÒk°Âr®Ü­ÉÞÔÔ´¢ ÌØØúúúr¾'#f-8¤( ’ÉdÎëÒùrˉ¢ˆýû÷¯*€’}}}Z S¹­ËÍ›7žõæƒ$IðûýèììÄÐкººàñxJf½/--iðÔx¢ðÉd²*"à3S?s}>Ÿï½÷SFBˆ …þÖ2!-¢¦zooï#~ÝlÜþýû166—Ëeú^XXÈù£[KÖ:€g-‹È‚ÍfÓv¿fŠ=×ßÁªÏhõyu©æÏ÷º,ï‹Çã$ÉÐÝìóùÐÝÝ©©)444`Ïž=9¿{EQ°´´dø·QUÕP©å –Û½{7º»» ǰÄ~‚€¦¦&ôöö"—õÈ`ûöíyg¥°@Û'Së}`` $Ö{<Ç7ÐÚÚº*žþN ÅŠûÎ,8Ž"Ë2¢Ñ(.^dÊJú“ .T¶n5¥Ð7oÞüŒgΜA[[›éM’N§MëøVZ©[-C*•²Ìüj÷K-ö̈x+a6yYæ/F†Zñ*˜ow]Ó½½½èêê2Tþ‹‹‹Ìß«™çÇãñ ££·oß.jaÏF?ÆÆŒKQ¨ªŠ……-óÃlM ß‘ÏçÃÌÌLÉ-L§Ó¹"À‚X,A,Wò²,kÖûàà Z[[át:‘L&‹>nH¥R¸qãš››µ¸%º¡¡¢Uiñ,I’L×o½õËÔ&ŠâµP(ô–e‚Z@Í(ô`0ØBù¿a"óðð0öïßÏ”{ÎÒኲ–»ªª–[ë4Å.˲V#¾TT›û»ægÍW7Ræáp8ï¨izåú-9NôõõY,×Ñщ‰ Ó{baaAû-Æãq(ŠÂ´™­¯¯Çàà !˜-Ùoæ ë‰D"šB×Ëiå$¼^/6lØ€ÁÁAíì]’$D£Ñ‚<*Š¢àêÕ««jÀ;-"?¬þÎY‚ã<®\¹‚™™–)ÛC¡ÐŸ-˜…ÔŒBùu˜;zô¨V¤Á }0 Õ Ô)VÈ’L&¡(Ša´t!ÐÆ/²,kyìVRkжœó³¸ÂsÇ N;2ë'Š¢eÁrõõõ8xð iø œN§á¸h4šSÑ:ù©TŠiããr¹Ð××W’Üõ\9èFõ!ègеê*ommE?P__QF™6èÙ"àiZ[,+i¦\dÇåÂçóáÝwßeÝܦB¡ÐwŠÎ"jB¡Ÿ>}úÿ»Ù¸ññqlÛ¶©ïy±XªI©Å˓ϗ/úsöB¬öµ h+ñ蹺™e¢ª*Âá°%žº™0*?\h°œ$I8pàêëë Ç% ¦He*+pWÙ˜ÝC ˆÇã¬=´MÉÌA7ËÊÉF)•;p÷»ihh@ww7FFFÐÞÞ§Ó‰t:mh½/--áÎ;èììÔi)ÒÚX kKpœÝnÇââ"®\¹Â2õPÿ—>üðêhP }ttô°×hŒ xüñÇÑÑÑaº ³²ËZRìú³u–._¨Õn³Ù4%cv^늶қ–sõp8lið –3*?\He¹½{÷¢³Ó°@$ÒétÞçô©T ÉdR œ2B–elܸѲÜõÌôb/ô~¢ÞI’,?{÷xsΜÃËDɺ²xBÛº@ë¶â²½1ÃY Œ XØ<$OF€<ä!È[^öqƒö)p€`Àü`BÛ+Þ ‹¢V¤I‰4Š×#òç6÷Kw¨ÖÔ©êªê®î©êþ~À`fº¿î®¯º»þýÕ­k•JåGÐŒ²{úé§á7Þˆœ(ƒÐét´Û…¢°MÔ qÓ5¤S}&tø©T*P¯×g7}óä]h³ÜwT»zTÕtRdÕþÕjÖ×ו:ËmllÀ /¼ir}ò¢Ú§¤ÕjÁ‰' \.'zá ;=é53aggööö ×ëÍ& ‚|ß7^ûF^(C¢÷ÕÕÕY4LF5T*hµZÐh4f™ÿcíéQý«Hz–——áòå˪ßÚ¥K—þ»™Ô%ÃzAÿÊW¾òÏàm™Ý·¿ýmxþùç¥mk_Ä’Fl[…@?m¤€ÇJÃ}’@ÚÚéYýlCÛögߤ&„ÌïO6‹w…˦©Ué,÷Ì3ÏÀ«¯¾yœ0 aoo/q5.éS¢ò0ëy=z4Ñ _LAÃö÷÷aww÷@ŽmNúýþl}ÑûÊÊ =zVWWáØ±cðøãÃSO=•Júý>ìííÁþþ¾PÌÓ¸wè}FÍÇkº¸pá‚Ê!žÙØØøëK—.-vap@Ð766þž²©ÕjpæÌ8r䈴:e4¥þ"–< ût:…~¿¯ÜK4 ¤w|½^ŸE-&ÅÝU!O²_ö|-•J°»»›4iJê_2W/¢Îrkkkðúë¯KE¯Óé}I‰NŸ2‡ýÚÚÜ»wO9F666æ–Å9×Ýn¶··¹ãüÙý‘ȹ×ëA·Û½IϤ¸W*¨Õj³`À¶ò×9Ž—ïËËËðþûï«4«xžçu.^¼ø s©Œ‡Õ‚þƒüà¸çy ‘wÔK/½/¿ü²ÒØó¤áT±í"æ¡“ÆÉd2ëš¶°<>R¯×•ÛÛE¸(äI÷É›²•0™L`8fZÝI„D$êÅûðáÃpëÖ-‚êõ:|ó›ß”Öº%n'Ks¿ßŸEë²k¾ÝnÃsÏ=ÃáP©ª–7]çœôû}ØÞÞŽ=, CÇÐï÷¡ÓéÌ® Uóårêõ: ‡CåÚPÖ‡¨kX^¾Ðã¢ò­V«ÁÖÖܸqCåP'_ýõ¿8{öìBßJcµ ollü;øc™Ý÷¿ÿ}xê©§¤cσ HåÍJä º§ª  žNR@&Âsíídœª¸»&æi ù¢!ÓÔFÍ,G¦¨ÝÚÚ‚¯}íkÒ‡ôÑh”Ú4­ä\™ÔT;Í}á _˜]zÐ`Ç «žûÁ`ÛÛÛÒ6hÝk‰47t:¹è=n”]*•fߢšB<σZ­µZ Z­ÖìÓl6¡ÙlB£Ñ˜=Ø›þªêS½^‡÷ß_å¡di0œ½xñâDZfó=žÌòg2ƒcÇŽÁ3Ï<3{ÓR*ãU m¾¤¯¨=f:Âd2™UsÚ ïé8ʶÛíB¿ß‡F£Íf3Õ6všR©FÆlØÑx<†Éd27g·+Bnj?¶Š8 ië^ZZ!ZZZ‚ï|ç;RŸâôhW…=/¾ïÃd2QîO²¶¶ßúÖ·à£>‚ .p^+»çH$­2"!éu5N¡×ëÍ^ÐC—tlSÅ÷}8zô([¤™†th›L&3¡UÉúE-äÿòòr¢èŸŒ™L&Ú×êkUɃܥK—TvûNŸ>ýß.^¼˜Ý›}lŽÐß–”J%x饗”Ä|<Ï:7è^@¾ïC³ÙL4ïy¹\†¥¥%Ç ®YôN ~¿õzšÍf*ÃÝd”Ëe(—ËÐl6gcqIç&rÓ'¿¸ç,­sí¢Ó³¥¥È·!€ôè6%n¤_ù¨ŠŒêõÔh4àµ×^ƒõõuxï½÷frY¿ÙÞØdØ™®ŸiwΜL&ÐétfsÎ×ëu¨×ëÐh4"›$:Õj¦Ó©±òÌÏNó«2™ŸAÆ©S§ Õj©LVT‚àmø/Ò¦„•ú÷¾÷½f©TúDö†ÙØØ€¯~õ«³÷ž‹ Û[Qåf,•J³'IêzÒ>•H^’Îsãñ8µqìªé!yM zö2¶¶Dvýèöyµ¹\âÃt:Õž¡Œ­Ö…ô» ×F­V›EŠôõ¡›·ªöKKK°¾¾¥R îß¿§N:Ðì0aö†8Û®=dƸýýýÙ°8Ñ4«¤©Òä,q¤I€¼O@•¨"šf³ ·o߆;wî¨ìö¹·Þz뿾ûî» )Ü­ŒÐ«Õê÷`Ef÷Ê+¯@³ÙŒ¬&ðž®dOؾïÃòò²ñ“ìw?µªá¬a ’áp8›}‹tlY¤Ÿ¤zž®e!}È+eIÛ¤Îô›Y ®_+¼ôO&ØÛÛ›Í8E¿ßWšî4Iúº¹¢2ÓÎßr¹ §OŸ†'Ÿ|ò@ÍâƒMòcÃüä^ÚÛÛß÷gÓôýOzÒ›l>ð<–——go×Sa:Ât:Uš9î•W^sçΩìû¹ .|2„ÍJA…êöååexþùç…ÕíôHO¦ ²ãÝÈKKK©Ýàd ޽=î„y€T{“É$Hu¼ÉW¶&íhðhÌ4øñxk~mÓäáºÍ'¿³³ËË˨i4x0'ç…oòÑ©>ÏvFË8o5ËŠ¸×>i6èõz³ùàÓœ4Š´«ë¼O¥s\†°¾¾ÇŽƒÍMù‹Õ>ï·A·®ÊýÌ™3O‡aøy¾öÚkpúôiî°ö‚ÙÝÝUêJßø¤WgšÐ¸¢Ò’Hu<)¸LÏTeº#c£Ñ€V«5ëEÒK¦¶$¤éCžª×U½[}2™ÌE_ìyZZZšU“vU¯/ôpÐ8,úSF³ÙTŠ„“R*•´òQuæ¸R©ƒÁ®\¹¢²Û/¾øâ‹uáÂóïÛ•`]„†á¿ÉD2žçÁ«¯¾z :ç]Ôd¦3ÅcÏöŸ¶˜jµ· ѵqì:ÐQ;ÝCÝV_EãI4Oªî§Ó)A0{@#âOüÒômÍUT;–ˆX“¦(RCF:0µÛíȾ¸L’—ãØPÕ.#‹Z9R•_.—•óSÔ9Žç÷ÆÆüò—¿TÑ“êh4z þR-åæ°QÐÿµÌæé§Ÿ†Ã‡Ï=]x:Õ/4YtÔÇ#ÏÂN&!sg“ö6[ªäeª]¤}ŽýÎxt-ÞDD.ŸoÏó¼h‡øJ–“aäS*•f…1=ß<±U3l¤F @Þ|·“˜ bNÎuV¡lª°‚.òûøñãpòäIøðC¥«ý[(º Ÿ9sæ‹Aœ–Ù}ùË_†F£1«²Ž"Î èýf5šì†Î³°Ì··‘ÉcêõúÂzÉ'E6«ij¡Ež|DÂOoËŽ‡N:$—>zº]vÂÑC()¼‰@“oV¸IÞØ^U¬ ™mí¼×ï÷aeeEXó—å»ÁóH’ëˆÔ®©sæÌ+?þñÏÆNX ¬*-ƒ ø–Š™HF–©¢÷íê`º LJ^;ÐÑL§ÓÙ8WÒÑ…´cç2¬Gå…ˆ*-ðôoöCoC~ë¦Mô!úDÿãäC–¤ñAšõƒp8Ýx<†­­-h4°²²ràa/N9åBtNö•e9çáh<+5³ž ¿þõ¯UvûÏà‡Ú‰I@v r<ø¦ÌèÙgŸ…ååeéÎÈìeq]i ¹ˆ3m! å"î<€Ï>û WN‡ÛÀIç÷—½ï¤»V«ÁéÓ§•Ýó¼?Š X#èžç}YÁžxâ ¥ý™ˆ|»Ý.´Ûm¥y¾u Ô,oä¢Dí4DàúL„½R©BàmÀf1'íàD¼‡Ãa"1ÈÂW×ÚÍÙ}“7ä‘ùLí7 ¾’7êUªªDµõA/•JpêÔ)øÅ/”&ƒ“ŽÚ2‰M‚þÙEuìØ1¥êvò´” fïn–=Uê;鎈W!²y\a£«ç DØÉw'+Y6 9y o“/Gr]ÌÓ„M7)S[­V¢É»È~§Ó)ìïï'ÍDˆêCBJ¥<ñĪEüîw¿[{çw̶­ °FÐÃ0ÜÙ|éK_RÚ×t:5vAûûûÐl6•ÚdÂ>g¯´"Fïó<À£ö1ú5šEl‹O‚-×4ýÚ\2t5­‡ç<ˆyÖ甩Ãá0ö«–I{y’6sÑ~Et’=­­­Áýû÷e»¬4›Í'àc‰ŒÀ Aûí·ëûûûÇev'NœPÚyú6U“êñáp¨<šv2ÌÅÔ“d1z'^®ôì^䯥_ø‘שG“²1‚`îe:ä“UÍŠy²}“æ2'?¹¿Då©e!ýÒ *ͤF̽pøðaAxØÙ»8‚ÞívŸ…!tÇK5MÒ`z2ƒÉdN|ߟ½×WtÒ§ƒ Yà5ݰ…™†¼„ž®´ˆy”6ä>¢çͧ…{÷й¹}“s ðh¦Az¢"2‰Rç[EЉ®°oÌáyžtô–)¬ôÉdòœJÏuÞ›ÕhÈÉ «RÓèéÁq•tú£"— ü¢ ꨼¹éÉú·Î«˜g-ѹ ûU9&=3))ë5šOÓ™"ƒ‚îûþ•“%N‰«MbjSZL":yó“ [È“µ²o#ãýxT3Ä~³ù-«MwL–ó¾I5(ý¡—¥EÞ…¼(Uí6¾‰°´´4ûMÆýý}¥}†a˜™ÎZ!è 0CÀÁ¹¬EJ5-“Wag‘Ýè¶ú¿¨ŠT=.zZ[Û hs»÷ŸY§›>^£Ñ˜ÓR«¤ò‚–Ï1Û?+= Ã#*våryn2*ÑmBj[z²ÆÕ‚&¯Øv>ù`•—c¹ºÿ,Ïol9Ýw‹ôŸ!ÃUð}?³©­tÏ^ëh2j±MH‹:.±Û„ÅÜ\O?-sÆ\û9­ZÝþ9ñ^û+Þ¶æyž´Ó€çy³wÖÊÚã\\6^aˆoÍB²ÁÖk ÅÜþc¸ºo¾ïÃÚÚÚÜ2òÒ¯íímåý„aX,AÃP:`¹\žõ–‰z’^³¶j¶¦ q›¯«E¦ ÅÜîC‡èˆÝï÷…¯ÍàžÑ„E`… ƒb·þ `<ƒçy‘oBKŠ­…€ÝiCÜÀækhÑiC1·kÿ‹beee®g;ÀC_{½lmmiíË÷ý̦µBÐÃ0”Ï¥ú9dBò­z½žÚTœ6_¬6GWˆ}¸p½,ZÈQÌíÙÿ¢®2ÜêêêuNnß¾½ð‘'QØÒ)N2ƒ‰Î}߇Z­aÎf–R| ޶ušãé6 8aÑi\ä(W‘G1¯×ëðä“OB£Ñிyó&ôz=Xü5+Â)A'™È{O9y‘F¥R'NÀ`0€Á`0{Û™ª3Iû:9ŽÍ ¸#¶6<Ö<Š9¢NµZ…V«Å­b§ÙÙÙ[·nYþœtBÐëõ„ïF÷}šÍfäT±6W›¤Š{¾±½ AÛPb9 C¸té’÷˜s‚N2u8‚ïûÂêø LADÆÕ«W¡ÓÉläY"¬è—~¿à­g‚ b‚øôÓO eœt€^¯½^ωêAÄ ºÝ.|ðÁ©¾hÈ4Î :Àál{{{:¯³CA.ûûûpöìYÝ dŽsmè"¦Ó)ìííÍf‘‹Û¶Ž ‚—;wîÀG}äd€˜A' øàƒàðáÃpüøq8|øpj3Ê!‚ ù`kk Ο?卵Rº¨vrz]†°µµ[[[ày´Ûmh·Û°¼¼ µZ ªÕ*”Ëez€}R¼3aîÝ»wîÜÍÍM¨ÕjpäˆømÞ¶ß¹P4™ÐïîîÂîîn†)§%ímŠfŸÅ1l³Ïâ¶Ùgq Ûì³8†möq·É6h´ç;ÅÙš±&°ñf³ ôÙ¼½ ÏæímÄVlM‹³º+Lp-½<°€1oo#è³y{)¢Ïº¸æ³“ºk™‡"Þlè³y{AŸÍÛÛˆë>Û–œ‹Ð]Ìdqýf‹úlÞÞFŠØTUDŸÓÂe_ŒÐ]£ˆ7ú¼xû,(¢Ïº Ïæí>(è9 ˆ…j}Ö}6oo#Eôy‘ØœN ºÍ)¢ˆ7úlÞÞFÐgóö6RDŸEØî›S‚®‚ínš"Þlè³y{AŸÍÛÛˆ+>¸Nç:ÅEa[†Û–ž8`cÞÞFÐgóö6‚MUú¸äSn"t—2]DoôÙ¼½QHŠè³.¶ùÀ¦Ç¶ôÉp>Bw-à ‹·Ï‚"ú¬ úlÞ>oDùokÞ8-è¶f*@1 Õ"ú¬ úlÞÞFÐgóöYᢜ­r·=cm#/7›è³y{AŸÍÛ#q!‹Ð]ÈÔ"Þlè³y{AŸÍÛÛÖ¶ÍãJztW25mò~óð@ŸÍÛÛH…¤ˆ>»‚kyçl•»ˆEŸ€EßE,0ŠX¨Ñg]Ðgóö®àb[z®ÝÖLŽ¢ˆ7 ‰ôÙ¼½ ÏvâByäFÐm86¤ÅSúlÞÞFÐgóöyÄåëLJz½®´ßáp'I©âÂ9sNÐ]ÈÔ4ÀcñöYPDŸuQõ¡ÕjA«Õ’Úw»]èt:Òc4 h·ÛÒãöz=ØÛÛ‹´ Ãêõ:¬®®J÷7 `{{[jW­Vammm¶ãñîß¿ÏMM¹\†£G ÷CìÇã1\¿~]š¾r¹ /¼ð‚Ôn:ÂåË—¥vYâÊ}ã” Ûš©y(TQHä ÏæíÓ¤^¯ÃÊÊŠ4M½^vwwgÿEöµZ+À¬ýp8œÛŸˆR©¤$èÛÛÛÒ€åååÙþ¢|ît:ö'z€¥¶ 0™L¤és›®gœtW¹víôû}ð<|߇0 gßžç€çy³ÿô2ú¿ïûsËÙméåä7»=}|v?¼ýòì`ÎѾy~íDë <ßDûc·çC–6²žõ‡M}<ö7Q¾Ñé!ö¼ëƒ¶‘-ùÁ;‡QÛfË Y‘Ÿ]ôÁÅ4£ 'Då¤÷û}ØÛÛ; èp@ y‚õÍXòÍVö‚¶aň÷Ð zÙÒðØã‹=T°yÈûÁý±ç`úÛó¼¹íéíèõ<ßTᥟÝ^ôÇ.]ô‰Ê±Y¿\¥ˆ EôÙ$.çGnÆ¡ÛL©TZtœÂåŠPÄB}voÿq(âyv…Ü º :bš"öy(¢ÏºÑç¬!µ]6’A_D&«¯È‚në…Ÿ&E,TÑçbPÄóLc»?¹tÛ3¹\Æ® ¶`ûµ¢B UôY4.œ§}‘U:Ç-r„î:($æím}6oŸl®bgqJÐÙÆ®€‚®†KçÔE,T‹è³.yð}ȧ°èLÖ=~ªÜçH<Š(žEìD¥Ýv¿œŒÐ]Ã÷}îŒhHvQHÐçbPÄó¼\È7TMâÞ›.ŒÐ“cë¹Ö¡ˆâYDŸuɃÏyðÁuœV™¬. ÇAAÏž"0EÏ<ø K}¶ÛóÝÉÝöž‡¼´¡ ÏcóùK‹<øŒ Å ˆçÀÍŽp4Î º ™Ê£mè6áêuBSÄB}6o$Ãö’Æ9AÏS7›Kº+¯IŠØ‘¬ˆ>ë’t)¢Ï"\Ë ôŒÀ=;\» y xš··‘"úì .V¿»6.“7›Kº¶^Øi’ŸQHä ÏÅÄå9¡G±èL–ßó¼Â½ï›eÑçÈ(ž‹·ÏO„‡íç-‚n{&òÚŽn E’<ø¬K|Ð¥ˆ>Û† çÀi…I+ƒÓ($=σJ¥AÌþ{ž¾ïÇ:æ¢ Ã°p5(žæím$>èRDŸUq)oœt—2Ù÷}ð}ªÕ*L&Ã|߇0 gÃÙˆ@Á/:øÀ`ÞÞFÐgóöˆ9\Ë{'=ÍLN+:÷<ªÕ* ‡Ã9A'¢ElJ¥L§SvËpíÆæçÚÆ‡ªüSDŸUÀqè)ãÒ|4¤j½R©Ì„›”¤Êü&¢Á,šw" ƒ.y9ÏiÚçÌ#ûqQÈ 6º5%}Z'‹u¥RR©4¡Ó‚N„Ÿˆ_¥RÁ``U„è>0äᣈ½°±Ã yŠès\\È«E ºÇ|“u†«Dä•JÊåòLPx‚N ïûÐjµ`:Â`00žþ8 ¨Ùë>`¸Чœ"úœ\9w‹tãB<ÃÓ›·GôIšÇT§¸ÈÃ$:Èç˜tVœÙ*vÙ·ĽÙèÞìår&“ 7Êf…[©‹ªèéÿKKK°´´£Ñö÷÷1jW ˆ…*ú¼xû,°1M6c"¿8Uîô÷Üá’Ë„ óD\+:ÃÐX„nãK"tò‚ºíœWíÊrQÏxbµÞ󘎠‹„X¡«TÇ+“õÅiâæa‡­‰"tÚž×fN¯§¿yÛ˖Ɇ»‰¶¯V«P­VáСCàyL&è÷ûÐëõf/Œq‘, UÛ0PHÌ“ŸóàƒI’æÓ†NGß\sàk¢R"’ ºLÔ£ÖͪäM[³:.—ËsÏÚÑßì¶ô2¶ÝÝNÔy.N4•"ð«««³ã’ÎuƒÁ€ÅÛ(n®“‡B¸ˆ EôÙ%Lä7#è"ÁVY'EUÐã ·j•|&,*"¡ÛÐéà‘Àê°jïö(Qû<íƒLtEi[^^†ååexè×`08ð±©`*âF…$>èRÄól“ù ¡«½4Q*‚ž$²§?×Б-Û†.‹ÔE6´{ v}”HÓ"΋ú£ŽeG¯kµZ°´´4·~8Â`0€Ñh4ùÑht`?E[×}.¢Ñç<¢ÚŽ®IT„®µG&@WÐM‹:0¿Sc‘7;SÜÊÊ xž7WM qr\²Ž×# C®H³ôzYç7Qo{ªí÷"»F£FãÀrÑ¡‡0•ç¢ÇŽdæím}^¼}ш›?T„žTÌÉ2!2AgwÆ_¢n„E^²cÓ/h!íÍ"âTu³"Ïþf×ó¶Sµa¿é‡‘¨ ™OÄŽæ Dä'“ ŒÇcF0`<Ãt:Íäüµ·½Mö6RDŸ‹D‘U´έ,#x‚eÂĨz±V]FªÝ•ˆ[²è›‡ŽÐe¨DÅ:û!¾Ç•8iÕ*D­ck(x$õzÚíöåä7yò=Na2™Ì>Dø§Ó©VØL…}6oð1˜ªb®+úsD zTtnZèaúâMëæáEkqªµÙã‰"vÞŽoª¬_*¿yÛñö˦'ªOh{Þ7ö¨Ïd20 gßÓé‚ 8°Œ0äÁ‡¼’FyF[# 7oÖn†HГˆ¶heàe‘䤤QÀB?ÍcˆìÓx艓·¶µÃf)$iä—ÊÀ‰m\§>²ñ!É!XᎊÜebàÄ©¶¡«þg—‹ÎIU§ÊÅçû¾µí¤E¼ÙÐgóö6bÛCUJå€êôŤ©E–¦¨ý±Û²ô©ØD¥‡éýM&“A‹ˆ7n(×ftk¯$UîQàdÿ¡ó„Z¶>IÕ¼Õ ‘:ªæímÄ6!É‚"ú¬ËƒàÁƒR»ét ÍfSês¿ß‡«W¯Î-ãm3N¹C2Yûñx øÃ"IúfˆöG\¹rEj†ál²šN•ýˆG¦°t:%»E!k¢ŒYÛ&ÒRaZ?ƒ'èI„Z'bOˆª g5L¨ˆ…$ ‰ôÙ¼}š”J%(•JÆ| ûSµ—áû>T«ÕDi¢!óa$ISÑ­]! N¢¡ÚF®»~îFu–EÏ2aÖ]/Eµj¬\.;]ÀP<å Ïæím}6o„7‚†Wņ‡É*^¿~}—ú§ö›µ®W®"ÎQ”­Wº3*•Š’]Šx³¡Ïæím}6oo#yðÁ4QsjÐA ªGûƒÁ` ñ^‘­ž «›··¬ms:BÊÓÉd¢Tc†áõ7i­w”-è÷rOb•X%Aßßß\ON@«ÕRÙÝíÒ²·ôÙ¼½QHŠè³.yð! TûRt»]¥ý}.èÜ!fŸ#í¹®c«óêRÓâ>û=N·Uvº¹¹É]ζqT«UmQO,0oŸEôYôÙ¼=b†rYõmâ;;;r#‚à>g±I-ƒtNjÆl'“Ém•|úé§ÊÌ<þøãJ'(7 ‰ôÙ¼½ Ïæí‹B­VS²›N§pÿ>O§¹¶þ3mÕ‰ÐÇ`0PšõöíÛÐëõ€ßó¦R©Àúú:;v †òüߦ)â͆>›··ôÙ¼=’-¾ïC¥RV«¥49®v÷î]¥ý÷ûýKÔß´D}†zCŠlmm]îííÁõëו¶FÇîNùÀz¶3XAÚ »Î¤mØétþO„ÍŒøÍo~gÓ‹éh"Îñm?†j¡jâ&Ó9v^ÄSu]šé‰sü$Q¢©í’ÞiFói䣉<6 ˜Š’³J’¤- C8wîœrÍËöööOèÍÙCD>®­n„nTÄéüñ¯@irÜßþö·påÊ•YÆê^ìqŸæ³¸U‘ô$íè6®Y‹O“ëy&¹VÓÈÖnªûP½Ž²ðÃd¹fkM€‰´}úé§pçÎ¥}Apãoÿöo':„ä¿êºð=d~ë³–ˆÓܺuk{4ý߈íg ‡CøéO ×®]Ój§H¿ Éêi_Ç.íh"É~mOcÊög¦"Ï,¢kÕít}Ê‹xê#îõb"=²´Å±ÍúáaggÞ{ï=åý ƒÿ 4OU°ë-¯Ÿ>ïÍh¢·¤ÉÖól…ëÛíö­ÕÕÕ!öçÝnnݺ=ö´Ûmé°´$ã"…D—Hâ ‹Î>u×ëngãCOÛšª•ÈÂç4Ž¡».mñ4µ]ÚåZZ¶Yû¸³³¿úÕ¯`8ªîëÁï~÷»v:» ‰0O¼eâ. žE‚.zª ‘®¿}ûöÖÉ“'¿Z*•žä%–eoo®\¹ÍfÚí¶pb—Ä4î±\s›k ’l›F-I’È3.&Ä<Í=²>Éu´hñÔÍ+ÝóœäÁÊ&a5¹Û àÖ­[Ú}¶ƒÁ_;wî7ô! ¹xKÅ€/è|QO}G¬Ÿ;^£Ñ8¿¶¶ö(¶ï¸xñ"|öÙgÐjµ R©@µZ/LzA&µ1-æi# ÁL#*—íÇ6ñ´)º5yFÙ¥ý §k«»-âw»E‹¹Í"Û `wwΞ= .\€Éd¢¼¿ ®¾ûî»ÿ~4MA,Ô²(]'ŠŸ#jjœ¨èš^–týœ¸onnî<÷ÜsÇ*•Ê鈴͆!Ü»w>øà¸vílmmÌfþa'°YHttQñ…JIDATì]z I£`É‹xFÙÚyFm—vþ‡¡]=óã#i Cbn‹°¦yLö›N§0assΟ?ç΃ÝÝ݈=ð¹ÿþ¸|ùòUHGÈ¥Qºhš"´>Ì ±Ê²8ÛÌ-;räHëë_ÿúÿ.•JÏ Ò§ÄÒÒ=zjµ,//áC‡fSýù¾Ÿj»»*iC÷fXt´oâiGE&Å3î>’OWHT÷'[g?&liû4ÏU’ô,«»—÷Ã{@&e]Ðív¡ÛíÂöö¶V4Î2ÿúg?ûÙ‚‡bÀ#—ÅÙfލy´æ6Þ‹/¾xj}}ýyž§6c>‚ ‚,€ >üùÏþÖþþþÌ‹¶h?ÏFÏ}Õªô¨vsÑ2ØÜܼøðáË­Vë=Ï[Ì›UA$‚0 ¯Ÿ?þíÍÍMRGÏ 1?¢V­z—íg†É6tËæÖ]¿~ýÚc=v£Ùl¾ñ§©EAã„a¸yéÒ¥uùòåÛ .ÆqD;RÈ *:€yá–Š9áÚµkWVWWÿ¾Õjý¬~GAl ‚Ë}ôÑŸ^ºté˜pe\t«ÜyQ:ÏNwY些7oÞ(•JÿïСC/û¾4b{AI•ñxü7ï½÷Þ¿ùä“O¶à øšp`Ögݪ:€Xœu>¬‹ÚîÝ»·{ãÆŸ<þøãÓJ¥ò2¶«#‚ Y†áÎÞÞÞ~çwþ|üÎklǵ¸®ÜvN0]ånbPðÇãqpõêÕ÷Â0üY»Ýn•J¥“(ì‚ HÊ ƒÁÿ8þüßÿý÷!Z°iñ ¾l;ѲH¢Ä•µ“5K¸Žg QÛ¬¯¯?yòäŸ4›Í?ñ}ÿ E_ADJ†÷ƒÁO>þøã]½zõ3x$âQ‘9/R7µ.UA'¶&[GÔ•>ÕjµtêÔ©‡þ£f³ùrµZ}Åó¼Ã¾!‚ L§Óëãñø·NçݳgÏþM§Óƒºxë¶®¨KÑt€è¨YQ+ÚÄýÐéó666ÖWVV¾P©TV+•Ê¡R©´Z*•V½Ïç€ ÃíìwƆF%ßtóA¤(¨ׯó<Ѷa„ w]†ƒ z£Ñèv¯×ûôîÝ»øä“OîA|絡«´³‹&’L"Ã#ŽèdV¥ñjû(›¨eìoÑ`–ñþ³ø 6‚ E…¬(ÞÿY&ú¯Ò&Õöe“DÄu#teÊ:Æ”ƒ"PŸ&€‡¢G¶ #¾y‚M¶gÓÆ xj‚îQ¶@-à‹³L°QÐAøÈÄŠ§3"Ag¿U«ˆ{”Øó¾ã<ÈŽ§ŒiA§ZZÜyߢtˆ"sgEA$]ltúw‘ûQ©Ð"Ž “ðEÝI‰ož pìBÎïâ zÔrÙ:A"%è"}Qtö[%2ç­OCäu¢ymâ :(P§J]¥Š=¶¬ÍœµÎï¨oÑ2à¬×]‡ Rd)èôoÝe<›¨*xö[Uøc‘DÐAãÀ²*õ8Uì*Ë@ò;ê;ê7 ¯Ó\ì“‚ RXA*3y"NÿŽ+èôï¤ânꛤ‚IÀÙ—,JWöPò˜eÉ£sAh¦¶ª‚N~ljÔÙÿ2!ˆßÞ¸˜˜ž¨¡hìzú›'ÞQÕé*Ëx¿ÙoYõz\A/iÚ#‚‰²tÞ:ÓQ:€¸ÊµÕg.# Ñ‘‰sñN"âQŽ‚Ž ’=Y :û_ç·j”¾Ð¨œÆD•;‹NEUì¬j;¹J;pÖðý¯*ÒÄ'A„jd*sú”È«VÁ›wÝ*x£¤EòªÚU¢s‘-»,ê?û›·8ëÙe"i<(!‚䉉¢IAgÿ«{”¸« 8ÏÖ8i I´z½×i[Yt.ŠÔy¿Ùhþ-SùÏÃX›‚ HNQ)'EÎþ— :û½È*øÔÈ"’d3Z™GU¿›¨bú-SùÏA$[·LGÜUÞ‹žº˜dßqKV®R…®‘ë|‹–±ëDÿY°ÊA$Y•»LÌéeº‚Î~ëFæ¼õ²mS'ká¡«áExT¹N;ûM"xÞzÑ2vl#tAhâÎåÎ[%èä·‰*ø8Ë2cQ‘¤ª°³Ë@ówÈY`FУ–£ #‚DwêWÞ2AgÿÇým²®r¡˪ÛÙoV¬UýµL´Ç¡#‚ˆ ?=Ž˜‹~ë:û-kO·JÈ ¶‰Žn;¹Š «FãqGYÓA¤H„ >lØG-StöÛtü±UtâV±ƒÂ·lû›M— Ø)A$š8ãÐEËã:û÷·5Ø*èñFAGq›·Œ·_«°]ÐiD¢Î[õ-Z&²á¥C :‚ H4*‚®+æôÿ¸‚Îþ·2çá’ðÐ™Ê xÙ ºlörG‘!+'£D4 Ag¿q—"t*=ÜAa™ÈFv\ØËADŒ¨—;½^¶½ÈVEÐÉo‘ ;‰KºöD˜tÞ–¨jytA>!èC­7!è¹ ‚Ξ¤4]d‡¯OE‰†tÕ2Ó” çŽ< : ïDŠ]7Êöå&‚ …&ªÊ=ЍêvzY®Å›GGÔ ¥Ó`§8Ah┓²è¼ÐUУ0qÁ  #‚Dƒå¤aPÐÓ/TA$S°'v:`¾"‚DƒÕå†ùÿ¼ i¼köIEND®B`‚WereSync-1.0.9/docs/source/img/gui-example.png0000644000175000017500000015052113125115721021723 0ustar danieldaniel00000000000000‰PNG  IHDROjÂÀa¶sBIT|dˆtEXtSoftwaregnome-screenshotï¿> IDATxœìÝw|eþÀñÏlÉn’M6½@„„n¤wA8ô𔟜Øð=õ8φz*¢§žbED,Ø v)âq"%‚ ‚]%@IHÝ$›-3¿?’`›ÂfCø¾_¯U²³óÌ3³;Ïw¾ó<3£lÙ²Es:8òòòøá‡ˆŒŒ$ !„B!„8ߨšB¹ºwê@PP~~~F” 6hv»åË—3räHúôéCXXXs×W!„B!šM~~>6l`Çž ès&“ eõêÕÚÊ•+¹õÖ[±X,¸\.ÊËË›»®B!„BÑ,t:F£ƒÁ@vv6 ¿þ–}.À››ËW\Ahh(Í]O!„B!„hVªªR^^Nyy9ôè’À‰'ÐwìØñÑ믿žìììæ®£B!„B´8¡¡¡¬^»Cll,š¦5s•„B!„¢åñóóÃj1¡óóóÃív7w}„B!„¢Å2èuÜn·ô< !„B!D$yB!„BˆÐ2lO!„BqN8~ü8Û·o?¥sHÓ4¶mÛÖè›äIÏ“Bˆß93YþÆGl-äâ›o Oˆ(çÀÂWùbŸ“ ¾×1ud,Àuló?Ü‚-2•›oèGˆ®)*¤aÏü…•+7±;«UgÆÕžnRÜ%´"ˆ !„h•¾ýö[ÆŒã³2óóóÙºu+ªªУG4McûöídffrìØ1ú÷ïOHHˆWå â>æ’< !„ÀK¯ŽF¶nÏ##ÏA/« Å•ÇÁl'ÅGQìŽ!D§a;ž… éÚ«N£)ˆVúË>_Í>`¢S˜µ‡tÛ0†j¹„¢õóeEBBééé9rEQp»ÝdffбcG"##q8^•²çI!„@ƒÐ.m`û!²2òq%Ä ·e’Q(@v:Ùå½1;È;˜ øÑ>É‚¢Zrˆ¿[ÅÏé'(׌Xã“I¹d]C à8È¢y_°¿Ýxnèy”ï¾ý…c!c¸ùšd,¥µÏçÊ?ÀýG¾‘FGîN”Ÿàûמc}ŽãocBgÍÆŽO^gÙQ˜Ñ·pm·<Ïû‚ô¨á\Òö ¶¢Èe ¤ã`Æ]:€6&¥b•K±qE¿ìË¡LÕaë@ÿqd`Œ±¿!„Þp: 4¶oßÎáÇONKNNæÂ /$77×ëòeØžBˆS¢»Á!rgQ¢FaÌJ'3]†³çÇã8á ST‡²Ü@{ºXTW.›¾üœ²"{ ¢³ßQ~Þ²™¥Ÿë°NNŒâ‡IõÐ*>9`Ã(mC1»ëž/Ú…°çogÝOíèÓ&½Ñ½ÁÀq¬_t„£»2)Kꈹì(»i $2"ÙŠâ¶aR@Ë\÷9ÑôèÝ Ûö_9´-ßlìÀ)QÜ'ØôÅç¬Ë¢‰³–s<ÛAPAâ¢B4+VœÖ¾._¾üä¿Gݨò5M#;;›=zpäÈ  £{÷îrÍ“BóoGBäæ$ÏÞ}z±tïÒœÓ8|¨rKGЍÎDèUœYÛø5ˆÍUãz¨sWüŸîûݹCˆŽ¨Œ1ª cïIÜ1qqºìÿß²:狉ëÏŸmæÍöñ«lîПaÃÒ)ÜCâPuŸ’~pÇìí‰9º“ ôÑVïFuUÅ5úLý;noÀÞ£„ÇÞÝKaæ ìj$¦ìÿ±%ìÏí3&‘è§Ã •râø l.‰‹BˆóËŠ+êœ>jÔ¨&¯ƒ/rMÓX³fÍÉÄ //´´4.¸àEñºl¶'„âšD§8=?ývœŒ¹(™åÑ™Øð8Ú›ÓØrðчÈ,‰í0£QVœC1@Îræ=»¼Zie”ªÕaFNBŸw„CªJy=ói.71ãïãÎ+XôõJvÜÄÒƒéô»êjR:´chW#é;²3«veàÆH÷±(nµÚõP‘t0æq<TB0¥€†Ó–‹ еíF` (:šä.!„Ü|óÍ'ÿýÆopË-·œ|oÿþý*_Ó4vìØAVVݺuÃívóÛo¿qüøqE¡G^'PÒó$„¢=áÝbà·£9|­ÄcVéÒNaËÁt8í-hª†Ru§½6£¸þíñ«V–)Ȉvò ]© » ói.Jrr1uH妇SI_ò¯­Éáç éôiÛƒèÁÉøïÜÌÁ­Ûqq‚ß… ˆÑp»4~Ïžt ªhš‚êRù=\jœüC+£Ì­a4MN( !ÎOÕ“OÒÓÓ½ Oeø¢Ü*………'‡æuêÔ‰ /¼ðäM#ÒÓÓÉÎÎ&..«ÕêUùr·=!„5¨¢“å(¹»v¢¢§s' š[!¼K ìÍdÏov ž®¡4Æðx¬dPX”‡6X“h¸JKpR™Ì UÆœŠ¬¥îùTÊŽïå3†±nìÅz;Äá¿&‡2—Uuã@ï Í¬OßÂ~ÀÜ»n7.Sz4µân€Õ#¦1$Š2(=º‹Ì²6Ä›T{EJ0!¦&¹÷ºB´Xûöík–åúún{£FâàÁƒôéÓçd"5`Àôz=:tÀ`0`·Û½*ÿdÏScÆþ !„8—h˜@{ó~Í/âèªCCÃ/6+ëÈ)Â;m¨ÈHtáýIí°žE7óÑëÇIŒ1ã(ÌæXiWÞ0’ØZBLÝó¥bÍÛÂ7Ë2AïOp ŽÒ¢\@LÏNëAU-ôéÊúUù@ÉýBQÕ†÷"úÓ/ôg¾ÏßÎWï!2X£(W¥Ç¤É ‹’äI!šRUO—/“6»ÝN`` }ûöåøñã'ß?~ü8}ûöÅf³QRRâuù'“'½^ßøÚ !„8'hÓ¥­Â¯é„u"ÆP9B!0‘~ëØêÿñ˜µÊ‡‘ž7Ý…aé—¬øå û~üBIÐ0½†æþý™Lšöû3¡ê›ÏØn;®eë>¨-=SÆó§þ ÊÑpá×ù¬«ÖRhéIŸ`õ÷^.Íó2©öž[ "õ¶àÓ¯ÙžONŽŽ „¡t Ñˈ !„hb{÷îm’rm66›í´÷«®ƒj eÞ¼yÚ°aÃ0åyB!ªnבÀ™Ç¡ƒ¹”k€.6ÛbÑZt„ýY¥¨U³èÍ„DFdÆ š‹²ÂŽåãRh“‡E)ãXúaŠ«wÕ1ŸêBTT(#:@s—SRKv^IåÐ<Ç6|Ìç?bvSûqVUHwú2uþ1$´ F_žÍŒœ(~ÁDFG`õ7 n{Ç3sän{B!N±råÊŠäièС˜Íææ®BÑ@.Nl^ÆŠ™Ï/]G&Üy%q.’ò!„h +W®¬¶g2™dx‚BˆVEQ‹ÉÎwàÛ‹Ñÿw9ÝȧPb™Bˆ&dßÞáB!„hzzbÆ<ÈË×øá./&ïx'JÕúgB!ÁÐÜB!Μ†=g?»sš»B!Î'Òó$„B!„ ±B!„Bˆa{Âkš¦át:q¹\Í]!ZƒÁ€Ñh”‡Ž áCš¦¡ª*nwÃ~,DKãëc%‰7ÍG’'áMÓÈÍÍ%??ÿ¬&OÒHœšc(±·¿­êu5 „††!¿U!|@Ó4l6ÅÅÅ-âDì×­Ws_¢Rý­oZ7Iž„WœN'ùùù$&&Òàù¼ÙÁ¥Q8yìÎdžE‹qÝuס(J£‚YÕ2;Æo¿ý†ÕjÅÏÏÏ«ò„¿s»ÝãïïÅb9ãù%~ˆ¦àMlúþûïIIIiôo²jÙ………äççK¼i’< ¯¸\.Ün7¡¡¡²ÓŠVK¯×c4}V^LL »víÂårÉ~!„T ׳X,>ÝW…8Û|o¬V+¹¹¹oš$OBˆóšªúîÙ@ªªÊ™n!|Lö)q®ðåÐAMÓdßh&’< ¯)Š‚N§“W´jÍ=^Q?‰3BœJö‰æ#É“ðZmÉSÿþýùé§Ÿš©VBœ_'OЄð=Ù¯„8Uc®Õ#É“hÙqEkçËa{Ò‹%„ïIœç _ÛÍC’'Ñ(µù`'Z ƒ.„¢5’xÓ!=O¢µóõÝö„µ†Xä<À{·=È‘¿ÎçÁþªªå>öÿ¸c#W¼ö‡«än^È{ þÃú]ÙØÑ™ÈÀ«îæŽKÛ!7®\Û!ÄédŸh>µ&O®Ã yúÍßðÀë<~…ò¬}ì-‰ÁÜ¿+](©O¿É½É~8 ð¿åïòÒ÷‘óÔ LîêÇ*ëBINé{¶k*„had zËÖ*b‘1ŽaÃ,<øíNJú À¢¸9¾aÙIè 'Ö>ÇÏï§ïMÿ`öŒ.DJÉÙ¿›Ã–0?/„­HíÉSáQŠ­½I¹0– %ô&âäT•Ò}ß0wöÖ,F±vbôM÷róȶóWpïÍË™ðÎ3 (aãƒaÁEóxaø~ûûJFÞÉÒy ÙÓîÞŸ5Óþo˜;û#¾?P A]¹æßOqMG#eéK™óÜüpØŽ¹ýp¦Ü?1ñ&ÏÉ:ü¬ñô»ê>fÝÎ?ß^ÇŸM¸}Ô\îL ÏOùˆ!³ï gæÃg ûUœ1,ßû:·>fãÎùwÓ9ÓsÔ«yô¶w‰|ôuîènöáWÒzT ”ž'ÑšÕLxöîÝËí·ßŽÍf«u‹Å¼yóèÔ©ÓiÓä·ï[­#‰>œ –³ÓÖŸA ¸³ae6IWõ"ı›yó&áo¯q×èˆÊþ´KJ»ÊÜS„8çÕŒ7………¬]»§ÓYë¦|ü3|¶d1ÍÊ¡7ç“ýŽz¨æ­ç­¯õ\õâ'|úø0¬öíÌÿ×l—<΂ŋùdöŒikû.Þ~ücÊ.žÏÆ3—dóÞÓ_r¸ößW5fÚ§ Àrp#‡ž—Rõ{3Æ‘šÌŽowS¢”“±j#î~cè¢Ô^9†®}{Ó9äü>g(;®híTU=啘˜Èܹs±X,?o±X˜;w.‰‰‰§Í+Ãö|¯µÄ"cÛa ·îàÛ]64ÀµU9IŒéeÅ•¹‘-ö ?8¢Ö +1¥vgĹÊjµ2jÔ(ÌfÏ'LÌf3S¦LaÈ!øûûŸåÚ‰ÚÔš<)A}ùû‹ÿdHÁWüóºk¹ó…Åì,tP¾%?R¹zx;Ì:=–Ηr]Ÿ"¾_s„zs•S&106ˆ³Ç•ü¬Áõc:l0`‰iG¸IÁ¾9µ‹˜”Ú“ÞL‡Ôñ$äl`k^ÃNôaø»Š)sy^îïM±6)#±îXÎîR Ê3X½ÉÍ€K’ Ž:(ݸöþ;ÛæütÕ{Ÿª÷Byz_^òji/ð|oRR/¿üòi ”Åbáå—_&))I.à=KZM,2´!e¸•ÿ݉Ms“õã*r;¥W°Õ–K™!ÆŠªÇ–2}âxÆÏ„i 8àDbŠç)³ÙLjjêi ”ÙlfòäÉDFF²ÿ~ÊÊÊš©†¢¦:[icD_&Þ×— »øÏ«O1c¦›9Ï]Iˆ-{@"AÆß‹ ŽÀ~¼·Ç’ªPÂè~rFÜÅÙ”ÂbL 5¦çšgðkN¢5¨k¨]bb"/½ô=ôO>ùäÉ¡zÞ”%¯¥Ç"CìPRC—°|ÃZróº0ñÂ`À/¶]uß±|[!½YÑéÌ„„›1‡šÑKâ$„ ¢§iĈlذ«¯¾ºÎÄI4¯ZºO4ì‡bã®c•«¨öl¶¯XEVXwbM`î8–¡ú5,X•]uS²w)m"%%“9‚XS6[–¢¡Qž±†eéåµVÀ”J¯ò4>ZyR·JYî!»1'^Â@×w|ð]:64·¬ýG(ñ|:±²ÚnJsööΣ<±*Œ«¦ $¤ADz¢_LøÎÏùl½Ž!#Ûc‚:ë •ìdÁÓ/²ì¨«ÞÒ…-W}ÏÑHLLä“O>!11Qž»qÖµ²XdˆeèÈ0¶/XÈñ®cHªÌÎ/àš?ÇñÓ33ysÕn²Kœ8KóÈÊ,ÂYù‰)B³Ù̸qã “Ä©«µçÉ÷+Ÿ>ûoöä;?"ºâ榃0veò#7òêópõ«Å”ÄÈ©3©£(ݸvjož˜5•ÍÁX¢’éÛ'‚íµ,G±ôæÖ‡¯dΜ{¹ê•2êÎ O?ÉŸ;t禙7ðêìLz¥U $~ø-̼;ŽÀš…¨ù¬~`« !$ôÁ-Ï_ËÈÓé ¬….j£Ã^ç•â‰L¯|â†íu0Û³Ù½e+Q—»¡íù;F]zžDk'·*oÙZU,BOÌTÂßùè1t²47þ_<ð.ó<”çKУµ݆"Äj±Ä”ÚH<犆ĈÒÒRvîÜ铲DÓPæÍ›§1¢¹ë!Z™ÒÒR2336l:Ý©Ý{={ödëÖ­ÍT3!îÃ?dìØ±>+OUUvîÜI›6mðY¹Bœ¯yyyDEEk„hMÒÒÒ-[⌨‹¯cNÕ³}o‡Ä›f"[\xÅ`0 Ó騳g]»v=ãùk6Õÿ®íßõM­Wõ U3`Õ6­®Ï5”ªª”••¡Óé¼¾ ²ªª'‡ë;v ½^/ÁLÑëõèõz  ñI™ cÎm ¾J¢TUÅn·ŸTu‘¼Ûínîªá5_+I¼i>’< ¯hšFnn.ùùùMžr»ÝãïïÅb9ãù%~ˆ¦àMlúþûïIIIñÙsž ÉÏÏ—xÓ $y^q¹\¸ÝnBCCe§­–^¯Çh4ú¬¼˜˜víÚ…Ëå’ýB¨®g±X|º¯ q¶ù:ÞX­Vrss%Þ4Iž„×䌞8T=pÐWeÉ~!„oÉ>%ξ:¨išìÍD’'Ñ(yR¶-AsƒBÔO⌧’}¢ùHò$¼¦(Š$O¢Õóuò$ûƒ¾'û•§j̵º¢q$y";®hí|9lOz±„ð=‰3â\áëa{¢yHò$¼VuÖC›hÍd ºBˆÖHâMóÐ5wÎ6­x#OLžÎ7Y¾;Û,„h½ªn5î«—¢õPKò㲯øü‹Å¬Ú]„WGÎLÖ,üŽôRÙÿEÝ$Þœ<÷<•ïaÞMwóMþé“BÆ=ÏÛ늩‰+VÁ͉ÍkÉì˜BrˆþÔIμwë4>˪ü[Hlò0®˜<™?t ª5+T;sùÔ«‰ ;ïòÆ&!=O¢µóõÝö„µôXä<À{·=È‘¿ÎçÁþªZB÷±¯øÇ¹âµ§¸8\%wóBÞ[ðÖïÊÆŽž€ÈD^u7w\Ú¹Ápýj3jÛ–-gWIÕX"ãé|a2‰a~Ô™ÜäíÞNNÄPþ46 =ŠÎ(k”eÆÚŽH“ÄFÑt䨫ùxNžL]¸õ½¯¹Uò=¯qû,éón£«4tè=ÎÔÜÙ|ÿþß9ôôä @JêÓoro²ŽÂ#üoù»¼ôÀ}ä<õ“»ú{n8u¡$§ômêš !Z ƒÞ‚µôXdŒcØ0 ~»“’~°(nŽoXEvÒz‡Â‰µÏqÇóûé{Ó?˜=£ ‘†Rröïæ°%LÆÍû‚b¦}ê8Fêq—“½ÿü´ze#F‘^ßvc/t‚ÑW¢Z Ûvã'É“ç¨Z[^Wíÿ*z½ÝÉS2åZ2›¾ü…Ãv”ˆþ\ÿÐt.ïhF)ÙÄ#_ÉÈ#Y:o!{ÚÝÃû³†£Û¶€Y/|ÁÖF"ŒãOãeÐaO ÏÈñ\9áO¤´ÉgûÖ, ®¨™¸>ýI¢ÛÅ—sÅe’8‰ºY­VF…ÙìùĉÙlfÊ”) 2ÿ³\;Q/“'=ÖÎ}éiB§ $©~…¹•I ³2pÊ$Æ`6à8ÆVÓ&¦Ä⧈ès—µ¯©^¾9µ‹˜”Ú“ÞL‡Ôñ$äl`kžw!ú€0ü]Å”¹<×å÷&Ø@›”‘Xw,gw©å¬ÞäfÀ%IPG”Àn\{ÿŒm#Ní}’—¼ZÛ <_À›””ÄË/¿|Ze±Xxùå—IJJ’ x[„‹ mHneçwbÓÜdý¸ŠÜÎcé¬CµåRæEHå´zl)Ó'ŽgüøñL˜¶€N$¦4ÅhÆ :p©à.8@¦ÖŽíƒÑ+z¬:Rz”ì2Oûª‚),†ð=Šb$46}yÙ­E2›Í¤¦¦ž–@™Íf&OžLdd$û÷溺¬¬™j(jò®µvç±å«÷ø|]:6ôh¶ÃØü/¬VjÂ?Ýâ.É¥<0‰ ª·tDUø‹ôJ IDAT,Ú]|œ‚ÜítÕ}Çòm…ôdE§3nÆjF/‰Sqrâ@&®°^Xõ 3`¶teØØd¬õœû,?º‰õéA u Ñ&gÖ:¾ÙZ1MQà䱯æÂîªc—ïWœ!³Ù̈#ذaW_}u‰“h^^t£¨8‹‹p™ÂóסÚ‘öõj¿´L†qAéjnÊÁ¥©íþ†¯÷UD#Sâ% t}ÇߥcSAsÛÈÚ„7 ó'ÄXȾCŸÑjo‹47¥9{H{çQžXÆUSÒ 5Ó=øbÂw~Îgëu Ù`®£NZÉN<ý"ËŽºê-]ÑòÕ÷ÄÄD>ùäå¹-J ŠE†X†Ž cû‚…ï:†ä ÊŒ-ð®ùs?=3“7Wí&»Ä‰³4¬Ì"œ•‘˜â+ÎÒ<m]ˇüéÖ³ &ô¡i£îgÛœ 9))(®øwùÕrªÞƒ‚æ*$ã·UÉ·>‹¾”ã…ö»‹2HÏwŸ^ ňYWN~¡Cò'qÆÌf3ãÆ#,,L§Ì‹ž'=aCoä+gñ· ݱÿw)IßÕ>‡ÜÛ﹘§^šÊ•Ï™ˆì9†! fvøw禙7ðêìLz¥U $~ø-̼;Ž@c;FOìÍš¯çŠ70}Þ RªgEj>«˜ÀjC ½GpËó×22¡áOþÐE btØë¼R<‘©ñ•Oܨ£Nf{6»·l%êr7´•1êÒó$Z;¹UykÕ‚bzb†¤þ·D¹€ “M¢‘¸ñÿâ¹€w™¿à¦<_‚­íè6l!P‹%¦Ô§Î£Ù9´ú+èL„D·§÷Å=ho­¼¥¼!‚ž)Élþi ‹~)GÃHp|oRqj£‚9.™N‡~äÛ¯v` Œ c×$BTN6„sA¯hÖmøK ~£ˆ‰ñ'§f}tÁ$t‹&ã§%|ñkJ;¹e¹¨ÔQZZÊÎ;}R–hʼyó´#FœÝ¥ºO°ìžÛX7éžXï-ôD TZZJff&Æ C§“ëÀDëôá‡2vìXŸ•§ª*;wî¤M›6ø¬\ÑD$µx‡ƒ¼¼<¢¢¢$ÖˆV---Áƒû¬)³¡±Cb̹­¡1ÃWI”ªªØíö“'¼¡iÚÉx“““ƒÉd’xÓ d‹ ¯èt:âããÉÈÈ`ñâÅ^•q¦ ’ ‰:œi`ñ6™Íf¶lÙròŒ 7ª]mÛ¶-ñññò<!|DQ"""ÈÍÍåðáÃ^•á;-ñæüàMìhL¼Ùµk—Ä›s€$OÂkz½žÎ;Ó£G殊-‚ÓéÄétÊ—>K|||sWCˆCâMó‘äIxMÓ4‡£¹«"„â%±FÑ’H_ŸB!„B4€$OB!„BÑ’< !„B!DHò$„B!„ É“B!„B4€$OB!„BÑ’< !„B!DHò$„B!„ PïCrív;äää ªjŸÕétDFFÙlöY%…Bœß$ !„h êíyÊÈÈÀår1`À†^çkÀ€¸\.222ÎFÝ[$­x#OLžÎ7Y§÷º¦5e\Y+xö¯øãŸ®aú8›té-WKønšJ“Ö¿dOüÿÉq×½­´{ÿékŠÐ|_ qž“XT¿F·ÕöuŸðuyBÑÔÛó”““ÃE]Dxx8Š¢ÔùYMÓ0™L¬]»–Î;׿tÍΑuŸòî§ßòóBœ˜‰ì>–¿?|}­­eD¡››×’Ù1…ä=J`g.Ÿz5‘aºz¦5S—SÎÞ/>`[·‡ùøÕ 1i:ŒµÔýì9[ËmyßMSižß–gÏ9‹œxïöäÜñ&÷&7¾‡¬IöOçÞ»íAŽüu>ö·PµåÝǾâwläŠ×žââð–Ú¸Èݼ÷ü‡õ»²±£' 2‘WÝÍ—¶Ã¯¹«'„h•êMžTUÅjµ6xèCppp½C**¸Éúö îz£˜Q”7Äc.ËdÏ,-µ!öÀÍ÷ïAñC+](É)}ëŸÖ”NYŽƒ¼ŒR¢þ@ NÏ)‡5ëw¶œ­å¶Ä囹4ËoKˆ³GbQ4ÅþiŒcØ0 ~»“’~°(nŽoXEvÒz‡¶Ôm¤rbísÜñü~úÞôfÏèB¤¡”œý»9l «ÿàG!jÑ öC¯×£×7ì ·¡Ÿ£l;½ÿÝÿñS‡‡¡ìH߈ª¨”îû†¹³°î`1е£oº—›G¶ÅT²‰Gn]H÷?XÙ´v' Dޏ&·eÅ÷±ûú×™9$pì{‹ÛÉæ¶ùÐ/°2u¨mþ©ƒˆÐ•shÉl^øòØQ"úsýCÓ¹¼£¥dü}%#oŒd鼅쉻‘¿¶YÁ;{ Þsß÷bú¬‹Y4í#†Ì™õ“G=O{åÆE*õ¬ß—teå—PPT‚)ù:¼kí´,}éY>Úp˜RÕDÄ…yhÆDMÕ¶mÉ&þËG ™óa‹žâ•¥þv “>À?_¾—žþ€û?¼\£~sï&re×½Ý=¼?k8ºm ˜õÂlÍó#¦ßå\fZÍOƒ_äÉáAØÓ—2ç¹øá°sûáL¹cÚÚXÚrÿI¿@Îï†3X‡v÷ðþ¬TÌþ>[²˜fåÐóÉ~jÁÿX异'ç½É;ó§ÓeÓl^Þèϰ±QìYö+E*€ƒŒ´õ”÷G÷ÁÊãüë P1Òu·>÷_,ü˜Gûïヷ¡¸ò"5o=o}­çª?áÓ'®àÒ¿ÝÉèÈ8&>ÿ!Ÿ¾S- Ї2ô¶Z¦5hý¶“溜'^™Ï;oÿ‹!ûßã­-EÿòÃsŸ}Í’…oó¯)ÃhkÄ3}ƒþú·u û]oòñ›•‰€>ÜCýÎ`݆µôW^ŸµŒÀë_cá’™ui1K×fU\SeßÅÛLÙåÏóùâÏxæ’lÞ{úK«ž–{jµÏíïæÌ¶qˆ£–íXÏ…kë¸ÍʈÔ`v|»›  œŒUq÷Cÿºëå‰V¼…WŸý/7¾Ê— ?æÙ+T6”ìI´.Í‹<©k™š­Žv÷”2¶3ÿ_ °]ò8 /æ“Ùw2¦­‘úÚ OŒm‡1ܺƒowÙÐwÖVå$1¦—¥žíÓPjÁ6øMâéWßäÝç/Çþõ‹¼_:§^}‹÷_¹ã·o²ü˜»öØRc827²Å~ãGÔy sZ\³{¿>žcW>Eg·…-^ƒ’§úÆ—{óYµ4rsÁµ4 åûWò³!•«‡·Ã¬Ócé|)×õ)âû5GpøµeDj"þ è»2vt{Ò<ôRâÒÿËæ¤ýè¤ïØ.Ô\€Çù÷aGµs_ºEšÐéIꟄ_anåÁ& ³2pÊ$Æ`6Ôr¦®~õ¯_,©£:¨LqôìhàD¦ }XƒVó£k÷tò0F¾–ù®<¶,|Ï×¥cCf;ŒÍÿÂßç3„Ñ!¼ñ§Œê_¿Bü«µ®:Í­aî6•gï_Ä»<Ä o´å’[îä¦áq˜¼ÍN©T[¾jøº»Kr)Lú}tDUü¤ÜÅÇ)ÈÝÎC7¬©<›ë¦ÜKÏò$Oçòws¦Û¸ÎíXGô­¥ŽÆ612l Ëw•’l]ÍFmÓÍõ×ËÓv*ͧ<°ó)ߤålÞxDœoÎÉXäA]ËtÔÑîžRFq6eƒ°Ôl½Ø×Á@›‹R Yò-; ÚrxÕ ºLêI°JëÛ>uª–±é1W~gŠƒÞLàÉíª`P@ÕÞ&êÂ0Û3±U~¯ºèQ<ôê l»çsÿ;e¨U‹®ÙæÖ±>žïX}<ÿt]›0n !κfKžŒ±½èÄw¬ØVD¿AÁ§êƒ# (É¢Ø 1&…Y¥˜£ƒÐQîb²m.ˆö«˜v¤CpF½•Þ—uã­/dkçM0ø>:zŠWç·PöãlžZÖ–_œC/«ŽÒŸcÊ»§¬!:O«X×Y;Óê]?ÚDJJ\ÅíEÙ¬\¸‰ãDZõ|º¼ˆ®©‰˜QN¾Œ s¾äÃïu ÕÞÓÉÄZæO@W\„ËF˜¿Õ~ˆ´¯w`«kEtþ„ Ùw¨7Ú©ÇâuL«wýjá8¾“=Çí¨O"ÚäÂájÐ&¯·îŽ3\wS‡a\Pºš…›rpi*E»¿áë}ÁÒ”x ]ßñÁwéØTÐÜ6²ö¡Ä]Ï6ƒsø»Qqžá:˜ëÚŽ^Ñ=øbÂw~Îgëu Ù“õ0% £GÉ*¾X ‡¦R¸}ß° •ìdÁÓ/²ì¨·?N!NwNÆ"4T§‡£òåtãWÇ2ƒêhw«3%¤Ò«J=ëàñ·`ŵãs±AÍ—vRˆÖH™7ož6bĈZ?ðÃ?pÙe—Ô Úl6¾þúk†êãªVsÊí’=$Z ›Ÿ½•ùqOòò5ñ§gˆõÍ/¼ã>Á²{ncݤwxr —‡öòÝ!<8'c‘/ø¢Ý'±KˆóBZZZýÃöbccÙ²e %%%8Î:_%%%lÞ¼™ØØØ³QÿZhØÓ¿áý-±Œ'ÂkJîblÛG®Ý ¸)ضˆÅ‡cèßÀýBÑ@‹*I»+„ͪÞöý%ß|³”E žáÏ¡k™3û{N4¦“ÈÍ÷ïÁ–‚Vrf­j,]ÌÇ/ÜHû­óø÷‡pú¨ì䔾ÄÈã`<0wå¿™uuQ|šç''â gÜëøå—ŸØ°üm¦ÊåÍ›§òʶR½Œ.Ž.º‡ëý‘˜f³(m«ÿó_;ŒNArp"*œÕž'E£C$äd+8*ß*<¡`3@úqN¶+y'T«F¤·­½¦ðë^¿•Wü©3€¿œjÔ˜¿N $FC­Çû „kü±ŸÊ“£ÜLëkw*dŸIhŽXä«¶¿ærª·ûuM'Iâ$„guîo¼‹7 ÐÀkžŒÁ \tI2o¿|”B7„ëUJ÷}ÃÜÙ Xw°ÅÚ‰Ñ7ÝËÍ#Ûb¢–iÃÍüüò£¼³÷ê=×ñMp/¦?sqµåÔUf9‡–Ìæ…/áp%¢?×?4Ë;šQpS¸ùCfÍ^Èö"?"{Œ¤ÿÉ.=²ô¥Ìyî~8lÇÜ~8SîŸÆ˜xJÉ&ùûJFÞÉÒy ÙÓîÞŸ5Šàöƒ¹4%’ïÓOœ@ÕüÜ{ór&¼ó CƒJØøà_XpÑ<^Šmëf½ð[óüˆéw9—™VóÓày²ßnfüå#†¼òã~á‘[¿¤ó(+¿üx€‚¢LÉ×ñà]ãhk[Í£·½K䣯sGw³_sÓ¨õš'E9ùú}š‚R™÷Ÿœ§ŸQN~¦âsUóè1…%rÑ_žâ…‚«øëK+¸úˉª~¹ôW^e½f~ÅÝc"ÐweHLÕTJv}ƾÁÊ}…(a=çãÜ{i{L¶µL»òzMcíw»8ž_NÌà™iíYrÝMüïö¯xid àØõ"¦ãE³$A¾µ©™ðìÝ»—Ûo¿›ÍVë<‹…yóæÑ©S§Ó¦Õw ¦a<ª­jÄ+°?[!¡ƒFN–ŽªJŒ¢pè„FT¼†4…Gu¼p²œ Ñ*“{hDë!+CÇDz 3iŒé¥’bm;uü§ÔMzÖ5&õÕð7€YÍ­ðÃ˳Á®AH˜ÆäÞ*m«Ú= þWcþkhµ×CUxcƒUk);\#¤²øèPÀ ‡WC›0Íý'ý<=ƒõ´¶?–ÌÚbQ͘w#m³âÔå̺˜EÓ>bÈœX?yÔó´W^`\¤Rûz•ljuñ¢1¤çIÏ$Þ4e¼©]ƒo¡–dðãò=öO¬h¥Ûxã_S~Ý3|6ª ®} yì‘Çù¤ÃnˆÙUë´É·ÝÉè_gc}t.7t0BÉ&UmÃ:ÊœÜÑ@H×QÜúÜt w±ãÛyôí_ùøP‚l[xõÙÿxó«|™Eñ/¯sïÌ2Âì»xûñ)»úy>ɱ¥3ùçÓ_Òý¥kˆÔ¼õ¼õõ•L{ñ’Cýñ¯-›UËÉ?°ŽÅi´¿*3‡´Ý4Û¯¼>kS^cáÅ‘ý2ŸûgfŠ}Üã IDAT6ØÃ" ¶“æz†—^éA sïÿý!ÞÚ’ÂÌäºöíMxHË»¿Gó4:N!hÑ÷¤—_NTÀïS‡Ö±ÙÑ“ûW&N5h%¿ðÜ]oR~ë[¬sçÜù÷»™Ÿô1ÓÚ‚šÿ3ß8ßã£Ï“1Û6óâu÷ðxògL¿"–_n$ÄÂtÒ—­¢|Ð zIâÔ*Õj—˜˜Èܹs™6mšÇÊb±0wî\O›·!CAmÝ:ÒË QáÿÙ»ïè(ªöãß™Ýd7½ @è½£4éUŠ(*/`}AQñ± ‚TÅ®?° ‘PDéE@jh!&žÝl™ßI „ÝÍfI àó9gÏIvfî}¦>sgîÌîÊ‚fÑO+2A´7ÈV¨”×ÍÀ»L£ÚiY¾Ûª²<ÙÆ°HðÒèw»F%ƒÆ±C*VhÞL£a‡“uø5±qg A@MP ;Iá§L;OuÒˆT 1‚ nº WOoQØè,ŽË“U¶f…¸ƒ 1vÊyØk»Ôr‘Ó ûç¢@ä¥>Ç÷8ÈyºÚxš£nÎ|᩟g„(›ŠÚ7$ß\[¾qÆuß%ûEÖ¿—^½zѧÿHfÇ·døj㘭åO}G´¯ˆQÕá_³6McÃúÓ¤»–ã¢:Weæ #¨f3êDPU?ªßVïÔ$250_Ç^cú·)‡—¢#´ÉÝô¬”{ÃÐtl5¿kw0°cE :#±ï¢Ê?[Ø}!¿›D-†¤E¹|ú«ûDæ/ƒ>ý6u99='1¡G”ÛoÚ0c·¡ýÛ–Ã[ÑÞôzWvr3Ó»»ÔÄO 14ªª'ùl:š_xn4=Ê—½dèôׯ(Î8—û¨ºšF‰Ÿ%•,ë•ßÛ³’1ùFbp£ùðr~ÓßÉ£=«â«ÓÔà?Œl•ÊêŸO£Þ•¹³güT]`CúÝƾU‡ éڟ؃߰õ‚†’sŒUqZßÛÿRü%qù”ÎwÛ«^½:óæÍÃßßÿŠíÝßߟyóæQ½zuÏ_¡Ó¨ï«q Ìé çŒvb}4(ì¹ `ÎT8§×¨–˜P¡yeToÁiY`'7™U6j( Ä„ƒ>²… @Å Ð^ð1)l=¯D€ÁsSqäsU¶fSøéO•v†ÆjÅ{ÕëuÎEWÔyÕ±ßy.Ê]NEä7¹Î‡Ü”ùÂS$W>ò‘ÏåO‘?’+ù¦øùÆ ®ËSCèøúûŒm`ÄfJâÀò¼úì"&ÍIåŒ$L¾Õ¸tÁNO`”/¦óéX] ³ã¼3·ÍÕt¶ ìüæ¾Þt” th§Èði˜;]ÖEÌ~5/O§úáŸÛÌ´¥Ÿ'%i^Ÿw÷Á†ÙVŽFf;øúPbÃ\\u,° Š'w‹²g&aö«~El‘N»Î—à‚·¾TÍVvß߯(Î÷(ü’¦‘×UÏýqÜaK=K†1‚ B›–êŠ1û$©ÀÁ곦'˯A—V‡žàò~dŸMËÝAu>]Z:"}ÉIMÃÞ–ÿÔ~›¯6'Ñ©æJâ¬íx¥®û‹2ÅYƒ§zõêÌ;—§žzŠŒŒ üýý™;w“³²\í(?&*œôRðÔRÀ;Z#ù,$ØÀ ¡#7S¨ú‚“+¹û‰‡O¨¬;Y(hÈÒ}¼ð ¶óD…ªL=¤q{-;}Ê‹£`.gq¸YvNªÂ^£Æ¨XÍÑîXDÝžå"‹‡¹¨pWp‘‹r«("§¸Ée>„›._\‹"÷)!þ¥$ß”B¾qƒÛ11œúÝï¤âç‹Ùó•ªøf&nh€•Ô„,ŒQx¹¦’÷D˜ƒe®s:?[f3mU^˜5—ÆA*Y¾ÄÐó¦ó Á;û2¬€7 ™HËÎmŸêü" .//-B•Âk+@A½Æã²¢èPѰåžùƒ=›”¬Ü'™UßP¼³“.Çf3“frö”³ÂÍ–#ò¯~\AH”1ƒÓ)¶ì¤Ä§bˆƨ((¿< \eÉ«õòßZûWl$³Îª¯\~†Š-©«üÀÛSiÛ1øª»…^ÁåñÏ8CªU!F`#åL&>åƒÐ)&°¥’nCQô€•‹'Rð Æ¨£Õ1ëÓõüQo#tšFm_¥Dßä"®W]íªU«Æœ9s˜0a¯¾úªÃ®zî–UPH˜†÷…í*Ô¨¯¡|C4ÂþVØaƒ¨ŠW>lëhÛÊLÔñÙi;·Ð¨á ¦$•×»U=ÁáƒÂmôNVù`Ê–`;íÜhÿ»³;+ÛË_ãÞêp­—®G.rN#ÕE.Êå$§¸ª§Xù0?ö›/_xÊažB¸µ_H¾)yn¿rÌnNæÀÏ+9¦¥Z°cÕ´Ñ­gɯñ˜ì62ÿ^Áâ´mC€‹aÞªÁ^©9™Ž 튜á¼Ì ž†ÕJ¨ŠÝt’¸åûÉÁP¥õ2eéæsähvR÷­båñÜÄ«u£…u Ÿ­9J†4[ ÇN“Y‚?+¥Ã)gHd÷‰,44ÌñëYu471bÛQ?kßnû«f'íàJ–qç•"—i™Xòú,V±–\Ð¥ÉP™îÝÙ0}¿K%ǚř­ŸðæÒÚßW'÷*€;㸢YÉLØËª9O2zE8CŸiOX¡­Y hΈ‡¢Y?áæýrˆ²Ld]<ÍÁ=GI³Oí~tÑýÄ»+’m·‘¾ÿKn ¤k÷ØÜIÎ9–¶³f;æÓkyï»ö¬ Á·ý‡ÛÎ}Ì‚ŸU:ßU½T®lˆë£¨·çU«V/¾ø‚jÕª•Øïç1îyË@¹&½éÖ2’>u6ùaÎ~‘óS±+~Tjÿ“ÿƒ£+yÄX‡†7á•7†3Ĉdš5 g 6çñ1™6g8ýfˆhÔÖUŒ,Fñš)‘ƒ;wÙ×ÊV?vÇW}¨3r>S-S™ù@{þ— Ær͹û¹w}{`ÞU٢ƱrzÙÆ}qšDÛxÆÄÎgö PlI¬ÞŠ•^aÔly'Ï}ö8½kú8Øù}¨9ü=ÞóÎÌ7â£ÄÀ‡˜ŽÏ°`zu‚|ñÔܧ˜6áQÚ¿–Áué=v6#jQ2ð‰¥Sàw<Öq<§ÍA4ð23º„£SÿFôocfðÖ^¼RÍø¯¹|+*ɶu»,Fý8ä¥Q>ÿAVj…ÃÊsúŸ;¯‰ H;wœUyk­†ÑZÄjÄ\ú 4¸½ŠÆÎý:ÆÒx µFygÜV“Ê7»ÎX@¯ƒ±vZù*¾ðô·¹7_®ÊÎÎP8tQäå>H\,×#-x‘¶ÁE]OÔÚÆy.rÈ«P=Ó{¸7Ìi>ô†,‹ª ç Oå?ó$„¸R‘Ï<ä›R8t( ,Ð:tèPò% Çlɬ3’M?âÕ%Ö|»î²²²8{ö,íÚµsoç½Ùdlà¿ÝÐyÙbúG;xM‹–Á–çïfzì»|9¢ZÑýwE™ôÿ÷ôèÑ£èÝd·Û9pàåË—Ç×·p†BׯéÓ§ÏU/oB@FFË—/§mÛ¶7:”¸¸¸…(Ì–Îñç ¨Q…p#¤ìýŽïOEÓ£ÒÍßÑK¹êY¤[Hþ3W8š?쿾äí-yàé*xߊóÿ/RÒwžŠÚÆ®.áw¦Þ¤ft+Á¾Óâ–&wž„p¬¨‹×’or•t¾‘ÆSiSÌÄÿ2›w^ÌÂ!CxïD$=Æ¿Í]åe=Þì®w·=i4!„¸$ß”i<•65œöϼMûgnt ¥ç–¼"ОE[Ú_ý½±±…ǯD¢”¸û†¼ë]–"7¿ä_”¸%sÊI‘ìן4ž„ÿj%yçIOB”¬‚'!Ä•¤ñtcHãI\“[ö™'ñ¯qCÞ¶'„(É5Bˆ²BOÂ#z½EQÈÎÎÆÏ¯äß(IRV MÓ°Ùl×¼½åÇf2™P½^­B”„¨¨(âãã©]»v©ïW’wDI*í‹iV«•øøx¢¢¢Jµq5ÉðÂ#z½UU9tèµk×.öô…“TÁÿý]Ô0qó*˜d 'gÃ\ç.»ÝNvv6ªªzüÊ}»Ý~©»Þ¹sçÐétÒx¢„”/_žýû÷£( UªTñ¸œâä’âþ/þ]ŠÊ=îæ0GÿÇñãÇ9tèõêÕó¸ áÉðÂ#ªªR©R%âããùþûï=*£¸ éõïQÜ“OOfŒF#;wî¼ôà­' v'ªP¡•*Uº5ûLˆÀßߟFqòäIöìÙãq9×’?$÷ˆ¢xšƒ®¥!I£F0ò¼íu&'á1NGÍš5媇y, ‹EN¶„(!v»êׯ/wt…(ÀjµbµZ±Ùäuä×›‰„Ç4M#''‡œœœŠBˆ[”ÍfÃf³a6›ot(Bô-B!„B7È'á±ôôtΞ=KRR’ÇexÚ½IºE‰¢Üˆ>èááá”/_ž€€ËB\éFæškVÜš æ‰kù­%É77'i< ¤§§³ÿ~*T¨@Ó¦M¯©,wÞdäÉ8âßÃÑÉ;o5*É7$%%±ÿ~êÕ«' MˆPš¹ÆÑw’kDQ$ßi< œ={–˜˜ªW¯^*¿ó”O’”(i¥y9$$EQ8{ö,µjÕ*µz„ø·\#nf’onMòÌ“ðHRRQQQøúú^zUsi|„(i¥¹½úúúuMÝ‹„—I®73É7·&¹ó$<¦ªª$! PE~ãIˆ&¹Fˆ«I¾¹q¤ñ$<¢i:îRRBäžäét:yÀ\ˆ"¹FÇ$ßÜ8ÒdÑ4M’™…ä_ ”d&DÉ\#„c’on¹ó$<¦Óé$¡ QˆN§»Ñ!qK‘\#„c’onŒÅ'-ýw^<Ž• öb +ó2·1±ÿÓüøí†T/ÉLˆ«•è~aQX´Ne³©äŠ,é84‹ÂÇT6g_ÿ°ÊÉ5¥CrŽI¾¹1œßy²ç“£ø*!ïï0j¶À¨wRÍÇÓ•e#yÇFÎVmKƒàB­åÂõ£¨Ûá?<ñh7bÅ­ïÊz¿šô>€ˆPµˆa% o>–…=Á¢i=‰Î›MûÅ_xvÔV½?‘¦>%SÕ&ð q5—û„¦°õw•?*Úx¢BîÕ+kºÂô-*Uo³ñŸÜÑr.ªLÛ ´ô¼;Fj²BR€F5o°¤©Ì؃î°SÙÍC]Áé]Qômki„œŒ Á‰*߯ó‚ÞØèn= çC+^^<‘¦zGÃnã‰õ,ûx ZM¿Ì¤û*ãí¬<É5ežä!“|Sȵæ7¹ž-5„ޝ/cåŠïùòí'¨{h¯/=ÅÓÚl‰løt);Sœ\y+Pßç3‡Py÷^[z²øõ®G ¡AÛfD{1¬¤¨^è~Ì;“¸×énTN‘d&ÄÕ\îŠFlü“¨“÷Uj²B†ŽžçÒ±îB²‚=H#ÂÓ]LSØõ·Êas|¼ÀY¾)jz—¨NÛ jôinçÕ.6FÅÂÆ ‰Å¹£†ÐᵯX¾|yîçëhâãlØ$îì÷,Ï÷-GX—ñL,Øp*Pžäšâ“\#DÙ"ùæêáלoÜàÞ3OŠÿ MéÒ*Œ5G“±PË‘•¼={ ›N¤£Õ ë°±<Ú©ìd9ÖÞÈŸó¦ðÑß§±y•÷öó4wô›wŠžÀÊ­èÕ6‚ G“±P޳?Ìfæ²íœJ1¡„߯CÆÑ·ª%s“ž\K§!¬Xð-‡b†ðßò¿\YÏùnÔbZÏ}‘ /¦86&=#DZwª€!s“F,£f— ¶o=NJZ&†òÂ3=©Tx ñªHχ"X»èCþl2–AW®eûÅ_ûèjîýèMÚdòû °äŽÌlŒ—F,¥ZvþOÒ3QÓÓ¸‘ï¶œæbJÑÝFóâfÍá/^dé†ücò¥f¯QªáuF!Ñ®QIc‰ Ub5þIPI¶Û‰VN&kDVÒ0¢€¦|Feæ)H°(„GÙ\O#J ñ*ŸŸPHÌՠѽ±¶þ°÷€Êi`ߦc³—ÆÀf>z0* Ù~Û¯²:L‡j nb§BþaJƒ=…¦t»æ<»Â¢M õ[Ùiåå¤ì0à¼â£B4üârŠù³¢êœ¾–×Õ0×…J®‘\#ÄÍKòMé䛢¸ùÂYgÿ`Õ¦tb煮!k/ó¦~ŽùÁ7ùªKy¬G¾å¥I/óEì\Žþ‹EN† 9š®»f4åmŽõr^ÝÌÅã›ø>.…Ê÷Ç`DOpí.Œ˜1šZaVö/zœ)n§ÓËmì6óÁò~Œšõ B|ðQês|Oz2·ñ€.„6…cÈhY{Çö”}ÄYßdÎüzøYŽð铸`g[¦´ ¸òv fç٣ Ýó4 >ÛGý'RœÞö”ýlñžÍì…5П^ÊÓ#fñi¯iÌ_Øß k˜ðøû¬îÞ˜ûC€ì3ìPcÖ篔¶…ÿ›ÎœÚu˜Òâ¾ü9ÙÞâ뮜[1™ç__FÝ9ƒ¨ä`™mS»Y‚݇ˆ$3!®VÔ~á QÁ¦r4*z)ìÊ‚fÑO+2A´7ÈV¨”×ÍÀ»L£ÚiY¾Ûª²<ÙÆ°HðÒèw»F%ƒÆ±C*VhÞL£a‡“uø5±qg A@MP ;Iá§L;OuÒˆT 1‚ †¬põô…Îâ¹ö\P0g*œÓkT˿ˠBóÊ!*¨Þ ‚5Ò²ÀNn2«lÔPˆ }d;:Ø(P1t€—|L [Ï+d‘`pç é"Ž|®ÊÖl ?ý©r"ÂÎÐX­x¯z½”‹zå~ž^Er~?5í"ë& ¤_¿~ôë×A“Öã¬gøUåI®‘\#ùÜÄÉ7¥oÜàº<5„ޝ¿ÏØ:ÿðõèqü醿²3’0ùV#àÒÍ#=Q¾˜Î§cq1Ì~uÏsÇõ5(t+ßvß|Âכޒ-ã> ÌE(±a.îd¹Éæ2v@çK°O UUÐlÎS•®\wž¼ÿgþ7ç:NŽ."Ñ(GçKpþƒËŠŠ^gÄÏ_¯‚^»vyÜßü&µŠ_„Öíéd§'%i^OîPf[9™íàO‰,3E‘«BVä~¡@lü˜¨pÒKÁ7R#Hïhä³`K€F„ŽÜL¡jê N® i€‡O¨¬;Y(hÈÒ}êìlç‰ ?U™zHãöZvú”ƒ"Îâp³ìœT…½FQ±îuÖ*X·“Ü`” Z?÷#ëäfÅËŸ@’<)Orä!n"’oJ!߸Á½Æ˜>†;¹ïg/fWÛg¨oféˆ6XIMÈ€—‹a*yOƒëÒ˜Fê–ÙL[UfÍ¥qJÖŸ/1ôã‚ã(¨Ž¶Wõ8¦+2v…âý©Ü÷izÿòîëv=¶,.dÙȽ‰h#31}`>þ‘—¿—— ¡Já­4œ.³bÈ¿ò!„¸Ì}"$LÃûŒÂvjÔ×о!a+ì°ATÅ+¶uTbf¢ŽÏNÛy¸…F o0%©¼vؽƒÃ5…Ûè¬òÁ•-ÁvÚ¹Ñß˽ÝYÙ^þ÷V§XÝÊÜ£b !4´`Š,²Ÿƒ’k\’\#D™#ùæzç›\n>a«àßøîßÌ{?Ä£«Úƒ6ºõ,ù5“ÝFæß+X¼#€¶mct1Ì[õ!Ø+•#'Ó±¡¹Ù†²cIOÃj%ÔGÅn:IÜòýd¸œ+õ¸ft»{ êjÆêôª#¾\ÂÁ¼wÖ+ÆpÊÙ}" süzVuç5#XX»|;I [ò¾ý9…Zª\­-¬kølÍQ2ì Ù2H8všL'¯dÒ2°äõY¬:cõ,!„Û¼ü ¼Ea¯M£¾îwªA£¾—¶t¨¬‘8,°ê Pš vÄ+d˜È_…Ó¹ ç,ÙŸû¿¿F¨ª`qÐÍÍÙô®¸*;í¢Âæ³áás»šÝ†ÝnÏýØìÅ»çÉ5.I®â¦$ù¦ä¹ß PE×aX6õ~ë>…Á“†°ð­ñ X˜Õé4|"«zƒRÛŰŠtíß„õ³âžE·3nÁ‹´ .ªý¦#´Íú¬}ƒ'îý¿¨:ô¸¯Õ׸˜Ä«P=Ó{¸7Ìè"ö,·—T! ~õâ‰v›˜²%¿ž:<0¼ ¯¼1œ!Æ@ü#Ьi8û<(]Ý–{ÃW3~Ыœ3P§÷hžo‚ª fØä‡Y8ûEÎOÅ®øQ©ýcLþ_ Ž^ph7%rpçn"ûÚ ‚{›E~?t!Äen½õM§Q?yi”Ïï ¥@­pXy®@ÿs§4‚"íÜqVå­µFh«s&?¸½ŠÆÎý:ÆÒx µFygåV“Ê7»ÎX@¯ƒ±vZùž‰BÓßæ^rUvv†Â¡‹&-÷Aâb±_$î…þÄåÿéwžl#á§L[~Ždû›¼óïuðºò«H®qEreä›RÈ7nP,X uèСäK·´7Ò§Oüýýot(B”),_¾œ¶mÛÞèP„¸éI®Â9É7×_\\\‰¿€Bü‹ÈÕ@!®VÔ•À±«ËÂ;ºo¼Ý®ÇϺŠ[ä!“|ãž’Î7ÒxBˆëH B!®É7¥COÂ#Š¢ å½7R® ‘KÓ44M“}Bˆ"¹FÇ$ßÜ8n¾mOˆ+LhBˆË$™ Qr$×áœä›Cî<‰k"¿¿!„¢´I®B”Òx‰ŠŠ">>žÚµk£×—îf$ S””Ò¾‚mµZ‰'**ªTëâßBr¸YI¾¹uIãIx¤|ùòìß¿EQ¨R¥ŠÇåNVÿw5ÌÿÅ¿Gá$u-ÿ_KÂ;~ü8‡¢^½z—!„¸Lr(kJ2¿H¾¹9IãIxÄßߟFqòäIöìÙãq9ž8¤¼(Ч'8×rbI£F0Øí~F]Q,7:×\ë´âßAòÍ¿‹4ž„Gìv;>>>Ô¯_¿Ô»Rq³°Z­X­Vl6y=¬%ArŽI¾¹qäH$3žsÿÌgÛ{3©ÑA&ø–ºw±mã’S-DtÉøGjphþ>úû4ö1²2°1ãÞèÌw£ÓzþLzF(yq/aÓ‰t” t6–G;UÀàb~+jÇY1g:‹·œ"Ën ¼a&¼ØŸj·\ÃU!„BˆÒãV_°ªU«2uêTBBBw¯áZÆ~Vî£[Ûz´íÊî™÷À–¾“…ÓÂoÈB–}û9Óï±³íD6x•§CÇ@öÿ|0o\3ñ¿þŽ­ywjùè ®Ý…3>cé·Ÿ3å¶#|öávÒóÊ´§ì#ÎÚ—Wæ¿ÇGN¥õ±Oø`g:ö¬}¼7u Ý^fÉ÷ßóÅìÑt¯à¦¿øðåÏÉîû_ÿovKä“×—qÊâ"¾B,çvñ·V›öµòNùŒUh]_Ï‘ígÈì){øÕr¯.xŸÞG­m³™·EG«‘£éCÿ·þ/?zžæ~–_Ö^Mýó]oòÕß³xrN.z™/Žå¸˜ß4Ò·/â³3Ý™ñÕr~øöC¦mG/wÖ¼B!„"ŸÛÒ8k@¹Ûp;i»Wp°BOZ„yv{ÊXÉî¼–Žùø:ö;пM9¼¡Mî¦g%àMù¶Ú¿šƒY˜ãY·ÍÆíݪcDGPÍfÔ‰0 ª~T¿­:Þ©I—dx—£c—šø©€!†FUõ$ŸM'ûøZþÔuà¡î5Ôëñ®H˜AÁtl5¿kw0°cE :#±ï¢Ê?[Ø}Áî"¾Bs™‚Ù‚ßU÷ôT|C ˜/fað®@‡ŽÕðQ@õ«M®ÁŠ;‚ÉÅ4[ËŸúŽ h_£ªÃ¿f/lšÆ†õ§Éq:¿èB+á—ǪÍÇHS‰©Q¡B!„¢XŠì¶WP~*¿ Ÿû 'Àv‘?~ØAÒ¾ »oŠfÅœÊö‹´ìŠ-ë"f¿šäßQýˆð×åYþ:…þÀ꿲h´Žßµ–Œ«fÛv~ó _o:J:´ŒSdø4¼\§Î—`Ÿ­UA³iØÒÉök‰¡»/¶ôó¤$ícÂÃëÉ­Ù†ÙVŽFf»Ëø R}B0˜Îi®h[Ùɺ`Æê‹J:èŒòcSñ óÁ’–Eó»ªÌKñe$aò­v9ôFùb:ŸžÛ s2¿Æ:ÙþÜw|üÙ^TnfXû ÊÕu!„B!+Vã .7 &L˜à~à °'meÕÙfŒ{çIú)€ô?fòüÊßInw'>!xgÿC†ð4iÙy¯çÖEsGçp–ÿ²—ƒ@«g©bÐHÝ4›i«*ð¬¹4RÉúó%†~\°VÅAAõ Ù@†…+8:¿H‚ËßËK †P¥PÃÊtÑE|x—kDMu-gÒ¬‰ßå®{¦ãlÞo¥Ú+àÍA°¥“˜a…(oÀJêétôxåOààýçºÀ|3H·@´Üé²0F bv:¿àEx³ûÛìÙµ˜—^›ÎªÚ3¹;úêÆŸB!„Â1:oU­Z•÷ßßí†Ø8·é'ëö涘0BCC §R«ÞÔ8»ŠM‰6 UÚQ/óW–n>GŽf'uß*VÏïĦ#ªUgÂ|ÍW›UZwªŒ;–ô4¬†PB}T즓Ä-ßO†Ñ«t¤±9ŽÅkOe³“t’Sé6ŒÕºÑº†ÏÖ%Ú-ƒ„c§É´QD|ø5dà=álš1ƒov#Ój%+q?+æ¼Éš€»y¸i`nƒÊ’ÈÚo·q>ÇNιÍ|¹:Ú«aT}öJåÈÉtlhW´¡ŒU{ÐF·ž%¿Æc²ÛÈü{‹wжm Þ.æ7çü7aGOH¥êD¬äXÝ\uB!„Bà~çÉÏÏy÷²«XN·æ" zÔÁ¯À% ½ê%ñóº3Øocä˜Î\xÿ1?c–B§–‘—n©‘-éz]´¦S%o@Gh›!ôñû†'îíÇ Q‹HlÕ‹ên¼ANñoˆ‰ýðúa,÷ßÕ‡ûF½ÍædøÔeØä‡ Xý"ûô¢wß¡¼´ì é€RD|—y{ß+¼Ú?ͳ§ß¾ÜÿØüêu7/¿úªäÇgŒ¡µÿj& º›{þ»ˆäÏðd›`T¯Štíß„³³âžASÙ”R ùd¬ÍàICüi<î¾›¦l¦âð‰ ¬êªé¤aIü£ûsWŸ{¸oä'X{?Îå宓B!„Å¡,X°@ëСÎãß%sÉý¸4b„B!„(ëâââ<¿ó$„B!„ÿ&ÒxB!„B7ûm{¢øÝÎ+_Ý~££B!„BƒÜyB!„B7HãI!„B!Ü '!„B!„pƒ4ž„B!„ ÒxB!„B7HãI!„B!Ü '!„B!„pƒ4ž„B!„ ÒxB!„B7HãI!„B!Ü ÷d¢´´4¶nÝÊáDZZ­®+Ðë©Y³&-[¶$00У …B!„âFóèÎÓÖ­[É0_ Nç ÷uù©Ó9ˆ ó¶nÝZÒ± g´TâÆÞǸõihÅ4ýw^<Ž• öR ­´\︭ ¿0ý¿÷ÒçîAŒ[ÅÃé²=,G\ž®ó›Bæ6&öšÿ±Ý\e !„×™Gwž>LÃaøû (ŠËq5MÃËËžŸÓ­[7cÚHX5 · …ÑdðËLº¯2Þž(<¦øÕ¤ïðD„Þ\=:K/nGÛe4G—~ÆÞ:ù|aC šŠ—[e™ùûŠé,Yø¢åˆë¯ðºÓHÛÇÙªmi¬+åºm$ïØXÂu•F™B!Ä­Ï£3M«ÕŠ!@ÅÛ çðž.?Þ=†µÈî} #úÎgy¾o9ºŒg¢4œn 5„m›}³-üR‹ÛÑv™Ã…ø,"UÁOÕá¥s}á²ÂÓY<,G\…Öÿ°áÓ¥ìL¹w:m‰%_Wi”)„Bü xtç @§êPU»Mã…aóxíƒ'®ø_UUtª§W6m¤î^Â3—²û‚7ÑÍûÒÛ°Ž?ZÍâÕæ™y© ¿Í›Îâ-§È²oØŸ /ö§š¡ÐrÚñ¼1û[ö¥yQ¯·eç±_ü…q®æÞÞ¤M@&¿¿ðKîXÀÌöÇxÉAÌo ]Lëù3éé»IN—±‹uÓ>°ÀrË_‹Ùp<j3èµi ªªw½ŽF|KÝ;ƒØ¶ñÉ©":Œdüà ü2úY>ô.“[çÖ‘säFNJdäìN|7jI Æíl“¼ÀÖ÷^cþþ,R?ÆÀÅ·óü¼±4ò)<¿…櫽; N÷iu:4ÌaƒÓr òt.¥ZvþOÒ3QÓÓ¸‘ï¶œæbJÑÝFóâf™Š1®®ˆ}Ëér¿ö#w×]5ÚÖ¸ÀÏŸÆ>æAV6fÜÛã©—àbžÜX.šé8+æŠqBgÞÂGWÔõuÏsßÞ’ðüKe¶d~›W¨Ì7:Vέy‹Q+¶Ÿ¡£|›GxatO*\¬/w¶Ó(7–q‘Û»1!„¥ËãÆS>«õr?öüF“£ažÐ2vñî«ðúßvŽ mû{<79ÐV¹Ãí)ûˆ³¾Éœùõð³áÓ''ðÁζLnxŒES?Çüà›|Õ¥<Ö#ßòÒ¤—ù"v.ƒ£À~a3,ïǨY_Ð ÄŸœ¿Xðòçdx‹¯»FpnÅdž}uç ¢RÁ~T¦¿øÐÙx8(×¶ãªïŒ¦½Ìu;6#Öí“øìLwf~Õ—]§Of^¨o—–¾“…ÓÂïÑ…,ëIúöw;9›07–±£˜¯îl×?ìrÝ\Š-kïM]BÆ}/³¤gÔ¤s˜C¼Ð²ö¸^G){øÕ2“9 jaÈÚÇO¿Â¼Úoóß‘|¿ji-Û¤æ·s“§¨ëg滌Û)](-ÿ; ËÑaüÐû}Þ,ÔàÒ²ö:Ÿ¯ÂÓiiÔ;븜’Y†ûÙâ=›Ù k ?½”§GÌâÓ^Ó˜¿°!¾Ö0áñ÷Yݽ1÷‡¸?î}¡ûЍÓñrŸÒ2àòü•Õý¨8ëÎüöó šò6Çzéó]ÍS‘ËE#}û¢«c4„Qmähºîº\—–µ›9ÅÞwÞar»àÜ®º0Ú*“Ìm|—}‚õ‰ƒ˜ñÉ8²wñÎ“Óø`G[¦49å|}y\~Ž·S¬n.cWÛOc÷bB!J›Çˆä?êd³Ù™¼`ÄUÃ'/Íf¿bÜâ2c·¡ýÛ–Ã[ÑÞôzW.p©Ö»»ÔÄO 14ªª'ùl:ÙÇÖò§¾#ÚWĨêð¯Ù‹›¦±aýirÔ Z H‹røõ˜­æwív¬ˆAg$¶ã]Tùg »/\Ù¥ÅTÔx…ÊU|—S¬Ø¼0„VÂ/!ŽU›‘¦S5c¡µf>¾Ž½ÆôoS/EGh“»éyå¥~çÅ\³e\Ôº¹ÛZþÔuà¡î5Ôëñ®H˜AÁ\Ôrð®@‡ŽÕðQ@õ«M®ÁŠ;A`›^Äý‰)vȉ'n«…f=já[Âq{ªÈùò¤L—ayÚ¶¯‚Q}Dª‡†Ó²s-üUPƒkQ?4“S)y¯=psÜ¢ët¼Ü îIev?º†uWä<¹\·bt+N‡ûÎLEmhÞÑtî{;‘^ ºÀÚ´©íEòÙt²Ü<>:ÛN‹»ãåän B!DióüÎS^‹Èfuœ¼®øÞÃÖ“-3 ³_uò¯,ª~DYçK°O³ UA³iX3’0ùV»<z£|1Ï;Yчvùr¥-ý<)Iû˜ððzr;Ú0ÛÊÑÈl§`ûÒåxþW—‹£ºŠ›¡Îp¦?÷6‡U Ûc£Ö>†‚ç$¶¬‹˜ýj^±œ"ü]u•,ð>G1äd¹nòcKO$Û¯%þ…ª(r9èŒòëUñ óÁ’–-ävzWû„•Û/ÒªÊ:¶ZogL #¾Éyq{Êõ|…{V¦ÇËЗ`cÞ†¢¨èuFü.-S½öüMÁÍqm™nÔé`¹_9?et?º†uWä<¹±\œÆX¸.÷‹.û¸éüó»¼Î‚f׊q|t¼/v'û­›1!„¥Íã³ÆKwž¬6¦ý†¿òÔ"žŸ9ìŠq‹KçŠwvVÀ°™I3!xgÿsy9i&Ò²óïúéPѰiygNölR²®\†WÇ|eüŽ–±ZäºÉ‹Í/ Cf(xXär°¥“˜a…(ïÜa§ÓÑॠ¢Iï:|°l+»knƒVÏRÕHÞò/¹¸=åz¾<,ÓÓeHñö=wÆugû-ªœ2»y²î4÷ç©èåë$ÆBuyºï8ªÿŠö›ãcûÇGÇÛéãx¸ý¸ƒBQÚ®¡Û^þ§Ë'žÏ¾9„gßréÿüaE½ÎÜCl;êg­ãÛmÿ`Õì¤\Éò#æ"§3VíAÝz–üÉn#óï,Þ@Û¶1ßàg¬ÖÖ5|¶æ(vÐl$;M¦Í³ñJ2¶œó8tÞ„=!•ªe°’SèÅ…†*í¨—ù+K7Ÿ#G³“ºo+çvÒQŒá”3$²ûDæøõ¬:Zô2,r>Ü\7†*ilŽcñÚdÙìd'äTº­èå`Idí·Û8Ÿc'çÜf¾\FíŽÕ0¢Ø 7 ÿYÆÿmPiÕ¥²³ó´bÇ­e`Éë³Xu¦¨7Cº(¿˜ë÷ š™”„RÌ…îHxº KAIÔYV÷£b•©úì•Ê‘“éØÐ0”À<9±p]ï;Îãwõ{pî®/gÛ©ÇËØÍJb¿B!ÜU/Œ°ó¿×¾â»Âÿ»ÇFÂO3˜¶üÉö7y-æ%&ÞÛœÇÇtfÚœáô›a ¢QwZW1r°¨¢Œµ6–;£åŠê w#ÖMÎq?û_ž ¥ÝcSèéAÛ¿ÌnS:»Mcö ŽB!„B8çQ·=EQ0 Ft:×'6› “É„ÙlFÓ\=–,„B!„eS\\œgwž4MÃd2a2ù³‹B!„BqK_B!„B7HãI!„B!Ü '!„B!„pƒ4ž„B!„ ÒxB!„B7HãI!„B!Ü '!„B!„pƒG¿ó$Daéééœ={–¤¤¤k.ëZ~LY~ˆY”EQ®ØžE¹¦²®Uxx8åË—' àšËB\_žæÈâæ4É7Ÿâä‡âæÉ¥CO⚥§§³ÿ~*T¨@Ó¦MK¤LGˆÂß¹3޳ï„pÅÑ HIW\IIIìß¿ŸzõêI"â&r­9²`s•ÝÉ‘âÆ+œ þ_œaî¼Q:¤ñ$®ÙÙ³g‰‰‰¡zõêøùù]—:%)ˆ²âz]é AQΞ=K­Zµ®KBˆkWZ9Ròà­©$sŠäÒ!Ï<‰k–””DTT¾¾¾(Šr]>B”×k›÷õõ%**ªDºÆ !®ŸÒÊ‘âÖ$y£ì“;O¢D¨ª*t!J‘¢(¨ª\ïâf$9RÜ’7J‡4žÄ5Ó4 Nw)9!Jžªªèt:y \ˆ›ŒäHq£HÞ(Ò×LÓ4I B”²ü+ˆ’…¸¹HŽ7ŠäÒ!wžD‰Ðét’„(e:îF‡ „ð€äHq£HÞ(yr穘´ôßyeð8V&Ø‹5¬LÊÜÆÄþ£ù9ÉvÍEIR¢ô•è~fQX´Ne³©äŠ,é84‹ÂÇT6g_ÿ°„(IÿÊ©]dÕÐv û9—÷=26òD‡Yšpíç"âj’7J^ñOš‰«f3zÐ]ôêÕ‹»<Ƥÿ;@fY»h9Î'ÃzÑ«WÞçÞ¡Œ›÷3'Lžj#yG{Srwjů&}‡ Y¨Zİœû_g{¡…lKú…q}zÑë™_¸PÚjNßöbOåÏw§w§¼wÔ‚¢h¤íþ§ûÞAóæ-è6tëm(ЉíSzѱß8–Ì—e((Š•óqÓк#/üiÊûÎEò‘Ï-òqJSغUǼ3H°¦+L[­ãË‹—G˹¨òÒ•øk8n§&+ÍÉýÛ’¦2mƒÊÉb‡ N×h[ËNmƒ“48q\eÖz•±«uŒß cÍE\Ÿ¨T8WÜó0ÏÌú‘£Ù70©åå£Gz1ì“ãXn\%àÊüøoWbÇË!æõiNßùc¹ô½…£ ¢ó°¯9cËûΞÌê1=¸èorœ–u„ýº3i‡©À÷ºzí/'Y»ÖxAÉy7Žkî”gãŸßbÇEû ?ßl§$o?oPÌÆ“õÔ·¼þþa>ó._,ý‚§äÎÆÑËâ5„ޝ/cåŠïù|æ*ï^ÀkKO?ÙÙðéRv¦Ø/•Û m3¢½‹VR¼‚ˆÖïæÇ}éV¬¤­?q.*c¹wèlç´œú†7?·2ôÓOy¬º7díbÞó_â;òK¶l[Ã[-wóÚ‹ËI°ùÐ|Ê2ÞéÏ»37pÑî¢ {»ß’Gæ&:Ô«@mÎËâVà2 *±ðO¢B~~IMVÈÐÃÑó\:ö]HV°iDxzÜÖvý­rØœû¯ª/p–§ŠšÞ%ªEÓëQ jôinçÕ.6FÅÂÆ ‰ÅÙ÷ äŠ/ß~‚º‡ñúÒ7¬áb>ú3¿©5°oú™cî,£²ªp~ü—s¹ïƒéàwüªÖžö{_ºêîMõ&Ò÷â{Ìü5 ©ÌgÞ‰Lx¸†ûûfYg=ÇšùŸðûi‡ä«‡_kÞ(Ö3OÖÔ3¤5¡mÃr  JÂ/ µ“ud%oÏ^¦é(A5è:l,vª€×Å_ûèjîýèMÚdòû °äŽÌlŒ—ž\K§!¬Xð-‡*ŽáÓ7:`8¶’·g/fÃñt¨Í ×¦1¨ªÙGW0wÆgüvÊ„±r{†>7Šî• 8]ŸŠžÀÊ­èÕ6‚ G“±P޳?Ìfæ²íœJ1¡„߯CÆÑ·ª%s“ Æ3„ÿ–ÿ…þ>}̃¬ l̸7:óݨŴžû"A_Lqz0* Ù~Û¯²:L‡j nb§B~Ó`O¡éÝ®9î°h“BýVvZy9);L#8¯ø¨ ¿x…OŽVôøWhJ—Va¬9šLŽ ~*|Ü|¾Œ}–ƒ½ËäÖ(@Α9)‘‘³;ñØoœ×óócáœVøJ[‡VýI@¯§h»q?L­>¹ƒ ç§Šcøôö¨{—ðÆÌ¥ì¾àMtó¾ô6¬ãV³x¹á6ƹʻ#–R­ƒ?;ÿˆ'é‚™¨NƒéiÜÈw[Ns1%‡èn£yqH3‚tšó¼ë,—nÁ©w®Ìcg áý«óPlÆ:¦Œ,^¾¹y3G?ŸÂ¤O~ãØEjÔ<>ýUÕòÉ;ÇÉdß²ßêÿ"]׼²£hÐÌ7wRßz ŸØ‰/ÎçÆýØ:}m^XL_ÄåO1è£ÌYü4õ}ÌZø Oì{ˆÿ›QP.ݺ+îg.m\ز€ç_ZÌŽo¢›ôâŽ,.•“}ð+^ž8Ÿ_Žgã[­O¿>{ªs‡sy¼,GãUNç×WG3÷À lCºóup ^ýâušœqRfq¾ÅIÞ(ù¼Q¬Æ“!¶#Í,Ó˜ÿI žØŽ*—›uZÖ^MýóƒoòU—òX|ËK“^æ‹Ø¹<âº\û…Í|°¼£f}AƒŒ¦}Ì›º„Œû^fIÏ*¨Iç0‡xé/>|ùs²¼Å×]#8·b2Ï¿¾ŒºsQÉËYáf.ßÄ÷q)T¾?#z‚kwaÄŒÑÔ ³²ÑãLùp;^nC ƒX|”úß3› )oóp¬dnã;]mFަë.ÊXƒ£Àž²8ë›Ì™_?Ë>}rìlË”–WíôMï¤bÜJv¤´¦c¨ŠõÌ:Öå´â¿ÿâ·í—W¥ãùj ÛñÙ™îÌüª/1º4NŸÌ"Ü °æMªX8ýãLßÓ‚çßèAR»Y‚Ýß<\_ÙȦa:w„Œ°Då­/Å7†šþ‰üuÞQ¹5:,©`ùŠÕ»÷Ûiö8¹R Nq+** zhT°©͆Š^ »² Y´ÆÁÓ ‡Lí ²êåí#6ØeÒÕN#ȪðÝV•åÉ6†E‚F¿Û5*4ŽRùð°BófnÊ? IDAT ëØ8œ¬Ã¯‰; jB€ÙI ?eÚyª“F¤‰T0d…«§·(ltGÜQTÙšâ*ÄØ)çÑóÑ6²ÎþÁªMéÄÞƒeûœ«›>‘øõˆäûU»HkÙŽ 5‡ø¸Í˜›žLýH+;§ßÏSs6Ók~g‚ÐÒvòõô}¼ m|Âù|é.Ò›¶& ·oþMç¹&ÿa£›16x‘%ÍrÏ'¢zN`Ôòò§wò~×ͼòM(#?éE´îX‰Î£–º•7'|KÀ˜¥üÖ3š´ÍÓòdÙ»™ý¿Edÿ”}£8ýÅ“<öܧ4^üU âb¼Îã§p×¶)„Ìý‚'ª{Cö.^sVfIöºÉIÞ(ù¼Q¬N_J@3žœõ<­S¾áù`ôÌï9š{ûÔ|l-ê;2 }EŒªÿš½x°iÖŸ.ºûƒD‹¡iQ._£žœãkùSׇº× P¯Ç?º"aÓ±Õü®ÝÁÀŽ1èŒÄv¼‹*ÿla·£‡~ìY7þ^zõéǰ©ËùöÎ;<ŠªíÃ÷Ìîf7ɦ7H$$t‚"E¥E,ð‚ 6,/*`EE)"(HSTTD Ÿ¯¢ˆÒAA H -Ä@ u“ìÌùþØ–$»ÙM64Ï}]CØ9ÏyfvæüÎsÚõ}ŽgzÇ b ¤a[šD™QÕ@’Û'ãw*ó̼­2¾TµîîzøÕ¦û5 Ts<-ë9~$‡Šb%´ )õòõ¦,4ŠHû~J·Ä…ÓP>Wç¥`¯K`új–¯ßK¶L|ýZg†û)·å^\ÄðqwÒ4PA lÂà'FÒ;Ös1s~àéMØ8´õ/ò"‰0*(Š ¸ ÕâI-9Fõ#À¯˜œBÇnŠJ"äøf¶-r´|•³Qfƒrã©ËÙ›Ü.‘­Ò—Í;²¡0Gá¨E'Á_Ð*Há …y G‚¤ÒnÚÕ„© ú Z„ ²ócß­!‚z¢@|$‹ Â©@ Ô `2ƒ¿Máçc ù@t˜=)DÝøQŠ;ÛBSøf“Êþ(¡ »VÁR­H¹ž÷Ì$³Ï <Û«þ–›*QW¤¿ç~;©CQ«.¦mïF€Ër½À…¦•q„ì­KÙ×—Ž&":ô¦öŽelÍqºèå´r5[ÍÝÔ¥6~Š‘È67q]=ÂøÅÒ¥k"ŒQMHäò«aUA mDóð<ž,®\w=Ò2×:T½¹©P#ËmFÂ[\AËÚþ A4½ª æ¬ ò„‚¢Nýú)ÔH×31ÝþCß?eS¶Ó|*C(­RZc?”Gãk/#ÄPò½1–ëŸ}€OGñÈã 1Ž㌎}z&_ß߃N:•léýôVòz¤<Ý ÿ^Îfÿ¾ ½¶fÕDÔå·30É‚‚Bá®/Y#®åÞ”Dü4¸î6¤¯bãq¥ôQPlWšŸûcÏy}¡lR7|¯^—V¦È¶ Ó–'ÿâë7&1öySûš›‰- ‰ ÓiF‚c°Ë¡âÑ©NWÓNBÄ™V8-'ƒ‚À˱–i˜ÓrŽq2óOž¹s ŽQ£P«MËBrq F÷—ßaT‹2ôüþóùìÇ=äb@ä$×ÿ2—¾TÍÍõÐ „ú;ù¬*ÍE—¡Äe)˜ûÉ2¯LfÅ =ÆÅbrn4rs^æ&ÙòÄÿxÁ3ÜùV=ïɰ®ñޱ¦…{ùdÎ^ "o$& êý5ŠR6mkìÎÈ?Û0òÝ^DTüÍ› »ÀÑ„¡“_d$ÐÏ‘Þ/éÆö»û®¿Ž?æÌoô.c£r汄§/»t‡‚Hþ]”ÎÊ Qðu†Â“B@´ D¿Z‚ãG ]ƒâ A”‡Â¨‚`£sr!»ö«¬:ù(ˆbÈ7V>¤Áªó` …¯÷¨¼˜*èÐH§_m¨´Du凇¶‹N)l³Jxý´—jE“øläh6ùG`5€êªÜ ïÀuIóY¶9‹N‰«øÙÞÇX@Ãe¹îJÓÎBËâׯ~#óÏßöŸ™(ÂNa(›³¸¼{¸CáÊje^&…ÉgtF $:Ȭ;]TC¡¥–£ÁB ùL«šQ]T¢»V×ç\—:ô/&PþÙ-ä¯×nà¶Çhö >H fòÞw©dc@äì%Û¿ãpí8?~¼Œß~¦_ç—ÂBà§Lº¥D9îÛNæOûƒ6ƒÛ±}ú;l»üqZú;ò5Æõæ®6Óyx}G^ïw¦ò§†Ñcò|žtÒÉìÕ#¹ã«²gPÖß%<½g8ƒüŸòÖfœîD4Q+ÈqרOáı߸¿×7%yÛ±iuèP Cð™\4wÇ•Áý±ryîR¤nø^7ªÜÔcmBßaƒønİõ'"8Š€¼trŠ¡–ÀΩô|,1A”¨4QRcÖ 8™ïR)¨N¿­!0s^:¹Åœ5›ÌMhìÆÍBb•âÁ© Ó™´<ާ§Í UˆJþ¦q }ßù˜³}qJêÎl9 n®‡J!x5G%¨y_šÍžÏÊSù5¸'“b p:xªì¼LD¶Ȩ¶7q÷–…Œ›8…å_ãÆ ÀMŸñϳàI¦ÎoÏë÷µ8=DÄJ[8ÎL·9kY4ÿ^œ>óúcPðoLȉd÷"Ì Âv”Ý9t‹ñCQŠö,düÒÚ<¿ä=úÖ± –³qV¦§ÿ:ç]ÖÆ¿D›%ÿ*A ,BàwXa³ š @@˜ b·ÂoÄÔ9{’nEó2 ,8¤sgGA?°eªLÜ噡‘‚Û"5®;®2ï• ¡:Wù{pnÕ°m² $ƒÙ¸ÆOŸ»Ûóåô…léò(m¬.ÊÍZ!´¾® óÿÌÖ†¡Óê[€<ÇYTô©.4Í=óg–iËè7æ²@ÐÉùõ5žZö ǯêãúTV+Âñ+È$×øZ!Ù¶’Õ_C%º‹GäVwÝœóiN룫ëùï¨ä–×H Mÿ–-—~d}÷ £?OàÕ?¦c¸¼¡ï GZýØj>?Ø™‰_Œ¥½ÕqœZ÷,÷|¶Žú  –¡ˆ}‹&°4þ¿|øß¶ü¼ï&,¸‘ïs,aÛ¹€×·4$¥Ùvf¼ÿþÛ E ø‡FíÚ¿`¿³æ Uì/ÀÙŸ ~Ä’ŸNަ ÝÆÉ|SPm"êÞɬÏÿKòCêr•Ó×Èèî8Ûé.ªÊ•œFê†ïuËa{ÛÁ_ù寣dêè¶ þ\ùéáM©mKýÞ\aXÃG?¤aÓ5òv/eáoAtéÙIms[÷ç#¦­aù×Kf˜»Óªp5 ¿ßO¾¦Sy€ƒ9–¤žt´Ç‚ïö«ƒÐrIß{ˆ<^Ñ)ÎÉÆn'Ü_E·`õ’í亽Bþ„šNñ÷4ÄÙq’›}î®GUžq%°)}[fòѬ Dõê@ÄY¿œûó*:¶ƒÔc6tŒ„ÕM&Æl§¨t¾“ÁJí¨:ôzl8qk¦2ï÷l´¼|ôò4–¶S-Ô@Ú·Àz"¬S–¤ºi˘÷Ã! µ¶}ò6ÛãSè\Ò­dÏü›“ámhkqÜœبŒr6$’¦@ˆ-Vئ š[ß©fAs“ÂÆh**…âb° ØBƒßÒ œYU8”ëh„t.÷Š ­ÀñÕ*WŠ+‹ì*½;ÜÙÎÎRXr«µÂ¸‚µÕ`F®çí¯ÒÈsYn*·¸ŽËþẎkU:]S¯Ò£,.4í Gü†Œ¦×Ñ>>‚ððpÂÃ#©Ûé:Y΋œ9á*šç¯â‹ÿ`:Ù;—±äo‡¶*^ê®Kß«ª»eô±ÐÅõ¾Ò›‹¢ì“Øý£ˆ 4 üÍ׋~#[Ø9´b1G[ÞL—„h¢¢¢ˆŠŠ!©Ç­4KûŒïŽØÑŽ,aâB#CF_K´_4}Fßñ£ñ,>h[*ï?·˜È‡&ñÜ„‘Ä-yžyùþ%8–½h»Œ÷~8D¡ÐÈúý3>ÛåÈÇÒäFºÅì/w’­°gs(u?9eî#wÇ©Døeñ×ß§ÐÛ”TŽÔ ïðªçI;±…O¦L$5«ð#²é5Ü3öfL€©1w=7„7^}’[ÞÈ dz –[ëûÒ„ÁÃ[3~òp†X‚±F· m›Hþt‘bmÍýÏögÆŒQ œ]AM¹óå ܜДaÏßÉÓÇrëìSèJ u»ÞËóÅèÑ¿bý¾ŸÌƒ>&0¦ ½ÿ“Bòwn’˜êpí Ö¬™v7½ÕÑSz{¶Ïâæzä{älüiاaëÓéÓ>¼L‡´»ógüÄœ‰O²'×€_@-ûÿ—>±p*;ÕÈî<òÀ/<œþSÍDµìEçD ;,Þé®Kü«¨»géc{FÀò/ÓîÉÖ;u=*¡í¸¹ÍKL[ö;»ÞäDÿY ¨kBüêßÊÓýóÐË_Rï²ÏY| ¯ÅbŒá©þÇ­ÏÍçšw¯>kÞði»%ÿz«¡JØU<5¡c^¸ŽÏš‰¿üúw¯Í”À6<>ó!&=7‚îN )A$õÃÌ— vEâî8s}nÞ‰åÏ]K‡)W1éóénlzî÷¥ŽÔ ßë†2gÎÑ­[7ïRI$N¬[·Ž~ýúaµZËí+Þ÷7ß±[>˜ÍÀÊ–Õ²Ù2k0ü9‚eoõ!\ñ ‰äR 77—%K–Ð¥K—óíÊ¿‘ÇoSîçíø Ì¼­nÕÇ¿ûí8Ë€o} =kR”Ô<î4R"©i¤nø–Õ«W_8e¾äâÆU«š_½ÿðÄ-›w÷ç#F¸˜\ÀÆnfÌÎV<6©;áªr:Hò… ‰äR ²ÄQ+þóG*cjÏš·#°íYÆ¿×æú!ñçWDµöí8FPƒD"-prÛÿøò`-zו‹ä\hTÞó$‘Ô R7<ÃÝÁ“¤f1„qùÃsYþpeúÓaü7¬®)É¿€š $ícá˜'øäp8WÝû×DŸçY•J!i+§óæØýd !Ét¿w}þ%‹0H$’ê#uÃ{dð$©6Š¢ JÖ‡”-kIÍ „@!Ÿ±ó‰_"ƒ§ÊàóíG)j$]E×GÏ·#wH”œ/¤nÔ r12Iµq‰DRsH”H.>¤FJÎ'R7|ìy’øŒòï±H$‰DR#%’KÅŸ iš¦QXXx¾]‘H$‰ä‚Bj¤Dré —*—H$‰D"‘H$=OŸ““Ñ#GÈÌ̬¶-9çIr!PöÝ,ç{ÎSdd$±±±UÛ–D"9÷Øl6NžPz_fsÞÙ¾k™+Ý/…”GWrâÒÃÒÅ"Êmú)6ÍÁu=nåí=Å(Š {ë»ü÷†+i×®#=‡NeU††¢ØØüB Ýûæ£àÖ†‚¢Ø9¶z ·tîÎÓ›l%ß¹±!7¹]"›Ä‡û—Ogämד’’Âõ·ÜËsî ïBë(«mý1üéY,KÍÁ øT›Î».ù^û=¿>nòöÒʬ®ë5²î]À°/²>ËŽ^¸‹Ù7´§Ó=Ÿs¨èÌ1ö–0´×(6äÖ„Åûæqnzø ×Ì9ÊÍûÍ9ˆªøÆTøùg3sº,±ç(LZaà“¬3‡e©Œ[«’V²ñÔq…=EŽÿg«LZ«rÀ‹òÂ9½;£ K#ÆfØ¿OeÚ•Q+ <¹ÖÀwYàÍ©y^¢šqÿü%,Y²„ϧ¦Õ—)Ÿ9>ð@c\ùès´ Ö~ð9¿ŸtqÅÕ0º¿¼˜eK¿äã׆Poë&~~¯ßªP65Œ]ÚR˯’}¾ÂB-ãV¾þ3ÇéÕÉüùŽÆ„`¹Àú ]UêŠþ¯|lgèpo²äoaæSŸðÀ'lØø¯^¾•‰c—®ùÓî…ż™’ÆÜ×Ö’¥»±¡g³õ‡¹{æ!j…›œrsmC"¹Á“o±ü‚—ßÙÅeÎeÑç‹xwÒôiU Ë…x™KµmÙ¾øp*÷·>Á¢'ÇðÁÎ×¢ïkm:ŸºTÚïéõ©,o¨Ñíëf-B J»`ÇêjÂ`¸m6SWf`¯ 'Â÷›Jd·è}ls7ç _½.r;ûw¯Eÿd(”Æ%§Ž+äaÏ1N??'Ž+è!‚¨ª–BaËn•]%=-ªüMx;”Iï’bÀe›ˆ¡Á‚~ít&\£ñP¬Û¡áÅãíÕœ'Õ :ýÕ1Tάœ[ȯ¦óÚâ͘ÜuÛGL~ís¶žð£V»¸Î¼Š_;McB× l{–2cê~:hÃR¯+CŸxˆ^q¹¬Ÿùïí>„þøí, nÅèYOÑ®¢÷²*F‚ëu"¥Kk÷§˜ÚñÔ¿ø!Ü»òì|&_ÍÿZHçc YôBÅûf¿Fß(…ü¿—1kúGü¸?%¤×Å==â0çmä¹ûÓðš6ÿ¼“Ùy˜[ÜÎÓö¥nÙ;H ¡ÕÕ~YöÙí¯$D´t~ü.—vÝbYÿ[å×Û>–¾>……’¯›‰¼lÏŒD’s>z¼3†©‡¯gâæ>ò>Q/Ì呦on —­â"/“°æ4¯eFQÀ¶ç[6Xú1íš:˜pÙ-wÓèÓ¬?~b¨×¢~?œ …Rsem úa­{=Sæ]ÆŸã缕ŠmH$—2xò-öS‡É iM—Ëjd† ÄÖDžÞ«»,ËMY+uÏ ¼÷ WäñËÓwóÑ•sx­ë^ƕӻn˜÷.cÖô…¬Ý—A¹mâ$n«o¢ "­«kÆõ/­âR—vÇ06{O½û#ý&]K„­}ÞÊ«CÒyú#üóü³ºïmžngE wÏåþq¹Œ|û1ñÐu©×®"Ù‘ƒoVUûëRìF“Ÿ½»D»6W¬ÏäýظËY:Ê;­t\|ž¹žO¶ÕaÀѨ¥•eS"7ÝÍ×ÓgðS‡èªRšµ£B­“—º˜Éãç±jO6„5ẇžgdŸº˜sâÑ[?¤iß0~^÷7'Nåbi=œ‰cû“hä§.fÒ¸¹¬Ú_@@ýž<øÒ®O´ ëÐûú†-ÚÌ©¶W"‹«j³{÷nFŒAnn®Ëc¬V+sæÌ¡Aƒî¯ìž‹˜+d肺 ìÍPHLü“®r\ש¥(8.ˆ®+° €P8~X嵃^¬£sW3AŒÒÓT>Þ¯QªYЫ•N+lÛ¡òu6è ¬7 nm+ð7‚E¡)ü´]eEØ„† îj­W_ø£LúÛ:×~è oý¨Ð¼“N'“ Û‚Ðó1a‚À4…"/žM¶ m| ÷O]Àç_|Ì íÿfÁ»›É)ñE?±žyK œ¶ˆO^ºŠü-̼œÀ;Þ䋯>frJK×¥;¢\Û_¼ûÒÇÜð*Ÿ}ù)¯ôÌ`þË‹9¨GpÅ#¹6*žA¯~È'ï¹(<ôB²ö¬åËÕ'©×>‹7þ¿‰”]äcsëƒÈ߯[/~Láõ¯ðéW_²ðù+8ðÖK,Úëˆéõ“²Ú~ãg¿Í{ï¾Hç½ó™÷{N…-‡ÁmúPgï2~+ií²^Ū¢N\]ÇÈ™ßØÕy r6¿Å‚ý˜úé¾úâ]^zqÎ5J1‡¾žÌ”?:òä˜ÞÄÕ¢qÛÖ4 õ~·•ºÓû¶£“ј˜?”€xZ3øëØ™öÁ -9ÛWüIî•B‹ÝEK,³%—"2xò-æ„î´-þ–ÙóW³/ç줲²ÜåôÎö'o¿ø¹=_â£/¿dÑô‘ôŠ3¹Ö:†JX¨×¥Öý¿p ¨â|CKoS<Ý»³ýÛ%C Iûá´v½h¤xçƒ'ºT­kW‘Fnñ£sµßì…/žæÝ!Â{­,í¨‰¡Y¹»~`_ä4 ¦ä;vü;<ƒ 60åÍÍdk%ø]GËÙÌ´Ñïa8‡oÖ®aéänìþï¦ÚÐu-k ßdÚ‹XüÙ«tÝõ&3>‰–·•YOÌ#Ð[¬Xó-³SŽòƳ²·PG×"ZµÇšº–½ùçÈÚ¥°%%%1kÖ,¬Vk…÷•ÕjeÖ¬Y$%%UmØ` Äi { ó…¶äC›Z‚zvHµì(PhRR·Ò`‹Mp÷U:/wÕI>©²ä¸cØ›5DпƒÎÄ«5†FÁ·» ¸¬‰F{ ôè ñÒU:M} ‚(ÈTø&O硯\£1¼¡N¤³ÔU¾‰Á½¥Tf[ØaõN… xAm/Öcòaðd ¤a[šD™QÕ@’Û'ãw*óÌØq5„ŽCo¥cí ,FŠö­f«¹ƒºÔÆO1Ùæ&®«çè~)Ü»‚_Ä•ÜÚ½fƒ…„îדøÏ¶z2˜ZÏbÕ“Héןa/.¡¨ïs<Ó;ÕKÿªZE)Üû=›ŒÝ¹¥k,ªkÃno“ÍÚ5‡]¢~µé~MCUÀOËúFŽ©xܺÚ†”úùzSE¤}¿¥[âÂéæpu^ æðº¦¯fùú½d+ÁÄׯufX…y[ÞáÅÅA w'M”À& ~b$½c½žœ_’{z6mý‹¼ÈD"Œ Š"(.(FµøcRKŽQýð+&§ÐQ14E%r|3[9æ,•³Qfƒrs›ÊÙ›Ü.‘M¾$×·(AmyxÚSt>ùŒEPPQì¦@0&3øÛ~>¦DÙ“J¸?Jqg[h ßlRÙ¥34Ax5ÏwK•k'øýÿæóÙ{ÈÅ€È=H®ÿeN9…“q¦ÛCËˤ00™ Ò¯Ô@¢ƒîh9Ç8™ù'ÏܹG ¨Q¨Õ¦e¡‚¢†ÑýåwÕ¢LWº—þU-7[@Ò™óÂHpL¶c%°!€P§ ª 4­j—¥4bî'ȼ2™,ô‹i¯gçen2œ)Oü÷<ÃoÅÑóÞ‘ ëïcZ¸—Oæì¥ òFbªßš­(emä°æÁîŒü³ #ßíE´@Å/ÐŒ°Ù° Mz1ùEFýéý’î`l¿û¸ïúëøcþÇ\ñFï26*çlKxú2ï† J$*åŸ3Iu1E¶eИ¶ 8ù_¿1‰±Ïk̘ڟP7eyÅË8•ãeõ.'ƒ‚À˱–‘÷ZWy ¬ådgwø¨•Ï×cì•ôÿŠåÓ"d¿ˆËdAÛâ¥è’;¬ôÚy£‘gùU±öçU¦ÉÎT5ïJpný÷ý°=;ù9…ø[PDI[òW5¶c/å¾IÓsr,J‰öì l%=†F‚k`KÏÆ.¨„ù+§1TšŽvê(Y[yÓÊÓ÷‹M‹£mŽ*4Ú8U #„,¯|Err23fÌà‘G!77«ÕÊŒ3NN÷I„(ø:Cá€I! Z¢€_-Áñ#®Aq Ê€#2QÁFçä ¥·É®ý*«ŽA> ¢ò•ßï¦P[(|½GåÅTA‡F:ýjC¥5rW~xh»è”Â6‹à¡·µDO‚S¦3iyOO›A«•üMãú¾ó1 ªÓsdǯ “\;àh…dÛŪ!0šÐØŒ›3„IJW¯t²˜Wå÷þ9%ug¶†à(òÒÉ)†Zf;§Òó±Ä¡R^ÍÃQ jÞ—f³ç³òÇT~ îɤœ©ÊÎËDdÛŒj{woYȸ‰SXÞø5n  Ñôÿ,1 ždêüö¼~_ «QΕ¶ŒŸ!˜nsÖ²hþ½Ü7q9}æõ'Æ àߘ;É(îE˜„í(»s"è㇢(íYÈø¥µy~É{ô­cA-gã¬LOÿuλ¬ Y|K.dðTsB›ÐwØ ¾ñ#lý‰pS–”¨4QÒ ¤p2ß9,(£w˜óÒÉ-æ¬Ònµ®2D©ßo¤ y8uý€‚òùž}‚µ¸òêH–¬ÜÆÎ¨_¡ÓÍ ¼ö¡2]r¯ƒž\;··¹—±GåšìŒoó>ÌÓ€*b4°çÛÐtÝ–ôt¡ë躉zFsÓòG™ºìvÅ1lO Ž! ÷0YE:1ŽU²Žäc©ˆ¢—,B"Cñ@wôZ £ ‹¿™ÉïßGr™ûE×u°`³1JÓJ|ERR¯¿þ:Ï<ó &L8=TÏÎ÷ž;Â"~‡6«Ð ¹À„ "v+ü¦AL³w¨è—ÍË0°àÎ üÀ–©2q—gç)¸-Rãºã*óþPÙªs•åé<¹Ã\Ù6Y’ÁƒlÊá£1 :Å9ÙØÍá„û«è¶¬^²×ÓÛÀœpÍóWñÅÆ° ìËXò·£ 3'õ¤£ý;|·‡\„–KúÞCäi€êO¨éÈACxX–yïŸÛ|Üì³Ôï͆5|ôC6]#o÷RþD—.ñTeÁ#%°)}[fòѬ Dõê@ÄY¿˜ûó*:¶ƒÔc6tŒ„ÕM&Æl§¨tx‡ÁJí¨:ôzl8qk¦2ï÷l´¼|ôò4–¶ãÔ@Ú·Àz"¬“–¤ºi˘÷Ã! µ¶}ò6ÛãSè\Ò­dÏü›“ámhkqÜœبŒr6$‰¤ÛÁ_ù寣dêè¶ þ\ùéáM©mv_–›-‘Ô6g°u>AaÚ–ïq½ ”9±;­ W³ðûýäk:™8˜£aq§u.ÝÖÈÿ'•Õï½ÀøÂ8´#¡tb:]MÄŽÏøt½Jçõ0C•|p¯K¾½v§©’öûH“Ëä­{©•5;lO%¤n8…‡“c/SQ.=ÆÒÁ£®%óÃ÷ØQ%é:º¾çýo÷‘¯ÙÉùë ÞýÕJ·îu1•ö’QÞž¥A_®(^μe»È¶ t{6‡w§Î»øø~Ž›b¨mUÏûp·KqKJJbÑ¢E$%%y•®² ˱ŠÛ4Aó’ѪYÐܤ°1š†ŠJ…âb° ØBƒßÒóJ±ªp(×Ñyåüü@ZAÉœ)« \U(®À]WéÝáÎvv–Âú#[…Fõ<¿bý¾ŸÌƒ>&0¦ ½ÿ“Bòw®S(ÁíñøÕLz}8ý§š‰jًΉvø7eØówòÆô±Ü:ûºHÝ®÷òücñšêpí Ö¬™v7½ÕÑsÆÒ¥RåðÞ?Êæ3¥·gû,¹ë¹!¼ñê“ÜòF%Ócø³ÜZßò+¿’åñ§aŸN„­O§Oûpιæî¼Å?1gâ“ìÉ5àEËþÿ¥O¬¡¤…ÒÙGø…‡§Í¥Ý¸6ìü}+Ñ7hçÝ­Q:ç©,Š¢€%½C€3FL½‹±cÒñ©BBšÜÀ˜)ƒ¨kRJŽ¡”ΟraãŒqTÅÑUëœwE6$’K9çÉ·h'¶ðÉ”‰¤f~D6½†{ÆÞL‚ 0¹)Ë•& Þšñ“‡3ÄŒ5ºmÛDò§‹|kkî¶?3fŒbàìjÊ/Oàæ7ZWÖHÉœžUÆP[wãÞWÓ#Ñó…¨Ñ—smø\fç bxÝ’ÐÁÞº´äN—p¯ƒ^^»ÓTIû+ñÅSM.“÷ã/uñJ+Êzª‚µAbŽþDjn_"ƒœ¾W’_@‹»y´ûjžZ'ßû7aØ„{˜1i$×Oφà†ô1ž;’Lˆü³ƒ/”üAXšsßÄaÌxe47N;‰¦XI¸ú!&>O ÐÉúk#'â{“èW’FrÞ9+v…AÐ<RM‚ØÒZF‘°ì¨Ó|'×¹­så•W¿Xü¡c‚ þpÉn:$ ~ßnàÉTÁàÎ:-KŠ »Måÿ¶*.£$èt (c¾lúöžÝ\îlä*¤f l‚¼¬+*sæÌݺuó.UM gùãðã­ï1¡£ë"[rá±nÝ:úõëWáj0ÅûÞáæ;6p˳X¿’!tZ6[f æ?G°ì­>„+¾³!‘\ äææ²dɺtér¾]‘H$b³ÙØ´i$++«òÞ¢eñóyÐÛŒïvþF\h,{l«¯{“—¯,TKÎ9aaa|öÙg´k׋EÎÿö«W¯öá‚Þ¢å°oÇ1‚$i“ÛþÇ—kÑ»®üq/F\õ<ùÕûOܲ‰qwßÁñw>bDCW% ØøÂÍŒÙÙŠÇ&u'\UNI¾°!‘\ Èž'‰äâ£ì°=Ÿ£ÆÐëž+ùjÎÿØsù’=ïŒô!‚‚¿>åóüÞ|˜}ûöQ¿~}L&“—žJÜ!ƒ'Iµ±Z­´lÙ’ðÇTÛ^u*™²uERST§â‹ Ltt4-[¶Äb±ÈÉ¿ÉE„¿¿?‹…„„Ž?Î_ýåqZwšVÕ}’ó;M¨ê¾²DGG“€ÅbÁßßß+ÿ$î‘Á“¤Ú躎¿¿?Í›7?gÃö$’v»»Ý~z°D"¹8P…ˆˆüüü0™LUº'ƒ¡7Ui€3${ }Œ¬éJ|‚¦ihšFaaáùvE"‘H$’ EQ&88ø|»"‘Hª‰|]½D"‘H$‰D"‘x€ ž$‰D"‘H$‰Ää°=‰OÈÉÉáÈ‘#dffVÛ–\0Br!PöåÏç{ÁˆÈÈHbcc ª¶-‰Drî±ÙlœØWYû–³õá,Êj”HíWqÓ]wѧQËPŸjL©Ù]xñý'hxæêj™+yòîiìH~”¯^CÍHš×ÇC ÷üú¸ÉÛ J+³5Õ P¼!#žØÇ°·žæò€½¼1h0F?Ág³û[RÓÓ/ãÞÛ×pÏâWèàk4þYñ$÷/»’Y¯Ý@mÙI~AàDI|‡÷ÅœF·‰Ÿ²dÉÇöÙÓ´¾X§RÔ0º¿¼˜eK¿äã׆Poë&~~¯ßÄ e°öƒÏùý¤~Ún‹.m©åWɾK8I$5ÛçL$DÁ? E%_:®k„=Ç8]ž8® ‡¢ªúÈ …-»Uv:>ªFð7áyQ&½[HŠÁu…\Ð`A¿v:®Ñx(ÖíPÈð´~jnÄýóZöùÔ¢£ú2å3Çç8Wåõ¡,¥µl _|8•û[Ÿ`Ñ“cø`g둾ÖSµŒ[ùúϧ„þøí, nÅèÉWó¿‡ÒyÆXB½Pñ¾Ù¯Ñ7J!ÿïeÌšþ?îÏA iÀµÃFqO8ÌyyîþÅ4¼&„Í?ïãdvæ·óô£}©#ö±ôõ),Üp|ÝLäeƒxfì rWñÂïõÂ\ij©ÊOì5²çI"©y*{Æ"¦à º ®{3ÿ¤«×uj) Ž ¢ë ,( ŽVyí ¤+DÆèÜÕLc€ô4•÷+djôj¥ÓÅ Ûv¨| úFëM‚[Û ü`Q@h ?mWY‘6¡á‚»Zëĕʅ€?ʤ¿­ƒp퇮ðÖ Í;ét2¹°!-1&LS(ò¢² T§¿:ƒÊy+䀧Pçq>˜ÜuÛGL~ís¶žð£V»¸Î¼Š_;McB× léU\.ëg–чYOÑ®Â÷«ø…Ô¥ÝÀ1ŒÍÁSïþH¿I×a«À—ç­¼:t!§?Â?Ï?Ë¡ûÞæévV p÷\î—ËÈ·£áWZöæ ¡ÕÕ~YöÙí¯$D´t~ü.—vÝbYÿ[éºK=3¹Óú®{W‘ÖìÈÁ7=¼>å4¼.Ån´õÙ»K48`sÅ:[AÞ»œ¥£¼×WçÀÅ×è™ëùd[<ZZY6%rÓ}Ñ|=}?ux.¡*¥Y;*Ô:y©‹™<~«ödCX®{èyFö©‹9÷'½õCšö ãçusâT.–ÖÙ8¶?‰fA~êb&›ËªýÔïɃ/áúD б½¯aآ͜j{!²JPmvïÞ͈#ÈÍÍuyŒÕjeΜ94hРÂý2xò=>j+R‰ìñwE®dÆâ}ä¥-aÆòîx¨‘¶?yûÅÈíù}ù%‹¦¤WœÉcËJÎï¼1凼Áâ/>fÊM:÷—DA¶¿x÷¥)¸áU>ûòS^é™Áü—s°„»t®Ð ÉÚ³–/WŸ¤^ûx, m| ÷O]Àç_|Ì íÿfÁ»›É)¹õë™·ÄÀÀi‹ødüM¤<8’k£âôê‡|òžSánãŠ\ìDþ6Þzñc ¯…O¿ú’…Ï_Á·^bÑ^Gû±~òOVÛo`üì·yïÝé¼w>ó~Ï&gó[,8Ü‹©Ÿ.á«/ÞåÅ¡WgÕR‹Æm[Ó0ôÜ­"'‰¤æ©ì93 â4…=ŽùB[ò¡M-A=;¤Ú v(4 )Ö¦Á›àî«t^|ReÉq€5DпƒÎÄ«5†FÁ·» ¸¬‰F{ ôè ñÒU:M} ‚(ÈTø&O硯\£1¼¡N¤³Ë¤obpïG)•ÙvX½S!(^øpÈðÒU„äoaîäåÞñ&_|õ1“SrXº.ÝÑâJ¯ô·úP1êué€uÿ/(ªØ—ÐÒkcЧ{÷`¶»“<PHÚ¿ µëE#ŵ†VDp›>ÔÙ»ŒßJzaì‡W±ª¨W×1ž©˜W¢gî¨Pë¶øÑÙÓëSFÃÍ^øâiÞ"ª¦¯¥=º®û|ËÝõû"¯ i0%ß „°ãßál°)on&[s+]GËÙÌ´Ñïa8‡oÖ®aéänìþï¦ÚÐu-k ßdÚ‹XüÙ«tÝõ&3>‰–·•YOÌ#Ð[¬Xó-³SŽòƳ²·PG×"ZµÇšº–½ù¾?Ïã–””ĬY³°Z­ÞWV«•Y³f‘””TazÙóT3x<éY¬zr)))Ží¿Ë9®j4צDGŽ?Jqg[h ßlRÙ¥34AøpYï4 hßj¶š»1¨Kmü#‘mnâºz•ëU•< ÇßžCAékƒ\ê‘‘Ø.=Ù¾‚ù ÓXµQ£CÏdðÒ'%´ )õòõ¦,4ŠHû~J·ÄÅé@מU:¾B­Ë¡Ò+äBË+ÓÖ*ä]}u®Äú~³sâï£(q‰„ªÎôv5Š^'æ»É¼·#Ý©ç©`××ülìÉ]×Ôâ¨X›ÜȰ§XùÝ~ üâèÙ§ Š@˜ëЦ‰Œƒ§Èݵ”u¢;C®­‹YµP¿×H:¶–MÿØB`ŒnDdþ^Òrµó>TíRÙ’““™9sf¹Êjµ2sæL’““+µ!WÜó-ÞkŒF÷—ßaT‹òÝÕ†Z]Ðü]žßÜŠç»Ç`l9^ŽÕóΦO¹–ŸEa`C‚JÓ«DYÍŠZÎ1NfþÉ3w®¡ä µÚ´,ÔÝ¦óøœ´üþóùìÇ=äb@ä$×ÿ²3ûá$Dx}båÐr3±$ñ#Á1ØŽ•܆Bý*Mª‚Ж&ÙòÄÿxÁ3ÜùV=ïɰ®ñžUV|Œ¢Èž'‰¤¦©ô9S ! ¾ÎP8`Rˆ„(àWKpü¤kP$ˆ2àˆLTA°Ñ9¹BI½‹]ûUVƒ|D1ä+o½4…ê<ØBáë=*/¦ :4ÒéW*-%]ùá¡í¢S Û,‚‡>¨ì¥hy™&Ÿ¥;ÑAŽs§WUr-'ƒU¨«A\–Òˆ¹Ÿl óÊdVl°Ðc\,¦½N~¹Ñ³Š×Öuú¡]h]¥¸Ðð¼Ê´Õ™ªæíέÿ¾ï°“ŸSˆ_°E”±%!Pcû1fðRî›ô1='Ç¢”ø`ÏΠ  !ÁQò¬ ®€-=» 毜ÄPAh:Ú©£deleäM+Oß36-޶:B¨` ÐhãT޲^à+’““™1c<ò¹¹¹X­Vf̘q:pr‡ì}ò=>íš(Üó¼»=‘î wñÞçsÙ#0祓[ŒËÅŠb@E  ( p2ßQÌüÃð+ø‡\;àÙŽbÏMhìÆÍBbͰe¹Nç‚S¦3iyOO›A«•üMãúþYž£VT6¸»G+ØgŽ" /œb¨e°s*=KL*…€BÅu&‘m2ªíMܽe!ã&Nayã׸±Ö¹_榴e\"‘Ôž+i ÷Mìù64]w…%=]è:ºn¢Þ€ÑÜ´üQ¦.»Eq ÛS‚cÈ=LV‘NŒ ¬#ùXj¢è% ‘ÇP<нVB‡ÀhÂâofòû÷‘\æžÑuìØìF̆Ҵ_‘””Ä믿Î3Ï<Ä NÕs‡ó½'ñUšó$tí̘JMwƒ†8»¬u³ÏR¿7WÖðÑiØt¼ÝKYø[]ºÄãn! ¢c;H=fCÇHXÝdbÌvŠì òvðÑËÓX~ØêUø›IDATî&µD"¹1Bl±Â6Mмd”‡j47)l̦¡¢’@E¡¸ì6‚Ðà·4Ç|§R¬*Êut^9—eÅVP2gÊ*WŠ+ÐvWéÝáÎvv–Âú#ëÓú÷`N¸Šæù«øbã?Ø…NöÎe,ùÛ¡eîôÊ­v8#4òÿIeõ{/0þ‡píH¨G*n ¦ÓÕDìøŒO׫tîQ3`qç“ ”À¦ôm™ÉG³6Õ«eòw§gf7ZïO¯Oªª­îòÖ« ¯5;lO%¤n8…‡“c/SQ.=ÆÒÁ£®%óÃ÷ØQ%é:º¾çýo÷‘¯ÙÉùë ÞýÕJ·îu1•ö’QÞž¥A_®(^μe»È¶ t{6‡w§Î»øø~Ž›b¨mUÏûp·KqKJJbÑ¢E$%%y•NÛó-Þ÷<éY¬~z«K?›;ñÒÂ1„5¥·1íêhü ‘Ü?x#§-æŠ×nåþgû3cÆ(Î.€ ¦ÜùònŽr²iiÂàá­?y8C,ÁX£[жM$"¸=<~5/¿~/7½j¦vëëèyy4¿ø7eØówòÆô±Ü:ûºHÝ®÷òücñºKç¯B¿ï'óà€ ŒiBïÿ¤ü›$¦:\;¨5k¦ÝÁMou`ô”Þží³4æ®ç†ðÆ«OrË9”LáÏrk}?Èw•™ 8ã'æL|’=¹ü¢hÙÿ¿ô‰5 ge°ó÷­Dß Aܹ™÷T:çI"‘Ô•ÎyǼ§ H5 bK;¡h ËŽ:Íwr‰ $ZçÊ#*¯~/°øCÇAüáR' C¢à÷ížL î¬Ó²¤&j·©üßV…ÃÅ`4@ƒNeß'S6}{ϪÂîlä*¤f l‚|V y¯Jp;F<~5“^Nÿ©f¢Zö¢s¢…à^¯ÊêܱtqŽŠJæô¬0†’غ÷¾:˜‰ž/¦®F_εás™3ˆáuK~0w>¹´äOÃ>[ŸNŸöá”çàNÏ×Zï–Ê®+ª¤­îó~ü¥.^ë«sR=Öˆ9ú©¹}‰ V@pfø^I~-îæÑî«yjp|ïß„aîaƤ‘\?=‚ÒsÄxîH2!òϾ@PòaiÎ}‡1ã•ÑÜ8í$šb%áꇘødvaë­7R~Éã<oƒCáIúmâĉ8p€ÓN; ·{h>R i2\ ÕÅÉ4M8Àĉ‡äx"20Ç!PUUE^^^ª›#£HUU@@!j€)eeeø|>=ü+2‚øý~|>ÅÅÅÔÕÕQYY™tÙD×´Ý&©—èšp¢ÛbåççS\\ŒÏçÃï÷Wû$1…'é7Û¶ñûýœqÆC6lOd´1MÓ4;‘‘Á0 òòòðz½x<žº§04ºÈ?àÜn7YYYŒ;V=Lwº2 ,˲,ÚÚÚRÝ‘aÅ0 rrrÈÉÉIuSD¤Ÿ/W/""""""€Â“ˆˆˆˆˆHRžDDDDDD’ ð$"""""’…'‘$(<‰ˆˆˆˆˆ$AáIDDDDD$ ZçIDDDD$M…Ãaiii‰»ÏhYˆ9Ñ‚ÁÙÙÙŒ7ŸÏ—°…'‘4 …8|ø0S§NeÊ”)=¶w„ Ã0â~?u„AÇqâ~«¦¦†}ûöQPP€ßï[·Â“ˆˆˆˆHª¯¯§´´”ÂÂB¢Ñhª›3¬bY¤¨¨(î~ O"""""i¨¡¡™3gbÛvª›2"±{÷n…'‘Ñ&Ñ05éÉ0Œ>ϕ“ˆˆˆˆHr—Ë…iš©nʈ‘‘¡ð$""""2u„' ÛKŽËåRx:B“†í%¯¯ ©ErEDDDDÒPì4Ý#æÕ¼ƒ›æüÏ2‡ô¸]ÏY<êyIC¡ noŠâã~Àýë_¡²ÁÂ{3._Áƒ×—‘m˜ùí6NÇôñ'pô>Ê;!j~½5¶ðæˆâ§ l w­ú6³Ü``€ccÛC·ÖTדˆˆˆˆHŠíU‰eVÿ”ïþ¨’sWmfýŒBŸì¡¢q>œè!¶=¾‘¦pVà«™‰Ê›üqó\û¯Í,¾ç1^:ïdÆ´Ö°{w#ÅYN›Óñ éC…'‘Qª¯¡h‘ƃ4¾È…Ÿ›€ï”éÌžbcEðßÝÆš=5XË.æ¹Ü/pß3ßcâ+ðOßbC×Ä/ñ•÷òÕSü-opë×·²àÆž{ägì™r#Ë'o)ÿ ³Æ;pð=žX·‡³îý·ÌÍÅ1È-æóç8–ÕbÚ[ŽãX´î}ž~àI¶Üi\ò­ïsË“ÈlyƒåWlä//ðÖŽ?P´ßY×±òžË(Étî}ž‡î[Ïöý!Æ”^ÄM÷ßÁâ‰ú²ú OzæIDDDD$ u¶×ÛË[:ŸY‘ÿbåêWù¨1ŠeF‰š¶1 î¾—ÅÅ,{ú5^y_ÊÊ`ü_-â®§Å¿ÝοÍÞË¿¯y‹FËÆv ìº×yl“‡k\Nùº«¸¼GùÏŽ®y“÷¢e\6=3bbÙ–ebF£X¶MÇ(CǶ±šßáÑ;6¹ò)þg×ÛüúѹìôN6ì c;NÃ{üÒº’5?—_ZÍÜ}ëyì­F¬Ö÷Y}ç“„®z†oïà©K?eÝŠTµõ~.lÛNªçIáIDDDD$Ít q'HÈ>›»ŸYÅ ?ãæE‹¸îþgy¿ÁÄqlÌp«-D0Æt ÆN;›ÓrmÂ!%=ÌÆ#´XÇê2ÆsÞ-˘5ÁƒËp0ÛbËv\3XOØŸOvFü6¶÷:9„ö½Êoܳlnv(Œ{êe|sVÛ¶í§ÍqÀ;™/_r ž¶A»€é§zøSm-û¶²“y\waVئhÁ×8åÓ¼ó}OB‘hÆ= ÛI3IÍ ç˜;«ØÈ×~ÈóÝÉm·[<¹îkLî(â+oÕñöÆõüdû>šÉÀiÙOÓ˜2ÀÁÁÏJsÌÎn£˜ò]ddÇ®¡1âàx{mXg»Íæ#„²NÅã€c:Œùó,BlÆÄŒ,Æy;ŽacàXfÓaê?}—›½Jûtak2ŸoKÜ»ÔWï““ˆˆˆˆHšI¶'ÛÆ2£9•Eß¾†-W¾FuëW(òØ8´³m‹Æ×Wò½Í%<üô9{œ›à›·réÚŽíÇžQ²»ÌŽgw-ßýø®‚L3^âåß5ð…Ù¹=‡Âu©ÏÈ- «¥–†ˆM¾ BýÁÆŽÅe‡qÛ¶Ž õ³Å.WVãOºš=û-Nñtž"¡ Ñ8磯 6@ÃöDDDDDÒNßÃöl‚ûä1lb¶Öòöæ­œð9&ùׯ{ù}ÕQLÇ¢­¥™¨?#BK݇lùÏwi²{®‘ÔùêVÞÆîLügòW°óÁïòDù>þ l¨å£ŠjšÌîõùþb1s]¯ñ¿¬&h™4Wþ‚§þ7‡¹LÆÝrºý\íå2§^¹Ñ-¬Ùô.‡Ž¶Ð|ô½÷{ê#}¯÷”(l*<‰ˆˆˆˆ¤™¸¡¦Ë˪ÿï¹–KçÏeî«yøS¹õ‘«™âvp<“¹øš™|²ò2.Z²‚ÝgÞÀW³~µ æ³dÙŽœ9Óü±ÃܺÔß­üwØ^ou9¶›ÉW®fÝ7'ñþêøÛ…óY¸dßÿéû4Ú1õyOåúÞÄø—³xÞ<þæî”Ü²Š¿/Éèý¸å¦qã¿ÜÀ¸—ï`ÉÜ9œá|çÙ=4%±Hn¢ž'cíڵΜ9sþ7&""""")‡ÙµkK—.¥¡¡¡÷22ã÷’Ñ1ÒΊ GˆÚíáÁåñá÷yp9¡`„ Ÿo†c[D#n¯C(ÁÆ?+3ØJ´K§M·ò­AÌn™Ä Ãë#Óëþìøf˜P(ŠmÄÔçòâó{ñ¸ p,¢maÚ¢6Nì~dø³ðÙAZÛl —‡L_&ž  °£ÇêsΛ6mbÆŒø|¾ÛËËËõÌ“ˆˆˆˆHº‰¶×+3Lks8nV$DK$Ôù>Ú¥ÛÞ‘Î-›£}–ïÎÁl b¶õ¶)¦>«PK=jŠÝ3ØLKÇ;+B¨5Ò³\¼i¶=‘ѧ·!i’X2çKáIDDDD$Ítôž˜¦™x¶=édš&€zžDDDDDF“ŽÞ˲Ôó”$˲€ëb¡ð$""""’¶4l/yÉœ'…'‘4ã8@€ªª*òòòRÝœ¡ªªŠ@  ž'‘ÑÄãñ¨¨¨ ¤¤„¢¢¢^÷3 Ã0:¿ïíëHÓuͦدñ‚Qmm-ÕÕÕ”––âñxâÖ­ð$""""’fü~?>Ÿââbêêꨬ¬ì±Olèú~¤õëübC`o¡0??Ÿââb|>~¿?n½ O"""""iÆ0 òòòðz½x<ž¸C÷FzH:^ñzÓÜn7YYYŒ;6a›Â“ˆˆˆˆH2 ƒœœrrrRÝ”´áJuDDDDDDF…'‘$¸ÊËËSÜ ‘áíÿÃf†È€]™IEND®B`‚WereSync-1.0.9/docs/source/installation.rst0000644000175000017500000000331113130576002021450 0ustar danieldaniel00000000000000.. Installation Instructions ############ Installation ############ Dependencies ============ WereSync requires different programs for different systems. Generally these programs will be installed by default on many standard distributions (such as Ubuntu) and do not need to be manually installed. - `rsync `_; required for all systems - `parted `_; required for all systems - fdisk; required for working with msdos/MBR partitioned drives - `GPT fdisk `_; required for working with GPT partitioned drives - `gettext `_; required for all systems Bootloaders ----------- WereSync will attempt to update the /boot directory of the target drive in order to make it bootable. However, some bootloaders, particularly MBR based ones, require a more bootloader-specific process. GRUB ++++ As of now, WereSync only supports installing `Grub `_ in this way. As of now, WereSync only needs to have the grub package installed if you have an MBR drive. If you have an efi system, you do not need to install any packages. You can tell whether or not you have efi by following the instructions on `this post `_. If you do not have efi, you need the grub-pc package, which can be installed with the following command:: $ sudo apt-get install grub-pc WereSync Installation ===================== PIP --- WereSync can easily be installed with pip:: $ pip install weresync Code Repository --------------- .. code:: $ git clone https://github.com/DonyorM/weresync.git $ cd weresync $ python3 setup.py install WereSync-1.0.9/docs/source/conf.py0000644000175000017500000002335313130614327017527 0ustar danieldaniel00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # WereSync documentation build configuration file, created by # sphinx-quickstart on Thu Nov 3 19:08:36 2016. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys sys.path.insert(0, os.path.abspath('../../src')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The encoding of source files. # # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'WereSync' copyright = '2017, Daniel Manila' author = 'Daniel Manila' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '1.0' # The full version, including alpha/beta/rc tags. release = '1.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # # today = '' # # Else, today_fmt is used as the format for a strftime call. # # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all # documents. # # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. # " v documentation" by default. # # html_title = 'WereSync v0.1a' # A shorter title for the navigation bar. Default is the same as html_title. # # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # # html_logo = None # The name of an image file (relative to this directory) to use as a favicon of # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # # html_extra_path = [] # If not None, a 'Last updated on:' timestamp is inserted at every page # bottom, using the given strftime format. # The empty string is equivalent to '%b %d, %Y'. # # html_last_updated_fmt = None # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = False # Custom sidebar templates, maps document names to template names. # html_sidebars = {"**": ['homepage.html', 'localtoc.html', 'sourcelink.html', 'searchbox.html']} # Additional templates that should be rendered to pages, maps page names to # template names. # # html_additional_pages = {} # If false, no module index is generated. # # html_domain_indices = True # If false, no index is generated. # # html_use_index = True # If true, the index is split into individual pages for each letter. # # html_split_index = False # If true, links to the reST sources are added to the pages. # # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' # # html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # 'ja' uses this config value. # 'zh' user can custom change `jieba` dictionary path. # # html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. # # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'WereSyncdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'WereSync.tex', 'WereSync Documentation', 'Daniel Manila', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # # latex_use_parts = False # If true, show page references after internal links. # # latex_show_pagerefs = False # If true, show URL addresses after external links. # # latex_show_urls = False # Documents to append as an appendix to all manuals. # # latex_appendices = [] # It false, will not define \strong, \code, itleref, \crossref ... but only # \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added # packages. # # latex_keep_old_macro_names = True # If false, no module index is generated. # # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'weresync', 'WereSync Documentation', [author], 1) ] # If true, show URL addresses after external links. # # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'WereSync', 'WereSync Documentation', author, 'WereSync', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. # # texinfo_appendices = [] # If false, no module index is generated. # # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # # texinfo_no_detailmenu = False WereSync-1.0.9/docs/source/api.rst0000644000175000017500000000071313120330026017514 0ustar danieldaniel00000000000000.. include:: global.rst.inc .. api_documentation: ================= API Documentation ================= `weresync.interface` ==================== .. automodule:: weresync.interface :members: `weresync.device` ================= .. automodule:: weresync.device :members: `weresync.exception` ==================== .. automodule:: weresync.exception :members: `weresync.plugins` ================== .. automodule:: weresync.plugins :members: WereSync-1.0.9/src/0000755000175000017500000000000013315166025014563 5ustar danieldaniel00000000000000WereSync-1.0.9/src/weresync/0000755000175000017500000000000013315166025016422 5ustar danieldaniel00000000000000WereSync-1.0.9/src/weresync/utils.py0000644000175000017500000000404013125115721020126 0ustar danieldaniel00000000000000# Copyright 2016 Daniel Manila # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """This module contains several functions common to all modules.""" from weresync.exception import DeviceError import subprocess def run_proc(args, target="", error=None, valid_returncodes=[0], throw_error=DeviceError): """Creates an runs a subprocess with the passed args. and throws an error if the valid returncodes do not exist. This expects either :py:class:`~weresync.exception.DeviceError` or an exception which takes 2 arguments, the first for a custom error message and the second for the output of the processs. :param args: the argument list to run. Passed to the first paramter of `subprocess.Popen` :param error: the custom error code to display. Optional :param valid_returncodes: the list of return codes which should *not* throw an error. :param throw_error: the error class to be thrown if there is an error. Defaults to :py:class:`~weresync.exception.DeviceError` :returns: the output of the process """ proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, _ = proc.communicate() output = str(output, "utf-8") if proc.returncode not in valid_returncodes: if error is None: error = "" if throw_error == DeviceError: raise DeviceError(target, error, output) else: raise throw_error(error, output) return output WereSync-1.0.9/src/weresync/resources/0000755000175000017500000000000013315166025020434 5ustar danieldaniel00000000000000WereSync-1.0.9/src/weresync/resources/weresync.svg0000644000175000017500000006754513117327354023041 0ustar danieldaniel00000000000000 image/svg+xml Jakub Steiner hdd hard drive fixed media solid http://jimmac.musichall.cz Created by m. turan ercan from the Noun Project Created by m. turan ercan from the Noun Project Created by m. turan ercan from the Noun Project WereSync-1.0.9/src/weresync/resources/__init__.py0000644000175000017500000000000013117327354022537 0ustar danieldaniel00000000000000WereSync-1.0.9/src/weresync/resources/locale/0000755000175000017500000000000013315166025021673 5ustar danieldaniel00000000000000WereSync-1.0.9/src/weresync/resources/locale/en/0000755000175000017500000000000013315166025022275 5ustar danieldaniel00000000000000WereSync-1.0.9/src/weresync/resources/locale/en/LC_MESSAGES/0000755000175000017500000000000013315166025024062 5ustar danieldaniel00000000000000WereSync-1.0.9/src/weresync/resources/locale/en/LC_MESSAGES/weresync.mo0000644000175000017500000000073313315166021026255 0ustar danieldaniel00000000000000Þ•$,8¡9Project-Id-Version: WereSync v1.0 POT-Creation-Date: 2017-06-13 21:49+EDT PO-Revision-Date: 2017-06-13 21:56-0500 Last-Translator: Language-Team: WereSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated-By: pygettext.py 1.5 X-Generator: Poedit 1.5.4 Plural-Forms: nplurals=2; plural=(n != 1); Language: English X-Poedit-SourceCharset: UTF-8 WereSync-1.0.9/src/weresync/resources/locale/en/LC_MESSAGES/weresync.po0000644000175000017500000002016713125115721026263 0ustar danieldaniel00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR ORGANIZATION # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: WereSync v1.0\n" "POT-Creation-Date: 2017-06-13 21:49+EDT\n" "PO-Revision-Date: 2017-06-13 21:56-0500\n" "Last-Translator: \n" "Language-Team: WereSync \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" "X-Generator: Poedit 1.5.4\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "Language: English\n" "X-Poedit-SourceCharset: UTF-8\n" #: weresync/device.py:1594 weresync/interface.py:322 msgid "Finished copying files." msgstr "" #: weresync/gui.py:70 msgid "What's this?" msgstr "" #: weresync/gui.py:147 msgid "Source Drive: " msgstr "" #: weresync/gui.py:161 msgid "Target Drive: " msgstr "" #: weresync/gui.py:173 msgid "Source VG" msgstr "" #: weresync/gui.py:190 msgid "Target VG" msgstr "" #: weresync/gui.py:205 msgid "Copy partitions if target partitions are invalid." msgstr "" #: weresync/gui.py:211 msgid "Source and target are logical volume groups." msgstr "" #: weresync/gui.py:216 msgid "Bootloader Plugin: " msgstr "" #: weresync/gui.py:226 msgid "" "This is the plugin which will attempt to make your clone bootable. Select " "the plugin which corresponds to the bootloader you want to install. If you " "are unsure what to choose, pick 'UUID Copy'." msgstr "" #: weresync/gui.py:230 msgid "Bootloader Plugin" msgstr "" #: weresync/gui.py:238 msgid "EFI Partition Number: " msgstr "" #: weresync/gui.py:252 msgid "" "Enter the partition number of your EFI partition.\n" "So if your efi partition is found on /dev/sda1, enter 1.\n" "If you are not running a UEFI system, leave this blank." msgstr "" #: weresync/gui.py:256 msgid "EFI Partition" msgstr "" #: weresync/gui.py:260 msgid "Bootloader Partition Number: " msgstr "" #: weresync/gui.py:273 msgid "" "Enter the partition number of the partition to install the bootloader on. " "This is generally the partition mounted on /\n" "So if your root directory is /dev/sda2, enter 2." msgstr "" #: weresync/gui.py:277 msgid "Bootloader Partition" msgstr "" #: weresync/gui.py:282 msgid "Advanced Options" msgstr "" #: weresync/gui.py:288 msgid "" "Ignore errors during copying. If off, common errors often stop the clone." msgstr "" #: weresync/gui.py:296 msgid "Source Partition Mask: " msgstr "" #: weresync/gui.py:311 msgid "" "A string that controls the how partitions are found on the file system. It " "should have two placeholders: {0} for the device name and {1} for the " "partition number.\n" "So if you have /dev/loop0 and partition 1 is /dev/loop0p1, the part_mask " "should be '{0}p{1}'" msgstr "" #: weresync/gui.py:315 msgid "Partition Mask" msgstr "" #: weresync/gui.py:320 msgid "Target Partition Mask: " msgstr "" #: weresync/gui.py:334 msgid "Excluded Partitions: " msgstr "" #: weresync/gui.py:348 msgid "" "A comma separated list of partition numbers that should not be copied or " "searched.\n" "If partitions partitions are copied, they will still be copied." msgstr "" #: weresync/gui.py:351 msgid "Excluded Partitions" msgstr "" #: weresync/gui.py:357 msgid "Boot Partition: " msgstr "" #: weresync/gui.py:370 msgid "The number of the partition mounted on /boot." msgstr "" #: weresync/gui.py:371 msgid "Boot Partition" msgstr "" #: weresync/gui.py:375 msgid "Rsync Arguments: " msgstr "" #: weresync/gui.py:386 msgid "" "Enter the arguments to pass the rsync program. For more information see the rsync website." msgstr "" #: weresync/gui.py:389 msgid "Rsync Arguments" msgstr "" #: weresync/gui.py:393 msgid "Source Drive Mount Point: " msgstr "" #: weresync/gui.py:401 msgid "Source Drive Mount Folder" msgstr "" #: weresync/gui.py:408 msgid "" "These are the folders that the drives to be copied will be mounted in. If " "unset, WereSync will generate random folders in the /tmp directory. " "Generally this can be unset." msgstr "" #: weresync/gui.py:411 msgid "Drive Mount Point." msgstr "" #: weresync/gui.py:416 msgid "Target Drive Mount Point: " msgstr "" #: weresync/gui.py:421 msgid "Target Drive Mount Folder" msgstr "" #: weresync/gui.py:431 msgid "Start Clone" msgstr "" #: weresync/gui.py:517 msgid "Error starting clone." msgstr "" #: weresync/gui.py:530 msgid "Clone finished!" msgstr "" #: weresync/gui.py:532 msgid "" "\n" "Non fatal error occurred: " msgstr "" #: weresync/gui.py:547 msgid "Checking partitions and copying: " msgstr "" #: weresync/gui.py:566 msgid "Copying partition {0}: " msgstr "" #: weresync/gui.py:579 msgid "Making bootable: " msgstr "" #: weresync/gui.py:628 msgid "Error starting WereSync." msgstr "" #: weresync/gui.py:638 msgid "Starting gui." msgstr "" #: weresync/interface.py:158 msgid "Checking partition validity." msgstr "" #: weresync/interface.py:165 msgid "" "Partitions invalid!\n" "Copying drive partition table." msgstr "" #: weresync/interface.py:314 msgid "Beginning to copy files." msgstr "" #: weresync/interface.py:324 msgid "Making bootable" msgstr "" #: weresync/interface.py:330 msgid "Error making drive bootable. All files should be fine." msgstr "" #: weresync/interface.py:332 msgid "All done, enjoy your drive!" msgstr "" #: weresync/interface.py:365 msgid "Bootloader plugins found: " msgstr "" #: weresync/interface.py:370 msgid "The drive to copy data from. This drive will not be edited." msgstr "" #: weresync/interface.py:374 msgid "The drive to copy data to. ALL DATA ON THIS DRIVE WILL BE ERASED." msgstr "" #: weresync/interface.py:381 msgid "" "Check if partitions are valid and re-partition drive to proper partitions if " "they are not." msgstr "" #: weresync/interface.py:387 msgid "" "A string of format '{0}{1}' where {0} represents drive identifier and {1} " "represents partition number to point to partition block files for the source " "drive." msgstr "" #: weresync/interface.py:394 msgid "" "A string of format '{0}{1}' where {0} represents drive identifier and {1} " "represents partition number to point to partition block files for the target " "drive." msgstr "" #: weresync/interface.py:400 msgid "" "A comment separated list of partitions of the source drive to apply no " "actions on.perated list of partitions of the source drive to apply no " "actions on." msgstr "" #: weresync/interface.py:408 msgid "" "Causes program to break whenever a partition cannot be copied, including " "uncopyable partitions such as swap files. Not recommended." msgstr "" #: weresync/interface.py:416 msgid "The partition mounted on /." msgstr "" #: weresync/interface.py:421 msgid "Partition which should be mounted on /boot" msgstr "" #: weresync/interface.py:426 msgid "Partition which should be mounted on /boot/efi" msgstr "" #: weresync/interface.py:430 msgid "Folder where partitions from the source drive should be mounted." msgstr "" #: weresync/interface.py:436 msgid "Folder where partitions from source drive should be mounted." msgstr "" #: weresync/interface.py:442 msgid "List of arguments passed to rsync. Defaults to: " msgstr "" #: weresync/interface.py:448 msgid "" "Passed to decide what boootloader plugin to use. See below for list of " "plugins. Defaults to simply changing the UUIDs of files in /boot." msgstr "" #: weresync/interface.py:455 msgid "The name of the source logical volume." msgstr "" #: weresync/interface.py:461 msgid "Prints expanded output." msgstr "" #: weresync/interface.py:468 msgid "Prints large output. Mainly helpful for developers." msgstr "" #: weresync/interface.py:480 msgid "More than two lvm options added. Please give either one or two options." msgstr "" #: weresync/plugins/weresync_grub2.py:105 msgid "Updating Grub" msgstr "" #: weresync/plugins/weresync_grub2.py:120 msgid "Installing Grub" msgstr "" #: weresync/plugins/weresync_grub2.py:137 msgid "" "Consider running update-grub on your backup. WereSync copies can sometimes " "fail to capture all the nuances of a complex system." msgstr "" #: weresync/plugins/weresync_grub2.py:140 msgid "Cleaning up." msgstr "" #: weresync/plugins/weresync_grub2.py:147 msgid "Finished!" msgstr "" WereSync-1.0.9/src/weresync/resources/locale/weresync.pot0000644000175000017500000001765213120113020024246 0ustar danieldaniel00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR ORGANIZATION # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "POT-Creation-Date: 2017-06-13 21:49+EDT\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: ENCODING\n" "Generated-By: pygettext.py 1.5\n" #: weresync/device.py:1594 weresync/interface.py:322 msgid "Finished copying files." msgstr "" #: weresync/gui.py:70 msgid "What's this?" msgstr "" #: weresync/gui.py:147 msgid "Source Drive: " msgstr "" #: weresync/gui.py:161 msgid "Target Drive: " msgstr "" #: weresync/gui.py:173 msgid "Source VG" msgstr "" #: weresync/gui.py:190 msgid "Target VG" msgstr "" #: weresync/gui.py:205 msgid "Copy partitions if target partitions are invalid." msgstr "" #: weresync/gui.py:211 msgid "Source and target are logical volume groups." msgstr "" #: weresync/gui.py:216 msgid "Bootloader Plugin: " msgstr "" #: weresync/gui.py:226 msgid "This is the plugin which will attempt to make your clone bootable. Select the plugin which corresponds to the bootloader you want to install. If you are unsure what to choose, pick 'UUID Copy'." msgstr "" #: weresync/gui.py:230 msgid "Bootloader Plugin" msgstr "" #: weresync/gui.py:238 msgid "EFI Partition Number: " msgstr "" #: weresync/gui.py:252 msgid "" "Enter the partition number of your EFI partition.\n" "So if your efi partition is found on /dev/sda1, enter 1.\n" "If you are not running a UEFI system, leave this blank." msgstr "" #: weresync/gui.py:256 msgid "EFI Partition" msgstr "" #: weresync/gui.py:260 msgid "Bootloader Partition Number: " msgstr "" #: weresync/gui.py:273 msgid "" "Enter the partition number of the partition to install the bootloader on. This is generally the partition mounted on /\n" "So if your root directory is /dev/sda2, enter 2." msgstr "" #: weresync/gui.py:277 msgid "Bootloader Partition" msgstr "" #: weresync/gui.py:282 msgid "Advanced Options" msgstr "" #: weresync/gui.py:288 msgid "Ignore errors during copying. If off, common errors often stop the clone." msgstr "" #: weresync/gui.py:296 msgid "Source Partition Mask: " msgstr "" #: weresync/gui.py:311 msgid "" "A string that controls the how partitions are found on the file system. It should have two placeholders: {0} for the device name and {1} for the partition number.\n" "So if you have /dev/loop0 and partition 1 is /dev/loop0p1, the part_mask should be '{0}p{1}'" msgstr "" #: weresync/gui.py:315 msgid "Partition Mask" msgstr "" #: weresync/gui.py:320 msgid "Target Partition Mask: " msgstr "" #: weresync/gui.py:334 msgid "Excluded Partitions: " msgstr "" #: weresync/gui.py:348 msgid "" "A comma separated list of partition numbers that should not be copied or searched.\n" "If partitions partitions are copied, they will still be copied." msgstr "" #: weresync/gui.py:351 msgid "Excluded Partitions" msgstr "" #: weresync/gui.py:357 msgid "Boot Partition: " msgstr "" #: weresync/gui.py:370 msgid "The number of the partition mounted on /boot." msgstr "" #: weresync/gui.py:371 msgid "Boot Partition" msgstr "" #: weresync/gui.py:375 msgid "Rsync Arguments: " msgstr "" #: weresync/gui.py:386 msgid "Enter the arguments to pass the rsync program. For more information see the rsync website." msgstr "" #: weresync/gui.py:389 msgid "Rsync Arguments" msgstr "" #: weresync/gui.py:393 msgid "Source Drive Mount Point: " msgstr "" #: weresync/gui.py:401 msgid "Source Drive Mount Folder" msgstr "" #: weresync/gui.py:408 msgid "These are the folders that the drives to be copied will be mounted in. If unset, WereSync will generate random folders in the /tmp directory. Generally this can be unset." msgstr "" #: weresync/gui.py:411 msgid "Drive Mount Point." msgstr "" #: weresync/gui.py:416 msgid "Target Drive Mount Point: " msgstr "" #: weresync/gui.py:421 msgid "Target Drive Mount Folder" msgstr "" #: weresync/gui.py:431 msgid "Start Clone" msgstr "" #: weresync/gui.py:517 msgid "Error starting clone." msgstr "" #: weresync/gui.py:530 msgid "Clone finished!" msgstr "" #: weresync/gui.py:532 msgid "" "\n" "Non fatal error occurred: " msgstr "" #: weresync/gui.py:547 msgid "Checking partitions and copying: " msgstr "" #: weresync/gui.py:566 msgid "Copying partition {0}: " msgstr "" #: weresync/gui.py:579 msgid "Making bootable: " msgstr "" #: weresync/gui.py:628 msgid "Error starting WereSync." msgstr "" #: weresync/gui.py:638 msgid "Starting gui." msgstr "" #: weresync/interface.py:158 msgid "Checking partition validity." msgstr "" #: weresync/interface.py:165 msgid "" "Partitions invalid!\n" "Copying drive partition table." msgstr "" #: weresync/interface.py:314 msgid "Beginning to copy files." msgstr "" #: weresync/interface.py:324 msgid "Making bootable" msgstr "" #: weresync/interface.py:330 msgid "Error making drive bootable. All files should be fine." msgstr "" #: weresync/interface.py:332 msgid "All done, enjoy your drive!" msgstr "" #: weresync/interface.py:365 msgid "Bootloader plugins found: " msgstr "" #: weresync/interface.py:370 msgid "The drive to copy data from. This drive will not be edited." msgstr "" #: weresync/interface.py:374 msgid "The drive to copy data to. ALL DATA ON THIS DRIVE WILL BE ERASED." msgstr "" #: weresync/interface.py:381 msgid "Check if partitions are valid and re-partition drive to proper partitions if they are not." msgstr "" #: weresync/interface.py:387 msgid "A string of format '{0}{1}' where {0} represents drive identifier and {1} represents partition number to point to partition block files for the source drive." msgstr "" #: weresync/interface.py:394 msgid "A string of format '{0}{1}' where {0} represents drive identifier and {1} represents partition number to point to partition block files for the target drive." msgstr "" #: weresync/interface.py:400 msgid "A comment separated list of partitions of the source drive to apply no actions on.perated list of partitions of the source drive to apply no actions on." msgstr "" #: weresync/interface.py:408 msgid "Causes program to break whenever a partition cannot be copied, including uncopyable partitions such as swap files. Not recommended." msgstr "" #: weresync/interface.py:416 msgid "The partition mounted on /." msgstr "" #: weresync/interface.py:421 msgid "Partition which should be mounted on /boot" msgstr "" #: weresync/interface.py:426 msgid "Partition which should be mounted on /boot/efi" msgstr "" #: weresync/interface.py:430 msgid "Folder where partitions from the source drive should be mounted." msgstr "" #: weresync/interface.py:436 msgid "Folder where partitions from source drive should be mounted." msgstr "" #: weresync/interface.py:442 msgid "List of arguments passed to rsync. Defaults to: " msgstr "" #: weresync/interface.py:448 msgid "Passed to decide what boootloader plugin to use. See below for list of plugins. Defaults to simply changing the UUIDs of files in /boot." msgstr "" #: weresync/interface.py:455 msgid "The name of the source logical volume." msgstr "" #: weresync/interface.py:461 msgid "Prints expanded output." msgstr "" #: weresync/interface.py:468 msgid "Prints large output. Mainly helpful for developers." msgstr "" #: weresync/interface.py:480 msgid "More than two lvm options added. Please give either one or two options." msgstr "" #: weresync/plugins/weresync_grub2.py:105 msgid "Updating Grub" msgstr "" #: weresync/plugins/weresync_grub2.py:120 msgid "Installing Grub" msgstr "" #: weresync/plugins/weresync_grub2.py:137 msgid "Consider running update-grub on your backup. WereSync copies can sometimes fail to capture all the nuances of a complex system." msgstr "" #: weresync/plugins/weresync_grub2.py:140 msgid "Cleaning up." msgstr "" #: weresync/plugins/weresync_grub2.py:147 msgid "Finished!" msgstr "" WereSync-1.0.9/src/weresync/interface.py0000644000175000017500000005247513257666063020765 0ustar danieldaniel00000000000000# Copyright 2016 Daniel Manila # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """This modules has easy, one function interfaces with the DeviceCopier and DeviceManager.""" import weresync.device as device from weresync.exception import (CopyError, DeviceError, InvalidVersionError, UnsupportedDeviceError) import weresync.utils as utils import weresync import logging import logging.handlers import random import os import sys import argparse import subprocess import gettext LOGGER = logging.getLogger(__name__) DEFAULT_LOG_LOCATION = "/var/log/weresync/weresync.log" """The default location for WereSync's log files.""" LANGUAGES = ["en"] """Currently translated languages. See `here `_ for more information.""" def enable_localization(): """Activates the `gettext` module to start internalization and enable translation.""" LOGGER.debug("Enabling localization") lodir = os.path.dirname(os.path.realpath(__file__)) + "/resources/locale" es = gettext.translation("weresync", localedir=lodir, languages=LANGUAGES) es.install() def check_python_version(): """This method tests if the running version of python supports WereSync. If it does not, it raises a InvalidVersionException""" try: assert sys.version_info > (3, 0) except AssertionError: info = sys.version_info raise InvalidVersionError( "Python version {major}.{minor} not supported. WereSync requires " "at least Python 3.0\n" "Considering installing WereSync with the pip3 command to insure " "it installs with Python3.".format( major=info[0], minor=info[1])) def start_logging_handler(log_loc=DEFAULT_LOG_LOCATION, stream_level=logging.WARNING, file_level=logging.DEBUG): os.makedirs(os.path.dirname(log_loc), exist_ok=True) logger = logging.getLogger() logger.setLevel(file_level if file_level < stream_level else stream_level) formatter = logging.Formatter( "%(levelname)s - %(asctime)s - %(name)s - %(message)s") def enableHandler(hand, level, formatter): hand.setLevel(level) hand.setFormatter(formatter) logger.addHandler(hand) handler = logging.handlers.TimedRotatingFileHandler( log_loc, when="D", interval=1, backupCount=15) enableHandler(handler, file_level, formatter) streamHandler = logging.StreamHandler() enableHandler(streamHandler, stream_level, formatter) logging.getLogger("yapsy").setLevel(logging.INFO) def mount_loop_device(image_file): """Mounts an image file as a loop device and returns the device name of the mounted loop. This mounts on first free loop device. This accepts relative paths. :params image_file: Path pointing to the image file to mount. :returns: A string containing device identifier (/dev/sda or such)""" image_file = os.path.abspath(os.path.expanduser(image_file)) free_proc = subprocess.Popen( ["losetup", "-f"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) free_output, free_error = free_proc.communicate() if free_proc.returncode != 0: raise DeviceError(image_file, "Error finding free loop device.", str(free_output, "utf-8")) device_name = str(free_output, "utf-8").strip() mount_proc = subprocess.Popen( ["losetup", device_name, image_file], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) mount_output, mount_error = mount_proc.communicate() if mount_proc.returncode != 0: raise DeviceError(image_file, "Error mounting image on {0}".format(device_name), str(mount_output, "utf-8")) subprocess.call(["partprobe", device_name]) return device_name def create_new_vg_if_not_exists(lvm, name, target): """Creates a new Logical Volume Group with the name ``lvm`` + "copy" and all of the partitions of the target with type "lvm" added to it. This is not a conclusive function and misses several uses of LVM drives, if your situation is not covered, please feel free to open a pull request fixing it. :param lvm: a string representing the name of the source lvm :param target: a :py:class:`~weresync.device.DeviceManager` representing the device whose partitions to add to the LVM.""" lvm_partitions = [] for i in target.get_partitions(): code = target.get_partition_code(i).lower() if code == "8e00" or code == "8e": # the two versions appear in gdisk and fdisk, respectively lvm_partitions.append(i) lvm_part_block = [ target.part_mask.format(target.device, x) for x in lvm_partitions ] lvm_test = subprocess.Popen( ["vgs", name], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, _ = lvm_test.communicate() if lvm_test.returncode == 5: # If the VG does not exist, the return code is 5 utils.run_proc(["vgcreate", name] + lvm_part_block, target.device, "Error creating logical volume group.") else: if name.startswith("/dev/"): name = name[5:] result = utils.run_proc( ["pvs", "-S", "vg_name=" + name, "--noheadings", "-o", "pv_name"], name, "Error finding physical volumes for LVM.") LOGGER.debug("PVs in LVM: " + result) results = [x.strip() for x in result.split("\n")] lvm_part_block = [x for x in lvm_part_block if x not in results] if len(lvm_part_block) > 0: utils.run_proc(["vgextend", name] + lvm_part_block, name, "Error adding PVs to LVM") def copy_partitions(copier, part_callback=None, lvm=False): """Checks if partitions are valid and copies if they aren't. :param copier: the :py:class:`~weresync.device.DeviceCopier` object to do the copying with. :param part_callback: see the documentation for :py:func:`~.copy_drive`""" try: print(_("Checking partition validity.")) copier.partitions_valid(lvm) if part_callback is not None: part_callback(1.0) LOGGER.info("Drives are compatible") except CopyError as ex: LOGGER.warning(ex.message) print(_("Partitions invalid!\nCopying drive partition table.")) LOGGER.warning("Drives are incompatible.") if lvm: copier.transfer_lvm_partition(callback=part_callback) else: copier.transfer_partition_table(callback=part_callback) else: if part_callback is not None: part_callback(1.0) def copy_drive(source, target, check_if_valid_and_copy=False, source_part_mask="{0}{1}", target_part_mask="{0}{1}", excluded_partitions=[], ignore_copy_failures=True, root_partition=None, boot_partition=None, efi_partition=None, mount_points=None, rsync_args=device.DEFAULT_RSYNC_ARGS, lvm_source=None, lvm_target=None, bootloader="uuid_copy", part_callback=None, copy_callback=None, boot_callback=None): """Uses a DeviceCopier to clone the source drive to the target drive. **Note:** if using LVM, any uses of "partition" in the documentation actually refer to logical volumes. It is recommended to set ``check_if_valid_and_copy`` to True if the the two drives are not the same size with the same partitions. If either source or target ends in ".img" copy_drives will assume it is an image file, and mount if accordingly. :param source: The drive identifier ("/dev/sda" or the like) of the source drive. :param target: The drive identifier ("/dev/sda" or the like) of the target drive. :param check_if_valid=False: If true, the function checks if the target drive is compatible to receive the source drive's data. If it is not, erase the target drive and make a proper partition table. Defaults to False. :param source_part_mask: A string to be passed to the "format" method that expects to arguments, the drive name and the partition number. Applied to the source drive. Defaults to "{0}{1}". :param target_part_mask: Same as source_part_mask, but applied to target drive. Defaults to "{0}{1}" :param excluded_partitions: Partitions to not copy or test for boot capability. :param ignore_copy_failures: If True, errors during copying will be ignored and copying will continue. It is recommended that this be left to true, because errors frequently occur with swap partitions or other strange partitions. :param root_partition: If not None, this is an int that determines which partition grub should be installed to. Defaults to None. :param boot_partition: If not None, this is an int that represents the partition to mount at /boot when installing grub. :param efi_partition: If not None, this is an int that represents the partition to mount at /boot/efi when installing grub. :param mount_points: Expects a tuple containing two strings pointing to the directories where partitions should be mounted in case of testing. If None, the function will generate two random directories in the /tmp folder. Defaults to None. :param lvm: the Logical Volume Group to copy to the new drive. :param part_callback: a function that can be called to pass the progress of the partition function. The function should expect on float between 0 and 1, a negative value denoting an error, or a boolean True to indicate the progress is indeterminate. :param copy_callback: a function that can be called to pass the progress of copying partitions. The function should expect two arguments: an integer showing partition number and a float showing progress between 0 and 1 :param boot_callback: a function that can be called to pass the progress of making the clone bootable. The function should expect one argument: a boolean indicating whether or not the process has finished. :raises DeviceError: If there is an error reading data from one device or another. :raises CopyError: If there is an error copying the data between the two devices. :returns: True on success and an error message or exception on failure. """ try: source_loop = None target_loop = None if source.endswith(".img"): source_loop = mount_loop_device(source) source = source_loop source_part_mask = "{0}p{1}" if target.endswith(".img"): target_loop = mount_loop_device(target) target = target_loop target_part_mask = "{0}p{1}" LOGGER.warning( "Right now, WereSync does not properly install bootloaders on " "image files. You will have to handle that yourself if you " "want your image to be bootable.") source_manager = device.DeviceManager(source, source_part_mask) target_manager = device.DeviceManager(target, target_part_mask) try: target_manager.get_partition_table_type() except (DeviceError, UnsupportedDeviceError) as ex: # Since we're erasing the target drive anyway, we can just create # a new disk label proc = subprocess.Popen(["sgdisk", "-o", target_manager.device]) proc.communicate() copier = device.DeviceCopier(source_manager, target_manager) partitions_remade = False if check_if_valid_and_copy: copy_partitions(copier, part_callback) partitions_remade = True if lvm_source is not None: create_new_vg_if_not_exists(lvm_source, lvm_source + "-copy", target_manager) lvm_source = device.LVMDeviceManager(lvm_source) lvm_target = device.LVMDeviceManager(lvm_source.device + "-copy") copier.lvm_source = lvm_source copier.lvm_target = lvm_target if partitions_remade and check_if_valid_and_copy: copy_partitions(copier, part_callback, lvm=True) if mount_points is None or len(mount_points) < 2 or mount_points[ 0] == mount_points[1]: source_dir = "/tmp/" + str(random.randint(0, 100000)) target_dir = "/tmp/" + str(random.randint(-100000, -1)) os.makedirs(source_dir, exist_ok=True) os.makedirs(target_dir, exist_ok=True) mount_points = (source_dir, target_dir) print(_("Beginning to copy files.")) copier.copy_files( mount_points[0], mount_points[1], excluded_partitions, ignore_copy_failures, rsync_args, callback=copy_callback) print(_("Finished copying files.")) print(_("Making bootable")) try: copier.make_bootable(bootloader, mount_points[0], mount_points[1], excluded_partitions, root_partition, boot_partition, efi_partition, boot_callback) except DeviceError as ex: print(_("Error making drive bootable. All files should be fine.")) return ex print(_("All done, enjoy your drive!")) return True finally: def delete_loop(loop_name): subprocess.call(["losetup", "-d", loop_name]) if source_loop is not None: delete_loop(source_loop) if target_loop is not None: delete_loop(target_loop) def main(): """The entry point for the command line function. This uses argparse to parse arguments to call call :py:func:`.copy_drive` with. For help use "weresync -h" in a commandline after installation.""" enable_localization() try: check_python_version() except InvalidVersionError as ex: print(ex) sys.exit(1) try: import weresync.plugins as plugins manager = plugins.get_manager() manager.collectPlugins() default_part_mask = "{0}{1}" pluginNames = [] for pluginInfo in manager.getAllPlugins(): pluginNames.append(pluginInfo.plugin_object.name) epilog_string = ( _("Bootloader plugins found: ") + ", ".join(pluginNames)) parser = argparse.ArgumentParser(epilog=epilog_string) parser.add_argument( "source", help=_("The drive to copy data from. This drive will not be" " edited.")) parser.add_argument( "target", help=_("The drive to copy data to. ALL DATA ON THIS DRIVE WILL BE " "ERASED.")) parser.add_argument( "-C", "--check-and-partition", action="store_true", help=_("Check if partitions are valid and re-partition drive to " "proper partitions if they are not.")) parser.add_argument( "-s", "--source-mask", help=_("A string of format '{0}{1}' where {0} represents drive " "identifier and {1} represents partition number to point " "to partition block files for the source drive.")) parser.add_argument( "-t", "--target-mask", help=_("A string of format '{0}{1}' where {0} represents drive " "identifier and {1} represents partition number to point " "to partition block files for the target drive.")) parser.add_argument( "-e", "--excluded-partitions", help=_("A comment separated list of partitions of the source " "drive to apply no actions on.perated list of partitions " "of the source drive to apply no actions on.")) parser.add_argument( "-b", "--break-on-error", action="store_false", help=_("Causes program to break whenever a partition cannot be " "copied, including uncopyable partitions such as swap" " files. Not recommended.")) parser.add_argument( "-g", "--root-partition", type=int, help=_("The partition mounted on /.")) parser.add_argument( "-B", "--boot-partition", type=int, help=_("Partition which should be mounted on /boot")) parser.add_argument( "-E", "--efi-partition", type=int, help=_("Partition which should be mounted on /boot/efi")) parser.add_argument( "-m", "--source-mount", help=_("Folder where partitions from the source drive should be " "mounted.")) parser.add_argument( "-M", "--target-mount", help=_("Folder where partitions from source drive should be" " mounted.")) parser.add_argument( "-r", "--rsync-args", help=_("List of arguments passed to rsync. Defaults to: ") + device.DEFAULT_RSYNC_ARGS, default=device.DEFAULT_RSYNC_ARGS) parser.add_argument( "-L", "--bootloader", help=_("Passed to decide what boootloader plugin to use. See " "below for list of plugins. Defaults to simply changing " "the UUIDs of files in /boot."), default="uuid_copy") parser.add_argument( "-l", "--lvm", help=_("The name of the source logical volume."), nargs="+") group = parser.add_mutually_exclusive_group() group.add_argument( "-v", "--verbose", help=_("Prints expanded output."), action="store_const", dest="loglevel", const=logging.INFO) group.add_argument( "-d", "--debug", help=_("Prints large output. Mainly helpful for developers."), action="store_const", dest="loglevel", const=logging.DEBUG) args = parser.parse_args() if (args.loglevel == logging.INFO or args.loglevel == logging.DEBUG): loglevel = args.loglevel else: loglevel = logging.WARNING start_logging_handler(stream_level=loglevel) mount_points = (args.source_mount, args.target_mount) if args.lvm is not None and len(args.lvm) > 2: LOGGER.warning( _("More than two lvm options added. Please give " "either one or two options.")) sys.exit(1) lvm_source = args.lvm[0] if args.lvm is not None else None lvm_target = args.lvm[1] if (args.lvm is not None and len(args.lvm) == 2) else None excluded_partitions = [ int(x) for x in args.excluded_partitions.split(",") ] if args.excluded_partitions is not None else [] source_mask = (args.source_mask if args.source_mask is not None else default_part_mask) target_mask = (args.target_mask if args.target_mask else default_part_mask) result = copy_drive( args.source, args.target, args.check_and_partition, source_mask, target_mask, excluded_partitions, args.break_on_error, args.root_partition, args.boot_partition, args.efi_partition, mount_points, args.rsync_args, lvm_source=lvm_source, lvm_target=lvm_target, bootloader=args.bootloader, ) if result is not True: print(str(result)) except (KeyboardInterrupt, EOFError): LOGGER.info("Exiting via user keyboard interrupt.") LOGGER.debug("Error:\n", exc_info=sys.exc_info()) sys.exit(1) WereSync-1.0.9/src/weresync/gui.py0000644000175000017500000007754013126746657017614 0ustar danieldaniel00000000000000# Copyright 2016 Daniel Manila # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """This module runs the GUI for WereSync.""" import weresync.device as device import weresync.interface as interface import weresync.plugins as plugins from weresync.exception import InvalidVersionError import subprocess import gi import sys import os import logging import logging.handlers import threading gi.require_version("Gtk", '3.0') from gi.repository import Gtk, GLib, GObject # noqa LOGGER = logging.getLogger(__name__) DEFAULT_HORIZONTAL_PADDING = 5 DEFAULT_VERTICAL_PADDING = 3 class NumberEntry(Gtk.Entry): def __init__(self, allowed="", *args, **kargs): """An entry that only allows numbers and certain other characters. :param allowed: The other characters allowed by this entry in an unseperated string, ex. '., ' to allow periods, commas, and spaces. Defaults to none.""" Gtk.Entry.__init__(self, *args, **kargs) self.allowed = allowed self.connect('changed', self.on_changed) def on_changed(self, *args): text = self.get_text() self.set_text("".join( [i for i in text if i in "0123456789" + self.allowed])) def set_margin(widget, right=DEFAULT_HORIZONTAL_PADDING, left=DEFAULT_HORIZONTAL_PADDING, top=DEFAULT_VERTICAL_PADDING, bottom=DEFAULT_VERTICAL_PADDING): widget.set_margin_right(right) widget.set_margin_left(left) widget.set_margin_top(top) widget.set_margin_bottom(bottom) def create_help_box(parent, text, title=""): help = Gtk.Label( halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) help.set_markup(_("What's this?")) def help_click(*args): dialog = Gtk.MessageDialog(parent, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, title) dialog.format_secondary_text(text) dialog.set_default_size(parent.get_size()[0], -1) dialog.run() dialog.destroy() return True help.connect("activate-link", help_click) return help def generate_drive_list(): proc = subprocess.Popen( ["lsblk", "-dnoNAME"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, _ = proc.communicate() if proc.returncode != 0: LOGGER.critical("Error reading block list.\n" + output) device_list = [ "/dev/" + x.strip() for x in str(output, "utf-8").split("\n") if x.strip() != "" ] return device_list def generate_vg_list(): try: lvm_proc = subprocess.Popen( ["vgs", "-o", "name", "--noheadings"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) lvm_output, _ = lvm_proc.communicate() out = str(lvm_output, "utf-8").split("\n") if lvm_proc.returncode != 0: LOGGER.critical("Error reading volume group list.\n" + " ".join(out)) if "No volume groups found" in out: lvm_list = ["No volume groups found"] else: lvm_list = ["/dev/" + x.strip() for x in out if x.strip() != ""] except FileNotFoundError as ex: # Probably means LVM is not installed on the system, which is no big # deal. We'll just log the exception and move on LOGGER.debug("File not found info: ", exc_info=sys.exc_info()) lvm_list = [] # This variable needs to be defined return lvm_list def get_resource(resource): dir = os.path.dirname(__file__) rel_resource_path = os.path.join(dir, "resources", resource) return os.path.abspath(rel_resource_path) class WereSyncWindow(Gtk.Window): def __init__(self, title="WereSync"): super().__init__(title=title) # Find all the bootloader plugins available manager = plugins.get_manager() manager.collectPlugins() plugin_store = Gtk.ListStore(int, str, str) plugins_added = [] uuid_index = 0 for idx, pluginInfo in enumerate(manager.getAllPlugins()): manager.activatePluginByName(pluginInfo.name) obj = pluginInfo.plugin_object if pluginInfo.name not in plugins_added: plugin_store.append([idx, obj.prettyName, obj.name]) plugins_added.append(pluginInfo.name) if obj.name == "uuid_copy": uuid_index = idx else: LOGGER.debug("Not adding {name} at {path} because plugin" "already added".format(name=pluginInfo.name, path=pluginInfo.path)) self.set_icon_from_file(get_resource("weresync.svg")) self.grid = Gtk.Grid() self.add(self.grid) self.source_label = Gtk.Label( label=_("Source Drive: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) name_store = Gtk.ListStore(int, str) for idx, val in enumerate(generate_drive_list()): name_store.append([idx, val]) self.source_combo = Gtk.ComboBox.new_with_model_and_entry(name_store) self.source_combo.set_hexpand(True) self.source_combo.set_entry_text_column(1) self.grid.attach(self.source_label, 1, 1, 1, 1) self.grid.attach_next_to(self.source_combo, self.source_label, Gtk.PositionType.RIGHT, 1, 1) self.target_label = Gtk.Label( label=_("Target Drive: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) self.target_combo = Gtk.ComboBox.new_with_model_and_entry(name_store) self.target_combo.set_hexpand(True) self.target_combo.set_entry_text_column(1) self.grid.attach_next_to(self.target_label, self.source_label, Gtk.PositionType.BOTTOM, 1, 1) self.grid.attach_next_to(self.target_combo, self.target_label, Gtk.PositionType.RIGHT, 1, 1) box = Gtk.Box() self.grid.attach_next_to(box, self.source_combo, Gtk.PositionType.RIGHT, 1, 1) self.lvm_source_label = Gtk.Label( label=_("Source VG: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) lvm_list = generate_vg_list() lvm_source_store = Gtk.ListStore(int, str) for idx, val in enumerate(lvm_list): lvm_source_store.append([idx, val]) self.lvm_source_combo = Gtk.ComboBox.new_with_model_and_entry( lvm_source_store) self.lvm_source_combo.set_hexpand(True) self.lvm_source_combo.set_entry_text_column(1) self.lvm_source_combo.set_sensitive(False) self.grid.attach_next_to(self.lvm_source_label, box, Gtk.PositionType.RIGHT, 1, 1) self.grid.attach_next_to(self.lvm_source_combo, self.lvm_source_label, Gtk.PositionType.RIGHT, 1, 1) self.lvm_target_label = Gtk.Label( label=_("Target VG: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) lvm_target_store = Gtk.ListStore(int, str) lvm_target_store.append([1, _("Default")]) for idx, val in enumerate(lvm_list): lvm_target_store.append([idx, val]) self.lvm_target_combo = Gtk.ComboBox.new_with_model_and_entry( lvm_target_store) self.lvm_target_combo.set_hexpand(True) self.lvm_target_combo.set_entry_text_column(1) self.lvm_target_combo.set_active(0) self.lvm_target_combo.set_sensitive(False) self.grid.attach_next_to(self.lvm_target_label, self.lvm_source_label, Gtk.PositionType.BOTTOM, 1, 1) self.grid.attach_next_to(self.lvm_target_combo, self.lvm_target_label, Gtk.PositionType.RIGHT, 1, 1) self.copy_partitions_button = Gtk.CheckButton( label=_("Copy partitions if target partitions are invalid.")) set_margin(self.copy_partitions_button) self.grid.attach_next_to(self.copy_partitions_button, self.target_label, Gtk.PositionType.BOTTOM, 2, 1) self.lvm_button = Gtk.CheckButton( label=_("Copy Logical Volume Groups.")) self.lvm_button.connect("toggled", self.lvm_button_toggled) self.grid.attach_next_to(self.lvm_button, self.lvm_target_label, Gtk.PositionType.BOTTOM, 2, 1) set_margin(self.lvm_button) self.bootloader_label = Gtk.Label( label=_("Bootloader Plugin: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) self.bootloader_combo = Gtk.ComboBox.new_with_model_and_entry( plugin_store) self.bootloader_combo.set_entry_text_column(1) self.bootloader_combo.set_active(uuid_index) self.bootloader_help = create_help_box( self, _("This is the plugin which will attempt to make your clone" " bootable. Select the plugin which corresponds to the " "bootloader" " you want to install. If you are unsure what to choose, pick" " 'UUID Copy'."), _("Bootloader Plugin")) self.grid.attach_next_to(self.bootloader_label, self.copy_partitions_button, Gtk.PositionType.BOTTOM, 1, 1) self.grid.attach_next_to(self.bootloader_combo, self.bootloader_label, Gtk.PositionType.RIGHT, 1, 1) self.grid.attach_next_to(self.bootloader_help, self.bootloader_combo, Gtk.PositionType.RIGHT, 1, 1) self.bootloader_partition_label = Gtk.Label( label=_("Root Partition Number: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) self.grid.attach_next_to(self.bootloader_partition_label, self.bootloader_label, Gtk.PositionType.BOTTOM, 1, 1) self.bootloader_partition_entry = NumberEntry() self.grid.attach_next_to(self.bootloader_partition_entry, self.bootloader_partition_label, Gtk.PositionType.RIGHT, 1, 1) self.bootloader_help = create_help_box( self, _("Enter the partition number of the partition" " to install the bootloader on. This is generally the partition " "mounted on /\n" "So if your root directory is /dev/sda2, enter 2."), _("Bootloader Partition")) self.grid.attach_next_to(self.bootloader_help, self.bootloader_partition_entry, Gtk.PositionType.RIGHT, 1, 1) # Start adding advanced options self.boot_part_label = Gtk.Label( label=_("Boot Partition: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) self.grid.attach_next_to(self.boot_part_label, self.lvm_button, Gtk.PositionType.BOTTOM, 1, 1) self.boot_part_entry = NumberEntry() self.grid.attach_next_to(self.boot_part_entry, self.boot_part_label, Gtk.PositionType.RIGHT, 1, 1) self.boot_help = create_help_box( self, _("The number of the partition mounted on /boot."), _("Boot Partition")) self.grid.attach_next_to(self.boot_help, self.boot_part_entry, Gtk.PositionType.RIGHT, 1, 1) self.expander = Gtk.Expander(label=_("Advanced Options")) self.efi_partition_label = Gtk.Label( label=_("EFI Partition Number: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) self.grid.attach_next_to(self.efi_partition_label, self.boot_part_label, Gtk.PositionType.BOTTOM, 1, 1) self.efi_partition_entry = NumberEntry() self.efi_partition_entry.set_hexpand(True) self.grid.attach_next_to(self.efi_partition_entry, self.efi_partition_label, Gtk.PositionType.RIGHT, 1, 1) self.efi_help = create_help_box( self, _("Enter the partition number of your EFI partition.\n" "So if your efi partition is found on /dev/sda1," " enter 1.\n" "If you are not running a UEFI system, leave this blank."), _("EFI Partition")) self.grid.attach_next_to(self.efi_help, self.efi_partition_entry, Gtk.PositionType.RIGHT, 1, 1) set_margin(self.expander) self.expander.set_resize_toplevel(True) self.expand_grid = Gtk.Grid() self.expander.add(self.expand_grid) self.expander.set_hexpand(True) self.ignore_errors = Gtk.CheckButton(label=_( "Ignore errors during copying. If off, common errors often " "stop the clone.")) self.ignore_errors.set_active(True) set_margin(self.ignore_errors) self.expand_grid.attach(self.ignore_errors, 1, 1, 3, 1) self.source_part_mask_label = Gtk.Label( label=_("Source Partition Mask: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) self.expand_grid.attach_next_to(self.source_part_mask_label, self.ignore_errors, Gtk.PositionType.BOTTOM, 1, 1) self.source_part_mask_entry = Gtk.Entry() self.source_part_mask_entry.set_hexpand(True) self.source_part_mask_entry.set_text("{0}{1}") self.expand_grid.attach_next_to(self.source_part_mask_entry, self.source_part_mask_label, Gtk.PositionType.RIGHT, 1, 1) self.part_mask_help = create_help_box( self, _("A string that controls the how partitions are found on the " "file system. It should have two placeholders: " "{0} for the device name and {1} for the partition number.\n" "So if you have /dev/loop0 and partition 1 is /dev/loop0p1, the " "part_mask should be '{0}p{1}'"), _("Partition Mask")) self.expand_grid.attach_next_to(self.part_mask_help, self.source_part_mask_entry, Gtk.PositionType.RIGHT, 1, 1) self.target_part_mask_label = Gtk.Label( label=_("Target Partition Mask: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) self.expand_grid.attach_next_to(self.target_part_mask_label, self.source_part_mask_label, Gtk.PositionType.BOTTOM, 1, 1) self.target_part_mask_entry = Gtk.Entry() self.target_part_mask_entry.set_text("{0}{1}") self.target_part_mask_entry.set_hexpand(True) self.expand_grid.attach_next_to(self.target_part_mask_entry, self.target_part_mask_label, Gtk.PositionType.RIGHT, 1, 1) self.excluded_label = Gtk.Label( label=_("Excluded Partitions: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) self.expand_grid.attach_next_to(self.excluded_label, self.target_part_mask_label, Gtk.PositionType.BOTTOM, 1, 1) self.excluded_entry = NumberEntry(allowed=", ") self.excluded_entry.set_hexpand(True) self.expand_grid.attach_next_to(self.excluded_entry, self.excluded_label, Gtk.PositionType.RIGHT, 1, 1) self.excluded_help = create_help_box( self, _("A comma separated list of partition numbers that should not be " "copied or searched.\n" "If partitions partitions are copied, they will still be copied." ), _("Excluded Partitions")) self.expand_grid.attach_next_to(self.excluded_help, self.excluded_entry, Gtk.PositionType.RIGHT, 1, 1) self.rsync_label = Gtk.Label( label=_("Rsync Arguments: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) self.expand_grid.attach_next_to(self.rsync_label, self.part_mask_help, Gtk.PositionType.RIGHT, 1, 1) self.rsync_entry = Gtk.Entry(text=device.DEFAULT_RSYNC_ARGS) self.expand_grid.attach_next_to(self.rsync_entry, self.rsync_label, Gtk.PositionType.RIGHT, 1, 1) self.rsync_help = create_help_box( self, _("Enter the arguments to pass the rsync program. For more " "information see the rsync website." # noqa ), # noqa _("Rsync Arguments")) self.expand_grid.attach_next_to(self.rsync_help, self.rsync_entry, Gtk.PositionType.RIGHT, 1, 1) self.source_mount_label = Gtk.Label( label=_("Source Drive Mount Point: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) self.expand_grid.attach_next_to(self.source_mount_label, self.rsync_label, Gtk.PositionType.BOTTOM, 1, 1) self.source_mount_entry = Gtk.FileChooserButton( title=_("Source Drive Mount Folder"), action=Gtk.FileChooserAction.SELECT_FOLDER, ) self.expand_grid.attach_next_to(self.source_mount_entry, self.source_mount_label, Gtk.PositionType.RIGHT, 1, 1) self.mount_help = create_help_box( self, _("These are the folders that the drives to be copied will be " "mounted in. If unset, WereSync will generate random folders in " "the /tmp directory. Generally this can be unset."), _("Drive Mount Point.")) self.expand_grid.attach_next_to(self.mount_help, self.source_mount_entry, Gtk.PositionType.RIGHT, 1, 1) self.target_mount_label = Gtk.Label( label=_("Target Drive Mount Point: ")) self.expand_grid.attach_next_to(self.target_mount_label, self.source_mount_label, Gtk.PositionType.BOTTOM, 1, 1) self.target_mount_entry = Gtk.FileChooserButton( title=_("Target Drive Mount Folder"), action=Gtk.FileChooserAction.SELECT_FOLDER) self.expand_grid.attach_next_to(self.target_mount_entry, self.target_mount_label, Gtk.PositionType.RIGHT, 1, 1) # End advanced options self.grid.attach_next_to(self.expander, self.bootloader_partition_label, Gtk.PositionType.BOTTOM, 6, 1) self.start = Gtk.Button(label=_("Start Clone")) set_margin(self.start) self.start.set_hexpand(False) self.grid.attach(self.start, 6, 10, 1, 1) self.start.connect("clicked", self.start_pressed) def lvm_button_toggled(self, button): if self.lvm_button.get_active(): self.lvm_source_combo.set_sensitive(True) self.lvm_target_combo.set_sensitive(True) else: self.lvm_source_combo.set_sensitive(False) self.lvm_target_combo.set_sensitive(False) def set_expander(self, val): self.expander.set_expanded(val) def get_selected_combo(self, combo): combo_iter = combo.get_active_iter() if combo_iter is not None: model = combo.get_model() row_id, val = model[combo_iter][:2] return val else: entry = combo.get_child() return entry.get_text() def start_pressed(self, *args): self.source = self.get_selected_combo(self.source_combo) self.target = self.get_selected_combo(self.target_combo) is_lvm = self.lvm_button.get_active() if is_lvm: self.lvm_source = self.get_selected_combo(self.lvm_source_combo) lvm_target = self.get_selected_combo(self.lvm_target_combo) if lvm_target == _("Default"): lvm_target = None else: self.lvm_source = None lvm_target = None confirm_dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.OK_CANCEL, "") confirm_dialog.format_secondary_text(("This action will DELETE " "everything on {drive} " + ( " and {lvm}" if is_lvm else "") + ", and make it the " "same as the source drives. Is " "this what you want to do?"). format( drive=self.target, lvm=(lvm_target if is_lvm else ""))) response = confirm_dialog.run() confirm_dialog.destroy() if response == Gtk.ResponseType.CANCEL: return # The user didn't cancel so we can continue running copy_if_invalid = self.copy_partitions_button.get_active() efi_part = int(self.efi_partition_entry.get_text( )) if self.efi_partition_entry.get_text().strip() != "" else None bootloader_part = int(self.bootloader_partition_entry.get_text( )) if self.bootloader_partition_entry.get_text() != "" else None ignore_errors = self.ignore_errors.get_active() self.source_part_mask = self.source_part_mask_entry.get_text() self.target_part_mask = self.target_part_mask_entry.get_text() exclude_text = self.excluded_entry.get_text().strip() if exclude_text == "": excluded_parts = [] else: exclude_text.replace(" ", "") excluded_parts = [int(x) for x in exclude_text.split(",")] boot_part = int(self.boot_part_entry.get_text( )) if self.boot_part_entry.get_text() != "" else None rsync_args = self.rsync_entry.get_text() mount_points = (self.source_mount_entry.get_filename(), self.target_mount_entry.get_filename()) boot_iter = self.bootloader_combo.get_active_iter() model = self.bootloader_combo.get_model() plugin_name = model[boot_iter][2] try: self._generate_progress_grid() self.remove(self.grid) self.add(self.progress_grid) def copy(callback, error): try: result = interface.copy_drive( self.source, self.target, copy_if_invalid, self.source_part_mask, self.target_part_mask, excluded_parts, ignore_errors, bootloader_part, boot_part, efi_part, mount_points, rsync_args, self.lvm_source, lvm_target, plugin_name, lambda x: GLib.idle_add(self.part_callback, x), lambda num, prog: GLib.idle_add(self.copy_callback, num, prog), lambda done: GLib.idle_add(self.boot_callback, done)) callback(result) except Exception as ex: LOGGER.debug( "Full exception info:\n", exc_info=sys.exc_info()) error(ex) copy_thread = threading.Thread( target=copy, args=[ lambda result: GLib.idle_add(self._copy_finished, result), lambda ex: GLib.idle_add(self._show_error, ex) ]) copy_thread.start() self.show_all() except Exception as ex: LOGGER.debug("Full exception info:\n", exc_info=sys.exc_info()) self._show_error(ex) return def _show_error(self, ex): """Displays an error in a message dialog.""" dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, _("Error starting clone.")) dialog.format_secondary_text(str(ex)) dialog.run() dialog.destroy() # Sets back to original screen to allow regenerating any misplace # parameters. self.remove(self.progress_grid) self.add(self.grid) def _copy_finished(self, result): """A callback function to be run when the the drive finishes copying.""" text = _("Clone finished!") if result is not True: text += _("\nNon fatal error occurred: ") + str(result) dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, text) dialog.run() dialog.destroy() self.remove(self.progress_grid) self.add(self.grid) def _generate_progress_grid(self): """Generates the grid for the screen showing progress. Sets `self.progress_grid` as the grid.`""" self.progress_grid = Gtk.Grid() part_label = Gtk.Label( label=_("Checking partitions and copying: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) self.progress_grid.attach(part_label, 1, 1, 1, 1) self.part_progress = Gtk.ProgressBar() set_margin(self.part_progress) self.progress_grid.attach_next_to(self.part_progress, part_label, Gtk.PositionType.RIGHT, 1, 1) self.copy_progresses = {} def create_partitions(source_manager, start_label): previous_label = start_label partitions = source_manager.get_partitions() for val in partitions: copy_label = Gtk.Label( label=_("Copying partition {0}: ").format(val), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) copy_progress = Gtk.ProgressBar() set_margin(copy_progress) self.progress_grid.attach_next_to(copy_label, previous_label, Gtk.PositionType.BOTTOM, 1, 1) self.progress_grid.attach_next_to(copy_progress, copy_label, Gtk.PositionType.RIGHT, 1, 1) self.copy_progresses[val] = copy_progress previous_label = copy_label return previous_label final_label = create_partitions(device.DeviceManager( self.source, self.source_part_mask), part_label) if self.lvm_button.get_active(): final_label = create_partitions(device.LVMDeviceManager( self.lvm_source), final_label) boot_label = Gtk.Label( label=_("Making bootable: "), halign=Gtk.Align.START, xpad=DEFAULT_HORIZONTAL_PADDING, ypad=DEFAULT_VERTICAL_PADDING) self.progress_grid.attach_next_to(boot_label, final_label, Gtk.PositionType.BOTTOM, 1, 1) self.boot_progress = Gtk.ProgressBar() set_margin(self.boot_progress) self.progress_grid.attach_next_to(self.boot_progress, boot_label, Gtk.PositionType.RIGHT, 1, 1) self.cancel_btn = Gtk.Button(label="Cancel") set_margin(self.cancel_btn) self.progress_grid.attach_next_to(self.cancel_btn, self.boot_progress, Gtk.PositionType.BOTTOM, 1, 1) def part_callback(self, progress): LOGGER.debug("part callback. Value: {0}".format(progress)) self.part_progress.set_fraction(progress) def copy_callback(self, part, progress): if progress < 0: LOGGER.debug( "Error occurred copying partition {0}. Marking complete.". format(part)) self.copy_progresses[part].set_fraction(1.0) elif progress is True and isinstance(progress, bool): self.copy_progresses[part].pulse() elif (progress >= self.copy_progresses[part].get_fraction()): self.copy_progresses[part].set_fraction(progress) def boot_callback(self, done): if not done: self.boot_progress.pulse() else: self.boot_progress.set_fraction(1.0) def start_gui(): interface.enable_localization() try: interface.check_python_version() except InvalidVersionError as ex: print(ex) # This might not work, if the user doesn't have a setup that support # PyGObject, but it's worth a shot. dialog = Gtk.MessageDialog(None, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, _("Error starting WereSync.")) dialog.format_secondary_text(str(ex)) dialog.run() dialog.destroy() sys.exit(1) # interface.start_logging_handler(LOGGER) interface.start_logging_handler() # logging.basicConfig(level=logging.INFO) LOGGER.info(_("Starting gui.")) GObject.threads_init() win = WereSyncWindow() win.connect("delete-event", Gtk.main_quit) # This is set to expanded so it will be centered as if advanced options # were opened win.set_expander(True) win.set_position(Gtk.WindowPosition.CENTER) win.show_all() # Then advanced options are closed so as not to be distracting win.set_expander(False) win.show_all() Gtk.main() WereSync-1.0.9/src/weresync/exception.py0000644000175000017500000000302113125115721020762 0ustar danieldaniel00000000000000# Copyright 2016 Daniel Manila # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Exceptions used by WereSync""" class DeviceError(Exception): """Exception thrown to show errors caused by an issue with a specific device.""" def __init__(self, device, message, errors=None): self.device = device self.message = message self.errors = errors class CopyError(Exception): """Exception thrown to show errors caused by an issue copying data, usually both devices face the issue.""" def __init__(self, message, errors=None): self.message = message self.errors = errors class UnsupportedDeviceError(Exception): """Exception thrown to show that action is not supported on the partition table type of the device.""" pass class InvalidVersionError(Exception): """Exception thrown when the version of python being used does not support the feature.""" pass class PluginNotFoundError(Exception): """Exception thrown when the passed plugin is not found.""" pass WereSync-1.0.9/src/weresync/device.py0000644000175000017500000022673113314721772020253 0ustar danieldaniel00000000000000# Copyright 2016 Daniel Manila # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """This module contains the classes used to modify block devices and copy data.""" import shlex import os import subprocess import weresync.exception from weresync.exception import DeviceError, PluginNotFoundError import math import logging import sys import glob import parse import re import tempfile MOUNT_POINT = "/mnt" LOGGER = logging.getLogger(__name__) SUPPORTED_FILESYSTEM_TYPES = ["swap", "ef"] for word in glob.glob("/*/mkfs.*"): val = word.split(".") SUPPORTED_FILESYSTEM_TYPES += val SUPPORTED_PARTITION_TABLE_TYPES = ["gpt", "msdos"] """The names, as reported by `parted` of the partition table types supported by this program.""" DEFAULT_RSYNC_ARGS = "-aAXxH --delete" """Default arguments passed to rsync. See rsync documentation for what they do.""" def multireplace(string, replacements): """ Given a string and a replacement map, it returns the replaced string. `Credit goes to bgusach `_ :param str string: string to execute replacements on :param dict replacements: replacement dictionary {value to find: value to replace} :returns: a string with the replaced text.""" # Place longer ones first to keep shorter substrings from matching where # the longer ones should take place # For instance given the replacements {'ab': 'AB', 'abc': 'ABC'} against # the string 'hey abc', it should produce # 'hey ABC' and not 'hey ABc' substrs = sorted(replacements, key=len, reverse=True) # Create a big OR regex that matches any of the substrings to replace regexp = re.compile('|'.join(map(re.escape, substrs))) # For each match, look up the new string in the replacements return regexp.sub(lambda match: replacements[match.group(0)], string) class DeviceManager: """A class that allows various operations on a device. Most methods in this class raise :py:class:`~weresync.exception.DeviceError` if the subprocess they initiate has an error. :param device: a string containing the device identifier (/dev/sda or the like) :param partition_mask: a string as the first parameter of a the expression: "partion_mask.format(device, partition)" resolving to a string. Defaults to "{0}{1}".""" def __init__(self, device, partition_mask="{0}{1}"): """Defines a device manager for the passed device. :param device: a string containing the device identifier (/dev/sda or the like) :param partition_mask: a string as the first parameter of a the expression: "partion_mask.format(device, partition)" resolving to a string. Defaults to "{0}{1}".""" self.device = device self.part_mask = partition_mask def get_partitions(self): """Returns a list with all the partitions in the drive. The partitions will be listed in **the order they appear on the disk**. So if partition 4 has a start sector of 500, it will appear before partition 1 that has a start sectro of 2000 :returns: A list of integers representing the partition numbers""" partition_table_proc = subprocess.Popen( ["parted", "-s", self.device, "print"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = partition_table_proc.communicate() exit_code = partition_table_proc.returncode if exit_code != 0: raise weresync.exception.DeviceError(self.device, "Non-zero exit code", str(output, "utf-8")) partition_result = str(output, "utf-8").split("\n") partitions = [] for i in partition_result: line = i.strip().split() if len(line) == 0: continue # It was an empty line num = None try: num = int(line[0]) except ValueError: continue partitions.append(num) return partitions def mount_point(self, partition_num): """Returnds an absolute path to the mountpoint of the specific partition. Returns None if no mountpoint found (probably because partion not mounted). The return will not have a trailing slash. :param partition_num: The partition of whose mount to find.""" findprocess = subprocess.Popen( [ "findmnt", "-o", "TARGET", self.part_mask.format(self.device, partition_num) ], stdout=subprocess.PIPE) output, error = findprocess.communicate() result = str(output, "utf-8").split("\n") exit_code = findprocess.returncode if exit_code != 0 and exit_code != 1: # if nothing is found, findmnt returns 1; this is valid code raise weresync.exception.DeviceError(self.device, "Non-zero exit code", str(output, "utf-8")) if len(result) >= 2: return result[1].strip().split()[0] else: return None def mount_partition(self, partition_num, mount_loc): """Mounts the specified partition at the specified location. :param partition_num: the number of the partition to mount :param mount_loc: the location at which to mount the drive :raises: :py:class:`~weresync.exception.DeviceError` if there is an error mounting the device. """ mount_proc = subprocess.Popen( [ "mount", self.part_mask.format(self.device, partition_num), mount_loc ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = mount_proc.communicate() exit_code = mount_proc.returncode if exit_code != 0: raise weresync.exception.DeviceError( self.device, "Non-zero exit code. Partition Number: {0}".format( partition_num), str(output, "utf-8")) # if no error, mount succeeded def unmount_partition(self, partition_num): """Unmounts a device. :param partition_num: the number of the partition to unmount. :raises: :py:class:`~weresync.exception.DeviceError` if the partition is busy or the partition is not mounted. """ unmount_proc = subprocess.Popen( ["umount", self.part_mask.format(self.device, partition_num)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, err = unmount_proc.communicate() exit_code = unmount_proc.returncode if exit_code != 0: raise weresync.exception.DeviceError(self.device, str(output, "utf-8")) # If there are no errors, nothing needs be returned def _get_blkid_info(self, partition_num, info_name): proc = subprocess.Popen( [ "blkid", self.part_mask.format(self.device, partition_num), "-o", "value", "-s", info_name ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0: raise DeviceError(self.device, "Error getting information for partition " + str(partition_num), str(output, "utf-8")) return str(output, "utf-8").strip() def get_partition_uuid(self, partition_num): """Gets the UUID for a given partition. This is *not* the filesystem UUID. :param partition_num: the number of the partition whose UUID to get. :returns: a string containing the partition's UUID""" return self._get_blkid_info(partition_num, "UUID") def get_part_uuid(self, partition_num): """Gets the PARTUUID for a given partition, if it exists. This is *not* the filesystem UUID and is different from `py:func:~.DeviceManager.get_partition_uuid`. :param partition_num: the number of the partition whose PARTUUID to get. :returns: a string containing the partition's PARTUUID.""" return self._get_blkid_info(partition_num, "PARTUUID") def get_partition_table_type(self): """Gets the type of partition table on the device. Usually "gpt" for GPT disks or "msdos" for MBR disks. :returns: A string containing the name of the partition table. :raises DeviceError: If the parted command has a non-zero return code. :raises UnsupportedDeviceError: If the device does not have a supported partition type.""" process = subprocess.Popen( ["partprobe", "-s", "-d", self.device], stdout=subprocess.PIPE) output, error = process.communicate() exit_code = process.returncode if exit_code != 0: raise weresync.exception.DeviceError(self.device, "Non-zero exit code", str(output, "utf-8")) result = str(output, "utf-8") for table_type in SUPPORTED_PARTITION_TABLE_TYPES: if table_type in result: return table_type else: raise weresync.exception.UnsupportedDeviceError( "Partition table " "type of {0} not supported by WereSync.".format(self.device)) def get_drive_size(self): """Returns the maximum size of the drive, in sectors.""" query_proc = subprocess.Popen( ["blockdev", "--getsz", self.device], stdout=subprocess.PIPE) output, error = query_proc.communicate() exit_code = query_proc.returncode if exit_code != 0: raise weresync.exception.DeviceError(self.device, "Non-zero exit code", str(output, "utf-8")) return int(output) # should always be valid def get_drive_size_bytes(self): """Returns the maximum size of the drive, in bytes.""" query_proc = subprocess.Popen( ["blockdev", "--getsize64", self.device], stdout=subprocess.PIPE) output, error = query_proc.communicate() exit_code = query_proc.returncode if exit_code != 0: raise weresync.exception.DeviceError(self.device, "Non-zero exit code", str(output, "utf-8")) return int(output) def _get_general_info(self, partition_num): """Gets general information for the passed partition number, to be investigated by other methods. It returns a list with the following information in order: Filesystem (/dev/sda or such), Size in 512B-blocks, Used, Available, Use%, and Mounted Location. Gleaned from Linux df command. :param partition_num: the partition for which to get the information""" mounted_here = False try: if self.mount_point(partition_num) is None: self.mount_partition(partition_num, MOUNT_POINT) mounted_here = True proc_formal = subprocess.Popen( ["df", "--block-size=512"], stdout=subprocess.PIPE) proc = subprocess.Popen( ["grep", self.part_mask.format(self.device, partition_num)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=proc_formal.stdout) output, error = proc.communicate() exit_code = proc.returncode if exit_code >= 2: # grep returns 2 if an error occurs raise weresync.exception.DeviceError( self.device, "Error running grep.", str(output, "utf-8") + str(exit_code)) elif exit_code == 1: raise weresync.exception.DeviceError(self.device, "No grep line read", None) return [x for x in str(output, "utf-8").split() if x != ""] # Output has the following columns: # Filesystem, Total-size, Used, Available, Use%, Mount Point finally: if mounted_here: self.unmount_partition(partition_num) def get_partition_used(self, partition_num): """Returns the space available in a drive in 512B blocks :param partition_num: the number of the partition to check""" return int(self._get_general_info(partition_num)[2]) def get_partition_size(self, partition_num): """Gets the size of a partition in 512B sectors. :param partition_num: An int representing the partition whose size to get. :returns: An int representing the number of 512B blocks in the partition :raises DeviceError: if the commands getting the size of the partition fail. :raises ValueError: if the drive has an unsupported partition table type.""" table_type = self.get_partition_table_type() if table_type == "gpt": proc = subprocess.Popen( ["sgdisk", self.device, "-p"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0: raise weresync.exception.DeviceError( self.device, "Error getting device information", str(output, "utf-8")) for line in str(output, "utf-8").split("\n"): if line.strip().startswith(str(partition_num)): words = [x for x in line.split(" ") if x != ""] return int(words[2]) - int(words[1]) # start sector is the second element in this list, last sector # is third element elif table_type == "msdos": proc = subprocess.Popen( [ "sfdisk", "-s", self.part_mask.format(self.device, partition_num) ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0: raise weresync.exception.DeviceError( self.device, "Error getting partition size for partition {0}".format( partition_num), str(output, "utf-8")) return int(output) else: raise ValueError("Unsupported table type") def get_partition_code(self, partition_num): """For GPT disks: gets the partition code (as defined by sgdisk) for the passed partition number. For MBR disks: gets the partition code (as defined by sfdisk) for the passed partition number. :param partition_num: the number of the partition whose code to find. :raises DeviceError: If the command returns a non-zero return code. :raises ValueError: If an invalid partition number (one that doesn't exist) is passed. :returns: a string containing the partition code for the appropriate disk type.""" table_type = self.get_partition_table_type() if table_type == "gpt": proc = subprocess.Popen( ["sgdisk", self.device, "-p"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0: raise weresync.exception.DeviceError( self.device, "Error getting device information from sgdisk", str(output, "utf-8")) for line in str(output, "utf-8").split("\n"): if line.strip().startswith(str(partition_num)): words = line.strip().split() return words[ 5] # The code appears in the fifth column, but the # size takes up two columns (one for value and one for unit), # so the code appears in the sixth column. elif table_type == "msdos": proc = subprocess.Popen( ["fdisk", self.device, "-l"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0: raise weresync.exception.DeviceError( self.device, "Error getting device partition information.", str(output, "utf-8")) lines = str(output, "utf-8").split("\n") result = list( filter(lambda x: x.strip().startswith("Device"), lines)) header_line = result[0].strip().split() for idx, val in enumerate(header_line): if val == "Id": code_index = idx break else: raise ValueError("Unsupported fdisk format.") part_name = self.part_mask.format(self.device, partition_num) for line in lines: if line.strip().startswith(part_name): words = line.strip().split() loc = code_index # the code is in the 5th column if "*" not in line: loc -= 1 # if the partition is not bootable the column for the # boot header is not seperated. return words[loc] # No partition found raise ValueError("Invalid partition number, no partition found.") def get_partition_alignment(self): """Returns the number of sectors the drive must be aligned to. For GPT disk this is found using sgdisk's output. This function is only supported by GPT disks. :returns: An integer that represents the partition alignment of the device. :raises DeviceError: If the command returns a non-zero return code""" table_type = self.get_partition_table_type() if table_type == "gpt": process = subprocess.Popen( ["sgdisk", self.device, "-p"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = process.communicate() if process.returncode != 0: raise weresync.exception.DeviceError( self.device, "Error finding partition alignment.", str(output, "utf-8")) words = [x for x in str(output, "utf-8").split("\n") if x != ""] for word in words: if word.startswith("Partitions will be aligned on"): result = word.split("Partitions will be aligned on ") # the first value of result will be "", the second value # will be "{alignmentNum}-sector boundaries" value = result[1].split("-") return int(value[0]) raise weresync.exception.DeviceError( self.device, "sgdisk returned abnormal output.") elif table_type == "msdos": process = subprocess.Popen( ["fdisk", self.device, "-l"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = process.communicate() if process.returncode != 0: raise weresync.exception.DeviceError( self.device, "Error getting partition alignment", str(output, "utf-8")) for line in str(output, "utf-8").split("\n"): if line.startswith("Sector size"): parts = line.split("Sector size (logical/physical): ")[ 1].split(" / ") # After the label there is minimum and optimal size # seperated by a slash. Optimal is second logical = int(parts[0].strip().split()[0]) physical = int(parts[1].strip().split()[0]) return int(physical / logical) def get_empty_space(self): """Returns the amount of empty space *after* the last partition. This does not include space hidden between partitions. :returns: An integer representing the number of sectors free after the last partition. :raises DeviceError: If the command has a non-zero return code""" table_type = self.get_partition_table_type() if table_type == "gpt": proc = subprocess.Popen( ["sgdisk", self.device, "-p"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0: raise weresync.exception.DeviceError( self.device, "Error getting device information.", str(output, "utf-8")) result = str(output, "utf-8").split("\n") total_sectors = 0 for line in result: if line.startswith("Disk " + self.device): words = line.split(" ") for word in words: try: total_sectors = int(word) break except ValueError: continue last_part_info = [ x for x in result[-2].split(" ") if x != "" ] # the information for the last partition is always the last # line in the output. The very last line, however is empty. The # last real line is 2 back last_sector = int(last_part_info[ 2]) # the third column is the "end" sector column return total_sectors - last_sector elif table_type == "msdos": # other possible table types with throw an UnsupportedDeviceError # in the get_partition_table_type() method proc = subprocess.Popen( ["fdisk", self.device, "-l"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) output, error = proc.communicate() lines = str(output, "utf-8").split("\n") for line in lines: if "sectors" in line: words = line.split() for word in reversed(words): try: total_sectors = int(word) break except ValueError: continue break last_sector = 0 for val in reversed(lines): if val.strip() == "": continue vals = val.split() loc = 2 if "*" in val: loc += 1 try: value = int(vals[loc]) except ValueError: break # When this trips, we should be past all the # partition descriptions if value >= last_sector: last_sector = value return total_sectors - value def get_partition_file_system(self, part_num): """Returns the file system (ext4, ntfs, etc.) of the partition. If a partition system that can't be created by this system is found, None is return. :param part_num: the partition number whose filesystem to get. :returns: A string containing the file system type if a type found, otherwise None. If this is a swap partition this returns 'swap'""" proc = subprocess.Popen( [ "blkid", "-o", "value", "-s", "TYPE", self.part_mask.format(self.device, part_num) ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0 and proc.returncode != 2: raise weresync.exception.DeviceError( self.part_mask.format(self.device, part_num), "Error getting partition file system type.", output) result = str(output, "utf-8").strip() return result if result in SUPPORTED_FILESYSTEM_TYPES else None def set_partition_file_system(self, part_num, system_type): """Creates a new file system on the passed partition number. This is essentially the same as formatting the partition. If the passed partition is currently mounted, this will unmount the system, and remount it when finished. :param part_num: the partition number to format :param system_type: a file system (ex. ntfs) supported by mkfs on the current system.""" mnt_point = self.mount_point(part_num) try: if mnt_point is not None: self.unmount_partition(part_num) if system_type == "swap": proc = subprocess.Popen( ["mkswap", self.part_mask.format(self.device, part_num)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) else: proc = subprocess.Popen( [ "mkfs", "-t", system_type, self.part_mask.format(self.device, part_num) ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0: raise weresync.exception.DeviceError( self.part_mask.format(self.device, part_num), "Error creating new file system on partition.", str(output, "utf-8")) finally: if mnt_point is not None: self.mount_partition(part_num, mnt_point) class LVMDeviceManager(DeviceManager): """This is an extension of the DeviceManager class which handles Logical Volume groups. All method names referring to \"partition\" in fact refer to logical volumes, but the names remain the same for compatibility reasons. :param source: The name of the logical volume group.""" def __init__(self, device, partition_mask="{0}/{1}"): self.name = device.split("/")[-1] self.device = device if not device.endswith("/") else device[0:-1] self.device = self.device if self.device.startswith( "/dev") else "/dev/" + self.device self.part_mask = partition_mask def get_partitions(self): """Returns the names of the logical volumes in the group, as a list of strings.""" proc = subprocess.Popen( ["lvs", "--separator", ":"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0: raise weresync.exception.DeviceError( self.device, "Error finding logical volumes.", str(output, "utf-8")) lines = [ x.strip().split(":") for x in str(output, "utf-8").split("\n") if x.strip() != "" ] return [x[0] for x in lines[1:] if x[1] == self.name] def get_partition_table_type(self): return "lvm" def _get_drive_size_generic(self, units): """Gets the size of a logical volume group in generic units. :param units: An indicator for units as defined by the *vgs* command. Generally "b" or "s".""" proc = subprocess.Popen( [ "vgs", "--units", units, "-o", "size", "--noheadings", self.device ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0: raise weresync.exception.DeviceError( self.device, "Error finding logical volume size.", str(output, "utf-8")) return int( str(output, "utf-8").strip()[0:-1] ) # The last character of the output is a "B" or an "S" and should be # removed def get_drive_size_bytes(self): """Returns the of a logical volume group in bytes.""" return self._get_drive_size_generic("b") def get_drive_size(self): """Normally this function returns the drive size in sectors. :returns: an integer representing the size of the drive in sectors :raises DeviceError: if the command returns a non-zero return code""" return self._get_drive_size_generic("s") def get_partition_size(self, partition_name): """Gets the size, in sectors, of a logical volume. :param partition_name: the name of the logical volume whose size to get. :returns: an int representing the number of bytes on the logical volume. :raise DeviceError: if the commands for getting the size of the partition fail to return a 0 return code.""" proc = subprocess.Popen( [ "lvdisplay", "-c", self.part_mask.format(self.device, partition_name) ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0: raise weresync.exception.DeviceError( self.device, "Error getting logical volume size.", str(output, "utf-8")) return int(str(output, "utf-8").split(":")[6]) def get_partition_code(self, partition_num): """Not valid for LVM drives. :raises UnsupportedDeviceError:""" raise weresync.exception.UnsupportedDeviceError( "Partition codes not applicable to lvm drives.") def _get_general_info(self, partition_num): """Same method as the _get_general_info method for the main DeviceManager class.""" # This switching masks around is necessary because df sees block # devices in mapper (ex. /dev/mapper/example--vg) rather than normal # (ex. /dev/example-vg), old_name = self.device self.device = self.device[self.device.rindex("/") + 1:len(self.device)] self.device = self.device.replace("-", "--") old_mask = self.part_mask self.part_mask = "/dev/mapper/{0}-{1}" try: result = super()._get_general_info(partition_num) finally: self.device = old_name self.part_mask = old_mask return result # TODO make this work if needed with LVM drives def get_partition_alignment(self): """Not valid for LVM drives. :raises UnsupportedDeviceError:""" raise weresync.exception.UnsupportedDeviceError( "Partition codes not applicable to lvm drives.") def get_empty_space(self): """Gets the amount of empty space left in the logical volume group. Due to a lack of strict ordering in lvm, this is considered to be the total space, not simply the space after the last partition. :returns: An integer representing the number of sectors free in the volume group. :raises DeviceError: If the command has a non-zero return value.""" proc = subprocess.Popen( [ "vgs", "--units", "s", "-o", "free", "--noheadings", self.device ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0: raise weresync.exception.DeviceError( self.device, "Error reading free space in volume group.", str(output, "utf-8")) result = str(output, "utf-8").strip()[0:-1] # The final character in the results is a unit, in this case "S" return int(result) class DeviceCopier: """DeviceCopiers transfer data from a source drive to a target drive. :param source: the drive identifier (/dev/sda or such) of the source drive. :param target: the drive identifier (/dev/sdb or such) of the target drive. :param lvm_source: the name or :py:class:`~.LVMDeviceManager` object representing the source LVM. If None no LVM is copied. :param lvm_target: the name or :py:class:`~.LVMDeviceManager` object representing the target LVM. If None no LVM is copied. """ def __init__(self, source, target, lvm_source=None, lvm_target=None): self.source = source if isinstance( source, DeviceManager) else DeviceManager(source) self.target = target if isinstance( target, DeviceManager) else DeviceManager(target) if lvm_source is not None and lvm_target is not None: self.lvm_source = lvm_source if isinstance( lvm_source, LVMDeviceManager) else LVMDeviceManager(lvm_source) self.lvm_target = lvm_target if isinstance( lvm_target, LVMDeviceManager) else LVMDeviceManager(lvm_target) else: self.lvm_source = None self.lvm_target = None self.uuid_dict = None def get_uuid_dict(self): """Generates a dictionary that relates the partitions of the source drive to the partitions of the target drive. This function requires that both drives have the same number of partitions with the same numbers. This function caches its result in self.uuid_dict. After changing drive uuids, self.uuid_dict should be set to None. :returns: A dictionary where the keys are the source drive partition UUIDs, and the values are the corresponding target drive UUIDs.""" if self.uuid_dict is None: uuids = {} for i in self.source.get_partitions(): try: source_uuid = self.source.get_partition_uuid(i) except DeviceError as ex: if ex.errors == "": # blkid returns an empty string if no # UUID found # This can occur for some partition types, like a # microsoft reserved partition continue else: raise ex if source_uuid.strip() == "": # If the uuid is empty/nothing, we don't want it continue uuids[source_uuid] = self.target.get_partition_uuid(i) # This next segment gets the PARTUUID values try: source_part_uuid = self.source.get_part_uuid(i) except DeviceError as ex: if ex.errors == "": continue if source_part_uuid == "": continue uuids[source_part_uuid] = self.target.get_part_uuid(i) if self.lvm_source is not None: print("LVM Source is: " + self.lvm_source.device) print("meaningless space") source_vg = parse.parse("/dev/{0}", self.lvm_source.device)[0] target_vg = parse.parse("/dev/{0}", self.lvm_target.device)[0] source_name = source_vg.replace("-", "--") target_name = target_vg.replace("-", "--") lvm_mask = "{0}-{1}" for i in self.lvm_source.get_partitions(): uuids[lvm_mask.format(source_name, i)] = lvm_mask.format( target_name, i) self.uuid_dict = uuids LOGGER.debug("UUID Dict: " + str(uuids)) return uuids else: return self.uuid_dict def _transfer_gpt(self, difference): """Transfers the gpt partition table from the larger source drive to the smaller target drive. This method is not intended to be called from any method but the transfer partition table method below. :param difference: the difference between the sizes of the two drives :param margin: the amount of margin to give shrunk partitions.""" partitions = self.source.get_partitions() part_alignment = self.target.get_partition_alignment() add_args = [] type_args = [] difference -= ( self.source.get_empty_space() - 34 ) # the empty space in the source does not count against the # difference, but we leave the margin just in case # It also seems that gpt disks at least have 34 unusable sectors at # the end of the empty space, so we remove those from the count for i in reversed(partitions): drive_size = self.source.get_partition_size(i) try: part_used = self.source.get_partition_used(i) except weresync.exception.DeviceError: part_used = drive_size space = int(part_alignment * math.floor( (drive_size - part_used) / part_alignment)) part_size = None if space > 0 and difference > 0: # if the amount of space on the drive is bigger than the # difference between the drives if space > difference: part_size = int(part_alignment * math.ceil( (drive_size - difference) / part_alignment)) difference = 0 else: part_size = int(part_alignment * math.ceil(part_used / part_alignment)) difference -= space else: part_size = int(part_alignment * math.ceil(drive_size / part_alignment)) difference += part_size - drive_size # this adds or subtracts # to the difference based on whether or the aligning changed # the size of the partition if (i != partitions[-1]): # delete_args += ["-d", str(i)] add_args = [ "-n", "{0}:0:+{1}".format(i, part_size) ] + add_args # part_size is in 512 byte chunks, but gdisk # expects a 1 kB based unit if should_break: break # this also rounds down, in order to leave more space rather # than less else: # if this is the last partition to be looped, let it occupy # maximum space. add_args = ["-n", "{0}:0:0".format(i)] + add_args type_args += [ "-t", "{0}:{1}".format(i, self.source.get_partition_code(i)) ] LOGGER.debug(["sgdisk", self.target.device, "-o"] + add_args + type_args) clear_process = subprocess.Popen( ["sgdisk", self.target.device, "-Z"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) clear_output, _ = clear_process.communicate() if clear_process.returncode != 0: LOGGER.debug("Clear Process error code: {0}".format( clear_process.returncode)) raise weresync.exception.DeviceError( self.target.device, "Error clearing target drive.", str(clear_output, "utf-8")) copy_process = subprocess.Popen( ["sgdisk", self.target.device, "-o"] + add_args + type_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) copy_out, copy_err = copy_process.communicate() if copy_process.returncode != 0: raise weresync.exception.DeviceError( self.target.device, "Error copying partition table to target.", str(copy_err, "utf-8")) final_proc = subprocess.Popen( ["sgdisk", self.target.device, "-G"], stderr=subprocess.STDOUT) final_out, final_err = final_proc.communicate() if final_proc.returncode != 0: raise weresync.exception.DeviceError( self.target.device, "Error randomizing GUIDs on target device", str(final_out, "utf-8")) def _transfer_msdos(self, difference, margin=5): """Copies the partition table from a msdos source drive to a target drive. :param margin: The percent of original size to leave as a margin in the size of each partition. Is ignored if partition size is 0.""" for i in self.target.get_partitions(): if self.target.mount_point(i) is not None: self.target.unmount_partition(i) current_proc = subprocess.Popen( ["sfdisk", "-d", self.source.device], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) partition_table, current_proc_error = current_proc.communicate() if current_proc.returncode != 0: raise weresync.exception.DeviceError( self.source.device, "Error getting partition table backup.", str(partition_table, "utf-8")) partition_table = str(partition_table, "utf-8") partition_listings = [ x.replace(" ", "") for x in partition_table.split("\n") if x.startswith( self.source.part_mask.format(self.source.device, "")) ] for idx, val in enumerate(partition_listings): # Standard line of sfdisk -d output: # mbr.img1:start=2050,size=1893,Id=83, bootable # a new version would have "type" instead of "Id" listing = val.split(":") pairs = { "part": int( parse.parse( self.source.part_mask.format(self.source.device, "{0}"), listing[0])[0]) } for i in listing[1].split(","): pair = i.split("=") if len(pair) != 2: pairs["bootable"] = True else: pairs["bootable"] = False if (pair[0] == "Id" or pair[0] == "type"): # Newer versions of sfdisk use type instead of Id # This test allows both versions to be supported pairs["type"] = pair[1] id_key = pair[0] # needed to create the right lines in the # final product else: pairs[pair[0]] = int(pair[1]) partition_listings[idx] = pairs partition_listings.sort(key=lambda x: x["start"]) move_start_back_by = 0 final_str = "unit: sectors\n\n" current_extended_partition = None for i in partition_listings: i["start"] -= move_start_back_by if current_extended_partition is not None and i[ "part"] <= 4: # Not a logical partition current_extended_partition = None if i["type"] == "5": # The partition is an extended partition current_extended_partition = i continue shrink = 0 try: part_num = i["part"] drive_used = self.source.get_partition_used(part_num) space = math.floor( (i["size"] - drive_used) * (1 - margin / 100)) if space > 0 and difference > 0: if space >= difference: shrink = difference else: shrink = space difference -= shrink move_start_back_by += shrink i["size"] -= shrink except weresync.exception.DeviceError as ex: LOGGER.warning("Error reading device.") LOGGER.debug("Execption info:", exc_info=sys.exc_info()) if current_extended_partition is not None: current_extended_partition["size"] -= shrink partition_listings.sort(key=lambda x: x["part"]) # We don't know what the name of the id key is, so we have to # concaterate it in. final_str = "unit: sectors\n\n" + "".join([ "{val} : start= {start}, size= {size}, {type_key}= {type}{boot}\n". format( val=self.target.part_mask.format(self.target.device, part_line["part"]), boot=", bootable" if part_line["bootable"] else "", type_key=id_key, **part_line) for part_line in partition_listings ]) LOGGER.debug("Proposed partition table:\n" + final_str) table_proc = subprocess.Popen( ["fdisk", self.target.device], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.PIPE, universal_newlines=True) output, error = table_proc.communicate(input="o\nw\nq") if table_proc.returncode != 0: raise weresync.exception.CopyError( "Could not create new partition table on target device", output) transfer_proc = subprocess.Popen( ["sfdisk", "--force", self.target.device], stderr=subprocess.STDOUT, stdin=subprocess.PIPE, universal_newlines=True) output, error = transfer_proc.communicate(input=final_str) if transfer_proc.returncode != 0: raise weresync.exception.CopyError( "Could not copy partition table to target device.", output) def _transfer_lvm(self, difference): # In general, partitions and logical volumes (lvs) are synonymous in # this method for j in self.lvm_target.get_partitions(): if self.lvm_target.mount_point(j) is not None: self.lvm_target.unmount(j) LOGGER.debug("Deleting " + j) remove_proc = subprocess.Popen( ["lvremove", "-f", self.lvm_target.device + "/" + j], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = remove_proc.communicate() if remove_proc.returncode != 0: raise weresync.exception.DeviceError( self.lvm_target.device, "Error removing logical volume from target.", str(output, "utf-8")) lvs = self.lvm_source.get_partitions() difference -= self.lvm_source.get_empty_space() for i in lvs: drive_size = self.lvm_source.get_partition_size(i) try: part_used = self.lvm_source.get_partition_used(i) except weresync.exception.DeviceError: part_used = drive_size space = drive_size - part_used if space > 0 and difference > 0: part_size = None if space > difference: part_size = drive_size - difference else: part_size = part_used difference -= space else: part_size = drive_size command = [ "lvcreate", "--size", str(part_size) + "S", "-n", i, self.lvm_target.device ] LOGGER.debug("lvcreate command: " + str(command)) with tempfile.TemporaryFile() as tmp: copy_proc = subprocess.Popen( command, stdout=tmp, stderr=subprocess.STDOUT) proc_output, error = copy_proc.communicate() tmp.seek(0) output = tmp.read() LOGGER.debug("Output for " + i + ": " + str(output, "utf-8")) if copy_proc.returncode != 0: raise weresync.exception.DeviceError( self.lvm_target.device, "Error creating new logical volume.", str(output, "utf-8")) def format_partitions(self, ignore_errors=True, callback=None, lvm=False): """Goes through each partition in the source drive and formats the corresponding partition in the target drive to the same thing. :param ignore_errrors: whether or not errors (such as a file-system not recongized by mkfs) should be ignored. If True such errors will be logged. If false the exceptions will be propogated. Defaults to True. :param callback: If not None, this function should be a function that accepts a float between 0 and 1 to update the progress. :param lvm: Whether or not to use the lvm managers.""" if lvm: source_manager = self.lvm_source target_manager = self.lvm_target else: source_manager = self.source target_manager = self.target partitions = source_manager.get_partitions() for i in partitions: try: part_type = source_manager.get_partition_file_system(i) if callback is not None: drive_size = target_manager.get_drive_size() complete = 0.0 if part_type is not None: target_manager.set_partition_file_system(i, part_type) if callback is not None: part_size = target_manager.get_partition_size(i) complete += part_size / drive_size LOGGER.debug("Callback:\nDrive Size: {0}\n" "Part Size: {1}\n" "Complete: {2}".format( drive_size, part_size, complete)) callback(complete) else: LOGGER.warning( "Invalid filesystem type found. Partition {0} not " "formatted.".format(i)) except weresync.exception.DeviceError as exe: if ignore_errors: logging.getLogger("weresync.device").warning( "Creating filesystem for {0} encountered errors. " "Partition type: {1}. Skipped.".format( target_manager.part_mask.format( target_manager.device, i), part_type), exc_info=sys.exc_info()) logging.getLogger("weresync.device").debug( "Error making file system.", exc_info=sys.exc_info()) else: raise exe def transfer_partition_table(self, resize=True, callback=None): """Transfers the partition table from one drive to another. Afterwards, it formats the partitions on the target drive to be the same as those on the source drive. :param resize: if true (default) then the program will attempt to resize the partition tables to fit on a smaller drive. This will not expand partition tables at any time. :param callback: If not none, a function to update progress completed. See py:func:`~.format_partitions()`""" source_size = self.source.get_drive_size() target_size = self.target.get_drive_size() source_type = self.source.get_partition_table_type() if target_size < source_size and not resize: raise weresync.exception.CopyError( "Target device smaller than source device and resize set to " "false.") elif source_type == "gpt": self._transfer_gpt(source_size - target_size) elif source_type == "msdos": self._transfer_msdos(source_size - target_size) for i in self.target.get_partitions(): if self.target.mount_point(i) is not None: self.target.unmount_partition(i) if callback is not None: callback(0.3) # the block devices still won't be updated unless the following # command is called. proc = subprocess.Popen( ["partprobe", self.target.device], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode != 0: raise weresync.exception.DeviceError( self.target.device, "Error reloading partition mappings.", str(output, "utf-8")) # This weights the progress so 30% comes from creating partition and # 70% from formatting. progress = lambda prog: (callback(0.3 + prog * 0.7) if callback # noqa is not None else None) self.format_partitions(callback=progress) # If it's done, it's done if callback is not None: callback(1.0) self.uuid_dict = None # uuids will have been changed. def transfer_lvm_partition(self, resize=True, callback=False): if self.lvm_source is not None: lvm_source_size = self.lvm_source.get_drive_size() lvm_target_size = self.lvm_target.get_drive_size() self._transfer_lvm(lvm_source_size - lvm_target_size) self.format_partitions(callback=callback, lvm=True) def partitions_valid(self, lvm=False): """Tests if the partitions on the target drive can support copying files from the source drive. :returns: True if no errors found. :raises: a :py:class:`~weresync.exception.CopyError` if any part invalid.""" if lvm: source_manager = self.lvm_source target_manager = self.lvm_target else: source_manager = self.source target_manager = self.target source_parts = source_manager. get_partitions() try: target_parts = target_manager.get_partitions() except DeviceError as ex: raise weresync.exception.CopyError( "Target partitions cannot be found. Invalid.") if source_parts != target_parts: raise weresync.exception.CopyError( "Partition count on two drives different. Invalid.") for i in source_parts: if source_manager.get_partition_file_system( i) != target_manager.get_partition_file_system(i): raise weresync.exception.CopyError( "File system type for partition {0} does not match. " "Invalid.".format(i)) try: if source_manager.get_partition_used( i) > target_manager.get_partition_size(i): raise weresync.exception.CopyError( "Information on partition {0} cannot fit on " "corresponding partition on target drive.".format(i)) except weresync.exception.DeviceError as ex: if "mount" in str(ex) or "swapspace" in str( ex ): # the partition couldn't be mounted because it isn't a # mountable partition (maybe boot sector or swap) LOGGER.debug( "Partition {0} couldn't be mounted. Bad FS type". format(i), exc_info=sys.exc_info) else: raise ex return True def _copy_fstab(self, mnt_source, mnt_target, excluded_partitions=[], lvm=False): """Updates files in /etc/fstab to be bootable on the target drive. :param mnt_source: the directory where source partitions should be mounted. :param mnt_target: the directory where target partitions should be mounted. :param excluded_partitions: partitions not to search. Defaults to empty""" if lvm: source_manager = self.lvm_source target_manager = self.lvm_target else: source_manager = self.source target_manager = self.target for i in source_manager.get_partitions(): source_mounted = False target_mounted = False try: if i not in excluded_partitions: source_loc = source_manager.mount_point(i) if source_loc is None: try: source_manager.mount_partition(i, mnt_source) source_mounted = True source_loc = mnt_source except weresync.exception.DeviceError as ex: if "mount" in str(ex): LOGGER.debug( "Failed to mount partition. Info:\n", exc_info=sys.exc_info()) continue else: raise ex source_fstab_path = source_loc + ( "/" if not source_loc.endswith("/") else "") + "etc/fstab" if os.path.exists(source_fstab_path): target_loc = target_manager.mount_point(i) if target_loc is None: target_manager.mount_partition(i, mnt_target) target_mounted = True target_loc = mnt_target with open(source_fstab_path) as source_fstab, open( target_loc + ("/" if not target_loc.endswith("/") else "") + "etc/fstab", "w") as target_fstab: target_fstab.write( "# This file is generated by WereSync. All" " comments have been copied, but they have not" " been parsed.\n# Any reference to" " identifiers during installation may be" " inaccurate.\n\n") for line in source_fstab.readlines(): stripLine = line.strip() if stripLine == "" or stripLine.startswith( "#"): target_fstab.write(line) continue words = stripLine.split() if words[0].startswith("UUID") or words[ 0].startswith("LABEL"): if words[0].startswith("UUID"): blkid_arg = "-U" elif words[0].startswith("LABEL"): blkid_arg = "-L" identifier = words[0].split("=")[ 1] # First argument of the space # separated list is the identifier, # which is split by a equals sign, and the # second value is the identifier proc = subprocess.Popen( ["blkid", blkid_arg, identifier], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = proc.communicate() if proc.returncode == 2: raise weresync.exception.DeviceError( self.source.device, "Could not find block name of " "device with id: {0}".format( identifier), str(output, "utf-8")) elif proc.returncode != 0: raise weresync.exception.DeviceError( source_manager.device, "Error finding device error for " "device with id: {0}".format( identifier), str(output, "utf-8")) # It figures out the value of a # placeholder based on context. However, # the part_masks rarely have enough context # So we format the part_mask so that the # first placeholder is the partition # number, and the device name is inserted # (comes out to something like # "/dev/nbd0p{0}"). # Then it can figure it out. out = str(output, "utf-8").strip() if (self.lvm_source is not None and self.lvm_source.device in out): source = self.lvm_source target = self.lvm_target else: source = self.source target = self.target result = parse.parse(source.part_mask. format(source.device, "{0}"), out)[0] # the first element contains the # number uuid_proc = subprocess.Popen( [ "blkid", source.part_mask.format( target.device, result) ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) uuid_output, uuid_error = ( uuid_proc.communicate()) if uuid_proc.returncode == 2: raise weresync.exception.DeviceError( target.device, "Could not find device {0}".format( target.part_mask. format( target.device, result)), str(uuid_output, "utf-8")) elif uuid_proc.returncode != 0: raise weresync.exception.DeviceError( target.device, "Error finding uuid for device " "{0}".format( target.part_mask. format( target.device, result)), str(uuid_output, "utf-8")) ids = str(uuid_output, "utf-8").split() for val in ids: if val.startswith("UUID"): words[0] = val.replace('"', '') # Have to remove the double # quotes from blkid's output. break elif self.lvm_source is not None: words[0] = multireplace( words[0], self.get_uuid_dict()) target_fstab.write(" ".join(words) + "\n") finally: if source_mounted: source_manager.unmount_partition(i) if target_mounted: source_manager.unmount_partition(i) def _copy_files(self, mnt_source, mnt_target, excluded_partitions, ignore_failures, rsync_args, callback, lvm=False): """This is an internal method used for copying files. See the main `copy_files` method for documentation.""" if lvm: source_manager = self.lvm_source target_manager = self.lvm_target else: source_manager = self.source target_manager = self.target for i in source_manager.get_partitions(): source_mounted = False target_mounted = False try: if i in excluded_partitions: continue source_loc = source_manager.mount_point(i) if source_loc is None: source_manager.mount_partition(i, mnt_source) source_mounted = True source_loc = mnt_source target_loc = target_manager.mount_point(i) if target_loc is None: target_manager.mount_partition(i, mnt_target) target_mounted = True target_loc = mnt_target LOGGER.info("Starting rsync process for partition {0}.".format( source_manager.device)) command_args = ["rsync"] + shlex.split(rsync_args) + [ '--exclude=' + x + '' for x in [ "/dev/*", "/proc/*", "/sys/*", "/tmp/*", "/run/*", "/mnt/*", "/media/*", "/lost+found", "/home/*/.gvfs" ] ] + [ source_loc + ("/" if not source_loc.endswith("/") else ""), target_loc ] if callback is not None: command_args += ["--info=progress2"] print("Copying partition " + str(i)) LOGGER.debug("Arguments = " + " ".join(command_args)) def run_proc(): with subprocess.Popen( command_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as proc: buf = bytearray() while True: byt = proc.stdout.read(1) if byt == b"": break elif byt == b"\r": yield buf.decode() buf = bytearray() else: buf += byt LOGGER.debug("Errors for partition {0}:\n".format(i) + proc.stderr.read().decode()) if callback is not None: for val in run_proc(): vals = val.split() if len(vals) >= 2 and vals[1].endswith("%"): try: float_val = float(vals[1].strip("%")) / 100 except ValueError: continue callback(i, float_val) LOGGER.debug("Setting to finished") callback(i, 1.0) else: for val in run_proc(): # If you don't loop through the generator values, the # rsync process doesn't run properly. pass except weresync.exception.DeviceError as exe: if ignore_failures: LOGGER.warning( "Error copying data for partition {0} from device {1} " "to {2}.".format(i, source_manager.device, target_manager.device)) LOGGER.debug("Error info.", exc_info=sys.exc_info()) if callback is not None: callback(i, -1.0) else: raise exe finally: if source_mounted: source_manager.unmount_partition(i) if target_mounted: target_manager.unmount_partition(i) print(_("Finished copying files.")) def copy_files(self, mnt_source, mnt_target, excluded_partitions=[], ignore_failures=True, rsync_args=DEFAULT_RSYNC_ARGS, callback=None): """Copies all files from source to target drive, doing one partition at a time. This assumes that the two drives have equivalent partition mappings, i.e. that the data on partition 1 of the source drive should be on partition 1 of the target drive. :param mnt_source: The directory to mount partitions from the source drive on. :param mnt_target: The directory to mount partitions from the target drive on. :param excluded_partitions: A list containing the partitions to not copy. Defaults to empty. :param ignore_failures: If True, errors encountered for a partition will not cause the function to exit, but we instead cause a warning to be logged. Defaults to true. :param callaback: If not None, a function with the signature ``callback(int, float)``, where the int represents partition number and float represents progress. If an error occurs, the float will be negative. If the float should pulse, it wil return True.""" self._copy_files(mnt_source, mnt_target, excluded_partitions, ignore_failures, rsync_args, callback, lvm=False) if self.lvm_source is not None: self._copy_files(mnt_source, mnt_target, excluded_partitions, ignore_failures, rsync_args, callback, lvm=True) def make_bootable(self, plugin_name, source_mnt, target_mnt, excluded_partitions=[], root_partition=None, boot_partition=None, efi_partition=None, callback=None): """Calls the appropriate plugin to make the target drive bootable. :param plugin_name: the name, not pretty name, of the plugin to use. If None, so bootloading occurs, other than fstab copying. :param source_mnt: a string representing the directory where partitions from the source drive may be mounted. :param target_mnt: a string representing the directory where partitions from the target drive may be mounted. :param copier: an instance of :py:class:`~weresync.device.DeviceCopier` which represents the source and target drives. :param excluded_partitions: these partitions should not be searched or included in the boot installation. :param boot_partition: this is the partition that should be mounted on /boot of the root_partition. :param root_partition: this is the root partition of the drive, where the bootloader should be installed. :param efi_partition: this is the partition of the Efi System Partition. Should be None if not a UEFI system. """ LOGGER.info("Using plugin: " + plugin_name) try: if plugin_name is not None: import weresync.plugins as plugins manager = plugins.get_manager() manager.collectPlugins() full_name = "weresync_" + plugin_name pluginInfo = manager.getPluginByName(full_name, "bootloader") if pluginInfo is None: raise PluginNotFoundError("No such plugin {0}".format( full_name)) manager.activatePluginByName(full_name, "bootloader") plugin = pluginInfo.plugin_object else: plugin = None try: self._copy_fstab(source_mnt, target_mnt, excluded_partitions) except DeviceError as ex: LOGGER.warning("Error copying fstab. Continuing anyway.") LOGGER.debug("Info: ", exc_info=sys.exc_info()) if self.lvm_source is not None: try: self._copy_fstab(source_mnt, target_mnt, excluded_partitions, lvm=True) except DeviceError as ex: LOGGER.warning("Error copying fstab on LVM. Continuing" " anyway.") LOGGER.debug("Info: ", exc_info=sys.exc_info()) if plugin is not None: plugin.install_bootloader(source_mnt, target_mnt, self, excluded_partitions, boot_partition, root_partition, efi_partition) manager.deactivatePluginByName(plugin_name, "bootloader") else: LOGGER.warning("No bootloader plugin specified. Not installing" "bootloader.") except DeviceError as ex: LOGGER.warning("Error copying bootloader.") LOGGER.debug("Info: ", exc_info=sys.exc_info()) WereSync-1.0.9/src/weresync/plugins/0000755000175000017500000000000013315166025020103 5ustar danieldaniel00000000000000WereSync-1.0.9/src/weresync/plugins/weresync_grub2.py0000644000175000017500000001463513125115721023422 0ustar danieldaniel00000000000000# Copyright 2016 Daniel Manila # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Installs the Grub2 bootloader. This works on both UEFI and MBR systems.""" from weresync.plugins import IBootPlugin import weresync.plugins as plugins import weresync.device as device from weresync.exception import CopyError, DeviceError import subprocess import os import sys import logging LOGGER = logging.getLogger(__name__) class GrubPlugin(IBootPlugin): """Plugin to install the grub2 bootloader. Does not install grub legacy.""" def __init__(self): super().__init__("grub2", "Grub2") def get_help(self): return __doc__ def install_bootloader(self, source_mnt, target_mnt, copier, excluded_partitions=[], boot_partition=None, root_partition=None, efi_partition=None): if efi_partition is not None: import weresync.plugins.weresync_uuid_copy as uuc # UEFI systems tend to only need a UUID copy. No sense in not # reusing old code. uuc.UUIDPlugin().install_bootloader(source_mnt, target_mnt, copier, excluded_partitions, boot_partition, root_partition, efi_partition) return if root_partition is None and boot_partition is None: # This for loop searches for a partition with a /boot/grub folder # and it assumes it is the root partition for i in copier.target.get_partitions(): try: mount_point = copier.target.mount_point(i) if mount_point is None: copier.target.mount_partition(i, target_mnt) mount_point = target_mnt if os.path.exists(mount_point + ("/" if not mount_point.endswith("/") else "") + "boot/grub"): root_partition = i break else: copier.target.unmount_partition(i) except DeviceError as ex: LOGGER.warning("Could not mount partition {0}. " "Assumed to not be the partition grub " "is on.".format(i)) LOGGER.debug("Error info:\n", exc_info=sys.exc_info()) else: # No partition found raise CopyError("Could not find partition with " "'boot/grub' folder on device {0}".format( copier.target.device)) # These variables are flags that allow the plugin to know if it mounted # any partitions and then clean up properly if it did mounted_here = False boot_mounted_here = False try: if root_partition is not None: mount_loc = copier.target.mount_point(root_partition) if mount_loc is None: plugins.mount_partition(copier.target, copier.lvm_target, root_partition, target_mnt) mounted_here = True mount_loc = target_mnt else: mount_loc = target_mnt # This line avoids double slashes in path mount_loc += "/" if not mount_loc.endswith("/") else "" if boot_partition is not None: boot_folder = mount_loc + "boot" if not os.path.exists(boot_folder): os.makedirs(boot_folder) plugins.mount_partition(copier.target, copier.lvm_target, boot_partition, boot_folder) boot_mounted_here = True print(_("Updating Grub")) grub_cfg = mount_loc + "boot/grub/grub.cfg" old_perms = os.stat(grub_cfg)[0] try: with open(grub_cfg, "r+") as grubcfg: cfg = grubcfg.read() LOGGER.debug("UUID Dicts: " + str(copier.get_uuid_dict())) final = device.multireplace(cfg, copier.get_uuid_dict()) grubcfg.seek(0) grubcfg.write(final) grubcfg.truncate() grubcfg.flush() finally: os.chmod(grub_cfg, old_perms) print(_("Installing Grub")) grub_command = ["grub-install", "--boot-directory=" + mount_loc + "boot", "--recheck", "--target=i386-pc", copier.target.device] LOGGER.debug("Grub command: " + " ".join(grub_command)) grub_install = subprocess.Popen(grub_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) install_output, install_error = grub_install.communicate() if grub_install.returncode != 0: raise DeviceError(copier.target.device, "Error installing grub.", str(install_output, "utf-8")) print(_("Consider running update-grub on your backup. WereSync" " copies can sometimes fail to capture all the nuances of a" " complex system.")) print(_("Cleaning up.")) finally: # This block cleans up any mounted partitions if boot_mounted_here: copier.target.unmount_partition(boot_partition) if mounted_here: copier.target.unmount_partition(root_partition) print(_("Finished!")) WereSync-1.0.9/src/weresync/plugins/weresync_syslinux.py0000644000175000017500000001510613125115721024271 0ustar danieldaniel00000000000000# Copyright 2016 Daniel Manila # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Installs the syslinux bootloader. This bootloader plugin requires the "root partition" option to be defined. Setups using syslinux's altmbr setup are currently not supported. This plugin assumes that the boot folder is in /boot/syslinux. If this is not the case a symbolic link should be created before WereSync is run. This plugin depends on Extlinux being installed. On UEFI systems this simply runs the UUID Copy plugin.""" from weresync.plugins import IBootPlugin import weresync.plugins as plugins from weresync.exception import CopyError, DeviceError import subprocess class SyslinuxPlugin(IBootPlugin): def __init__(self): super().__init__("syslinux", "Syslinux") def get_help(self): return __doc__ def install_bootloader(self, source_mnt, target_mnt, copier, excluded_partitions=[], boot_partition=None, root_partition=None, efi_partition=None): if root_partition is None and boot_partition is None: boot_part = plugins.search_for_boot_part(target_mnt, copier.target, "syslinux", excluded_partitions) if boot_part is None and copier.lvm_source is not None: boot_part = plugins.search_for_boot_part(target_mnt, copier.lvm_source, "syslinux", excluded_partitions) if boot_part is None: raise CopyError("Could not find partition with 'syslinux' " "folder on device {0}.".format(copier.target. device)) boot_partition = boot_part elif boot_partition is None: boot_partition = root_partition plugins.translate_uuid(copier, boot_partition, "/boot", target_mnt) if efi_partition is not None: plugins.translate_uuid(copier, efi_partition, "/", target_mnt) else: if root_partition is None: raise CopyError("The syslinux bootloader plugin requires that" " the root partition be defined. The UUIDs of" " the /boot folder have been updated.") try: mounted_here = False mount_point = copier.target.mount_point(root_partition) if mount_point is None: copier.target.mount_partition(root_partition, target_mnt) mount_point = target_mnt mounted_here = True mount_point += "/" if not target_mnt.endswith("/") else "" extlinux_proc = subprocess.Popen(["extlinux", "--install", mount_point + "boot/syslinux" ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) extlinux_output, _ = extlinux_proc.communicate() if extlinux_proc.returncode != 0: raise CopyError("Error installing bootloader on target " "drive.", extlinux_output) table_type = copier.target.get_partition_table_type() if table_type == "msdos": bios_proc = subprocess.Popen(["dd", "bs=440", "count=1", "if={0}usr/lib/syslinux" "/bios/mbr.bin".format( mount_point), "of=" + copier.target.device ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = bios_proc.communicate() if bios_proc.returncode != 0: raise DeviceError(copier.target.device, "Error installing bios to drive.", output) elif table_type == "gpt": attribute_proc = subprocess.Popen(["sgdisk", copier.target.device, "--attributes=1:set:2"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output, error = attribute_proc.communicate() if attribute_proc.returncode != 0: raise DeviceError(copier.target.device, "Error enabling boot of partition.", output) bios_proc = subprocess.Popen(["dd", "conv=notrunc", "count=1", "if={0}usr/lib" "/syslinux/bios/gptmbr.bin". format(mount_point), "of=" + copier.target.device ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) b_output, b_error = bios_proc.communicate() if bios_proc.returncode != 0: raise DeviceError(copier.target.device, "Error install MBR to drive.", b_output) finally: if mounted_here: copier.target.unmount_partition(root_partition) WereSync-1.0.9/src/weresync/plugins/__init__.py0000644000175000017500000002333713125233320022214 0ustar danieldaniel00000000000000# Copyright 2016 Daniel Manila # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """This package contains code for bootloader plugins. Bootloader plugin is any class extending :py:class:`~weresync.plugins.IBootPlugin` inside a file name following the regex pattern "^weresync_.*\.py$". These plugins can be installed to the site-packages directory (for example, using pip) or to /usr/local/weresync/plugins. For an example plugin see the `GrubPlugin `_.""" # noqa from yapsy.PluginManager import PluginManager from yapsy.PluginFileLocator import (PluginFileAnalyzerMathingRegex, PluginFileLocator) from yapsy.IPlugin import IPlugin from distutils.sysconfig import get_python_lib import os import os.path import weresync.device as device from weresync.exception import DeviceError import sys import logging LOGGER = logging.getLogger(__name__) def translate_uuid(copier, partition, path, target_mnt): """Translates all uuids of the files in the given partition at path. This will not affect files which are not UTF-8 or ASCII, and it will not affect files which are greater than 200 MB. :param copier: the object with the DeviceManager instances. :param partition: the partition number of the partition to translate. :param path: the path of the file to change relative to the mount of the partition. Should start with "/". :param target_mnt: the path to the folder where the partitions should be mounted.""" mounted_here = False try: mount_point = copier.target.mount_point(partition) if mount_point is None: copier.target.mount_partition(partition, target_mnt) mount_point = target_mnt mounted_here = True for dname, dirs, files in os.walk(mount_point + path): for fname in files: # This if block seeks to avoid opening huge files since # they are unlikely to be config files like we are looking # for. fpath = os.path.join(dname, fname) if (os.path.getsize(fpath)) / 1000000 > 200: continue try: with open(fpath) as file: text = file.read() except UnicodeDecodeError as ex: continue uuid_dict = copier.get_uuid_dict() text = device.multireplace(text, uuid_dict) with open(fpath, "w") as f: f.write(text) finally: if mounted_here: copier.target.unmount_partition(partition) def mount_partition(manager, lvm_manager, part, mount_point): """Mounts a partition and figures out whether or not the partition is in the LVM drive. It assumes a numerical partition is not a logical volume. :param manager: a :py:class:`~weresync.device.DeviceManager` object representing a possible mount. :param lvm_manager: a :py:class:`~weresync.device.LVMDeviceManager` object representing a possible host for the mount. :param part: the name or number of the partition to mount. :param mount_point: the location to mount the partition.""" try: part_num = int(part) manager.mount_partition(part_num, mount_point) return except ValueError: pass lvm_manager.mount_partition(part, mount_point) def search_for_boot_part(target_mnt, target_manager, search_folder, exlcuded_partitions=[],): """Finds the partition that is the boot partition, by searching for a specific folder name. The first partition that contains this name or /boot/ will be returned. :param target_mnt: the folder to mount partitions :param target_manager: The :py:class:`~weresync.device.DeviceManager` class representing the drive to search. :param search_folder: The name of the folder to search for :param excluded_partitions: A list containing a list of partitions which should not be searched.""" for i in target_manager.get_partitions(): if i in exlcuded_partitions: continue try: mounted_here = False mount_point = target_manager.mount_point(i) if mount_point is None: target_manager.mount_partition(i, target_mnt) mount_point = target_mnt mounted_here = True mount_point += "/" if not mount_point.endswith("/") else "" if (os.path.exists(mount_point + "boot/" + search_folder) or os.path.exists(mount_point + search_folder)): return i except DeviceError as ex: LOGGER.warning("Could not mount partition {0}. " "Assumed to not be the partition grub " "is on.".format(i)) LOGGER.debug("Error info:\n", exc_info=sys.exc_info()) finally: try: if mounted_here: target_manager.unmount_partition(i) except DeviceError as ex: LOGGER.warning("Error unmounting partition " + i) LOGGER.debug("Error info:\n", exc_info=sys.exc_info()) else: # No partition found return None class IBootPlugin(IPlugin): """An interface class for bootloader plugins. Plugins implementing this class must implement the :py:func:`~.IBootPlugin.install_bootloader` method. The name of a plugin should simply be its filename, without the "weresync\_" prefix or a file extension. So "weresync_grub2.py"'s name would be "grub2". :param prettyName: a human readable name for display. Can contain any character. :param name: A unique identifying name that should be easy to type on a terminal. Used by users to tell WereSync which plugin to use. See above for exact definition.""" def __init__(self, name, prettyName=None): self.name = name if prettyName is None: self.prettyName = name else: self.prettyName = prettyName def activate(self): """Called at plugin activation,right before bootloader is installed. This will be called before /etc/fstab has been updated.""" pass def deactivate(self): """Called at plugin deactivation, after bootloader has been installed.""" def install_bootloader(self, source_mnt, target_mnt, copier, excluded_partitions=[], boot_partition=None, root_partition=None, efi_partition=None): """Called to make the drive bootable. This will be called after /etc/fstab has been updated. :param source_mnt: a string representing the directory where partitions from the source drive may be mounted. :param target_mnt: a string representing the directory where partitions from the target drive may be mounted. :param copier: an instance of :py:class:`~weresync.device.DeviceCopier` which represents the source and target drives. :param excluded_partitions: these partitions should not be searched or included in the boot installation. :param boot_partition: this is the partition that should be mounted on /boot of the root_partition. :param root_partition: this is the root partition of the drive, where the bootloader should be installed. :param efi_partition: this is the partition of the Efi System Partition. Should be None if not a UEFI system. :raises DeviceError: if a bootloader installation command has an error. """ pass def get_help(self): """Returns the help message for this plugin. It is optional to override this. :returns: a string representing the help message for this plugin.""" return "Installs the {0} bootloader.".format(self.prettyName) dirs = ["/usr/local/weresync/plugins", os.path.dirname(__file__), get_python_lib()] regex_analyzer = PluginFileAnalyzerMathingRegex("regex", "^weresync_.*\.py$") locator = PluginFileLocator([regex_analyzer]) __manager = PluginManager( categories_filter={"bootloader": IBootPlugin}, directories_list=dirs, plugin_locator=locator) def get_manager(): """Returns the PluginManager for this instance of WereSync""" return __manager WereSync-1.0.9/src/weresync/plugins/weresync_uuid_copy.py0000644000175000017500000000457613125115721024404 0ustar danieldaniel00000000000000# Copyright 2016 Daniel Manila # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """This modules contains the code to simply translate the UUIDs of all text files to the new drive. It does not change anything else. In order to save RAM, uuid_copy will not copy files larger than 200 MB. This works for many bootloaders.""" from weresync.plugins import IBootPlugin from weresync.exception import CopyError import logging import weresync.plugins as plugins LOGGER = logging.getLogger(__name__) class UUIDPlugin(IBootPlugin): def __init__(self): super().__init__("uuid_copy", "UUID Copy") def get_help(self): return """Changes all UUIDs in every file of /boot to the new drive's UUIDs. \nDoes not install anything else. This is the default option.""" def install_bootloader(self, source_mnt, target_mnt, copier, excluded_partitions=[], boot_partition=None, root_partition=None, efi_partition=None): if root_partition is None and boot_partition is None: part = plugins.search_for_boot_part(target_mnt, copier.target, "boot", excluded_partitions) if part is None: raise CopyError("Could not find partition with " "'boot' folder on device {0}".format( copier.target.device)) plugins.translate_uuid(copier, part, "/boot", target_mnt) elif boot_partition is not None: plugins.translate_uuid(copier, boot_partition, "/", target_mnt) else: plugins.translate_uuid(copier, root_partition, "/boot", target_mnt) if efi_partition is not None: plugins.translate_uuid(copier, efi_partition, "/", target_mnt) WereSync-1.0.9/src/weresync/__init__.py0000644000175000017500000000000013130654307020521 0ustar danieldaniel00000000000000WereSync-1.0.9/src/WereSync.egg-info/0000755000175000017500000000000013315166025020014 5ustar danieldaniel00000000000000WereSync-1.0.9/src/WereSync.egg-info/PKG-INFO0000644000175000017500000001557413315166022021122 0ustar danieldaniel00000000000000Metadata-Version: 1.0 Name: WereSync Version: 1.0.9 Summary: Incrementally clones Linux drives Home-page: https://github.com/DonyorM/weresync Author: Daniel Manila Author-email: dmv@springwater7.org License: Apache 2.0 Description: ######## WereSync ######## `Installation <#installation>`__ | `Basic Usage <#basic-usage>`__ | `Documentation `__ | `Contributing <#contributing-and-bug-reports>`__ .. image:: https://github.com/DonyorM/weresync/raw/master/docs/source/img/weresync-logo.png :align: center :alt: WereSync Logo A lone hard drive stands atop a data heap, staring at the full moon. Suddenly, it transforms...into a bootable clone of your drive, whirring hungrily at the digital moon. WereSync takes a Linux hard drive and effectively clones it, but works incrementally so you don't have to spend so long backing up each time. Additionally, WereSync can clone to a smaller drive, if your data will fit on the smaller drive. Because WereSync uses rsync to copy, it can copy a running drive, though certain parts of state may not be preserved. Why Use WereSync? ================= Hopefully, you think this project looks amazing and you want to try it right away. However, you may be skeptical about the usefulness of WereSync. You may be thinking, I can do this exact same thing using gparted or ddrescue. Hear me out! There are a few reasons to use WereSync over the other tools. - **WereSync is accessible to less-technical users.** It comes with a simple interface and clone a drive with a single command while your computer is running. No booting to a live disk or pushing through a long initiation process. Unlike `dd` or CloneZilla, WereSync requires a low level of technical skill and has an easy learning curve - WereSync can run while the your main drive is being used, instead of blocking your computer up for hours at a time - WereSync will incrementally update clones, making subsequent clones much faster. - WereSync works quickly, a single command copies your entire drive, no booting to live CDs or managing MBRs. - WereSync can copy to a smaller drive, provided your drive's data will fit. - WereSync creates new UUIDs for the new partitions, allowing you to use the old and new drives alongside each other. Full documentation may be found `here `__. Installation ============ WereSync can be installed using the `setup.py` file. .. code-block:: bash $ ./setup.py install If you have `pip `__ installed, you can easily install WereSync with the following command:: $ pip install weresync For more in-depth instructions, see the `installation documentation `__. Basic Usage =========== **Note:** WereSync requires root capabilities to run because it has to access block devices. The gui can be launched with the command:: $ sudo weresync-gui Which generates the following GUI, though generally the advanced options are unneeded: .. image:: https://github.com/DonyorM/weresync/raw/master/docs/source/img/gui-example.png :align: left :alt: Picture of WereSync GUI To see the options for the terminal command use:: $ weresync -h To copy from /dev/sda to /dev/sdb (the two drives must have the same partition scheme) use:: $ sudo weresync /dev/sda /dev/sdb For more information, including how to copy the partition table from drive to another, see the `Basic Usage `__ documentation page. Documentation ============= Documentation can be found on the `Read the Docs `__. Contributing and Bug Reports ============================ First, take a look at our `contribution guidelines `__. To contribute simply fork this repository, make your changes, and submit a pull request. Bugs can be reported on the `issue tracker `__ WereSync currently has huge need of people testing the program on complex drive setups. In order to do this please: 1. Install WereSync from pip:: pip install weresync #. Run it on your system:: sudo weresync -C source_drive target_drive #. Report any errors to the `issue tracker `__. Please be sure to post the contents of ``/var/log/weresync/weresync.log`` and ``fdisk -l``. All contributions will be greatly appreciated! Distributions Capability for Drive Copying ------------------------------------------ |ubuntu| |debian| |arch| |centos| |fedora| |opensuse| .. |ubuntu| image:: https://img.shields.io/badge/ubuntu-stable-brightgreen.svg .. |arch| image:: https://img.shields.io/badge/Arch%20Linux-stable-brightgreen.svg .. |centos| image:: https://img.shields.io/badge/CentOS-not%20tested-red.svg .. |fedora| image:: https://img.shields.io/badge/Fedora-not%20tested-red.svg .. |opensuse| image:: https://img.shields.io/badge/openSUSE-not%20tested-red.svg .. |debian| image:: https://img.shields.io/badge/Debian-stable-brightgreen.svg If you are able to test any of these systems, please report your exprience at the `issue tracker `__. Any help will be much appreciated. Licensing ========= This project is licensed under the `Apache 2.0 License `__. Licensing is in the **LICENSE.txt** file in this directory. Acknowledgments =============== Huge thanks to the creators of: * `rsync `__, whose software allowed this project to be possible. * `GNU Parted `__ * And `GPT fdisk `__ Keywords: clone,linux,backup,smaller drive Platform: UNKNOWN WereSync-1.0.9/src/WereSync.egg-info/entry_points.txt0000644000175000017500000000015313315166022023306 0ustar danieldaniel00000000000000[console_scripts] weresync = weresync.interface:main [gui_scripts] weresync-gui = weresync.gui:start_gui WereSync-1.0.9/src/WereSync.egg-info/requires.txt0000644000175000017500000000003513315166022022407 0ustar danieldaniel00000000000000parse>=1.6.6 yapsy>=1.11.223 WereSync-1.0.9/src/WereSync.egg-info/top_level.txt0000644000175000017500000000001113315166022022533 0ustar danieldaniel00000000000000weresync WereSync-1.0.9/src/WereSync.egg-info/dependency_links.txt0000644000175000017500000000000113315166022024057 0ustar danieldaniel00000000000000 WereSync-1.0.9/src/WereSync.egg-info/SOURCES.txt0000644000175000017500000000230513315166024021677 0ustar danieldaniel00000000000000LICENSE.txt MANIFEST.in README.rst setup.cfg setup.py docs/Makefile docs/weresync.1.rst docs/source/api.rst docs/source/bootloader.rst docs/source/conf.py docs/source/global.rst.inc docs/source/gui.rst docs/source/index.rst docs/source/installation.rst docs/source/issues.rst docs/source/translation.rst docs/source/weresync.rst docs/source/_templates/homepage.html docs/source/img/gui-example.png docs/source/img/weresync-logo.png src/WereSync.egg-info/PKG-INFO src/WereSync.egg-info/SOURCES.txt src/WereSync.egg-info/dependency_links.txt src/WereSync.egg-info/entry_points.txt src/WereSync.egg-info/requires.txt src/WereSync.egg-info/top_level.txt src/weresync/__init__.py src/weresync/device.py src/weresync/exception.py src/weresync/gui.py src/weresync/interface.py src/weresync/utils.py src/weresync/plugins/__init__.py src/weresync/plugins/weresync_grub2.py src/weresync/plugins/weresync_syslinux.py src/weresync/plugins/weresync_uuid_copy.py src/weresync/resources/__init__.py src/weresync/resources/weresync.svg src/weresync/resources/locale/weresync.pot src/weresync/resources/locale/en/LC_MESSAGES/weresync.mo src/weresync/resources/locale/en/LC_MESSAGES/weresync.po tests/test_device.py tests/test_interface.py