pax_global_header00006660000000000000000000000064147007526310014517gustar00rootroot0000000000000052 comment=43b0f7ae3d3aba4377209cf512e844e6718b7484 xandikos-0.2.12/000077500000000000000000000000001470075263100134215ustar00rootroot00000000000000xandikos-0.2.12/.coveragerc000066400000000000000000000001141470075263100155360ustar00rootroot00000000000000[run] branch = True [report] exclude_lines = raise NotImplementedError xandikos-0.2.12/.dockerignore000066400000000000000000000000161470075263100160720ustar00rootroot00000000000000.git/ compat/ xandikos-0.2.12/.flake8000066400000000000000000000004141470075263100145730ustar00rootroot00000000000000[flake8] extend-ignore = E203, E266, E501, W293, W291, W503 max-line-length = 88 max-complexity = 18 select = B,C,E,F,W,T4,B9 ignore = W504,E203,W503 exclude = compat/vdirsyncer/,.tox,.git,compat/pycaldav,examples/gunicorn.conf.py application-package-names = xandikos xandikos-0.2.12/.github/000077500000000000000000000000001470075263100147615ustar00rootroot00000000000000xandikos-0.2.12/.github/CODEOWNERS000066400000000000000000000000121470075263100163450ustar00rootroot00000000000000* @jelmer xandikos-0.2.12/.github/FUNDING.yml000066400000000000000000000000171470075263100165740ustar00rootroot00000000000000github: jelmer xandikos-0.2.12/.github/dependabot.yml000066400000000000000000000006251470075263100176140ustar00rootroot00000000000000# Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "cargo" directory: "/" schedule: interval: "weekly" rebase-strategy: "disabled" - package-ecosystem: "github-actions" directory: "/" schedule: interval: weekly xandikos-0.2.12/.github/workflows/000077500000000000000000000000001470075263100170165ustar00rootroot00000000000000xandikos-0.2.12/.github/workflows/container.yml000066400000000000000000000024261470075263100215270ustar00rootroot00000000000000name: Create and publish a Docker image on: release: types: [created] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push-image: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to the Container registry uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build and push Docker image uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 with: context: . platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} xandikos-0.2.12/.github/workflows/disperse.yml000066400000000000000000000002771470075263100213650ustar00rootroot00000000000000--- name: Disperse configuration "on": - push jobs: disperse: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: jelmer/action-disperse-validate@v1 xandikos-0.2.12/.github/workflows/litmus.yml000066400000000000000000000014541470075263100210620ustar00rootroot00000000000000name: Litmus DAV compliance tests on: - push - pull_request jobs: litmus: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] python-version: ["3.10", "3.11", "3.12"] fail-fast: false steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip setuptools pip install -U pip pycalendar vobject requests six tzlocal attrs aiohttp aiohttp-wsgi prometheus-client multidict pytest python setup.py develop - name: Run litmus tests run: | make check-litmus if: "matrix.os == 'ubuntu-latest'" xandikos-0.2.12/.github/workflows/pycaldav.yml000066400000000000000000000016251470075263100213500ustar00rootroot00000000000000--- name: pycaldav cross-tests "on": - push - pull_request jobs: pycaldav: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] python-version: ["3.10", "3.11", "3.12"] fail-fast: false steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -U pip pycalendar vobject requests six tzlocal pytz attrs aiohttp aiohttp-wsgi prometheus-client multidict pytest "recurring-ical-events>=1.1.0b" typing-extensions defusedxml python -m pip install -e . - name: Run pycaldav tests run: | sudo apt install libxml2-dev libxslt1-dev pip install -U nose lxml make check-pycaldav xandikos-0.2.12/.github/workflows/pythonpackage.yml000066400000000000000000000025231470075263100224000ustar00rootroot00000000000000name: Python package on: - push - pull_request jobs: pythontests: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.9", "3.10", "3.11", "3.12"] fail-fast: false steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -U pip coverage codecov flake8 pycalendar vobject requests six tzlocal attrs multidict pytest - name: Install dependencies (linux) run: | sudo apt -y update sudo apt -y install libsystemd-dev python -m pip install -e ".[systemd,prometheus]" if: "matrix.os == 'ubuntu-latest'" - name: Install dependencies (non-linux) run: | python -m pip install -e ".[prometheus]" if: "matrix.os != 'ubuntu-latest'" - name: Style checks run: | python -m flake8 - name: Typing checks run: | pip install -U mypy types-python-dateutil python -m mypy xandikos - name: Test suite run run: | python -m unittest xandikos.tests.test_suite env: PYTHONHASHSEED: random xandikos-0.2.12/.github/workflows/wheels.yaml000066400000000000000000000021531470075263100211720ustar00rootroot00000000000000name: Upload Python Package on: release: types: [created] jobs: release-build: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ['3.x'] steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build run: | python setup.py sdist bdist_wheel - name: Upload artifact uses: actions/upload-artifact@v4 with: name: release-dists path: dist/ pypi-publish: runs-on: ubuntu-latest needs: - release-build permissions: id-token: write steps: - name: Retrieve release distributions uses: actions/download-artifact@v4 with: name: release-dists path: dist/ - name: Publish release distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 xandikos-0.2.12/.gitignore000066400000000000000000000002161470075263100154100ustar00rootroot00000000000000*.pyc *~ build/ .testrepository/ MANIFEST .tox/ .*.sw? .coverage htmlcov/ dist .pybuild *.egg* child.log debug.log .mypy_cache .stestr target xandikos-0.2.12/.mailmap000066400000000000000000000002141470075263100150370ustar00rootroot00000000000000Jelmer Vernooij Jelmer Vernooij Jelmer Vernooij Jelmer Vernooij xandikos-0.2.12/.readthedocs.yaml000066400000000000000000000003731470075263100166530ustar00rootroot00000000000000# .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py xandikos-0.2.12/.stestr.conf000066400000000000000000000000431470075263100156670ustar00rootroot00000000000000[DEFAULT] test_path=xandikos/tests xandikos-0.2.12/.testr.conf000066400000000000000000000002431470075263100155060ustar00rootroot00000000000000[DEFAULT] test_command=PYTHONPATH=. python3 -m subunit.run $IDOPTION $LISTOPT xandikos.tests.test_suite test_id_option=--load-list $IDFILE test_list_option=--list xandikos-0.2.12/AUTHORS000066400000000000000000000004751470075263100144770ustar00rootroot00000000000000Jelmer Vernooij Geert Stappers Hugo Osvaldo Barrera Markus Unterwaditzer Daniel M. Capella Ole-Christian S. Hagenes Denis Laxalde Félix Sipma xandikos-0.2.12/CODE_OF_CONDUCT.md000066400000000000000000000064241470075263100162260ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project lead at jelmer@jelmer.uk. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq xandikos-0.2.12/CONTRIBUTING.md000066400000000000000000000004171470075263100156540ustar00rootroot00000000000000Xandikos uses the PEP8 style guide. You can verify whether you've introduced any style violations by running "make style". There are some very minimal developer documentation/vague design docs in notes/. Please implement new RFCs as much as possible in their own file. xandikos-0.2.12/COPYING000066400000000000000000001045131470075263100144600ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . xandikos-0.2.12/Cargo.lock000066400000000000000000000353571470075263100153430ustar00rootroot00000000000000# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 3 [[package]] name = "addr2line" version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" dependencies = [ "gimli", ] [[package]] name = "adler" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "anstream" version = "0.6.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" [[package]] name = "anstyle-parse" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" dependencies = [ "windows-sys", ] [[package]] name = "anstyle-wincon" version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" dependencies = [ "anstyle", "windows-sys", ] [[package]] name = "autocfg" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "backtrace" version = "0.3.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" dependencies = [ "addr2line", "cc", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", ] [[package]] name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bytes" version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" [[package]] name = "cc" version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26a5c3fd7bfa1ce3897a3a3501d362b2d87b7f2583ebcb4a949ec25911025cbc" [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" version = "4.5.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" version = "4.5.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", ] [[package]] name = "clap_lex" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" [[package]] name = "colorchoice" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" [[package]] name = "gimli" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "indoc" version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "libc" version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" [[package]] name = "lock_api" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", ] [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "memoffset" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] [[package]] name = "miniz_oxide" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" dependencies = [ "adler", ] [[package]] name = "mio" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4569e456d394deccd22ce1c1913e6ea0e54519f577285001215d33557431afe4" dependencies = [ "hermit-abi", "libc", "wasi", "windows-sys", ] [[package]] name = "object" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f203fa8daa7bb185f760ae12bd8e097f63d17041dcdcaf675ac54cdf863170e" dependencies = [ "memchr", ] [[package]] name = "once_cell" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "parking_lot" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", "windows-targets", ] [[package]] name = "pin-project-lite" version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "portable-atomic" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265" [[package]] name = "proc-macro2" version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" dependencies = [ "unicode-ident", ] [[package]] name = "pyo3" version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15ee168e30649f7f234c3d49ef5a7a6cbf5134289bc46c29ff3155fa3221c225" dependencies = [ "cfg-if", "indoc", "libc", "memoffset", "once_cell", "portable-atomic", "pyo3-build-config", "pyo3-ffi", "pyo3-macros", "unindent", ] [[package]] name = "pyo3-build-config" version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e61cef80755fe9e46bb8a0b8f20752ca7676dcc07a5277d8b7768c6172e529b3" dependencies = [ "once_cell", "target-lexicon", ] [[package]] name = "pyo3-ffi" version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67ce096073ec5405f5ee2b8b31f03a68e02aa10d5d4f565eca04acc41931fa1c" dependencies = [ "libc", "pyo3-build-config", ] [[package]] name = "pyo3-macros" version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2440c6d12bc8f3ae39f1e775266fa5122fd0c8891ce7520fa6048e683ad3de28" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", "syn", ] [[package]] name = "pyo3-macros-backend" version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1be962f0e06da8f8465729ea2cb71a416d2257dff56cbe40a70d3e62a93ae5d1" dependencies = [ "heck", "proc-macro2", "pyo3-build-config", "quote", "syn", ] [[package]] name = "quote" version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] [[package]] name = "redox_syscall" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" dependencies = [ "bitflags", ] [[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "signal-hook-registry" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ "libc", ] [[package]] name = "smallvec" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ "libc", "windows-sys", ] [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" version = "2.0.72" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "target-lexicon" version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tokio" version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", "bytes", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", "windows-sys", ] [[package]] name = "tokio-macros" version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unindent" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "xandikos" version = "0.2.11" dependencies = [ "clap", "pyo3", "tokio", ] xandikos-0.2.12/Cargo.toml000066400000000000000000000007041470075263100153520ustar00rootroot00000000000000[package] name = "xandikos" version = "0.2.12" authors = [ "Jelmer Vernooij ",] edition = "2021" license = "GPL-3.0+" description = "Lightweight CalDAV/CardDAV server" repository = "https://github.com/jelmer/xandikos.git" homepage = "https://github.com/jelmer/xandikos" [dependencies] clap = ">=4, <4.6" [dependencies.pyo3] version = "0.22" features = [ "auto-initialize",] [dependencies.tokio] version = "1" features = [ "full",] xandikos-0.2.12/Dockerfile000066400000000000000000000014651470075263100154210ustar00rootroot00000000000000# Docker file for Xandikos. # # Note that this dockerfile starts Xandikos without any authentication; # for authenticated access we recommend you run it behind a reverse proxy. FROM debian:sid-slim LABEL maintainer="jelmer@jelmer.uk" RUN apt-get update && \ apt-get -y install --no-install-recommends python3-icalendar python3-dulwich python3-jinja2 python3-defusedxml python3-aiohttp python3-vobject python3-aiohttp-openmetrics && \ apt-get clean && \ rm -rf /var/lib/apt/lists/ && \ groupadd -g 1000 xandikos && \ useradd -d /code -c Xandikos -g xandikos -M -s /bin/bash -u 1000 xandikos ADD . /code WORKDIR /code VOLUME /data EXPOSE 8000 USER xandikos ENTRYPOINT ["python3", "-m", "xandikos.web", "--port=8000", "--metrics-port=8001", "--listen-address=0.0.0.0", "-d", "/data"] CMD ["--defaults"] xandikos-0.2.12/GOALS.rst000066400000000000000000000004341470075263100150210ustar00rootroot00000000000000The goal of Xandikos is to be a simple CalDAV/CardDAV server for personal use: * easy to set up * use of plain .ics/.vcf files for storage * history stored in Git * clear separation between protocol implementation and storage * well tested * standards complete * standards compliant xandikos-0.2.12/MANIFEST.in000066400000000000000000000003501470075263100151550ustar00rootroot00000000000000include *.rst include AUTHORS include COPYING include README.rst include Makefile include compat/*.sh include compat/*.rst include compat/*.xml include compat/*.sha256sum include notes/*.rst include tox.ini graft examples graft man xandikos-0.2.12/Makefile000066400000000000000000000030171470075263100150620ustar00rootroot00000000000000export PYTHON ?= python3 COVERAGE ?= $(PYTHON) -m coverage COVERAGE_RUN_OPTIONS ?= COVERAGE_RUN ?= $(COVERAGE) run $(COVERAGE_RUN_OPTIONS) TESTSUITE = xandikos.tests.test_suite LITMUS_TESTS ?= basic http CALDAVTESTER_TESTS ?= CalDAV/delete.xml \ CalDAV/options.xml \ CalDAV/vtodos.xml XANDIKOS_COVERAGE ?= $(COVERAGE_RUN) -a --rcfile=$(shell pwd)/.coveragerc --source=xandikos -m xandikos.web check: $(PYTHON) -m unittest $(TESTSUITE) style: $(PYTHON) -m flake8 $(PYTHON) -m isort --check . typing: $(PYTHON) -m mypy xandikos web: $(PYTHON) -m xandikos.web check-litmus-all: ./compat/xandikos-litmus.sh "basic copymove http props locks" check-litmus: ./compat/xandikos-litmus.sh "${LITMUS_TESTS}" check-pycaldav: ./compat/xandikos-pycaldav.sh coverage-pycaldav: XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-pycaldav.sh coverage-litmus: XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-litmus.sh "${LITMUS_TESTS}" check-vdirsyncer: ./compat/xandikos-vdirsyncer.sh coverage-vdirsyncer: XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-vdirsyncer.sh check-all: check check-vdirsyncer check-litmus check-pycaldav style coverage-all: coverage coverage-litmus coverage-vdirsyncer coverage: $(COVERAGE_RUN) --source=xandikos -m unittest $(TESTSUITE) coverage-html: coverage $(COVERAGE) html docs: $(MAKE) -C docs html .PHONY: docs docker: buildah build -t jvernooij/xandikos -t ghcr.io/jelmer/xandikos . buildah push jvernooij/xandikos buildah push ghcr.io/jelmer/xandikos reformat: ruff format . xandikos-0.2.12/NEWS000066400000000000000000000042351470075263100141240ustar00rootroot000000000000000.2.12 UNRELEASED 0.2.11 2024-03-29 * Various build cleanups/fixes. (Jelmer Vernooij) * Add multi-arch docker builds. (Maya) * do not listen on default address if systemd sockets (schnusch) * Use correct port in kubernetes to not conflict with the metrics port (Marcel, #286) 0.2.10 2023-09-04 * Add support for systemd socket activation. (schnusch, #136, #155) * Add basic documentation. (Jelmer Vernooij) * Use entry points to install xandikos script. (Jelmer Vernooij, #163) * ``sync-collection``: handle invalid tokens. (Jelmer Vernooij) 0.2.8 2022-01-09 0.2.7 2021-12-27 * Add basic XMP property support. (Jelmer Vernooij) * Add a /health target. (Jelmer Vernooij) 0.2.6 2021-03-20 * Don't listen on TCP port (defautlting to 0.0.0.0) when a UNIX domain socket is specified. (schnusch, #134) 0.2.5 2021-02-18 * Fix support for uwsgi when environ['wsgi.input'].read() does not accept a size=None. (Jelmer Vernooij) 0.2.4 2021-02-16 * Wait for entire body to arrive. (Michael Alyn Miller, #129) 0.2.3 2020-07-25 * Fix handling of WSGI - not all versions of start_response take keyword arguments. (Jelmer Vernooij, #124) * Add --no-strict option for clients that don't follow the spec. (Jelmer Vernooij) * Add basic support for expanding RRULE. (Jelmer Vernooij, #8) * Add parsing support for CALDAV:schedule-tag property. (Jelmer Vernooij) * Fix support for HTTP Expect. (Jelmer Vernooij, #126) 0.2.2 2020-05-14 * Fix use of xandikos.wsgi module in uwsgi. (Jelmer Vernooij) 0.2.1 2020-05-06 * Add missing dependencies in setup.py. (Jelmer Vernooij) * Fix syntax errors in xandikos/store/vdir.py. (Unused, but breaks bytecompilation). (Jelmer Vernooij) 0.2.0 2020-05-04 * Fix subelement filtering. (Jelmer Vernooij) * Skip non-calendar files for calendar-query operations. (Jelmer Vernooij, #108) * Switch to using aiohttp rather than uWSGI. (Jelmer Vernooij) * Query component's SUMMARY in ICalendarFile.describe(). (Denis Laxalde) * Add /metrics support. (Jelmer Vernooij) * Drop support for Python 3.4, add support for 3.8. (Jelmer Vernooij) 0.1.0 2019-04-07 Initial release. xandikos-0.2.12/README.rst000066400000000000000000000140341470075263100151120ustar00rootroot00000000000000Xandikos is a lightweight yet complete CardDAV/CalDAV server that backs onto a Git repository. Xandikos (Ξανδικός or Ξανθικός) takes its name from the name of the March month in the ancient Macedonian calendar, used in Macedon in the first millennium BC. .. image:: logo.png :alt: Xandikos logo :width: 200px :align: center Extended documentation can be found `on the home page `_. Implemented standards ===================== The following standards are implemented: - :RFC:`4918`/:RFC:`2518` (Core WebDAV) - *implemented, except for COPY/MOVE/LOCK operations* - :RFC:`4791` (CalDAV) - *fully implemented* - :RFC:`6352` (CardDAV) - *fully implemented* - :RFC:`5397` (Current Principal) - *fully implemented* - :RFC:`3253` (Versioning Extensions) - *partially implemented, only the REPORT method and {DAV:}expand-property property* - :RFC:`3744` (Access Control) - *partially implemented* - :RFC:`5995` (POST to create members) - *fully implemented* - :RFC:`5689` (Extended MKCOL) - *fully implemented* - :RFC:`6578` (Collection Synchronization for WebDAV) - *fully implemented* The following standards are not implemented: - :RFC:`6638` (CalDAV Scheduling Extensions) - *not implemented* - :RFC:`7809` (CalDAV Time Zone Extensions) - *not implemented* - :RFC:`7529` (WebDAV Quota) - *not implemented* - :RFC:`4709` (WebDAV Mount) - `intentionally `_ *not implemented* - :RFC:`5546` (iCal iTIP) - *not implemented* - :RFC:`4324` (iCAL CAP) - *not implemented* - :RFC:`7953` (iCal AVAILABILITY) - *not implemented* See `DAV compliance `_ for more detail on specification compliancy. Limitations ----------- - No multi-user support - No support for CalDAV scheduling extensions Supported clients ================= Xandikos has been tested and works with the following CalDAV/CardDAV clients: - `Vdirsyncer `_ - `caldavzap `_/`carddavmate `_ - `evolution `_ - `DAVx5 `_ (formerly DAVDroid) - `sogo connector for Icedove/Thunderbird `_ - `aCALdav syncer for Android `_ - `pycardsyncer `_ - `akonadi `_ - `CalDAV-Sync `_ - `CardDAV-Sync `_ - `Calendarsync `_ - `Tasks `_ - `AgendaV `_ - `CardBook `_ - Apple's iOS - `homeassistant's CalDAV integration `_ Dependencies ============ At the moment, Xandikos supports Python 3 (see pyproject.toml for specific version) as well as Pypy 3. It also uses `Dulwich `_, `Jinja2 `_, `icalendar `_, and `defusedxml `_. E.g. to install those dependencies on Debian: .. code:: shell sudo apt install python3-dulwich python3-defusedxml python3-icalendar python3-jinja2 Or to install them using pip: .. code:: shell python setup.py develop Docker ------ A Dockerfile is also provided; see the comments on the top of the file for configuration instructions. The docker image is regularly built and published at ``ghcr.io/jelmer/xandikos``. See ``examples/docker-compose.yml`` and the `man page `_ for more info. Running ======= Xandikos can either directly listen on a plain HTTP socket, or it can sit behind a reverse HTTP proxy. Testing ------- To run a standalone (no authentication) instance of Xandikos, with a pre-created calendar and addressbook (storing data in *$HOME/dav*): .. code:: shell ./bin/xandikos --defaults -d $HOME/dav A server should now be listening on `localhost:8080 `_. Note that Xandikos does not create any collections unless --defaults is specified. You can also either create collections from your CalDAV/CardDAV client, or by creating git repositories under the *contacts* or *calendars* directories it has created. Production ---------- The easiest way to run Xandikos in production is by running a reverse HTTP proxy like Apache or nginx in front of it. The xandikos script can either listen on the local host on a particular port, or it can listen on a unix domain socket. For example init system configurations, see examples/. Client instructions =================== Some clients can automatically discover the calendars and addressbook URLs from a DAV server (if they support RFC:`5397`). For such clients you can simply provide the base URL to Xandikos during setup. Clients that lack such automated discovery require the direct URL to a calendar or addressbook. In this case you should provide the full URL to the calendar or addressbook; if you initialized Xandikos using the ``--defaults`` argument mentioned in the previous section, these URLs will look something like this:: http://dav.example.com/user/calendars/calendar http://dav.example.com/user/contacts/addressbook Contributing ============ Contributions to Xandikos are very welcome. If you run into bugs or have feature requests, please file issues `on GitHub `_. If you're interested in contributing code or documentation, please read `CONTRIBUTING `_. Issues that are good for new contributors are tagged `new-contributor `_ on GitHub. Help ==== There is a *#xandikos* IRC channel on the `OFTC `_ IRC network, and a `Xandikos `_ mailing list. xandikos-0.2.12/SECURITY.md000066400000000000000000000002611470075263100152110ustar00rootroot00000000000000# Security Policy ## Reporting a Vulnerability Please report security issues by e-mail to jelmer@jelmer.uk, ideally PGP encrypted to the key at https://jelmer.uk/D729A457.asc xandikos-0.2.12/SUPPORT.md000066400000000000000000000002501470075263100151140ustar00rootroot00000000000000There is a *#xandikos* IRC channel on the [OFTC](https://www.oftc.net/). IRC network, and a [Xandikos](https://groups.google.com/forum/#!forum/xandikos>) mailing list. xandikos-0.2.12/bin/000077500000000000000000000000001470075263100141715ustar00rootroot00000000000000xandikos-0.2.12/bin/xandikos000077500000000000000000000021031470075263100157330ustar00rootroot00000000000000#!/usr/bin/env python3 # Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 2 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import asyncio import os import sys # running from source dir? if os.path.join(os.path.dirname(__file__), "..", "xandikos"): sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from xandikos.__main__ import main sys.exit(asyncio.run(main(sys.argv[1:]))) xandikos-0.2.12/compat/000077500000000000000000000000001470075263100147045ustar00rootroot00000000000000xandikos-0.2.12/compat/.gitignore000066400000000000000000000000461470075263100166740ustar00rootroot00000000000000litmus-*.tar.gz vdirsyncer/ pycaldav/ xandikos-0.2.12/compat/README.rst000066400000000000000000000003641470075263100163760ustar00rootroot00000000000000This directory contains scripts to run external CalDAV/CardDAV/WebDAV testsuites against the Xandikos web server. Currently supported: - `Vdirsyncer `_ - `litmus `_ xandikos-0.2.12/compat/common.sh000066400000000000000000000014321470075263100165300ustar00rootroot00000000000000#!/bin/bash # Common functions for running xandikos in compat tests XANDIKOS_PID= DAEMON_LOG=$(mktemp) SERVEDIR=$(mktemp -d) if [ -z "${XANDIKOS}" ]; then XANDIKOS=$(dirname $0)/../bin/xandikos fi set -e xandikos_cleanup() { [ -z ${XANDIKOS_PID} ] || kill -INT ${XANDIKOS_PID} rm --preserve-root -rf ${SERVEDIR} cat ${DAEMON_LOG} wait ${XANDIKOS_PID} || true } run_xandikos() { PORT="$1" METRICS_PORT="$2" shift 2 echo "Writing daemon log to $DAEMON_LOG" ${XANDIKOS} --no-detect-systemd --port=${PORT} --metrics-port=${METRICS_PORT} -llocalhost -d ${SERVEDIR} "$@" 2>&1 >$DAEMON_LOG & XANDIKOS_PID=$! trap xandikos_cleanup 0 EXIT i=0 while [ $i -lt 50 ] do if [ "$(curl http://localhost:${METRICS_PORT}/health)" = "ok" ]; then break fi sleep 1 let i+=1 done } xandikos-0.2.12/compat/litmus-0.13.tar.gz.sha256sum000066400000000000000000000001251470075263100214610ustar00rootroot0000000000000009d615958121706444db67e09c40df5f753ccf1fa14846fdeb439298aa9ac3ff litmus-0.13.tar.gz xandikos-0.2.12/compat/litmus.sh000077500000000000000000000013121470075263100165550ustar00rootroot00000000000000#!/bin/bash -e URL="$1" if [ -z "$URL" ]; then echo "Usage: $0 URL" exit 1 fi if [ -n "$TESTS" ]; then TEST_ARG=TESTS="$TESTS" fi SRCPATH="$(dirname $(readlink -m $0))" VERSION=${LITMUS_VERSION:-0.13} LITMUS_URL="${LITMUS_URL:-http://www.webdav.org/neon/litmus/litmus-${VERSION}.tar.gz}" scratch=$(mktemp -d) function finish() { rm -rf "${scratch}" } trap finish EXIT pushd "${scratch}" if [ -f "${SRCPATH}/litmus-${VERSION}.tar.gz" ]; then cp "${SRCPATH}/litmus-${VERSION}.tar.gz" . else wget -O "litmus-${VERSION}.tar.gz" "${LITMUS_URL}" fi sha256sum ${SRCPATH}/litmus-${VERSION}.tar.gz.sha256sum tar xvfz litmus-${VERSION}.tar.gz pushd litmus-${VERSION} ./configure make make URL="$URL" $TEST_ARG check xandikos-0.2.12/compat/serverinfo.xml000066400000000000000000000500321470075263100176100ustar00rootroot00000000000000 localhost 5233 8443 basic 120 0.25 caldav no-duplicate-uids ctag $multistatus-response-prefix: /{DAV:}multistatus/{DAV:}response $multistatus-href-prefix: /{DAV:}multistatus/{DAV:}response/{DAV:}href $verify-response-prefix: {DAV:}response/{DAV:}propstat/{DAV:}prop $verify-property-prefix: /{DAV:}multistatus/{DAV:}response/{DAV:}propstat/{DAV:}prop $verify-bad-response: /{DAV:}multistatus/{DAV:}response/{DAV:}status $verify-error-response: /{DAV:}multistatus/{DAV:}response/{DAV:}error $CALDAV: urn:ietf:params:xml:ns:caldav $CARDDAV: urn:ietf:params:xml:ns:carddav $CS: http://calendarserver.org/ns/ $root: / $principalcollection: $root:principals/ $uidstype: __uids__ $userstype: users $groupstype: groups $locationstype: locations $resourcestype: resources $principals_uids: $principalcollection:$uidstype:/ $principals_users: $principalcollection:$userstype:/ $principals_groups: $principalcollection:$groupstype:/ $principals_resources: $principalcollection:$resourcestype:/ $principals_locations: $principalcollection:$locationstype:/ $calendars: $root:calendars/ $calendars_uids: $calendars:$uidstype:/ $calendars_users: $calendars:$userstype:/ $calendars_groups: $calendars:$groupstype:/ $calendars_resources: $calendars:$resourcestype:/ $calendars_locations: $calendars:$locationstype:/ $calendar: calendar $tasks: tasks $polls: polls $inbox: inbox $outbox: outbox $dropbox: dropbox $attachments: dropbox $notification: notification $freebusy: freebusy $servertoserver: $root:inbox $timezoneservice: $root:timezones $timezonestdservice: $root:stdtimezones $addressbooks: $root:addressbooks/ $addressbooks_uids: $addressbooks:$uidstype:/ $addressbooks_users: $addressbooks:$userstype:/ $addressbooks_groups: $addressbooks:$groupstype:/ $addressbook: addressbook $directory: $root:directory/ $add-member: ;add-member $useradmin: admin $useradminguid: admin $pswdadmin: admin $principal_admin: $principals_users:$useradmin:/ $principaluri_admin: $principals_uids:$useradminguid:/ $userapprentice: apprentice $userapprenticeguid: apprentice $pswdapprentice: apprentice $principal_apprentice: $principals_users:$userapprentice:/ $principaluri_apprentice: $principals_uids:$userapprenticeguid:/ $userproxy: superuser $pswdproxy: superuser $userid%d: user%02d $userguid%d: user%02d $username%d: User %02d $username-encoded%d: User%%20%02d $firstname%d: User $lastname%d: %02d $pswd%d: user%02d $principal%d: $principals_users:$userid%d:/ $principaluri%d: $principals_uids:$userguid%d:/ $principal%dnoslash: $principals_users:$userid%d: $calendarhome%d: $calendars_uids:$userguid%d: $calendarhomealt%d: $calendars_users:$userid%d: $calendarpath%d: $calendarhome%d:/$calendar: $calendarpathalt%d: $calendarhomealt%d:/$calendar: $taskspath%d: $calendarhome%d:/$tasks: $pollspath%d: $calendarhome%d:/$polls: $inboxpath%d: $calendarhome%d:/$inbox: $outboxpath%d: $calendarhome%d:/$outbox: $dropboxpath%d: $calendarhome%d:/$dropbox: $notificationpath%d: $calendarhome%d:/$notification: $freebusypath%d: $calendarhome%d:/$freebusy: $email%d: $userid%d:@example.com $cuaddr%d: mailto:$email%d: $cuaddralt%d: $principaluri%d: $cuaddraltnoslash%d: $principals_uids:$userguid%d: $cuaddrurn%d: urn:uuid:$userguid%d: $addressbookhome%d: $addressbooks_uids:$userguid%d: $addressbookpath%d: $addressbookhome%d:/$addressbook: $publicuserid%d: public%02d $publicuserguid%d: public%02d $publicusername%d: Public %02d $publicpswd%d: public%02d $publicprincipal%d: $principals_users:$publicuserid%d:/ $publicprincipaluri%d: $principals_uids:$publicuserguid%d:/ $publiccalendarhome%d: $calendars_uids:$publicuserguid%d: $publiccalendarpath%d: $calendars_uids:$publicuserguid%d:/$calendar: $publicemail%d: $publicuserid%d:@example.com $publiccuaddr%d: mailto:$publicemail%d: $publiccuaddralt%d: $publicprincipaluri%d: $publiccuaddrurn%d: urn:uuid:$publicuserguid%d: $resourceid%d: resource%02d $resourceguid%d: resource%02d $resourcename%d: Resource %02d $rcalendarhome%d: $calendars_uids:$resourceguid%d: $rcalendarpath%d: $calendars_uids:$resourceguid%d:/$calendar: $rinboxpath%d: $calendars_uids:$resourceguid%d:/$inbox: $routboxpath%d: $calendars_uids:$resourceguid%d:/$outbox: $rprincipal%d: $principals_resources:$resourceid%d:/ $rprincipaluri%d: $principals_uids:$resourceguid%d:/ $rcuaddralt%d: $rprincipaluri%d: $rcuaddrurn%d: urn:uuid:$resourceguid%d: $locationid%d: location%02d $locationguid%d: location%02d $locationname%d: Location %02d $lcalendarhome%d: $calendars_uids:$locationguid%d: $lcalendarpath%d: $calendars_uids:$locationguid%d:/$calendar: $linboxpath%d: $calendars_uids:$locationguid%d:/$inbox: $loutboxpath%d: $calendars_uids:$locationguid%d:/$outbox: $lprincipal%d: $principals_resources:$locationid%d:/ $lprincipaluri%d: $principals_uids:$locationguid%d:/ $lcuaddralt%d: $lprincipaluri%d: $lcuaddrurn%d: urn:uuid:$locationguid%d: $groupid%d: group%02d $groupguid%d: group%02d $groupname%d: Group %02d $gprincipal%d: $principals_resources:$groupid%d:/ $gprincipaluri%d: $principals_uids:$groupguid%d:/ $gcuaddralt%d: $gprincipaluri%d: $gcuaddrurn%d: urn:uuid:$groupguid%d: $i18nid: i18nuser $i18nguid: i18nuser $i18nname: まだ $i18npswd: i18nuser $i18ncalendarpath: $calendars_uids:$i18nguid:/$calendar: $i18nemail: $i18nid:@example.com $i18ncuaddr: mailto:$i18nemail: $i18ncuaddrurn: urn:uuid:$i18nguid: $principaldisabled: $principals_groups:disabledgroup/ $principaluridisabled: $principals_uids:disabledgroup/ $cuaddrdisabled: $principals_uids:disabledgroup/ $cuaddr2: MAILTO:$email2: xandikos-0.2.12/compat/xandikos-litmus.sh000077500000000000000000000004221470075263100203740ustar00rootroot00000000000000#!/bin/bash -x # Run litmus against xandikos . $(dirname $0)/common.sh TESTS="$1" set -e run_xandikos 5233 5234 --autocreate if which litmus >/dev/null; then LITMUS=litmus else LITMUS="$(dirname $0)/litmus.sh" fi TESTS="$TESTS" $LITMUS http://localhost:5233/ exit 0 xandikos-0.2.12/compat/xandikos-pycaldav.sh000077500000000000000000000015741470075263100206730ustar00rootroot00000000000000#!/bin/bash # Run python-caldav tests against Xandikos. set -e . $(dirname $0)/common.sh BRANCH=master PYCALDAV_REF=v1.2.1 if [ ! -d $(dirname $0)/pycaldav ]; then git clone --branch $PYCALDAV_REF https://github.com/python-caldav/caldav $(dirname $0)/pycaldav else pushd $(dirname $0)/pycaldav git fetch origin git reset --hard $PYCALDAV_REF popd fi cat <$(dirname $0)/pycaldav/tests/conf_private.py # Only run tests against my private caldav servers. only_private = True caldav_servers = [ {'url': 'http://localhost:5233/', # Until recurring support is added in xandikos. # See https://github.com/jelmer/xandikos/issues/102 'incompatibilities': ['no_expand', 'no_recurring', 'no_scheduling', 'text_search_not_working'], } ] EOF run_xandikos 5233 5234 --defaults pushd $(dirname $0)/pycaldav ${PYTHON:-python3} -m pytest tests "$@" popd xandikos-0.2.12/compat/xandikos-vdirsyncer.sh000077500000000000000000000021401470075263100212460ustar00rootroot00000000000000#!/bin/bash . $(dirname $0)/common.sh set -e readonly BRANCH=master run_xandikos 5001 --autocreate [ -z "$PYTHON" ] && PYTHON=python3 cd "$(dirname $0)" REPO_DIR="$(readlink -f ..)" if [ ! -d vdirsyncer ]; then git clone -b $BRANCH https://github.com/pimutils/vdirsyncer else pushd vdirsyncer git pull --ff-only origin $BRANCH popd fi cd vdirsyncer if [ -z "${VIRTUAL_ENV}" ]; then virtualenv venv -p${PYTHON} source venv/bin/activate export PYTHONPATH=${REPO_DIR} pushd ${REPO_DIR} && ${PYTHON} setup.py develop && popd fi if [ -z "${CARGO_HOME}" ]; then export CARGO_HOME="$(readlink -f .)/cargo" export RUSTUP_HOME="$(readlink -f .)/cargo" fi curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly --no-modify-path . ${CARGO_HOME}/env rustup update nightly # Add --ignore=tests/system/utils/test_main.py since it fails in travis, # and isn't testing anything relevant to Xandikos. make \ PYTEST_ARGS="${PYTEST_ARGS} tests/storage/dav/ --ignore=tests/system/utils/test_main.py" \ DAV_SERVER=xandikos \ install-dev install-test test exit 0 xandikos-0.2.12/disperse.conf000066400000000000000000000006571470075263100161160ustar00rootroot00000000000000# See https://github.com/jelmer/disperse name: "xandikos" news_file: "NEWS" timeout_days: 5 tag_name: "v$VERSION" github_url: "https://github.com/jelmer/xandikos" verify_command: "make check" update_version { path: "xandikos/__init__.py" match: "^__version__ = \((.*)\)$" new_line: "__version__ = $TUPLED_VERSION" } update_version { path: "Cargo.toml" match: "^version = \"(.*)\"$" new_line: "version = \"$VERSION\"" } xandikos-0.2.12/docs/000077500000000000000000000000001470075263100143515ustar00rootroot00000000000000xandikos-0.2.12/docs/Makefile000066400000000000000000000007471470075263100160210ustar00rootroot00000000000000SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) xandikos-0.2.12/docs/source/000077500000000000000000000000001470075263100156515ustar00rootroot00000000000000xandikos-0.2.12/docs/source/clients.rst000066400000000000000000000020131470075263100200400ustar00rootroot00000000000000Configuring Clients =================== Xandikos supports ``auto-discovery`` of DAV collections (i.e. calendars or addressbooks). Most clients today do as well, but there are some exceptions. This section contains basic instructions on how to use various clients with Xandikos. Please do send us patches if your favourite client is missing. Evolution --------- Select "CardDAV" (address books) or "CalDAV" (calendars) as the type when adding a new account. Simplify provide the root URL of your Xandikos instance. Hit the "Find Addressbooks" or "Find Calenders" button and Evolution will prompt for credentials and show you a list of all relevant calendars or addressbooks. DAVx5 -------- vdirsyncer ---------- sogo connector for Icedove/Thunderbird -------------------------------------- caldavzap/carddavmate --------------------- pycardsyncer ------------ akonadi ------- CalDAV-Sync ----------- CardDAV-Sync ------------ Calendarsync ------------ AgendaV ------- CardBook -------- Tasks ----- Apple iOS --------- xandikos-0.2.12/docs/source/conf.py000066400000000000000000000034451470075263100171560ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # 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('.')) # -- Project information ----------------------------------------------------- project = "Xandikos" copyright = "2022 Jelmer Vernooij et al" author = "Jelmer Vernooij" # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] # -- 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 = "furo" # 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"] xandikos-0.2.12/docs/source/getting-started.rst000066400000000000000000000032251470075263100215120ustar00rootroot00000000000000.. _getting-started: Getting Started =============== Xandikos can either be run in a container (e.g. in docker or Kubernetes) or outside of a container. It is recommended that you run it behind a reverse proxy, since Xandikos by itself does not provide authentication support. See :ref:`reverse-proxy` for details. Running from systemd -------------------- Xandikos supports socket activation through systemd. To use systemd, run something like: .. code-block:: shell cp examples/xandikos.{socket,service} /etc/systemd/system systemctl daemon-reload systemctl enable xandikos.socket Running from docker ------------------- There is a docker image that gets regularly updated at ``ghcr.io/jelmer/xandikos``. If you use docker-compose, see the example configuration in ``examples/docker-compose.yml``. To run in docker interactively, try something like: .. code-block:: shell mkdir /tmp/xandikos docker run -it -v /tmp/xandikos:/data -p8000:8000 ghcr.io/jelmer/xandikos The following environment variables are supported by the docker image: * ``CURRENT_USER_PRINCIPAL``: path to current user principal; defaults to "/$USER" * ``AUTOCREATE``: whether to automatically create missing directories ("defaults", "empty") * ``ROUTE_PREFIX``: HTTP prefix under which Xandikos should run Running from kubernetes ----------------------- Here is an example configuration for running Xandikos in kubernetes: .. literalinclude:: ../../examples/xandikos.k8s.yaml :language: yaml If you're using the prometheus operator, you may want also want to use this service monitor: .. literalinclude:: ../../examples/xandikos-servicemonitor.k8s.yaml :language: yaml xandikos-0.2.12/docs/source/index.rst000066400000000000000000000003241470075263100175110ustar00rootroot00000000000000Xandikos ======== .. toctree:: :maxdepth: 2 :caption: Contents: getting-started reverse-proxy clients troubleshooting Indices and tables ================== * :ref:`genindex` * :ref:`search` xandikos-0.2.12/docs/source/reverse-proxy.rst000066400000000000000000000024331470075263100212370ustar00rootroot00000000000000.. _reverse-proxy: Running behind a reverse proxy ============================== By default, Xandikos does not provide any authentication support. Instead, it is recommended that it is run behind a reverse HTTP proxy that does. The author has used both nginx and Apache in front of Xandikos, but any reverse HTTP proxy should do. If you expose Xandikos at the root of a domain, no further configuration is necessary. When exposing it on a different path prefix, make sure to set the ``--route-prefix`` argument to Xandikos appropriately. .well-known ----------- When serving Xandikos on a prefix, you may still want to provide the appropriate ``.well-known`` files at the root so that clients can find the DAV server without having to specify the subprefix. For this to work, reverse proxy the ``.well-known/carddav`` and ``.well-known/caldav`` files to Xandikos. Example: Kubernetes ingress --------------------------- Here is an example configuring Xandikos to listen on ``/dav`` using the Kubernetes nginx ingress controller. Note that this relies on the appropriate server being set up in kubernetes (see :ref:`getting-started`) and the ``my-htpasswd`` secret being present and having a htpasswd like file in it. .. literalinclude:: ../../examples/xandikos-ingress.k8s.yaml :language: yaml xandikos-0.2.12/docs/source/troubleshooting.rst000066400000000000000000000022111470075263100216260ustar00rootroot00000000000000Troubleshooting =============== Support channels ---------------- For help, please try the `Xandikos Discussions Forum `_, IRC (``#xandikos`` on irc.oftc.net), or Matrix (`#xandikos:matrix.org `_). Debugging \*DAV --------------- Your client may have a way of increasing log verbosity; this can often be very helpful. Xandikos also has several command-line flags that may help with debugging: * ``--dump-dav-xml``: Write all \*DAV communication to standard out; interpreting the contents may require in-depth \*DAV knowledge, but providing this data is usually sufficient for one of the Xandikos developers to identify the cause of an issue. * ``--no-strict``: Don't follow a strict interpretation of the various standards, for clients that don't follow them. * ``--debug``: Print extra information about Xandikos' internal state. If you do find that a particular server requires ``--no-strict``, please do report it - either to the servers' authors or in the [Xandikos Discussions](https://github.com/jelmer/xandikos/discussions). xandikos-0.2.12/examples/000077500000000000000000000000001470075263100152375ustar00rootroot00000000000000xandikos-0.2.12/examples/docker-compose.yml000066400000000000000000000002641470075263100206760ustar00rootroot00000000000000version: "3.4" services: xandikos: image: ghcr.io/jelmer/xandikos ports: - 8000:8000 volumes: - /path/to/xandikos/data:/data restart: unless-stopped xandikos-0.2.12/examples/gunicorn.conf.py000066400000000000000000000016101470075263100203570ustar00rootroot00000000000000# Gunicorn config file # # Usage # ---------------------------------------------------------- # # Install: 1) copy this config to src directory for xandikos # 2) run 'pip install gunicorn' # 3) mkdir logs && mkdir data # # Execute: 'gunicorn' # wsgi_app = "xandikos.wsgi:app" # Server Mechanics # ======================================== # daemon mode daemon = False # enviroment variables raw_env = [ "XANDIKOSPATH=./data", "CURRENT_USER_PRINCIPAL=/user/", "AUTOCREATE=defaults", ] # Server Socket # ======================================== bind = "0.0.0.0:8000" # Worker Processes # ======================================== workers = 2 # Logging # ======================================== # access log accesslog = "./logs/access.log" access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' # gunicorn log errorlog = "-" loglevel = "info" xandikos-0.2.12/examples/uwsgi-heroku.ini000066400000000000000000000004601470075263100203710ustar00rootroot00000000000000[uwsgi] http-socket = :$(PORT) die-on-term = true umask = 022 master = true cheaper = 0 processes = 1 plugin = router_basicauth,python3 route = ^/ basicauth:myrealm,user1:password1 module = xandikos.wsgi:app env = XANDIKOSPATH=$HOME/dav env = CURRENT_USER_PRINCIPAL=/dav/user1/ env = AUTOCREATE=defaults xandikos-0.2.12/examples/uwsgi-standalone.ini000066400000000000000000000010731470075263100212250ustar00rootroot00000000000000[uwsgi] http-socket = 127.0.0.1:8080 umask = 022 master = true cheaper = 0 processes = 1 plugin = router_basicauth,python3 route = ^/ basicauth:myrealm,user1:password1 module = xandikos.wsgi:app env = XANDIKOSPATH=$HOME/dav env = CURRENT_USER_PRINCIPAL=/dav/user1/ # Set AUTOCREATE to have Xandikos create default CalDAV/CardDAV # collections if they don't yet exist. Possible values: # - principal: just create the current user principal # - defaults: create the principal and default calendar and contacts # collections. (recommended) env = AUTOCREATE=defaults xandikos-0.2.12/examples/uwsgi.ini000066400000000000000000000010451470075263100170760ustar00rootroot00000000000000[uwsgi] socket = 127.0.0.1:8001 uid = xandikos gid = xandikos master = true cheaper = 0 processes = 1 plugin = python3 module = xandikos.wsgi:app umask = 022 env = XANDIKOSPATH=/var/lib/xandikos/collections env = CURRENT_USER_PRINCIPAL=/user/ # Set AUTOCREATE to have Xandikos create default CalDAV/CardDAV # collections if they don't yet exist. Possible values: # - principal: just create the current user principal # - defaults: create the principal and default calendar and contacts # collections. (recommended) env = AUTOCREATE=defaults xandikos-0.2.12/examples/xandikos-ingress.k8s.yaml000066400000000000000000000017731470075263100221270ustar00rootroot00000000000000apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: xandikos annotations: nginx.ingress.kubernetes.io/auth-type: basic nginx.ingress.kubernetes.io/auth-secret: my-htpasswd nginx.ingress.kubernetes.io/auth-realm: 'Authentication Required - mysite' spec: ingressClassName: nginx rules: - host: example.com http: paths: - backend: service: name: xandikos port: name: web path: /dav(/|$)(.*) pathType: Prefix --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: xandikos-wellknown spec: ingressClassName: nginx rules: - host: example.com http: paths: - backend: service: name: xandikos port: name: web path: /.well-known/carddav pathType: Exact - backend: service: name: xandikos port: name: web path: /.well-known/caldav pathType: Exact xandikos-0.2.12/examples/xandikos-servicemonitor.k8s.yaml000066400000000000000000000003071470075263100235150ustar00rootroot00000000000000--- apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: name: xandikos labels: app: xandikos spec: selector: matchLabels: app: xandikos endpoints: - port: web xandikos-0.2.12/examples/xandikos.avahi.service000066400000000000000000000007461470075263100215370ustar00rootroot00000000000000 Xandikos CalDAV/CardDAV server on %h _caldavs._tcp 443 _carddavs._tcp 443 xandikos-0.2.12/examples/xandikos.example000066400000000000000000000001551470075263100204350ustar00rootroot00000000000000# This an example .xandikos file. # The color for this collection is red color = FF0000 inbox-url = inbox/ xandikos-0.2.12/examples/xandikos.k8s.yaml000066400000000000000000000027461470075263100204600ustar00rootroot00000000000000--- apiVersion: apps/v1 kind: Deployment metadata: name: xandikos spec: strategy: rollingUpdate: maxSurge: 1 maxUnavailable: 1 type: RollingUpdate replicas: 1 selector: matchLabels: app: xandikos template: metadata: labels: app: xandikos spec: containers: - name: xandikos image: ghcr.io/jelmer/xandikos imagePullPolicy: Always command: - "python3" - "-m" - "xandikos.web" - "--port=8080" - "-d/data" - "--defaults" - "--listen-address=0.0.0.0" - "--current-user-principal=/jelmer" - "--route-prefix=/dav" resources: limits: cpu: "2" memory: "2Gi" requests: cpu: "0.1" memory: "10M" livenessProbe: httpGet: path: /health port: 8081 initialDelaySeconds: 30 periodSeconds: 3 timeoutSeconds: 90 ports: - containerPort: 8081 volumeMounts: - name: xandikos-volume mountPath: /data securityContext: fsGroup: 1000 volumes: - name: xandikos-volume persistentVolumeClaim: claimName: xandikos --- apiVersion: v1 kind: Service metadata: name: xandikos labels: app: xandikos spec: ports: - port: 8080 name: web selector: app: xandikos type: ClusterIP xandikos-0.2.12/examples/xandikos.nginx.conf000066400000000000000000000024071470075263100210530ustar00rootroot00000000000000upstream xandikos { server 127.0.0.1:8080; # server unix:/run/xandikos.socket; # nginx will need write permissions here } server { server_name dav.example.com; # Service discovery, see RFC 6764 location = /.well-known/caldav { return 307 $scheme://$host/user/calendars; } location = /.well-known/carddav { return 307 $scheme://$host/user/contacts; } location / { proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_redirect off; proxy_buffering off; proxy_pass http://xandikos; auth_basic "Login required"; auth_basic_user_file /etc/xandikos/htpasswd; } listen 443 ssl http2; listen [::]:443 ssl ipv6only=on http2; # use e.g. Certbot to have these modified: ssl_certificate /etc/letsencrypt/live/dav.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/dav.example.com/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; } server { if ($host = dav.example.com) { return 301 https://$host$request_uri; } listen 80 http2; listen [::]:80 http2; server_name dav.example.com; return 404; } xandikos-0.2.12/examples/xandikos.service000066400000000000000000000004501470075263100204400ustar00rootroot00000000000000[Unit] Description=Xandikos CalDAV/CardDAV server After=network.target [Service] ExecStart=/usr/local/bin/xandikos \ -d /var/lib/xandikos \ --route-prefix=/dav \ --current-user-principal=/jelmer \ -l /run/sock User=xandikos Group=www-data Restart=on-failure Type=simple NotifyAccess=all xandikos-0.2.12/examples/xandikos.socket000066400000000000000000000001601470075263100202660ustar00rootroot00000000000000[Unit] Description=Xandikos socket [Socket] ListenStream=/run/xandikos.sock [Install] WantedBy=sockets.target xandikos-0.2.12/grafana-dashboard.json000066400000000000000000000040731470075263100176440ustar00rootroot00000000000000{ "__inputs": [ { "name": "DS_PROMETHEUS", "label": "Prometheus", "description": "", "type": "datasource", "pluginId": "prometheus", "pluginName": "Prometheus" } ], "annotations": { "list": [ { "builtIn": 1, "datasource": "-- Grafana --", "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" } ] }, "editable": true, "gnetId": null, "graphTooltip": 0, "id": 10286, "links": [], "panels": [ { "datasource": "${DS_PROMETHEUS}", "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "gridPos": { "h": 9, "w": 12, "x": 0, "y": 0 }, "id": 2, "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false }, "text": {}, "textMode": "auto" }, "pluginVersion": "7.5.11", "targets": [ { "exemplar": true, "expr": "up{job=\"xandikos\"}", "interval": "", "legendFormat": "", "refId": "A" } ], "title": "Health", "type": "stat" } ], "schemaVersion": 27, "style": "dark", "tags": [], "templating": { "list": [] }, "time": { "from": "now-6h", "to": "now" }, "timepicker": {}, "timezone": "", "title": "Xandikos", "uid": "k7dunuVVk", "version": 2 } xandikos-0.2.12/logo-alt.png000066400000000000000000010704431470075263100156560ustar00rootroot00000000000000PNG  IHDR cHRMz&u0`:pQ<bKGDIDATxy,[UڻsϽM(`D@)bԈhbT$ 1NlbTEEāQxo9k~ݻSOۧj]{XwuBDj~@Z?5>JQT.QZUEƶ 61Vkը9 "Z+0,:,{?E:9fVRdUε5!"D$"ukvO;(52""fqQs@keuɶrADIb:Tp]o-:lqɶ۲.D$"k7w i8i@D̩ntvC;"FQ7;A.PK@Dǫdԛ惙H״mCmb<"L\vg74^-eg,Z%;Uu۳W0l7!n8:[+#sHm"PƠm\:GU.սv*N,[$:=BA],h4Z3)wp]7sZ66o#TK$Mnd9m7&(tP.ŘVT(ί a>i \,ZI8vh v u#/€0v{:䁈\s4SCCIX3|-$˶?:ta7S85Z8 Z~X)tRh#n`cСif)ڱݨ92 )up'y}߶>j")۶bF;ޡZl8u>tcۡ +zfQw=`ݢ#U9*v[6Vzpob:lS7GO>GQlQ%_qf-Z-jj3S:u zOAsaY^ۋ09Z;wLtU;t1Yp]Jo2<"cvy "FIhۡ!hQ \X趂:NZ6R^-emo;*[v[^0acBӸ+W%bg𛏂8E|! 0/MFuyX|rZkݩjmkQSPj7mmD:ߢ qw !h^Q6 Sg9j c[: DQ4v :F%)RuǪEXy?r:ta9vk)z[pW`9ƀsFj M9 xJ8%{(軾q| P6)J:Wa?NJ}ն;aOlƍ5rl=y1Uj:Ena+"ulS w/ypOaߋK]n)P):tb g}Kݳ4ڜP|j?loB\ې0'a //.eq m(5Vv:-彴p[[S Cyj{ 9 9Y͹˚stX!OE¹Dʣ- F톌ώ$D1^sΠW6;ء>̚\u7v)e^'i$FXNBf Uv)ޕZWV.dP+BdG8:Q"ûVVKajS }oTNš 8$e;ǭC{4 @ WhZ#L;mBIހl +"ܠ6;ŻYL6Z^wlQ_V:|c=n.=Lm|+T$lrss0EDS4iqvm_۵рJݷ^O7- i}8kCq,N->BTd- SeСNb[gwvP-5¼F!{{F4JۡA-A7pyM^O;tȮ2S7eG iӖdgĪ”P`)L D֚ɴuLKZRzS"# n>_'S%ܯ/k6Rd ׬:7 U-UmEWKYkp4aaTxn;iB?opYk sQ ILgYDT@F76BX{Ӵ)ڝj<5Dٱr:tQ tRuC͠ol&QIYh "bLj1u[4|WFaRZkBCQm~fj2ͥicMʟA5qgSu@D~:LkE:(H.*o%zsD¢cE{~(MLIT;跎0Rp~#P佼PٶI8$1 )QwA "q'hG Ao,|M{`n]"*_1({B53!TBBS~Tfh8j;L.ߠsFDkm *;.vǧ>)"L2Sw#N~)*hJuQG\1a&V.¤i`T"3uCQ*^ mX?ּ*oj "J:よZ4 ^.C)=6Cܼ"B,,iRPvPV+}0 ZW/[ LΫs,|UuӦK\`0(EOIc ijW)nzyT,7ƬVѻ9Ty s/I8Vu\(k!tVxk" &UNN0""X|Q?< pL&0SHD(FU;.yO$z<:ci}dZfh4J[)>ڈ D;KDV V&VD]~3 iL  Kc(eɼ ɫfK:x$,EDٕ$ag]dgQydA/w-}y1y!n2,ͫƕmUu9y/{0Xaً7J/GhO^`6tb@AO\m U.Ν uV] zRo.YGLb@uB3@7} H+{c5\.$DHA8mp;S{敵h׊>~ڪ%}oj1> e}HĤo93 O{Dpsz5IU[h_"bukl릟x/|֫IDn tȃ0ƺ 6%y{ 4S\'~e<(rnWh[w1fwr`B 1';Ma拸Mq :pI-12pjN1 yA7&s6|)\m[E<~:ea'峮n(8ފF%DT*\1l&j5Z'4멹BUc"ݛ?zwv(0veuF:ta.vVy}DU@>k/4ƆS>;g/(?&[ۥu HePXf`wc'+6 @$Y~:ߢSC.wSͫ'{xv7_X we83_ ,-SӲa``K~cAxAGbh&oDB^Uk&N[m#]_.qu3L,8VX>>gig=SDک-NTNsNwݖv^.]۱!1%s:tNٮ'Y: @BKd+dMO"6..e mYSk2%c}PVtD WߦY8viV;c9armٳbL;xx3^7DűNXJ95ֶ;vC:,B^RZwLۂt DK'kn\w3pI1dU`.CY4:mpb%)b˝ݡCprj .wt5>n#dNBd}gw~Oݙ wuL0|^Ίs ##M{fԇnp䊝܏bGYEN,^r)ʪ.žfPa!E"b|LۛyzMvK,/FlVCi[e8˟0Ru9:6q$+ s szj9A߼f˻k[(6G? nTNKBXH^toAi=wgnZm VۡC+J)'AYgY%Xk]<v h4JtPΆ ]x Š;2sG.6`6*B:@vZ!; lXH-X7'P@1Ϊ SJ[? uH ܡإSYu;n H`[(EUcY;B uņ+iCntQ 7nF5=)5f1NIƘ(ZA8NСVpsLڳ_[`12۵@6dvGO#p5ʭjp3ܫņCZoNMt3qNw@#TyAԧrm-ͨ8n8ZZ(C*o]MVǙu+W[s;[@N[٘Op(r7㫃Vhgպ2 6i#% 6֪UƐm2Ό<:wtaKU@KmMX 5qW"8`Fj sW^3wHFĬ(՞)8/j/rׅo |.5N9J>|Ë`a{yu9J_kPu2,( ϑJi(A,-TeO)fli@YB6uRȫp9uh5pA1&W5"*EIb [.SY 'aQ  N&YEQ;h4mp*%fd^С sc\{ӨWqRgyvS"t[wzm;cI~@DXRiiMOOWݴS+UUy:4 1>V>֍!N(gӲhEj *t>O<A8UW__g^O1"bLuTuĦ{5з=B[6f +{K<pR 3ËQwE3'@Zycwp?ݙWxO?YE}tuc)y[PG.bbXPc1Qu> )5XݖtSh(!Ԥ;ҜェW(6Ykə':r믵rEaVe{ѡntbxC5jLauRWrh+n5ZԡCv[p(^Y᮰8&8D7]'0B CDt)m֦]Eta]LŲ/&bkujgi\1c1S'xTvww8 XZ7[7 !󸁍FL/{+h5$Z^JcgG8,֝9#0˔wPs/xRV+&Sfpu3ϥ.TQV_ޮ։ KC2>Q4a"vh5V+U৺3kuRb/B>\ZcV쾑;CDaniTYkuu61EQ4 XGhByȬǡ&tasI:7o!Ϻ vbah4E6*hqGbW,v; fx8TXShKXP:`ؖXT\*&}`k{ HʆOц *s[ĺ#w1&ۅF@$ d$o\7$g,2gNh(6/HuR}ҚM:.&R ox. E}Ya+5EKL6kbnf1xUޒ.~1wsAҭlvDvLBn^.hѡC#Lcv[fV۪.<ՆftX WAk]wro)]PڇQmSK+9ʽ<v:3(QUuMx@r2:th\f<X]΀c`h۩n[~w @ q׊Сtc5!voMOK9W8u Bf qYF43>x"Ԗb6Xqݛ 8wAk! Vp5+d]幪 DD[7le'Ko'vZ:յQC*sׇSg?JU :u @YNIny9Iw[Չ0M8Y@K;28udՁ8Q=Ӿ`%BFpx:}"x/ "34:g`uZ[剹2H) H؞NИ5͟L.?n 趛wSS~ 61EiX0kۦ[A<}^Dd-7aΈH9N0ju2'!mG0RTǰ#kzJ,,֜97V['wh>x,0C"٠QBi5fIpL_а_9gUR־%"rɝ3&pf&jK^&[~nwPJT~ Cl^\<⯌ ;gt>ܫBצek8_hgR{YF ,aM p"갻@D`;4V*CIt @;_;ܩ(6!@Ic6EklۺÖEY?X|R/I)+1T2KI RI]kNʕ7CY%[A^^+\i֥1=i}WyttZpޯBmd]US|1U g^~K.KjǐlXym{ΪAN7!@KT+"]aN0,ss?~9םM&6_5BƞWೳ?ۨuX{ RxZuf;N}Qp>nyuNs4EE/:L^q*|㩧P>yzl ;C┛WfY.?A;*d2Xm7sCC1>Vys 5M`뵳,c}/mcƘ"0ڞU8cH7p֙s/"V?{w@;iq =Wݽv*e7ŭZ0UB|u+ ˩^\^}p>[v m./W΄z08~/Iʶgy!V(0HsysŧTϻVSΒ3~&Kx9-B)w)=aa(7+ϧb[l-|uu_&8&P~Q->VҰqm9VzýySMl$֑Q$ZW.8';v$fHoE栍{3k@d3UQM`mZh\Zd"8V)Eƌs(NpzáQM!5pwꈏ(IWnWth>\%;nϪCQ ZC><ש/MWޚb;4Nx"ect h5`P$r[nV EuK͔@jRʘ0?|*c>f[0e@DC;0y|0 BGB?9 AwNvDq3KwB)(!l%xW0}ZD(ywwr v0g',(?}JKrlCHHځm(H(@"ytjvWYSXa  mYQ;4:œ$&6.)s,J+zLhEL,3+Tu\3w}ai)]pMyORz3X9^FОi9h}Uh>;nMiСixoqY_cQÂSELEё6qoo㚋KQ]$DX1h"iX%ĄMJ6ޏw:TkW|ƉuZIn5eW`3rK:̅0rH |yh%a1K}r]A[U2+gkDN aPmHL87bϋحJ;0XA^]+hB1Zk-& k-{ZGnKj*>3[!BWmlcYtY_SO"R:a%Oy~5V|؞)M؅iUI[4mC}"BNVO7DLuԖKr⠮&CX[Ǎ"†_}rVM7x ~S0ŽC&ϾrKe/mVWR|=|[nb:ڍ:u*7?;&~Z[z}RLJ+S';grx X$-mK%\^~=˲G/_oλL)Ӟ'Tm6oO0CzܕW?!˭@7 x<˹~Xy{W:uJ'Rmnx ,P)wb[/,ȔNW\Z hUey`捅lR&q7IsPas+0jRMӀniqZrmÙ%#>8^$1Uy`K[2VX=OAcJw.w*aB-d%җܝ_ڵ5@Fk=7ٷx7p\MI{SjڎyAi:5`3E5U[2K/v:Y$_7> gtBu$pY&-beUaMwo[4d12+'&nTخ9%IƹxVx;th&RBRT‰=s,:]:g3倦OND:'JxFh)EnO;rnv!T5)G^rB|Vf3NA\\kTδwyhݾǛL /i?SSܒS KJ]AU ;i6lW0I8o)<5ԽYVwx;, MZVZfp+51$HTC-etuGG:vhso8e_y($ìieqF|btL)?,ǧ WFQ4UuAl-{3kGAJ !(kQcRqa붇w/6c-[!2 etTZH_j !/2Z+J]Jb !*Ab kAވDD7 [` [TVP7)  ^u o%* ԋQDP1X˖q8HFCLbȨu(9cL4hhBNme o">'pl!!LF@DkֺBK33k֗.ᆭ"BWp@Dc{oY}Pc^-[Hlk>G"( ;w "Zc'CkEZ~~_t䆽^O8*IcN@+@DҐ,"q9s5$ 8>Ia82ZIX =ceH i9 ְeK$̂`M脈B5X@æ-IG t~#6I M Q[>HQá&lX zp DX=uM]hh@u5>w]XNƭi;+RG՜~c?lh4cM _&K`^ws\߿:B8 z=…t˗/^lt]{57pGױ+W%X3%e_I[3G>׽uu W\a~7|xGj0HFcDԑv4قr,:! ) P`44n~w|= ý~̙n?G>ŋXG[[va~2sܜ_O*6<T7V8k\WKdaqv8LMZ dϵꉻW0/)3IXDdˤh5 ): @(z77}ŋ"k<}ApuOx?ՄS3Q@Z{r_p˭,3X D (<{gg޿G=ᗯ\EGIRZ{usſ|_+ッF#pF8fн{II9Iq\Gu+:Ÿ_xoy 4J篽/~^zrrbܰB`N5(77wu`$RĜ^> OwyO~^x%ls-:Q@9mqn_͎[ؖ6-.f:*Vvs+MN((׹@셥S[UÚ @qFP4Eb(n\RiXcEW^YMa̖HiO Fj1);NfN3s^{/wm@;*2a#IOo'K.h"J\lt.F&l'p};}zZ)PdYl>?}W>=9]â_y)Eȹk^t/~ɋ^W╀ Ik)D֎l 0=ɟIMoW.EL28 HK~mo~3PoһB_ϼE(M̥(v"y:\QNNF2p?S?`:(bN"ON`0|} "1ۓ~?VBE.?99^{1[+"F&"Bpus>^>E{^}ǒYκ˻("z$1o.IS Xl950ԤJpi HsX ke8LYXF HBDN$&ש ":W)x7;;B :-XO8 bxt D?~3Wqtrrc` su׾UwP?8s-kԂj-"8:r~_ypt&\7(QoG~eU܋Ԟk"p,^Dx4J8e_zMJ52s21%l~`XlN.OO/JimV2 bJbN[cfHgϞ_~s~.y7uCB.i&1X Ox/=uP:O X;'qG^!1 ,>cFCc [cVm+Fx!7U^yB[CnO~m O׍܆NNtv$ׄX T8 %)F):p8onw}cG=]/_&AL2P1(Nxt?g׻?x><4V g$;#-Ǐ~y8:KC7b9*u$wWΞG{gáp8[D"DD'[B&{S+?t=֚9&M+@+彽ໞ= OZ؊܍0UfҤ=J{H fp|_Ͼ瞋@' ] \>3"klFpuM7ws=a?G//y'b(?BRr2n!@q<h5:y. Ӱ VH i VN ʢ^Eul '9<#>GGGBDY8ByΛ3{g?xw^*1 ! $a,+Dmտ7O21lT?u(QE|w#" @2Q@7x>B, "H׾!}>qc5Ǜߎyk|McD>~o|pX)K  kN !Ѱm}; &a ʺ`đ1]{9?;{XYԚƉX|Z^OO<8z:֚0ھ!0ے@d "9<{wtH~Qz1e _s7~~a?c(KVy>$+`{Vށ}#+v. 5)R:x47|c h~[r?//r[2wMCϕ%/Ir1F+^Yl~ؚ}\-c2Ƭ,RT*}bQy5_WëuQ,:G#`E })mYXPCCDdIy+^?򜸿'@$cۿk7.3 7}'>61:z~5IEGb$ITۄueb gK\)"EoyA|0Ä Ow/\ ToW~Wou|2AN-?mpOOFfw5Qttɷ~۷sbY 6+ qV&Y0soƧ u&Sc"$&$9g$p`P` I(T_ϿwlG#*3XdIS-4EDHp0?zOWhE`/niKXo%(Vnޜml0J)_n'wWjoMfnXHoE0%3ox7==8{^~(H+F`CȰ Ǥo|7_wqY4]t'~'щ` b __99{5YbJLغn \>"׹BX1swyTZéx%(3(ҿkϽ;Q8d̲BS{ ִ _xϜ9֞ct(Ilۿ󻷿}*-az :8x߿ g_5RyH(g뒄ي#,v_Zk5ւ bkζr9]VE<u8WU 2gn5ME<۝0 |Svb )͞Euwl{ޝv{gшL0X,bu $M|>ޅP=`+짎2R^ϜcCB:-8x`,lӗ5$V9|+^7YkT}(k_-if-ל9{o2gDh1>/(h4?{?sv` "KJaI;0[V⽃~ʼn5""`MRNSy(%R* ~7wX Z)ޑdˁ_xwa B@9x"n R?c?64&6Ub`ppkp 32JRbkG$ddXHGQO_x`(+ $4m=A`Pؿr =wWK֝v|jϓPʤ$b\-~M 9eNOu:t ;NEQoϿ_Xkږ8Xc˗Ϝ;}9?űFr24Hfk;Rw2$ &)y7`TDFEy42`4';_RH_]֗k,{W_b`M6T{/wE^UIS֑1H߽ꦛAf#_?ZU[_щ(&P \{^T*E{~p8Uq;ؿA)kɭy8K!~םDq߱!/9bf̹_k' qWokL+kxuXp:C,P-M7=ys&@շXQ/&Tx _}Eq̖2fY}cP|,8wF{g@)/3,k W5}Ts/}HX^W/۝Լ:\Ƿ~aUv@ۚu׵ufc@Uߘ^쬌JwgFQ4 ׽.3Wcw;=XH>TRQ/Pd6V+%ykAk$d'L,gJ1}^㪬"Wmr|^XI<7{{{u6׼5`3"B+1t-ԩ.׻w^x٪˴߿}{߂!ZƼy#+a RJ%y_4/ ^ λ2Ŵ [w-7f5xA%\s%/W/_EXafk H_o1кׂ- d@l枧KD7:X kmW smKfUֆ snFb j5NfZi:GMz"2Hoox#Gh &9^?99ct ݓ+WI#vҒ՟Fq?BE,|UfvuMF?;c[% *qc{NNNn]AOPwuիK2=yOEP<'p(̔R7˗R=ҹFDL-荍i5povlk{;"&Cj-clF#=4:V(X?0f}4m}Iܴ@0Wfb>,":ϊh4ώs/]e[ImF&$Hd9F@ 9MIQJQ@XQ C5__jE7ᴼH AV*, YLE;Ӣ_14!jvt5b'0O!oo[LpL҅Xr۴LY} $TNu%IeR$Ix9{G] $Z1b(~j 3v3Bdaf@4OZsQm"` B/]ĸK!bAɽdw2͉*Ka*ׯ<3uJVڢ!Q~6/S[k.kL'R*:)EjHdȘz@8ʒ+Ei{}#AD @N"26a;tTD2.8-XY1YE*(q{Ba$Wg5BDz||jҚueU 8ozJ兓UC2]|) A&gN6$4l]L T{j4JjS,ko?=oqq2sGQR_r15"O,D$V w#"*kq̃DElʕ㓓Edj@ĩG$.DZ7X"\Hq@d@)DrS&aZfRDXuNLl$[0cB6x.F0a\18 TfA x%?t*FpzS"8$MWn}bX @Ap}qŁXE mIkBo VJDRj RCt_v4j!T's(1@lRٌYH×2N@D2VG 93d[T]ԊCҰ2C9Iz\DT0,~4ao^\0-*d j-ܘɢSZRDvaR#),.L>zwu$=9G_ ]* ˷^ if:Pos&L4Ӈe2aDԈ,LU5'#@ fP#QjD,㝪"rE"m"R(ݍOj.!J&M*ܚe4 c(nɝ(OYktvdgD@̋|m-;~wOќ{OVY|Vx}wvېPxSvT0YsXЌmU+GISA- Vͤ YUI$#H6pt6)ap[[a0xOZͨ7*W&VKj[tK `ه"ub6[뜺a̬R]9Y% "4Ԣ3sM9=Tx5wS2fk'xN|ΊSݙ4M⍅^) Ϩ#7ghs[Ź[7Y@6՝ƚm]GyJ%(bcC.w~ DLi 'Cɉ|5#D2+-S2hqOqq?8ZvAw'xD LjDX B" u _ @!=y3& 9H 쿧- u(H>oW=SZf- kU1ݭK$<(fsr \h>m )5" d۝\ǁ$b0goleo[  m琖.?E*DE9r$#h {fx}trf!c)ۺU]hR/aǣhor2 Eec]ₘ#2ZHrJ(?9H(v("¸+#o3A$gGR8m :FPVR|")@*փ1dEc2O"BD@yEzf# P߇tt"[q1BF:@h%ujr_$h>Ю"bu\3"; kn*H!O{Q{ btuPX"Js8{18{cy'γ~0  d48NFWh`M )gn;hH5 ɲW, ɚ9Zq$h臛%}* "(ґ  m"S]$04)*!&'9t}+$U̬j~]7|^t _{.}:KZQR}I#hg7/n̼bѣg~?c?F;?xUWgЅW~DZZI9i":;ph#)UH-CY ^krtu08DEbWLfl&抧t8O%"]Fm4u=$`r8ұp`NsϞ={dpLQg#DA5T+y$1}hzyAKS# ek'dM~ه?Hw@rk~~o__}{ޫ eNUmNދ(I%ڡ!(60]""fa]>۽n*/vU,}|w}|U_e{{oKx#LwHvkAlI>}Ã8/_׼~'N#TPD 9QDV|g|w?@>~iEQEr޳l4'R>IOzғw|7AB&3w-Jv9PTpg|篹/~ы~ʕ#*7.,K,k^x/1I@|/w H hN#E"ʥ.(PlL- 9YPQY%g s~(wܲKn0΄s,0P[B%cLړR2QqJp:){H<OC?S_x29gwWƧJӜjNې aϳ3? .D22c??YB:t䆓*;)a{8[Gc_Ms~)ՒLq저ut qZG_5gO#RVY-_կG{ BYLA@Tـ,Iݛ?͚;?-%WE`4zƷ~s~;\=kwo}#?'>u_{vF}`v=A2NUwZ3WIYD6'O{̣};{ȷju9X\~K~^v?wl\b89h4NO/,&f%Yrw*29 ~GPqܬr;օl>کx;ݛV$,:Iuj" EknA8.R"HB(F4)Nk>Ug,XhV_?+ãKW/srty0>Q5 @6ZbEV/ V ѧ}]~/"+/pݵ_O\S"o^b81:R_U_fp sҥ+.+Z a;L}˗.]NH+O?#DPdCD( @./RK!?Y W/^\4z>yȃGm{i$ ֐0*^L~iI(]fft KÔ un׼\:1`;XD8J o2ۅ|er39)eqȅ5G*4m(Ot]efnBM"ȑbXH 4NdCnZkwLJ+Ri"(z. "w||rxrtX~|ʩv:qMS0gǏ$ 7(e5it2sXӶ ",3(@ab'/33a˂>D: 2/r-4^X#, ~(RH$\IODlD&DG/,y Z.іQJ6z4TôtڢBTs7^#g,B!Dd~1*' @GATG} CD` eY ^jcu^~4EWI RZDeVb#BX֙6) n>OMÄK>aC!@:@߉MFP= Rso|.t{} L)4^/+I-8H9ס̗o\ƌZ NT78 3"+fN$c"@RYD@qć>ϞݿCDQga2&к7ewvp B$&B|؇?~@=X7&YiG0ɯRC,rwR;*f">8YtI  g [xt`ikˇe HGWrUMmv屚UP& d,+}N Sq<XpbeL[$v6ߠ;:ӽOaJVP(hvgwEtA`fcZ6h㿫v0gDc8(뮃uYF/c]C6e圶Zab&zN2+%HD_BNjf 8 OQ"b&Lp"ҤcY|W*o"j/@( p߅Wkzǰe k},8Κ(Z4$7Ь4m/mD"PREei`0Ȯ)ffo-o"&MgZmRQ@^?P$&:6ؑ+]'<ܟiR̊?3gT\",xFiYF+b-$٘1 X)K^^7gi<`{ :k pb'5 Gb,UK֗]Q#FDt@.s"Lx$`q& 5 ~QaK#i^/VNwf$A$3kA-ø,[|+EÌ,ZcJZjOڮy+Q}]V7hER''^s^ [;>oK"bIIČ(> 9{~ D84E!b/"$&TWOH.>xDEZ$PT _bꐹS=Zf6VyG=?u~єH%ĝQDU( 4uLЀe _ыΜ󢪀(!45X+"Y|^a fXh4F1L8>cKf,$"qb{S_o~ e@$d p4")o23'I2 ˈeQQCƿlDq`\11MGYË{ BE7ֿ NF. 0^V""bI<19 "*;݃j #A4@Ȅ0i"=}kL2$Q2:9>Fd4/n0#99y'?O "jDXtӳ3;&tr ~~Us<]9[y D'irNDΝ?Ї>zc?A}CϞ;s NF@R$&cAx^X!6Ӯ.۹uZB=}deEqn\ʓccBK! !"k` #,^o/;wo}twww= FecPTȯ"2 4dɶ1D@wY皠L`֦HJ1 R4.,5ȑz7|׃ƟujRQFE3OHp : 23ADqW^u۸HSx;٩z : W+O& `DDBijw#`P:q0]D*(f"]c8 30*"%[_ׂ_L =~g}gU< hHy2i݌\KjJRe8|x'g5@4/7[*S }=q CTZ)I N{A#_ ޹@D/89:*\7L-uǝ3oYL4N ݅(D88qt>Ι؏3g'> <3a081uD&$)ö11EplhlSS"Cœ.5ʅ3 ` 3Vs:7]g/}+_z= q?>8tHX(ӻ/~^qωY`06kf%LDZRCLVzDdtDBL\=4NpCDحǽHQ`$yqߠKbRY&UHGȉ!8C{ج۬0Y5U$ 1 |B&|A  `ooq,SZn|mL2SO)!,Z&a00Xj,5> }P*s fYHFD>ql-J24I\ wfje07n>-lԴ:lW^i-8Cݣ>pG|pDP~*fKim0dVBP~dz !qW$Q}$U PeJPDrtt[V'Ƹ/PGLܩ21hdO60D w(қYGhHN渐p#VlsZ"l=<<__o/8-a1ETeGl#D,1">Se2\~DZg~pM5UoB* ?SWAf 9?89*FI̘+@FJ- =VQP-!*G(̨&GeOH!ah(P|8-1,01!̍WO4 wOR TkUB`FF..H)FH+.Qf.N<+>cT N6&PX83QSLy<`]&yJ%ݾ-Kr#*fFBRJRh?@c^& w)157.o"|ڂ#y0]ńee&JSزKHD(cf !g%L@i+Yhd]oX= х9}!2&V0VQ yxf:6p|r2,N"D{ɥWUyk_O/'~'=_Ɨ~~}h0tXiRZyW1$"Z(Z7A]L.yF$3q<' Rf\P|?8q|pYQj7֍gՉZX^K-"b;3㎰ƞ}Hi!y\51J(RdV ɇdqg3S)0sZrgFꉻ/לZY@-t݉*9THә]4Uʂ˳9d44HLàN6,6~V' + \_E,O1-3od0})a.ܘ*-, tQ0$Ș x]dMVk^D:I8-|S#=Y }}'p %$휈4ŹҪwL./?٧|ʧ>)Oyg냳I5 (0T"8"t.ڤJFYJWNC$MTi<">` sYJE`Lr~C\ Ú<,ݍe-g躨{1V# X)f\X.Wؖ՛ (ܸ~s(4 T,z%N/jVЄ=7&Nh;^]% ~M^ r %-cݜs@ aEdY)ejv rHDu}xY0* K)u#@4DB]t4PXqO}")r5t^I>C&*L"Pj  [A֓YdyG!5\߿zV"#=c4U_,#>sx gzCT*b+ˊ"{/ ~w}>5IH(IfypGO6hI,@D͡7]Y1h/UkƇ7nFaEPwOԓEo{?E8)IǮSF0 &5"=Xz,4]25Iv}F^3NN[ 伖 3eZ&q v27 7P,ȭ`يf!MxgWv+:FN'Vqf}cv4+GW!%x"5$+y3R` )2 #c(q~37!Rj4*pFȩJ |dn4̬zZKi n*X,!7"22#"m/!âV1f2#TRD`6Š*VpHDO@3C52n4&û̹P , 8,*t`;;i5Sp$#,m0#q$a yQys_8M3U7' k3۾'hDDie̛UPo'Z?1wUsri&.$?dTwq@h;L #5S9m)Ks Ue-#Hc/?=u̹8EQTtMo߫~& K~_9^|T_og>=+hXĚij7x8?sְ{nߙY(ⲀIz|5Sn)PlRVe'+%CELRАLkٛ4NLXfĕږJ5DĮ/ p;كHSIWc*#8U[X@r#g,u}>bsgы_I'"[Dq?͇Y=??~;<;/eN]ΰxwάY4"bL0fbĒ[q ^i ܗ sN#cJ%ZSS\>sv0bF"҉+Jm9',x=$F7G/_9y| 7=}w>kx D)$&{Ob& d̞ijʉU #|f2 B&L%c(_?ydOʌp.T &)϶=AL[(DHE S JcM\t~F@f^0?r)`[?ES$줤>9˨ŋsυH{ǝ~w_k?w@GB@RU~B d b~?3O/O]{MbEPCבR-wJ4F+NjWԚ$`m*96mj "Z#4)w[6س'OK9E*A5 ܁{_ŋ7Wl~H.'*+Ny}v8F d:e(,2KpVf߰LIb WdPŖΙm,na*_J&-c%tW._%/}ɟIozӛ@G*hP #/iH3~?~~}'ccL}x"""dYjڤ1. R*(*^ڳ?կʯz߾jq0(;uN+Qn.k-gAE(OOy&? Gҳ -@ MxX/aA:%g̒u@*MdAU@sZO+Fx9"'P8wKc@^F,g (oQ2El,D&W<ߗFI  P{pw/W}h4aFv6nF3 ӳ+Nv_S]<M},;_];(!kQ Zg@EVptrRpNi#x9߱6MULJ&QM VS^~6'{Q ]uN6hJ7" j.T@ Aޡ"5Hu5viYƴHFʕg?yt3>+7^⃈9ILcYnVb~Z>d^8Wz ccsۿ7& UCHݷ[*ѧu0}'8`Hjdtl-Cڕ DM,ӃP}x:! (+, q;d~d!ɨUHHd$F#0gqJuH3P]wd}e`57-9@5o/_#ȬupI+$+`1YVW9bg ".SZݮ1c'gO<ŮRHcP{)aВ{ҊHPYN B'0aєmg}!Ku6kRDNeg&v^x^eʕ/Y .9s FIem#KRk} G\3Dc00qt0AP th"O.`N)*єJe%͕׼{!LRmoaP4G+YJR$&fs/\lQO 1F%ND;יˊXXZ{ bWԯ,6F؉hv lk'G3gp[w=Ϡ[U΄'&zKfZ@`eleyy}Q,DW0r<{Ӟ'/#88?JF"׫%k3Ǐ9[GL3>~T((#wlW=ј_E_wAXBR3!DTiBàF}^*I 'Bjȅ"""RjzV5 V\a'gPBD]@K?,|q,NFu2 #Wkt߱L22; O;E4 6ino l"rõ蕆KPZd^)IDDR 35)E$"k'Hvv._V00@x/ʯ{rd]-N˩?oE$TZeYU]j/( +(V3%d,#y0 zgaG$*\Up񗽷'A)Zdc-goBB|!" ak8IN"Xo}ۏ>Ǭ 3qlMd l ʜg1x\`HeawF(acrCnTvc߽ ׮0ϱa:@T V1.$ '_01\SR{hT緿酩QNRv8WsD+'Ρq VrIrlPG';oZ^Z[Znv8Ns $J݂^b9i 0f8'KG"!nDFaBDοTe!s Z.zJChkKB\so|//9?cw&֚I2Z:[UJv2.E˷gϞ=:9~Ӟa(#iB;BCbz%b$ e<)S&$4'8G$qі֤E_63:e2 /Ek-[&OO<5S=sh;1-lUPƁX"Q9N܃B\@bG c] ?cS3-|n樜7$h@W8ֈ`$!)* 'C0ȤrZfBz2O@ly`0L~?\{͵Z18i @Sz#pS_{ h5mfJ "zq~-77?',X :JHartDR4xǿpX4+oLC+☬Ea4\!bO1!aHJNl$ 1"ADHE#RZ 5l^L4W ":J?dt)y6N#r#(FF<7Ӄ(PrOZ{x I8=" \|@wX0 0AkΞ_l@a6Z,[Z X ,e2+es܌5E]e8EՌ[", ~E=bdJ1HmR @X$A?If#09]>d̔`JU4牰R mP!\-~xF͙u-c܇ÚtJxfvsł|-:MA0h4P:F@@sXsCĽB"R",8,O3~d(aJpL.MP/qr<BaM Iɉ|FBhW=$x9X\(0cQ>GbGä*3#,wJ>8ٍUm,=g347m:Axib@R"[L-@Su#Rx~_t㋰J[ˀ6H]L@XqHDR4soF$_ٰ){Lp3f !K [Ie)Dg-DD (ti 22v(R(aΜ1#^ Y3E|,wvG: 31,PXHQ;TKܣ`,BVṲ+Y@͹r5!DJDZzO2 į){b\ Jy{6A|LYz,&aGJD8cTdLmx}7o-.BP(&+-2VHps4fxW')4Q_1e"Jg1fkMdf d2a )ԻH"4`/Ę*B:{;Y<|` [bN7)4/v?{{Ӻ ٜiޝ -fW"9U>T L}=]1l7ݱqs\"cS<m57~(D֘D &b| *8q,玏fC!QҔQ%*J%2!<( &iK;PUU}\|PYȑGm]Ffh4D! kleH`z w"[C> TmN@ڨ$+aiPm"q&D7xt~Hlhq[}Ɨ(V6';*}N<\{BzUjY>ɠު*14l φ2wmt2à +CEEj\Y66r-a&Cay iJdCDFDӺrirCɉU++ bcT 7db2ߖ|Mֶ2el3[K sDLq5t-%Vx{~ѯ~Җ硶.$"Kkp! j26#  T?e%"QUjZ5^g۹:aƠӆ,9؋"yGņʹ7 rQTrWݼ錙{-xS8t2vX(_b?O*a&۸0`SOH"f+&Llp3{D]j^^{p׻^fXj-2g(&f6 ^d~+P4 #Wzl4foh4[7FƖ/W}Km e};}-CT4>Ie|ph*" T]֯eR#l֯7yx}$΋KWF$i&I2u+V{h4∳RM̱ X[E}KS61Ą(H;:+"Rb[wmذ4Zk b~&z}ld2TmZՕկ|ok4Jer152{l]*ꥹ<- bK^ok#M^oyӾǬu0mkB6۳bmWSn45ʫv%ڿ[C6\*^,j}Ĺ?3^;6BiN*o 7?qb_)Kd#Nz,GP @UF"237a/Z+EoxHJli4lVb"jL&{׉'ߓO<`kOc>uܝJ-]9) (*J\ON2d=8}iư<3i?lskIj BW+;Xrwާ^gJwĨS~Mq"qUxGMn׿JlȐzC8a: >y6Ƕj{殴Ơ (O,$CCõhr&[t4'IW"(3kgC%qEf'Zч\dN95%XB`*||֟gpS<,͓ϳe˖񉵫/M5W\q [IV̀100 Ev=ƪT !7!!r6*o2$.+zE6ID؈'!66oӉOVXv؁G饤E(+Fɦ7°Eh7mv1R4S<&pd9W{I0 PV5ƨ}DC 7;DY`jn CڮƧA>Rǫ+4I#n]\n ^ai UڶiF[nos׿Pwb119.Gl,:~c8>愈/ nF^dNw΂3,YzP "{?±柝rU1sU6R,{ǻ}GWM#Aao$\Z6ƌoX=q{iǪBD56&<̥Ǣi@fk J\jM#c4d3I-afĝyR2XB 0@'PyLq >ܜ= +hO LMy%ͦh Q^<+'"U0;"_%"© EXVQ(=*X % A'vUL 2Ge3ԃ8Y1l`ε⎈I5Xy,Vp_~1YfV*[_\T"5gX"FD\*ƖZlYf)` TQGaG%>:b_<>'82L6C#_M)@1I=:똅0 $fPhD))@e&5U w߸`UP"Iıi4:Sh)Ԟ0L rBQIͩ!RPgcCv~YOyYW_xwrrjTIs*2T"c-T__sGSgY 3?ef0ΆJnrIE9789$|֪^zG>hx zsN$vJ5u>O|_ܳ}XeRjbA:iE2̀Wf%ARx %4e"v Ȁbk$!E Cl-5cT@oL~3*:CLKk-sd 3@*"N Ӑ"S8·3D ܼe<%(Rh"t8ea,cP~t"eĆT#[D *LDoz+WjԓɉH6tptJ""^rUYk[ *gj,qW30߯K:<OKSESѥJ* Ukٹ&KbqΗ~ݗׄngL/i/ *18_Kf(B WL=;}hB2b`%k`T# X`t~}Sweu^!QDʪSki͟Ldhmɴr U(K^)ۖ(: < BXAz\ot  q9zlo1MO1܃9C8јdԹ8|2,MSQ 7wJdW[<}:T*^F^ݤ*t6<-}05"1);Wf6gs!6tJRt=̲*6Fh1nreN/ ˷ln:EͦO?/|6Į+oJ$x/σy`lb劕+rm Q)S>AdL6/*Wqm4]@YDG?(ŗ###֭HܽlE0Wp7*WjfhE3ȹKcPz k׮%&&n W^ S݊ Y ϗ]fdY\8'iddPJLylllŊwl!U8n<<S Qm@S^~lrfl|݊c~իWpMY\J* x6l'Ƿ旿c; kmҢc4۬gתJl'=c{ū2"B": /e_W^W4Audّ ]hV}k< :^}p&yʛyblي^qPR2KDLEӡ /WmozRAd17}!Ho%3\|TdՎ(+N!T&IxV壻>]gT veWw^RmtWF^ɘDLI}Ck֮2=^ehuPUNvt%ʊ!p%PU/ڟfxxhϽYM(}ZcZ;: @oTvsll@5˽8 !SCDfxkCsiKizP3x*e bIϗ'XW[po~+]J~߸ !P3/ GdB&Y*1z/)g.S  .ObGuig=(ǁxG5tŊnJu(>qٯdSjVm4&+s_gW ]@P^a@3WZv4 r$f3 e'P2Jl GW*z}=s=ZyaR>wJIJL6S&\/CUUlvQGY i,rH\fg1m,J䜂QE$# NL9wu< `õZ"cZ.*Q5ưe+LY\#^]vuB|NTo+vu' &hcs0QV5;ZB(lZ:蠃jjG[ju(?`ݙ\FC{k}4b니  J%5'<[(2 P|UΘ{g|z?'> 62>ŕ5dm<\1˖/?l^,-NBGqh% SDVNupA#ÜGAdDd,CuW셯yk`fM ˜ZDE`mW7|Rc4u*;5ƥ.Z̻5KO8pd]rEb$h6d; (,VX?\?_fW.Q `lDf~ӟDKɭeߚgw~%b1 wI`C^R)'DHReƂBP( ysmv[U~e}Յ%@ 3N g"w 9Kio(6Kr0eu|>nUpGtAIB_{1l%Q#<أĂ3vGDE|qQw>"q#oѬuisic q1N;C2t@d2o1}"vao ^h*"9/xK{[ܹ&ο/V.cKۜ %-L x1i3k3r@TOK#%ê$yuͅ$>}j1 [hޚ U LݫPQ#C38*S8gIS(l5uI{؃qܱXLp?ld ~ڶGk6$u Xvhq=4O9Ľ#%gD5kG9.  LWvXRweǜ"³^jY:CkڅB'JvO "4-uSEDiT ^=Gj}!2`JLB*[|{? 1z3UUDld]{}iD$/VӿI.TWTSLS7]nLʬ 27 /%x&-Bɿ;p_5%/kUF (/KJَgϋ3 {<^tPD@E-<vjDj14GeACg^e%OHO#=~ON&7Uj}!ϋR4$bfx:qg@&4SPtm25'xل2%=f1Fk]jtXק̳ͪjGUX%YUTTOx\mxU,2|ۖK9I U062 ߨ?3O=lYݝЋ_]v_ncj^.{ȃjԃ  b,E/cHԻ.I Oz?`qk]^%ac E9C}5Y&} "=3ˮ1}L#$*k6b 5s~yJ&eR'(1Yˀ:מCR޽wS;E/zFg`s͸5md!/zo+Vˁh;b^p=KW* 7 AydV*>}xg]%*y=a[7Wcf#"DU֯2lИЇǜ1b,ED3> ͉ͱ5%)/nDε9TxN=El̋Ɓv`T5=g:E}^1%`k7o׿M8KDkE aKݖ~=rWG&{flW`_]t/-$ j@,}cו~Gb3uMxoyGXf5;Ybg?A|@v'9EuER?E/|~bCbmx}`@y9\R#i 8PIѸ z6g?[輣ht͈ 8O ,j?%/ 'ɧx< 91ˠu מǥ'["CCP!2$S"jD v[=;&3+o9g`@aZW}Γ -겘%*3(Zߴ1g>9x^lbc*r!8 ދp@&/{3a8C2d4/QzoHIzva{#CqlCp#Yau~sƷMy}Cd(/QXFc.k!gQ߾LPnowIbDZ8Ȕ2wj$lP?1yݫ_flHT=p` (G3YVZm(-:3t@^>j^ʗO]as{L*ރvͷadZ@=J⳹"t1FYjY9ډɉ8q 0Cٜv 5vhH'!D@l|l-o~ݫ^rn|Vp2m =J%bȐKo9'~S{i'-QAߕjA]$s!Xpz9Soq-+@&6̤*5w#/E[G Auw?HIߜ"HDJpFHmd|ڈ,gvXILvdr󐭠l{vS_ɂO*_ W_}U6B2vb0PrȢ+"lx>L'IS"@DFΥGq'XY{kJ g! UVyɋqNNݼZK֚4Ic|l=~߽g7폹L @LeͪW%Gs>a DZkCfQ *!lFU|$7_WϞZ@k/xϻu!qI(DQ%ؐeR"%RffÑws~}wsecUYL,pR?}|kFF$(f6IIM{]|uW,SN/ԏeO~o}֭tkd@ Cr\"˱54T}ǻ{_=jAyʹFF*zIO;eU嚫NzI7  V@'t$:;*̽83Tmf*5eա~AKnlmUDT*Oݎ8??~ oJk].}ȃGy!˖V-|KTN2qpG?Cu&F٨xR,TUUs.u3<)OsuAAeɌaCeõ'>q}7}m>(eȉJV^y?O%uw=gr ?$^1fꌵ+>>||`C )e%Ԇ*f1YM.2ϙgvч@=uȆ0$sCa,ԽeBۛo'ɾMnA50T"v3x)|ڟ/wofjL4:::62|8ns̱5ˊ:ouc9w?yo~]z?ydC'PEF{ǁxYg}H:ǻ0=V(Yb(ja.]E,GoߓN:SNw߽kCf!h[ ʎqʻ}(]SUJ^>/E8j6 1lۺO}{bCPX 9O~Gx_¿l>쿎~#8??g?ſwl3!I!R]n{ԉ'p=䐃8k%?&ihY:uG݅6_tۡb؃࿾VBeQB3tpF 7ܐ$IE*f+Fv\3ӦAy;(jIC$Bpw[aoXR˂ɲ"^={w]/0m` 4L[6tGzAg>a[7s p:42T]rt0x-}" pO}oxӿ}ͷm}ccevu}gڕ`2 'e~ݏ=yέmi$@+W\zڵr$)'Ydy>IHo\[nںZz]׭?pVZ12R <>į ?xÿu5\s7x;1+WX6:^{n{ǚ+t;D~?a^ސ9hhhƍ[.uwb+yѢY8 9I8묘] dQP"{7oظzl*_~R~!YXK0RV ^@D @:M`WK\ 4b.ku AQf}X~}:ak>{sB!%#2HjQ⒚StxF (PP!0P3E}=9(MNY&2!oH ]Z?=`,"5&o1AckD-u(lW$3,LU N2sp^-YS!`8+US mPu${fsÆ E:N%R|&P0~ATZ.M;>5f||Q[jۜYX}h}m ۉPFD1"!(Q, 8 &MydL(21TLsF DQLsDd&bA!"9|%+[t0 aӬ_YC@RDз[s0{"QD$j+ 1L]W'ʡd-9XSN -Oץgկ UB`䪪eVO_,6($u|2T=vdk >=cӆm9%e@fx[n—zͷ =s˞ Ugt=604w߽Ȉ*v6DR&ɝg\Sk~QT{|AgX_y,lTb F̓TeF1㙑v YUL%Eѐ5 -_1u1Hz] iƆFغ!Z8Y3(PIRe.rާա! I>ZPfVFROi?;b7+ա231G.Ucy#jzԁuE67".'BIMzemH ʗQ9=&5n3I tfT*h,Rtי[U,s -{ 3 0؀qaWf3 S{Tj͆682,mSv }zÉlD6 mjD ,͝v ZZxILL @23tCYDV*d2 ^X N` Jg7'^$ AH \K="T8awŚ5kV\1qmp/&Ӵey@5͠zU*W)LL-8]up_{AO'⇈ n?fh`Q_; ؄N;֟03sVsEzF(ŕ ^;F9@LbVUU"P}] (X唲k@Lٲ+TmY \^1hI&"\},^nA`"ޗTafcwRa&%P9yqZʬ*AΞ< Vf>FlCNׇ *ѵX݁E >O:au+Ü6c˟?~ :L /0_zHql˗necw;C>cs]+٦7   'OwN22g~XBgQWo='>YH@\/w N!cq!!w;.9㊮H-eکc7lܰAhS2l:ȚFCDiMǯ_O~>eS '.Bm_NЁvνp<P AL ;:3w' E N#Qж'Wbny}I iU}4 B\d^zŵ_׿uw땰J\*9QT(I@Gsӭ5}ȃ'HQؘLuGu2*XȈc; e(ԍag{z K)t*SDakv`) kԥ.(+x6l!=*E2"xJpUuEQzoʫ?O~C+6)/l81lR߲k8,mog/呧?O)83qti!DF0F^@HE{W|10TB\5|H@%׍vCul *ʱFABwb1o*S6CV.,/J])U* R=Gv XaL!v#)TDՖrE}g&(<|CuWFãl1ђs#)d H}|"1_7~sXrj @RA _,yӶ[At XA)# F,>p`i[CXs[e'z}e֦^n˝ILF$Iz~36DDcYlھZF-04LytW񘧼䥯7VW(N`IHQj)9dԓ@k$Dquuyr/~L[ɸ8c#Tv<%_V) FAO+/C-L`alPu|0Y\I vbB_)x1`A?El0VqB4w*csC/ksY0w+ M} c࿿~Y>[AedFrkJCYKsDqTefե~j#^pѯys?5R8o2" Z8fjF?s~`&8XREOTU\܁g vNzm?ӥ-"͢cEv&W9ӈY짩W>Js45V|4P1@t% ]q)T7TF+mXϸ7s[0z3F=ק̗4.]n˼nh&V3<$vv5D5">|] .ԡl$"sBIT9of4=}PږӉȋ̯3ut&⠰~XBDvwοWUkjoی*qMV%)SpW0u5"-¤,,\NDd30(8w "Rh??7m0V v8b#5ͺ ԷQ> do8a`jã{>s[1UT~JZ(f(E$@/d۷wy(cCB5 ʋO IDAT3?S_@,H9$hqяoV3{B*/I;匾B)7o^ͷ޾v"l6j|3w˾t`52=4wfBъWqO@/X?0H `NQ.;k̇fɍ+!^ s_`6Sa 韘!.(HTJRx]bN}D/M\HN?_~wlܰa#0{>/|鋻_|>^}p oyg#t6h!HO+t-%_p?O2g$ge #ٲi=xi?3:kS ux|}饗} _sӦЈT@Bia( @`[ǟOͯˋ jԥ tNTrdd+l$.<\rɥ7o}P%f ~}߫*+-?;G<s7c(.{ -5`KtO"URA}~r y%}2L_tvDaƘRk|:IpM9p-咣,!"t N,3̯n\Bpq" ['&W^uOqѲw;∇?g=ukD`KrBB'ɇ?[xt ȄiL65=?qkW"hL"uGg跽=?Ru8uUH ?NbcRukGP1NةA %[?_򷛷N5d d1QxC'&Ė[[~~=vig>c ˃'/u9@JR$ι ,uW *+;-|~`X;A$\~~׷lZ+UlW EU݆ԉ͛&/7ey9O}وHU#k/!,ʝK3")/ax@E}GC+4hO$i=<'rcf!3WE'Ï;qcky9DR/qv~/}/xxΪBL"|^ANQ5l+!" E>~hNI`>/}_WFµUؘDR ON5 >I憍w|{?~o/x'o cى+ څީ Baud#p3Z;PJ $_JMUQ>Yo_\}\7~]6 kkϮa KB<,`&AT*}_^kP\i$rKfKdm~1}oqe@UtPBAd~)ޖvY40M9wR7k& WE6TPΨ'gtĆ [;T&f>>O?+ޱ}hC/zȈ$Qb!V6F%R/Nԉrq& ޏħ ƪ69uT_B xrj%/ ykMgUy2r|UcCN U/{&qjH5Po$vh㳟2V"C50BFWڿ{- 2DdAuij-(ME"Ц㗿 /1VxHƩk{!xzZ:N& [[<9/r `A rm?z*:n1;.Zs3vPVZJl:g={8⬔=raD_- QLJJlLU(E\vų[$l-|oMo7̞(@׿-*[Pjy D04yYg[nce XU<ׂ%eS/^TD՚]敾?eTETCߪ;jHT(-T7U5D0gstgP:eY~q$/ڙٛr@!S/~K?ĵ8k !T6oM9z(&еW]G?:_&2ѵaۡ^ytid 'T[*19HVhHype]d6/`֘~ y,'||+_uͷ!FqmvsO{XR/Sd+աWCWgi,!KY<PحwT@Dsos`r "G]@3zKpkO6S>EgɉɐvD%ޥ ѿ]Ϥ󆖌tl=_욫+` $Oҧ<>(fpN݆I-^\:\9{ﷷkԹ4+T4:{S_Llӟ@֝׭&X+w^y%bh/S!-D,,/^ɫ_7l6ãq;A}!˘M1$P5&&?ox1/,}5pՉ97CCЬf>gTcrjc:l^v*ƹA9_ݏUH^? U>|cV8(8&cC0Cxo\i@" 0=ADDy-CU֒{~s4Hz)S(<ԥfd ~yʽZ%>i)"1JeBzS:ѧn0``fxK]dŀj\r_^7nc3׆DH :AA$xdզ^|b!})XE \ʤGO3΄Pp13A/;R?@ry}zvhkke M_w=l+C#+A 02=Uĩj\R~K 0El3o$78db8n-KX$4^+ΩwY3/ íYyukJch[KDb so(=z?2\Pns ,sYd҆X !(ˢN)^^PTع͇l4!6 |k/ 2kM_a͈*gKYɐEk䎄- dyDTvb(Mٙ8RSf6&m8ۣ:>^l'17L!2<c~_D4+䨂"jLK`#nm_lX\ruMT{'<\-\Y9B-^XzcX'ʆA"RaZw b6dL"ww>$i:Y,-z c.mnܸl]@H -6^kگՆvqbBh sι$2k@ %΢4sC%,@ o> 3hJO矪`b'{da*5!4&&3@תNw#60vڌ%Df&@ЯwK{ /DGD.u6V&HY`'0Un~3)v_hGS/ꐰI*@`N42|G?` p$86t mVʈ*hll J9cV G x"ڸaĦ;^$ >&b22H(WgB qWx_ wmI'G(;exd r$DD%E1xzP ƚԹLvΪ/ ,&29HMem3 ?110 nc{l(۾LLbD>DDSTtJ:h!1s?H;c c۩D߁(l-~y[ P颋~uѯe(Oő)U4/^Dd{?-mn*g=Dm.]Đ\"T218 VZ{N҃H}̑L@w۴lٲu"lgYN(66MS5]7iy~~+  ;gn8 P3)Ƽ[:@rܯۨ֘m.P[|%g46lal C$@K^b ׁA״x;*NʪJڊn9na[S'qdկI7gE /EF./ŧ?D3럄 Օ+VgEΈiQrViyIZC bDa}V C0d%W!Gqx7~:7lܸfCc `EN,hd)1D6KDE!7Q52&F]ǰеDL%[{t^q~sbkM+t1j/XϿ>$5:Ew) VsAԉBQi L$7bD͗8IRP@[KرMǓH{&Ј@s n?Vi V*-F Z[I?OMCtZiu/ۍ]Dwf693fiyqa1mrBr6QkbUa EpV^BT.q2~aٲeCCCyRÁek˖-mH j|Pi ɍ;".Jƒ.r.s_ E-7FA% Llёц#Çz%RzXb9@ܭ rY.g?<Ӑ ^ӌ_ B|T(@ ]aDD0"Cm?߶nkD<%u91{3"^N;zMARlMRŮ;MƎpi[WUK^њaE@mg*RGW U;EZw+ouz1JH;<<ʣMM޺vĖbcu9(ag2Z)[G1[HN II9S۳Uaei+Z5/PV!%@XPD+W25VpkCCMX):V &V#6S۷L iAN re+0Dhll9ijaUs. q-^1SQHdW ~EwyHږ iSOƄ-[Gj(FǠ\\(*ZdΫθВl̃ eW&-ƭ A+ՊeF7o `ddd1\ 6rGۙ\:k:FxL.Ql=9GO7c5#a0eW^y͵Xnucr2W ;]aUW syr-C ߚDD444466ek=O v3.={$K4(O|Qw3{ʇf0̣ל\`(e,uqw]$(8z)5ἳ(ڿ^yc4I=rk晢jমM ΁1"7,kV^Ң,ί&F'=W4ع@B~v$홝Yx̰F*M'$"׽o͊\U"6fdlw/>ϱ cثWMqL lA|ш" @t;A`k~cTSp>c"d,1Y6DDp) Z%>;DMLn-TώQv$gu +Vuכn^ )5oqP TYnt9YL4Slr[!)>si$TofiL*f羦Vwƚ͊l}%3tJ ƕ/q9!%f9-[D>XLfJam;-~!&ܫ 4 M ̹N,$r yӈ< P0Tt q'Ib5]švB4YvsscU144bG)AdI .!$đi-A-k'喪mcJGdabPkHՀ.M5q%}$Ez 1Q^%JǦ;05P? 0?Oh4A2v}v9紷ܥQ8TDƵ$Ix޸ʫI2iy4 jFGk+6u<&26nq e'YI59SߐulQ֪V.ƇDY`((Hjãe+.ub\I\g,̺RWe7cL\+Z>[co~4X-HSL P~mhFKۄiՄ'(#ӵ9;e{yC(Yոl3[%QsE9٨."r6d! q!z,n#\S%Ұ2r)@D"{+[ҭ[j#m Ii7нkE;RR(60,+TxQZڌsGN3ءPftxIS߾Z8O(޴iS,sp0ċ*Mvg؁ԥ+\H1,CCL !R"rbSB^)5B*$ZD!#Kh=p)gcGcJw:(K2\Ye[%"gk,D|֖Q-߂6K^:yyEroϳtt @Le6VecGF޻Αo{,[ @>~PX2[ne KQJmܥJ `1 kyʉhRؘ! @NcPE)ÝF,1 4WYX̸켛=^lDTL{*Vґ:L|37k&LXqY HTɤ!*`F:΁emcq,pqfCfDH[ PUf&wD^%cV4-&sm7L~ZOCPYٲu ^ıU)Br!ky{^AbV(voODS*fT6ȩP׮[nuU,S 6ģ#N4O,=0*Ήf54<bfN8HZ-l+`%dUʛS,n'ʷ(Hv;H7`h91XrJ4C(`@dHUMwB6mVsKbZT'ucT眪ccLaTm{|Aۛc,D UM$}o#]_V) j3[(gOY۟u)Q#3ˣKO9jV"$|EԤjpeL̆XkR&=g7)V ]F_!(c<2qϼ9 X2k$"[ghCX$Mo3;{':%7 ?#([B#˖ GJ4z>`"Ĕ4HoDa(u|n AuC ML:#+ ʢ[t~) *oJ 6T OlP=憘ělkvYET5W5?%i68DRKM >mz8Tܕg[~6Td"*"69\b|fc*YUK^%YSb,C%QPЁxu) )d0م)bKdۂq6 gaY1ǵj\L%LL$f $lC[,Qezji/##11gf/I>Qoi_9Du Tu +Ө;S*dso} "b@0h7` 6'w DҝgsAs_Y1l-JV+Q-15~D1w);nٰaKT3qVSS,6Hef͉zԃx* J &pi=]c(*bHS6zG/Аs޿/<^%dm< J!ENJ|-aMQPBdW?~2QlN&@q;޽zd̆7֤o}p ;bٳDQ/x}͛7|ԋ(usLU˳UU/$z[sW0<O;C ސRK2R=# rSSo@D罉U+W}+_G?4e/$IǶAGwa&3LHUFwŕJM\#+z=P ,|Ak@{]Y׏c2~<11G_rD'g]w͖Sĭ@)-”+-+HV9!QQT@ @w]+C;1xpYkÇ^8۩SM#cF]XQ VpghTGL^F8_}ækV-۸Q'%iBN=q~x=utM40.W.\ 'pphj1l[=ha( DArکģOyZۿ%KkZoz_ -Zl8IrAӞ˺mmgǺ|D9J_jsv|Y|-["Tj-N, JЅC qbrv_yARoR&*CkH!y<ͩ;~͋9[džVXRpNJ)YmwR-]ǹ[+^VR eUwq D\05}[ $ĕ z}ttNt-`^GFV^qWpmX*y(D* %rbی۳">FĕaAXB@/IdlxŲwZEd6)1 #i4*gk&I#V@YВ9Jdž)'s*Hk:U{r"@mCmT=mm+wU\SۨmPDf *x.~ס,>蔕- ͛or2ib kBeB#ZĤW._lL}s|l1}$)*/A̡|^W5<"jiiasU6t /|;An̝9㋄rJ?{ky( 5Nb@'aivRZL;sM8K0[k6zؑGzAe^J5lʂ@hȷ,,@+L{ -iҔClDUu1Gm+J'3N@*C!"cAѻb2I{dfeW_:kJGF9NukVL[ C@i8u,Bc,3|r8U@jSjvmxv;XE<#L2VUÔTʪm/HN(X:62~jCD8^jQǜNngغyK> /%, u;Q,E lϝ/ǯX>6f:#yDL4YF<ȑ.k P'g2W^ux[\ln=`&USO=48xϗ;"!\q6|}c)wt֮[#)L-aHcr]xt!-1EK!Uy׬\ݔNDJZ󃷿 ~ "WzFظLEHD$'WDc۞J^'f /wp@ &9L< Kݼ`>CGFs̜\[eCk>+W.l'Dd{5 &pqBuAD1=-<\w D$$I{Gu- 3 3 2yʓ]3i(E]!R.wm?gI%Xϼqā hW6eSݱiox _Zږ~Y=wUW\yg_Ɩ -` m6?~ٲe1 Je1lX{z75, u<`ȝn/J|sq }U"I$Yv=qZy=HiFM+HǜVzq/kshXm`ǎZ4FVDvBSN9CK N5@/.@%rKђ`bGiOyn5&IK犪P?n)'E1-G?~elUӥDf, 8^8n\>65܌ٶj5,P2>D>z]֮=GR;4DD!yȡt}$S*H3@3; wMT׼<[_z}V 7רH] Htdh1焌`Us%+9S/AA`rqUcz2T\hnWȉPIAȪH29~ܑx֌֬1yWq$ Ts 5r }#m!Hı&^OC;y@M 1_wzSthtU$l?|Xn/|ɹ}ҳy{6SH$ ќS e+zI[QPHms;fR kZm^(, Y  j\APE6 cS>רW*Lefy>eVhe$ 1?t1ý' w34G3s)BqJ=iaeIs%(p̘kByrVr{ʒv?E^Š:\ eѱkԻ=f UI;0v患vȇqĸa=a?oE[cGD #^ wN%0- n=[6B@֦GuT.jƊSz{yA1l/=9o8y/0">= 0n"s3w.tLSm@> T`*rfvySR,I67 ބ3W< O{7!/F)&ʌM[ }c^W]k*cIA]>ĭX5B YũE BSdе24 ""Fn4D5G?sOj%ھKhC"^D>m؞E_pBJ; " >]\jB&Ҽ7E 5q$g,FhVp +2 3~oq!7H$[V_G>cd &L4Z%^!iCg'BDO?)jCfJqZ9l*ym[k}?.Z⠃sl޺hLNN\+~ TX핒z6x$P<9'>~=a@y^(.}ٝ E-6UJ_][ubjs= bHsbHG<ݿZ b&FȂgm/pEͩNoQ70ʾ2]̩=):Es~ @` pbN Q =X?(`\w_0Phr\ȖrRll"N=LllIOzCB J>-d}2D J룵ؾo暿]+H,-׆*ET5꥞ˮU.7 㘈ISM!FN03V }ˆ3m=!%3K'vwzLDgvEK}˲ py]}rF/!(~lץ5SElF[&9WwgQr>maQҔ(hekB8K3 ;{RbBY e EoNLsVc" w,!Pn{ ]ѸR 3s2TTĥҬ?!~ӟV/KPE'bvy^zJ[+{c;} V*h<!3Q]$A+֫Q*zX}!+ ǝ-ډYײb0d wV1,i%6To+ ? {G[_(yHIpW »=[\[ȹ4QmKVVB >%.>1lBR܅-~p!P2QկyS=0ɐ<+n{=~ ̜[qJo(+7>OoNljnh(5@KEv^zhYDA0+uK s)Xk0fsrl]o? .2r !^JqN]AD(2DW?'{iԆ* R8` &\ gMƘM=8` %/>MSKdlԆq PI^3`I&I UHjTƨBFW|#}^!Y-`UZuAǓK8qg!*!RF̶SLL{ADJUy2.EH`֯]׾ox]VOndFj6IPe3#QbeB5dbǝ E{R0(&4LE*S(^>Z4|eAqKezyh/ r4k`سD3Jmg@yy׻7e3;VQKBXYC"7B$$!n<{~]( 12Ua8ԫE \V`d \[աD@2w|@5A6 XhBDb]:H2eu<)/z Gz*U1D5pV }}r`DL h ,}@c,PmvAh5#:Z_G7׿ի0S*NP >GCjE6Cf}|{K_G? 26z"O AD 3<7{$ߘj$I꜋(M]`'@ P 6Lya^Zwދ?O~K_|OtӦ;H 0 JвJAxC^ۑ[ԧ;i k$ Yb7M> Wp]}&s.UVFPXU(2}IO8+o{.{W.M*:/b0I<}*E3SgӳZS9 ]knTDCCUso~g>q\Qc"⽪ A'%8ߨO6^{zΓiQ3u,־ dyG:cFoͫ@.;:I@GAm UEgзVǾEg(2>7^} Z?MQџ>%8Yj/9HR$A1g0 1>sx"* `B} #FPJ$8wꜽ?NUuuOLLa֯3U'9;9 zLΎ J$^~[NͱO{_7z! R %@dj $vqCcG> q0UIa|;1ǂ+l$g Ј1䯪IbROЂ12 >wE$Phʙp!;(U>>7+ſuܦB[L8pl]v^by9t+"S0s4% YY9C7M " Ͻ]pƙg}NNM3XE( C8kTY" 8U %fNNBlyAⳏ;OnukƐcbzO/W^unϝa4F&'Jl!2v߰iӧ?R!81 f3V*# 9̹)5 V\gLhh !86fzj$O>?zXO#lT]@FHmp}_~+ܸimS۪ Üjׯn߃oߧ~{"qbsib;A ;/,#y-mV0#`>S6CUwXQ,D»i[o099}/RI=!y# B Te.ӀEd&N;яyę.Md\)td"QM] Ôp: Vs>g!|'? K^UB*qj *ζm|Wk}h.14+lG%GǖsJX:]jh@& Iǟ|mǿn׾S {vXpZJ[2&QD)7׵P" 抁20/]<<%`B+8/@"0ofvkVrǶ MCw<4t/ÇTМИ(|z292|CC3p_45UM&cL><D\/9q}9}CУn74s7ɫgr)/}-SI lؐ 8~s眽n ]*ˇFLRȳz$c"xAbޞ-adMx ,kjBmnFd|65WU:Ze|bz扱|]曡YMT ;^:@}f( ]DKkU TL9+Ȱx5Ш*H@J m*j̔‚i51lXD;߽/?~\}7m,jf|Es"uӜJYVM=={y#~݄51Ҫ%΁c}ԧ><֪ ige|l|ֹn;ȕΪ5Z'rYbzn(R6#ᩊ_C+Q^w dR!PD+Γ„VO| mL󾂡Po"r{yeWy$b4b+Pg2hd ?+fY2 `0KԀTPwlMb\NG'ԋE qBC!/=yWK~{UW_{?MADD `պV~{n^_R*iO!Y?3s CLxcrWo51 (,h4N:s¤y-4GХőVLWOA ,Zd b)h^4u $>K1 Gi!)WNKGӌ_TX̓DVϜSea-~ U|uP S:fI0 f]b0]@" ;{^$^/F]"zO2F-#j蕕(d^Nɝ'*wʱyG!g=o#\R y|>Hw~} Ta/yq2Mn)RrRz}x?7Tl13?Ys֡=Op޷4_C^XF~pcO u =CTc{  ?[_d;(jp#92>I鳼wMmHeQAF.`jē~N , KS- V*)M@D̩<푃A봃Ytꐖ_h&%gaĮD%rSUk1cϱ8ۜ5:`6}ʐ?@ƳM!S9q٧4|I3P]3G 怙 FDAEHĘ/u AK!s_6]C:j t 7{-WQY(( 񜧄,|r4$3Q:lư1 B'QWCA(XR( 8pZ^p">*!m!r4 _nEN,EZg #KUeȥŴ`I>"WՌe4LjgoJjқDnX -c@I-I|iPL1N߰iJVbޑ®>~=ϝ}IϹk0VĊai|Ūԋ_ҩmx -w y;ow|MԕQw~?ؑvѮDDydiϜwi󬆶fT=Oeu$D VY+"OYHJB蠶3~L| /%/ů. r )+LNny`U036m|k\} 7[c" XF>O趚ſW_q_?yꓞ' Fl1WqBJT!2C-eYb5p{{ մ&|T]W2"^ c.*х29ϊ칯F ;1QC0ɒrLԳ={d~*׃b^;gWG?rEJ&*hmjz};+Xc+P  xk @ˉe^q%ʗ{N:WL0[kZ}-bŽxr}Rs 5z?odus.vNe 5nW 9 fiDx:`"@UԌ j)yI`_X>1$玟=3)?'DQIE%2*s>76bcg$427jJMH&1\H!$ysarK_  4"MC(]PjLҳ^q1LbVIg `EQ_˘ n--Ik#na)cUQ,3? "Pu"މ$%vGF!CeV~鋟泞U8q FdS{?~\8 V$ZZq (rqb8[%NkLeW?y?zV5v:>+E"H{lݒam%N0*4wt7HD|. Ġ{B Qo 987u)L) ݻ7x`ll TڶxI0y(gl32aՊ3?:ԉ$|!0MY(BHQ$0%%9Ω*ĉqm7uxl2tW^Y-x4J*P}5,jnTYF]yT/ ̷=uy-cnZj[$t4 ]R([KGc qV>:VQbJ@(X\c?CNUAdTՊX+x6*Lb̡.I9( ĶAC*XZ ,1[uuCÒw*CXY>^zu֫$ ;]_ℂ'V~qQlӳ = `/n\#ݻ 'L-eG5U3zsg(^p3^X/ THi/(hwCr '~E;pIDHBXÝ= iSagh=¯@͊ SŃ 8ff3WnU_-~(SrOUeE|,Y"a.Ř;0pW.q*نA06,at1mZ$KEX4rsfA4Jk"`wy#EqSUctwsí %A 8(Dė($KikKs.3lq9J\9}|o5#-7Kl {&iKOE[L d'%( 31q6xѹ;l& 0O֤0_kCp0hZWL2#fT+P2áP`T5GB&l`4dh x)vy@|_Fb dUu *.LZR $;(J GAiXU P Z-kZגxrjڧ X8%9#V!bb&ebi% ?=FSܧ^ucI|AkP+4u \@I,ypuc(f^P lbM`rma^ՀD6>ik4lOؠR,`gYY[u"d|%h'С3^ǿKTA κ ]RV$`@uQ`R{x8#߰N*͓S[6O^s՗^z߯ͷ:}k!w~_wv<֓U93rs 7:/똻Ȩ34zGt~7}2: u!&%]2NԠũ=@CO   6nzwr]OUJ03nln{k֍'NâPlA_9\B0?٦MJE@og0(G?|މT!4FDk[l B8Uώ4$h9o??K6oز;S6s0ByLW_ϫ&DAk֬9}|sA-֏ư_lsC䈜Susn#v3!@Dda&kTW'-LZ԰u7lf@ID6A8FE-3QZR@Yk?PoA\Ӌ~Ӌv߮?uÿ\ ]zۭ;=g{h  eIaT<.Rb&~FH2a{Mdge" fTPk/X) ?o?ї/꥗^. 2բ/ɖ lf|?O?3?g>ا>ɫW0-,5urX5gt#Q_<0 W=;j<@瓝ǫOCa[^Ņ-/}Zs~&IŰQ}Z"*(`?;2djZwizh &ԙݭB߷׿o}jr84Z8Ns{tUR 1 ̀W؆%AU [Z09'QTvj X@䬰aK~{ ϊBZߓic`8:DSSۈ@fC[j_W9?]uR_qjR,BU!fRRU(e-/}'>O;|;o٣C^$s(&ߚ廝hW`hPF۝ņ=m Q_ P Tӄ?BQ"Js:쫮r'A N0qwl(Jr-_W;)Ox+N=p,tΖ-[s뭦T!2>_^{x˖-[nnx3Ka`2mEY l #"UvT8E55U-U*N) We@,L“!faI?嚫so|=- +wy#h+JAAUg*J + ț;A/{KX _7FA]iD`H8-h› !_c̗UW/dJT)uVR,|3s@a@cr?Iǜ%sptСԛs[߾yMes6nT 5o^$$LW#'<VPPZTs*ɔ[| _<'3 xUIf| *2,`2oܽdR k赼OI.\?TG*?׾=~xXY16&V<">2ZaDp얛W^qkUdaΎje(Xk՞[nݪC'CSKv%cno;ܸ4>A0Sk]dlŊNnCL*AàİQy?#Ϝyb5IqW-6W 8F{{2RTc㿾:wn@!WrBsV *fΟ8{ӯ+WcSRbElPefYHfbQ ޅ?Eެv[0\:e鮊]؝*#G e hTs.N\rɥ= gw%O׉&[TW짿87.o.Ȯ^ǀi[[UⷿǝiĚue!bBԇ4D aòΤgm:sTre|o5vCA=iKbCN-7pfp;KD϶6;ns^\csúi{B 2HIӧaG@} JK5/e MV_⚫XeX׫2Hr-WLz");E8(Pn<^_B _I.Gs/Ç{z NnhӮ.+.q&&D@< ED UzN)k^7-xb}fP& T Z'&0,wE(s9k5\Fa:ēS~s;Z+x6; Z~SUsD%_ !'μ2(4sIOyǹI*1w_:#ќ4;i[KD@:#DC Nrt=b`g}.qbRAZ䬼-H)e]]Yjֳ1Xү'+ImWZ%|A@-"m ;^r.oh\^¬R(6-mUMW½{>#GlxfO09Li9#dcGo5ۺqsǿlMpIKC1.̉yY]ө@DFe, N ؞*?sgX$*Ue23ɌMG?wD␣AbÏ_zU˻E5D5{K2P.WIe5! LNm{ÛNͥXT)1MiCNu^ֶ[{<`߻}w[z͓[7n|nß.ƛNVmAE|tIw^ׁ4:0[}<)G2)]>Ht.yerΙQ9#!CW0/6ݨ@={ f|ȡN5uSubԁ& ߿ƛ9,[ kƐs&V~KU/~ʁxxx x K_} 5$xՇ|Xd)$g@PW|Dl2 !$c?gzA׬_ko/6nUSӒ4@Ԫr8_f{ Qa5Ų1!y^蟨]Q]ߢY Jw|w gN< (XIuC+fb3noC9`wݸ4oNMʜ6n U&G66WYW}aweǼ!'Dlj#N5tuL 2T}~7 b]w̡W)c(@"C,ԶY1vӏg}~ÎnjCQhU,|_:=\2nrJlYSc)&~㋾'נENK-#YEt苂Q:GOC[[!жs3[#ەH饵+ܰYt4~kwq$&K}ய;"c D$L-wa2&탬ĎdS\c_z#B#Ÿ,γ@AZo}; )O|:1DD$XE< &BJ?ja!p Uh 2w匏}AXώ(uTV'`x>|?]Sl i~2 ҇?vtd52*J%mk:uz U I,E U.:)*LDA`sι 0\f `Ktιi)+fݟ U֊1& $a8xn!g+~R4J0#" uCP=Rb0C[owU4VsU%VRe^AYtC8&싄a$SӇG>_$'k1cxgU%ϯeТ10pWrȤ?{g x7 Oyo|)'v!ƧUӽIY'V:y'}ዟ;k۶F֌+&]JuDŽl._7Cpe*X}S`NƵXైj<y1 [^Z[kD$8Nz,^_Ź)zf^B: yM3٭+s#jHGCa2NIi z[<@0$bl2}2}!`q5{/~9 TaդYC~X{Rzʡ9*vSO(Ū"Lin ɳ Ă}8RO%P C@aG/y*>'a$>9Gyxj3fM_O4ϙJ4ߜ4sOPa!"".6h%oa+.} 92 =6[ly{"9X%͍y/h 2jǣ/\q8}ߪTH(J6CM1ݯv @b &uX"gUDJ vKKr̼ey\vi-!Ǭr9jjmjVJ@\Zڤyjiw5oz8ggr}B\*1/tFӥt *8lE!1SC21 UGinIQg]_3 Uf/S__tL:nfoV' n|C'="Ƒ(t6JcקהZ[((!Q CC8(^ bx@,̯rXpNslV{8g;VqjzYh% }{:6J&Ҿf 'Nև3ziJ&9m-BO8r٨oR[d-ƊgvTJ@*Zu&5͢\{*΢HM~NAظyM7߂d=$ >;6lTX)kJh $q,+3X @$M|!*u%/&6A7׊C;%/xO7c z!N P^1^f|YHDL#D L Z6ť,ǜ?:GW"MӤZ6>ﻵkA lu}=Է:aބ %0@ C;9/th|:&H 8LPSp40T}e.ngWBYV‘AfBj$u6"ZDڦ-v]ʚl4%T8 T 4f: 4&|af1Zk̼wlRK哯XUqp8Hk`*`f]^H͞AfhlEX"c Lq 5hn!maV%f(ᆛ]MB18/{ NXyͷ߶irgA[ e^-TDWL`&(7ҕG@ZuP`SKtd%Sr݀ݨr@AdU%3( #"!R6~kϛq1 QםvsON8ҰÞ^C5#t/D iHuhH VM3') 3UD"R\:8mՖK%md&"Ô-:{ -cl +&4ʊˮiJ+g]kG>VL!>Er:$,qpR©Z5*_zK~pO.M82S!6W#b`rY v~C $JDN&́Xk +WTn|3LJv&EeN36C#(uKVZ]*#0fL!9W4n}SK}~z%s޳e_Lɑk}Nb2\qw` nlyrg׾%(#_eom3Z J²ORol*e*ZPn/sH50N$JXqV!bzޥeV%yR5N0#0 L.Rsx+"2#%tߙXar`Y 5Wي۪fe&bsm)Zqi1 Xg>B'ߪT)<~3)m,UΒvI"^ H**$"!3 g !LY?YDL'T<$Z uc+nݧ]F;;ˮsQtNCkB0104g"\v4qS0iT2UL8v*(56fw]:󴓤n[2d5ӻ"s2s'Q&6!6 ~QIr<+afQ=epf8þە:V)%qsbzz (rcZ琊MR,LkVyXv8U6e20MY.XV@Tզ8F/ZPKt~frq&Ȁ@( P rKɹ'4 JZCr 4j YY8*(H$(7k@D-TZ(tGgKeiZ6&>C M-;+S8 Q(l\ ȹ:y<3 `3~GSgmx_1sW9ʼnp"  JLZsh .gdthbC?"Wc}כnY5WnYl= ?+iT|?zũ/Ch& NN}KMNnv"RiFE1~Y1d}PeO, Vno<^ADMn>\R@Q|:e@̆4 ądV[<1d8ղ2q֙< *a$~A-f!7 q\ڭ9A= @F+cۦg}jmU(s@͎/m!EXCamӥ(}ǟx~o0%g""*YDԣMYCq>>sdY;r~q^:  $~)ϯMW1ޚ&ɲ1nxq*Q?w\:0aT+2pdr/7'jȻmüz5kWsWJA:zNd}կ/'R%apRpr&69S8-8 ˳)yBtY{91Q.'‘1Y hŖ=Tg NsPǸ$iքA:˪F=LIJ-rQJ%N!ٲqR<Ȁ1ʕ+9{/|vdCpblCP+ڽdUk[Y*ؤmk[`ŭ[;#Qh Ǧu]9. &(y2LS}er S .Qw&H&H@ʬL c eM$٣Z} j T*de,L(x/Aդd+p°)9+7oĒ`s=z:3u K-ۭ]A0mb)ܮb~nV7ʉqI, *A3Dw~3Ր8Tr.d4! )aGuG= O8Ϝ?OT% I^4>x[`'9|"7y|!ոŨ;\ȡHOIgfsP_D0'9Czc4G]x8Iy`1A 0+iڻ|6iW 9f#Zm: {3Җ.FR!NāP0zBώBT?]]P+OTgA|i*/׉~ \tM4}=_Jɭֹ;{.2ښ`nٲ%y/XyF_J! oEtɯ-'Y &a=c~FyZACA1&0vY=!B5ROSZlmt 2SUO!SޛRN?:0xނÆW 9J+w')qȇFr>2Lm i7 P- `Tc2C4N9 h5J[?MW֘n @)o"#5r2VNn4w3>KS;I"3"s^em(;HܼyZ!2iqN&7\VWLᬚ.\L"mwށ o#%c*iI94T5µD] X֥ dy=xN^Wg]Vc^yMw]]B"纸8Y.7L-3rf)BpŁIJy@&u4^wٙdz;K/56orwʔcJl5zHcMc v/ $ND&pbVz3+h/i4[UՐ$qF3T iBzfKl}wrGeW\TuYMO$]˕*YŪ9뿪6eJMI߷֧Z݇rXoqٙ'0:\ U6Nƛ3p~tJK~s_|ϰ?g8H/Wu. ǚ L0FY;l'?ߝZFc٬mz ]ONLD";1Oyjd(q.i`% &,Zw_HN[7 hl_6oJhBPB+DL fc%(K9ZQ^">Bvn6 Z= 2uS/L4v$Sťrz|9 €ly_ Emg!t߮x6Y#)8tk&Vnn]ЌߖC_`Cu,ry I TIfd, hvt뼘)'O%E]gB[eݼ ,2F}) '5T8BcxS>֦KQo9q5A[ng?K@h h74 x(}5}{JB0Pnu _ޙ |:()!.c3:}zL"e2T׿ ioU?S-]Hh`xĺ5̳vkǿJkz y|#lϪe-. S|I|e`sA2&u^dYIs~"gt݊|.;u&'Ō̊BAHmj=}G<ީ Z_{!˕2QQ&)czfi)~i W ڐ')gͅN39X|' Y={ D:V k-["&gJ?NU9>/`'[>\;Ʃ 5qG60W[A`]f c\. 䜸F6KsH3 }wI)s5,=o7 /Dv b"h3/ o4M|(Ezu괬mE8ȺCڷm6]bX?8o_;_eW\iԏ6U[?{H)CT {>1Q  U@;z*hy"$w AdF9U47eVUG*RC=K^^ 7Hܓ?_\͐àvAd  o30%p~)HZI,dꛕXz5 /M3=o2OMW5@e){ ~q4zK1-<qĘؘi:>ODo )|TU@ad﷮LFub!׮Lgxv,^ɬPK!#L gD/Ռ-SU3h.oA1C:&[1u֙'ZcA`LKn {ȑZQr7/} '6VԦt"3kJۂDNj Mԭy/N]zl-+xn8¤_.>}Gp@TqSr/pP6`x=k5UmؓITzr["eR 7@e@"6n|L̡ٷ ܶu5{g YDEK CcUH 0=щ(Ile`ʽwr*"yР& h7%+ 1pоOz67gV}l/J#"" LOOcELK"n@'ӫV=OHB$[&?}_|/p4dpOWU_d}q}]4;Ɖ} ~7zeAX/-K @2 =r`y^άPZ뜳Ke+4ọbdUuvi޷M׭ Ą4vY\Qg1b&F) :'<5z!5I-VU(q73RxޮHXҗ8ycDr#ZVǫPf=rk#ϺPj׾nݐΒ/" 2k(6ܰ[I9"1*<~A&](_-/sHZ]Ƕr^Ǹ 7Qn$ϒ6! Ԧ/M =y쩶.ؼ!2mN A"o9JBhZ o w}"k66P`Xpk_;wyJ=*PU*q/G25g<lLfBODAxӞFox%眀؂Wtۮ'Ɛ]0qbEX ԗ*~ʤs1 c=KZ5h4J%?4@\rLU1 BHS<:=`yk|?~|_NWQeL@ GBjr`:p${S`D)¨ugv/xnL$qɴq 4@`wCys-*dXRh։ݴCbӦMP!RTsP`!S˗3)oɠ)HԩR<˲U( ,L;CH@ˮ9A4/ [_S`=a_UXT7+ym߇?qmRyu${.oc8mQzM}ѽ~NJьN)wT{6Tt/*aN̒ ḧP8{ %_W7o۴qkVX@OjA.G^rf|*R<*>7Ʒ_7y2 -,]Y:D.5vqOs]ͳ>j-OK~IJjZ!$iD&c;?g{CL NV[ 4R.y~*`3 hTصF%/UQ"8񖿴Vhw9wE=v[13aӶnMoxO+bRuDt}ϑWvq<#nxh<]$He"S A_gSUQ82m 9B$IRo_<=3?fx R_[NB`6!&*wa9/!"Ք)NYe`BY| TS6DG?1" 8J 8 *[w?'?~СhY|8.o?ՒR"9VsRՉrs Xb ZܫkE8FE ߣ mx0``Y+KP]l2V&tNݚ5kƷa!bjIr;0v-iNϮ_a} _rO~>>*(E$*( n޸jͪGx nQ̕*Ą6>+V%$6t'R$ %z熆,s_",Wdi2XITIDQ.+^G~>tpRgmӛ7o[^vo0AJcDHĵPeq$+Voݼ+ѫ^+(U[jC _~;H?"lݾfwSIwZ&]V {ԫU$"BB(FJ=N5u^o/ b \R. 3!"gjEFE *MԓNvqs>W p\jqmz[Tvju2V=ssl,hXH7솣zNlUIH;jZT7PEcZ-d՛O%7tZҹR`T.ŵ?_u+|sUkVYz5޴imwޱurÄwRt$ZW*Խէ= `VH vKߟ=0̂tL"#G uu4> [ۈ<Jp-=/zZ{(䨎^|R5*a!TjӆىkOW!HZKLƙm#;~E &ԪqRtǝo\w'}c9 U. xnu*D([FPG1AGLG#DԒ8 @̽gP ~\Wv@x57 vN$0aRVֺ[xm%D&q2U(TOg\ w< ;y'C+)bYC_˖0Y%s\~5в`&9j3XusiШfIf|3_$SXԨxY^!!Vb]H 3@B3 38ڌ^<CWؑaglh>U sȄA ̒|T3ÅvXy[DpMTݶ ҖM[ ;_+Ħ2D,l_k2$cdf6b)i%F"@ H3UI_=9aJ])s1Q@0h Fsi=:p6-y* KDz7 Yg~Krm D6ov . rYH84ft$}jҺW[]/vR:]ǧ>be+n+'wo X4 ƾA@N)  X/ ;aDCzA~SǞ&ZBޮ44T%Y1KM6 %Q$rȃZcRC{y. 'x ̾d#~o"}GdRGwN[֍xU;ZebRurXlN}+>=񱏜Nl) <_&j5Ed:T9%v,.AJfY'+_in˖ʺk%.yy+Zxn?i+ h"1xA{) )Myag}ۃ95-Tk]6]Ӄq\.[_tC zz։8]9ݼ8odw bYZk}ZaW`=EjДƦn#"QVN:S^}K@M8N0Q^ -1fnMzJnڸo|NjNyٖJ`6Tk>Ag}G=Q1ƻ׿UKRFb !3`!|~އu1f8Dlˀ(6"N{zsxy 3yPàq1 vhd=qɮXz'~bB:;Azn(HECW'5j5S.'6Qt-Q_ϟw8j$-_@e8"}3H@&4zP9gN159<:gű?y '=}2 ʕk Ll 0ԉ>9'|} ͯyG>׮k4#"c0Q<"FYYCN&7;[=U/9p=? 0#! M "Ęz3ZT?S:z!c֨5Ť.yt6QZ]I5Ku+&l]zRLve )5RCm7CgDe"J*ZB'mo'c"EU$&8I͕)/22BQdNE ;%M`(O./} K:+5eT\i9I[#3,+2%0v~dl~144E^H" DZi/qI &^Zf,͸ "@{N@R*USUj6mx?/@e0'i Ϭw)#a,uRq)"qǝJJ]P1Y:^A7VYOxʱ]cBQe-EH@mc矰N׽n/;JF k4Hli{S pqU ŧ+_:Ob!`FBZ213AhIYVru.D3/І苞K9d,Ew!ŀS«_U+w߹a[)XXa6HMo-ۭ]晟޶(\,|QrϺ׺`tuИIDd&A!i@ccb㦭zrEU$&blȒ$-c^VMhhz\K<}邋~r8dì1)ATQ02_5-Wl.*{#^ʗy+*\u2aBP"12G! {97%hs#")^]P=Bfgj̀d1L-T i kWvc? 4S9 ԨVy5G.a%mC&쥪شaCVyT{yYhEPÕ =ɭr%1A9 r err}DC!vxL*Xw.ƻ%3Z,*`=H0<]Nn2QY/+lpm QVc_^^?+W:Q . Nmsןg<혻x1K_Ge k&ƎƓx~|K~צ8,GQȄ $QUf6lj6QQlRJɭR)ImaסcKc~sԝ`fhgU'W];w\}h 'S;zhח)-V6T,Z.!)p}\D1O}\eҗ:bU*N`S_LO[fm`:?dT1zOl]@l HB`CdLT0sQŶ c ןѪqlmCc|-a`o|}vז *3T kC "6*(mWE>Ye[nUe(Rj( MjIq qUUI;ut""Hcre CZu"+u=`@UgÖJ &ܵ4\WG$eƓOŧ|maTrb-H՘ZyUV{=>9 <}KC?6e.G A5ѐRpi@TK^j Xoxg|,ATY.-0 ){\u7{fxx@n ` κ 0ۯ[}w_~W??u 8 8dUըouk>z~wuY A ceȊEq-M{!9J|-p}]Z٦T.#@nR)@ 2ipWAQ"|/zU_Q-juO/zɩ/'>;¢rhnElD.j%l6ovW_K[nmrzu׃TeikV(sZܹ-Wu՚#⭓T*$> _g/}֯g j "12ʽ?>8mn_7n6o}ۮ6;_wٟ:AR.Gcj MТr3ќy*WVo/Jkm#] "HA,s7Yf_1?j@>R8U``(,[%"V0z|g?^U! @VKֺ;7n*W\~埞g"*PwxjB?畯zsp@LeՂ8U0L#[3_o}{oƍ5p`@Gژ|u.;|w_9Qu6MiԺa ^ _S0{Ak f\̾ؒYSuܞ >_:*BdXt pW_%{|CI];Ԟ _W4; ̖쓟~8v((S%`aU8::߹a~Ejۮ~#c="J*(~G@^,I~2¨Kj0ضiw?tq"X<\#Y\=EZe7+fĄÍ7GĂȨ!bJ|%Yh ,#t gZ2%y&fΙV+]TAD*hduλd=NLrg`x9Hl@vy_މD90ao2e꿾SQ c@$sZ}kS $?ălNOx3;\$R`TYb 29(ؿV",MD㕕k˯?'?oH oяG>aD+Tg G.Noяyy;'?~xDY<"xwchh@Jf Z5Oh1 U^5"R#,,s}\|͐"sNO lTE4ތP&4OUwP3#~̉>"MBpɭ[7|sH ؗkC-IB;L>quQyB9jXt:]]*97N*0*MLǯoy; [`~pQ;Wdb+kP ȡ-0׼C|Ͻ;T6AHOns ;HǪNDȖk RvYv]t;e˘} N.Xj$zL30-̴6~PaT6WDU""jXP5IlMYaJpZ,Ȱ: I@tݯ,2^ES) H_.9= Z=^?V*pRq!Z Umܲ/>~M)qa#siEa(>O oS*eDIY!)P#b|PqbMF( U?>yӛdjckwH,9kIDX!0U7ݱzĻcyDRJ7]7 QB$)yjGcdРt:UBl D/,`^\N'?P 0˅5\ 61A'aHWQD@U~kUTIeHR0\C4ttԥa~gΝܺ9aXuAXBh^7[7j_HhS@F׽.^goI# "6 'g^7ǂ"HQl9P. *WAKĘujE1HBLQOlrĽ~N;aJA) Xzoo%"%Ze!3]B2)ËBE_ϔaui?AD] pVf$B JЉzy4b'˝?,MJwy|Ūz8@qD'I261d<& <2? y@޳a>MŁ4=8{oyQ 69WiEF͸V&X҉ NjQy "S U6yWz+R+oL:vL@F(!HA:zPfsb2b_g h䝳ъH1Ʃ/㣨CzKVg֤72>zo:4E|7o=OW#%Q:Sy@Ef7K} ԬG?}ͦT*UjRk|&0Z 8%Cj_{ ǿUw)WJw #~ FUoZF%-Cr/bEMVID^H':5s KkD#p[ ` o2*sȴ6gutɝnM>|^1~a0Eo1SN^jN[+Ip8[Uk/~KF$рhFbSAJ"]Rɿo_j,Jd9M8c&R%fqʤ I<9D{ d*@^GM5S><Į(LiMU_e|8t9N͍ft&o( &|'fz!A9_Q_l Ta*(8ħ}+G6&H,`495]Y[S=~æ*@lSUR0}2iQ{Gn +"Jn[EUĩ*!e8OYx;֯ y= \uG7PC/ $"/9DVd":E#/1=KL].a!Fjg: lD8񸧝OTкV%"AP}?89'YQ# oHBϝWX%`*R73CFg'n{׼X0ր&ư@lFZ"nJ|=/4>6dKa')wkAϮcѻp<,=Zl)p*I'=g}tPxI8Lrժ'{ss* `*]0 5lCB1BR^TD*b|Iwpd,AU-h1G᠘Qo!/Ą"-(uֺY„ vf& "A4Y`{hqإ=Jyw^߆Ǐ)PPʵ%˚w꙳ֺٺXD>" J%ʘS%UEH"DdӞ8hp_ir krVUèO~Ojq @R̪0l e9EcU JAGBtt <DPw.K " UQM+W&VnYjE`J4(ZB窾B#[)% #ђ+mBk!ќ$^-q9g6oQivɕ{rA`mC4c|Q^pi@DL`L`=ŊYgɌ)3\ _6Ny_̆M)E,!։U$5j+.'= :uNsҟZ 0&|`@?E aU-ezW!&2Ln59%eqRTHH:b/e 9"?n@Yk =Xb::;tawAV57v eȂLpNxȃ¹_:iR L8=U-\{ɥ?;ceT,.7 ю9+1 *j[6l6)z2,sÆnwJ[y3Caֻ/>%ʙ3/Y ZrSw?g/ nm h 3ɃD塖 MkZsM$z5=k:GC`ep'Ũ8U('|`:-)N͂)]X;*UG<>_⹻}ht|l]"8+DF2]qiqQ8DmKNg|APvzZUgOgF™os)D sSӵVq ۰qs9Fh)ڃSz|Z)UQiٗe/mqNurR|c3 Kr gGKC4fB<KAD h")C"xiMMJaXKZKV]wÍ7GWCpgsnЌ C dnz8_iR˻}k'RE!m]-ӼF5XڻLSؒ(uGK_e ZD,Է4ƳX ylnoEnRK {}سZ, :VIUsUoK#2JI3>8apg`͛7&O#Tؼysdzy??gn÷Gg32M==?uFk2o#C6h #7a cdH_ϑٸJ aR%"%*E(9c8& 0 L&H$ I,gͫb <wqa8FnTkp}~S97/WJt 3>9h@v ժOE3}Q ∨sG]jZ<KG5:K(88Lnh9ϠG3?$Bq#i[RIqWM,FxDqԤv~ ԓoqءyjts5?Ij:`>(eo"?/ԧx|M8N\?_ß>4IieSռc+P% :hyHjHg$^`\z`2( 0dw]fu9PiwaGyR3 k-i@^mιnX$n~0 0/9=#9}kz'Oqy&y}WDEvMFQےt7UTt#5Acwwd2eeƊQ1gĦtB̳8Hy[ ZLAjRE)*$7H[ܵ-W`II܍eu_Q6g|LD_RfE}PVUԒ8K'q;dw?֯].;Y6 6791 sZ@iA!mnM𥦜V&tBG%Ӵi&=$2;ps7^xo_7l:%J%m}S"v˯c؊{mlڍu=7\ʬ" 20/@ @~q+ϸ&{r( jfQ/1ǪV\6ɼevCzDV/O[`K2$t,\ܘ ha螛-bݤnݳט8L?D'V~*q*dk35)ThXh9yc7v=*F.;%i!m8Dɯ( 5(%iҜӻhƭ\ ӺUI*\-)-] #3O6m 06Ҟ*)P_yť~eŊիWML{+W^Ey %I$"LDggkne<:F'i&m 3_Q4OSWJ3L:={f6 ]e 0``r& Md09GP$0Dƀ PBwwf~TwOO;fwfuϳjRWW^;?%gpZ=RhnwۙN=+g[F-6ZAȖ@XSι4u`"2 !ţY]CGXGANwyfk%\dy3$rUA ),HIkDlaUjB}Cc-Guʆf$Cr^xb!9tzo*&`*CzeD(xeEU JJ %bNQ;;/u"SDJB:Z 33,(8z,)' hSWn"pW2 _Ǝ뮍&ȣ3?˟.XcVZBdAWɉmێ;cN{)rjىZVfE0< [n*SG$H!7V[ E͵k(\f5"Jo/^p_׿0Q GS*27wZ[_1XK!37|LoJS߾r:]pK)70Oi bg%^eCDCdN0 XSԝ-+}M:6`s|a0=@4Q7GC[p跷_1J3m+&߂ƅld̘| {If,yTa=*)cz fYr82~q(8ef] wIBDub:1YDH>pՁ|N3Mjzy16N-M64C{rδ>S_iQ-:'YT~@~wwe+ D%|MlV"#,{{tRng+ .ej68GzqPj+XUD(I<8

lI(:`|#%-P9GXK37wGs qUao:K_:iݗ\1,f?xj`*GY r PKUnxtDl p 179G^4u7YUUXx.Kgc<6~f*}m e0QƩW] G9X^^[x5!_ /B҄CGSvޠ뼧ES@&ڗGĨT`o'?< K0-vbݻyvE"*GZ1qQ&4(LN{Q7&qkΠZJNW"6ਬ\lɋRq0Z`"oGF}>E#;-f's.elSrSj!i=+xјÉ= "wp #.ejQڬ;(gjB `@!pJ3O]{lIP))O [N{Oz  RRC&I@Sve,F,_"+IcDYD86)!*z "d,DLT_7;4! U'&&&cٺi-7תկ\hrkҨ/)&:lm0!ٖP "c 8*" #X(RZ ZVñWםs"iIDΊzx#ߑujbDd:&T2"x=_T4(Ws9$@J92f,b";9`LL+?Dž"m3!ݪHʑrL ۖIqO'QK 2VDְW+a`ëQ ;FE5m&J"#c) Bh&M/客,"q7aKy}d -4sebVUnPbqF6bբԻjfFJ^^t *`~Dj {癘@iJ$Po q# Z9Y3g_+5h~=[ԫ?dfV@(z+x(x!@\*979E[ގp? ^A;-V} |ekb R{xK~PYC}bimdH 3`as(dSZM)rrc&&x"7ˡC wE3zX&@  ɹ]q7 6\%_B | <ȟȪg(ދKwoh6NW.ˉѺTIX{!Y-۽/Ma>{[˺N9 F\^hIr0 0̰yY͂TP0->@_DT: _O!@[Y&--Cm(!T̕  6\@dsdwDNwyKʶy[5D6c.ݹ J*ZRj P* D I+Ci%#y*ikGqO9D-_x)ׂK~V;jlڹHY:&7m7͂)†cfh\<׮]صsvy!:Qc3@&kYDo΀WК$n7aa@ 2$%6*PO!k4*#5' s6n u` kɚ"╄]ip^1Zd0NX'i7nw}g&jơk[k6S@gR߅ ;aQH(@.M5ER1jXpHTvf.r܆/FBUiE i9J&myA3iw-%.`RDuQYR6^S%8Ԙ3i33>y?[ qE[YB`H=Tpbf _p6\_>Z^H#dk7:DGBO~qzHs>׽uDž++%+g"2NF҂TiȘUETsXOٵ#+ ;fv̵rdz̉'?@Qg45ͅçvs;vU'B$Oܻo3GQEWċދG`Z<Ӵ*%4r4IWtמ69㌿эOiX;Z<=S%KVVR2m~<† ?`WJݻO ~j2z^v¶cc=u(++2'-#:H}(ֈe#)]]'Ni H W\y (>KW(+O:3 4G20W^,8dEֹJ O~,oYŻ-oxNe)!ϳܒBTS<Rg/|YOZ*fhްGPF*gSS"UnOy#MQ}O{ݿu}ڿ vD!& Ia~?|&^xKFMm.Uo+ @\$=AZ m c x_Y"2Or֙xÇ暝;w^qq5p/@=Dj-s^2GQ!~cfo*!ղTP\ZV{3뤑*^0-YUd)ZƤ.O5tTV"K69o]]d\{QYhЄ``iԻT )+g +zH2 \: 5 ,Qg۳Zח[[y!ARS(fS5޹r 1u9XՅ% ;t= S5|nk`s̞gqJ@NSD˩kU 2V  UMsm;dCQv~ mJax{~c7ETU<7}=-g%BYDTbPD6c ٙ#;sۻ]wݞ={߿{kvۯ~NhBK6l T|NYFc(:U͋2ı50 o_3;@d'&YR3$թ8wc=~'tdEBUk gSgMO>w{Y7m<@i<S:\G}6w=8f}/iTCsy"Ҥ<~dAy@z= qHʨ414U5`EQPSYnHZrnƙxF9Ё"5d-DDh^v Wj<9pHfwg -HͅBIKOVB-JnKC5H^-3G{9wR$ Aښ:J[=AeBzQ(" HE!y2嵫LxWwIq˝γrwVfPPxQD|`nXA9l^w>ޅdTH1'ۥGd>SPI4@]=mgW$=vO|{c;}7鍭 ȠҐٌ@lGh OPm>n[~`xKԥ?뮻~nnkۯyС={:tСê dk)wZ d"aU!텼(86eK5+GQTo/,4<4M`|A N֨s٨لs{yq̱"ksS,eE A[씳{z*!؀.sw4Ò1HcCk#{+_]o&o7Qܐ.ܢ >h;֤6{U@UQDGT\4}k#ܺΔ/6DŽ?-zv L"2*` m@ n]=mmst1U 6bVl]? 61)2 tDZ5ܪhbK.k\[ `ٕXO*i$G,:^)Cl0v{ ] w5?hj]M2JE)/vf[[~>tX~D0Rp "B/Z~&H XPͳLdI(1[6OMV I%D}!*!%xE<TqP2ԂXx}+HuTKvb>{/ڷv]w=yb°x%U(n! ŧ{]G=QBjip/Xi,n>koeU-#&jTGBkqi|yZm.I.IWeGf2:P(i6x਌Ⱥԅ_DJnŽ!VS, _/ՖSQWq0{ˀ֬Yhthg+l6=?}QK0hR I~>#ߐVŅ}ϯEA#oZejjj~nnT$$y-33M3CuS7RU! {"t/,ڵs}wl߿}]_&osﮈOۡ)OOoVŧ>?&[Q'b-iz6OXR\ټe8яLcKQHKyWɹlf'___ʫ:R&2n0Kl4Po+ēOx{.w鷼9lhnם9rYj z?K)JvL-5/3& TB$0D'ݠ%w+FTX\`hƨr]_.7rX@鐅Jk'KX6X/"}t#a]3{{S|g j童swldUe^ݱd'1;; fU6|pe-OB+ D `8W,92#aCjmT=YܻTUԋ_rU2{{׾O~_~pH]<9k^ G&f@w?qvRzi,@P$k84Ⱥš|{|g=i?px{OS.=٭7v;7ɝpcV +/@)YJY؍iX(I{] :E(2a# $ !3CqAj GDi85V;ˏ` Ri2*9}>18xUrPlp ̘_}^2<|_& ֡X}{l (SrB{'19]`]R 2w;'wN/zK4ׅ0JD333(F]`MT?yeto1 Ү 2JGFBޚ@&PX/|TF$ `=Hj+cmYGڀN:_cwT2~v1g|-M& .ZLn>pߵ$[U˔zgJȚ߹;>slxV)TvKHC[ R!u+cZ|zs- aF-qFi!a<)T cҪ_mr/K]fK\xs1($rB"f5(I'Q| Ucwh%KZtQID).Ґ;4NJ:rc,+@DT<-ʕ~ D>nˡhǪ@GAHtGe_܀~KQcg I ?9tHgeڰwΪ2DI+yìT6@>(!f/DFs4n!F͋hVP=L`bCEis^I5]x{}sN)U#äE3i e&tR2:tFYM֪FCBu66%3ob^ 8+^!wlޖG†] UnG ZB2 gA (V:_v@% %z7d! %HTՋ`F|YK ڊW6oԧ>%p#V 5SOT  ÝQ$ $8mSIDAT`ӻӸH_(,%wvx ܤÅ%޽ny5p1ȋQrCGr(߱ҒSQ mUt;RaT0 v qH**hqg9+T*DL`̢!{wny#gM .c:Gdqք^0g$b5` DЙ@1V*2A 9@Vp@{&(,idNj0T: T '˸Ẅ 5`y""Qdc^$moekT7Wֻk 1A+Ѡ0YWRksT%Pf TUէBGW"i%>'?D0A5Fpuj"ʆ((S\sBpuتľQdclk#A𣯮zuԤ o0K5$A:C:Bl?vG.5ՏV.}noP[5qdҽb%R:$Ŷm~_=nr &6*aW|-ιiޙ s{"1̆Ae4Q9o;zԫD!"oMوOela+:]!OyǥDpL*NEU FHw[AnɪIFsv=zPxvOx'qo#c̒:Ki(5kH@-Km#]Vk.k=%˃|[ʮeKiH8,@HA)򈥗CSG~FI^>Ķ`߿Waҳ$YU&BEY<P6r.)!b/ #YUAL b扉I]VxMi*TrfFt0ZL:4&"CP"cH)jq:EՎ"@ZQ5Z@F;-W.oEz7pu:h%<~+tl/R Zֈ$%3 0_;[{wosbUBԦFR>2Y[# o`S#RzEҾ{)E sPdÝAf&5 Ȧ]]F?$ɸӀ HP0GX7ʜLD۬nt  kh-ƟE(Xe%^c7RQ ht^D2D }"b !/IgA##ȓUF}>HC_LF$z7|%-OkwVOJlJxkڋ6l&z䒥,=*KUu΅"QdW2?<1a? $xVn UTum`L:ֺcnzQk87,ʂ w!r1&* ]-q0E0[Xʼ\HnK_T5pJjKr܃m'nEU YZY/s{Vԝ2fyZC:"ޯ(Z{C7 K@|WSMa^DnOQs\<2?5 UѲ4g@Lj|VR i_o>ffZT|;?ڱcGebQoW7.pۖ zL]a`lbH>i v#UFƧ fofNe={wӖ+$P.W"M/&/qzWu*mbiaUߝ>QCE(&lVA@ ")Smx0{9E]bR/ܾsw";wey -* HY*AU ӣrTO2dq![F:<ؒ8AǮHD5Z Y*v!`* X!N5JoQbb $^Ю}^җ]}8q P6/b*Q<#OG&+1kٝ61Otd"/* U*&h\7"r(h4=`V;N2U xj^OacP?-ME5`1븩2{,ғ\V5o*6[6@LH:xʉh9DЇ?~$`JyR+޹F3mħurC/r)ؼ޻(pY-$DFQdcӕ$9;vddݦ1pPq2x峬/"}:##ݸTY]Kqlz CD0IÊg `}?YRpʬ {Js#n`Y͒1f`Jcc`Z$ ' s^a>*ˑ1$qq.݀Ue0XGe \R ) nޒ/wdEUՋa+^Sӗ]qwl FVKK>xU[mYT}a1a6^$I0BUU(}X8Ǖ$]]LjPk`! "<k$Y2yΉ4 LjIU~>{lh:5ϗ5Bآ&PhXK%tۚ\?G\>Ƃ{l[4/\L!8x`压GJ2]1կ~i6j6cQXBv CU l^#EmPhk|׬J()(OTCV{ؤQUìB"ӳSNYrp6݉ imjwydH Vߠѐo҇h[i`]lo eTFvn:i(N`80 ͢R#w i |*R4v}>p]^Z//bkS$[7H>)!U* JEm .6DqIDD4 Nk!f {i؀ Ct:ˆ.GfJ0 Zk puaY/\"ׯ}#ˏ_ϧnWC+<q%V qT_s5`-9e_:PZfY -tOz7./֓ԧJNјǭ7t{~owzψ7Tu#ҏYk;%""%谛)ڣdqn]:<<.J k)0$տ/k;J?AV[HdaC$G6mˁw}_??wF# a< sT%"7+_X'??1i?IO~nw3/f*:z + `=OwpvzaReyVM54uQK8fJ /浯x_E x g% $嘶>#PP\="iռncgl"#{^I<5UOF{z~LSػ'7 Lkh3@X5#F#{R,#$uP~#h0Z[5*׌1D_Ϝ3w?N'bǪxqD* ̋KTQm%nҦ/? i7۲W@*P?@"B,EDUJ.GoӋ4 (jf n*}У%BkPm`uD3>Q/xK囹ē3jeUc"c ֚Q&[ɉxr*ˮ>?uoǗvo'mm#ȶ9Bě-vjB?'c}4WIZJ+: -f/e6p?@ḭSlvmJl U[B4gw{_>~xLldkUDމxP!/ixոV5S;>oX. DFdXXڗ3x{U1Dizꌧ<^ئpd"g4c#5G~r)K(?kO (^_6XĢZv%Sz4ULii? C Y #wxs/H/4 BQz?ǽ|?MV&)TƶxLm}}_/=UOx>| v9gz\4^yz(L{‡?/]5)A x 7CŞ}.J!Z " ٻi~&4mV׽]Ou`rvsW=Dɺ &b9Hd"f&s{%wO#k:*#&9D+0"#ш~2P=eA8{`2E5Аم b"W *DdrUd^h?/_};߯ԦM4fH}M%Q^5xR*T{|ÛƷ3?al8XBd[f7(=KlZYOҗG= Z2D6Sԋvzc!!Z}IX2( ȸHEPE&hH0DkޯۿSz:;K{GB\x&y=Aw4%Kb qaFxt:ce# îW˾I*ԋM.hA#P|بI@yiՔvWZ-U෗^WO}o/je%CWE}sHUA.`Ep)[W=6 N|gLnW>/ K 6DƦw&EvA K`/c)ΑEЂd7U`{KqGP9f=0cGU` ^ե:ULN}h@ $%xQP}K_zr@<CI@.':4cRUU ٿgO>Æ۠>>kJ+__<)?l"0"v$#1r҉'ukD"ڢsM4gN3U JhSrNч}nmfk\8h*-E*FA* I}ub__fak$x|A1T2|q^ ^7v>~wSGF+~gQ T/:ޅ#Vjz)ާi$ QQQJmߞ[_w"+#ӕ1\#]ߟsmnw3W*k|d+K~+9Y`-034Cc>=+QEecͮ]]e:n~[ʢ ZK;Dh'?u{CTqLӤkK_w'qv׼5oXh8V;{YSOi:^y,Qq,&;` 0\ΕptXvrr݃#|eOw@:-+ZA#Dɵ{XgC]t%gyJ5 .S-0'GYh {.{{<[ \S&} #@=,f*1@wÛ}_>vꗿm &cW]/q{*«!{Ecb ~´0K/#3UUˁ Āz)3pP Hsen5(S>&2,>T| "BJqåG}{nMpW[&"6\:QYE1ٷ@_4t"ܯd,TzKV[ko}[_Vls/>!0DqUc]؎\Մ"C9({eղDd*mdŏ ](k S/Q)Ç:U4u}mnRtjȀq‡# pdMҡHD$d?%ܿύ+jgW 9k'F>͋GO"׈1ۨsHJ fN{%Q#o|㛟9W^~&qYg[K/|7U=T9oU4pm-{r#V^FVY eDƪ+ä7@ra PV[bbbϋaW+XUCi(w+hBt|zɉ~_^ Ñ>8YRXq'G L]f+Xb^U-N b{XqȤVɉJmzzZNL&*͛7MNNmٲyrjG۶mk6۶mK֦jD%Zl۸:9KjLtB $'Cɒ!ּڨ-j$P!Z S쥿ctm6m]5Q\}yC?XSww"9*KUTzz/U3 P:ˡ̘y@Rxp~w~?+9!7w)9H :X$hG?ُ~&6jLFڛf *i]pzͅ4M'i6y_?!esT)Q3Ǘe}j`hˆCĦUʭ `߮`Za6ִOMN8Z5xvvSdm8Tɉ$B'292HJ:moO=D& 9X%*V\ jR@cDe^( A`6 >1$b吤Zo_Px 1H,l`"c`HJ[1 J5ڌB]^Wm[wiThe )f31QRԚrp6Q o1Ykkj06y,]{{yrl* '%U6dw|zЃ_-^͋owCΎ_6D^c]t뢫"OTg,򠢶*o>HS);b1UXЍ݋ͣ(} _ݻ+թB FX%Qb0]E/;c/%oɡsNyp{DދPA;*h!(nLi/ܟDP -S՘8dZmwaֻpP&kE{ŕS^ oDꊩ(,*\(툜pV$M@~ HL c=H?4?h.E lk+ְZ k5UTPFkZR_aV#c_R[ikS8Cwu^&f[NXfcxG3(1 Id4C(DThJDU"z 0HԀ0Zcd,3&I9O/-<@ՇIB=F˝T 0ʤJLkϾ]f\4[au^gs/(kDAԱn1;,mNNb&**^ [׿K7ΛCX{\d<=[ \ *Vb[)ex÷9:x6x k𸑭 pEk9ut7!wp)L`՚j AL BOFWi!j;U?ɪ)1)rϽ( QHTQUC 1'Ddq5qeI 8s5*«>ˑ r0:>/[o!Q@V٤.%"-&$Q)} 5ˀIQ5< md>^("*mƄVmGe@}ʢ[gX",s7yӛ~βι `by`߾srUxk[2 3*2`6l !(42Dڢ2<VTw%W)=5KÕs9jYk"fkBJ[2l-7j9,30[y/ޒ8T%kDo &hjEۆ$*Jdԃƕ/7*K_aHI0L^dZUԥ\''#gE(vHvll,{Y dXU9QXN+ybqF]y4uMl6S"9/:ĻxQb/AP@, `d@sf#! (D.ƞUs(b\*Ak !͂cL {YU/b6m{V ޳(QUuީw&klc.:j TkZT@^o$Q%)C>=vIPF$3uԒKPbt G:ȕK'ovF./.3+/wjy\ک! n3WKR,2K&wleG|-X AĤa C2&SN(@TUA[($`̠lf*" Old@ާeT2!U"8!UIދ>p`c1>pdTl(r4>2F[ mQQtNU(EQĔwUk*`&Ϣ"AS^W(yXV!G>hN,V*YEwR`spRW4{~0 mz޿}ߩ2İ֥<8߬E%y} W\,[bwK54Dz54M>윫7iXד$i:77h4>\sssyaz;|x9&.MS{s*N@iWa;!2ꎣHRٟY p xU pF-Wt'.pٵ^&n>/ji1k mA*܀#„汐k(2kGaڵKUɰxsF@b@Ȓ)hCC.R<YL%N{'e ^&"Vug /N(3#@D*\"Hk$M r-Tf?Npz3M$L`SH@}*Ĉ"0j@D,ԥrC-U<"&7֦sA x8 γ eLv*yrd*0(D!YU%J6(gʑ%& %Li8,Fc!ȚJ\}k9%`O{_*UjJA1PK9w<G!PsOܡCbe6#@ 3ȍ ͇&ue,L"ONsw.MI4f3]o47 bj6FlhHO9&BHf咦iqSQq^i4*U;缦f`jrf)\w!c[.-h4MP:/;zv* NY c$W''xW 9:xVnߙ"Xzx?9U=&y7:=y`\alTjSF "ec%n@{,;Ֆ^6FEE(Dm1! ^0w>x=\^]p@ ^gtI:/{/⤙fgT*5@>IOzBH>s@4M4,~ȸh4zh"/wP3Ӵ+zAɣjl8ZmRܷ~Kc*|!Pkz_7;e=^*up6KF5J_..t'`]l^BE!o(H-[ߊ 333LVkq\eJ5MNV*ىD((j%lDZ&&&8J<\̻GP.S/j.I8ƒtYr q5;{QEBnwۻO<_{!+  iq>!/z_Ԇ꬐YĂE>o Wns[o0(^U`(H$Pd">^4HT f@xG9u{M4Ifi@d9I.MF$I6IsIӥ.MkG@u(80x|EGՉ4qyi-7;ƧG>pܝD R Zz zcN\&xbc<<63:L7\Yj_ !%CDZԢ҅S2 DH G9鷺mOۦP^R)H]U)B=B+q9+Ҫ0quJ "Fgb~}#r҂JɈi!9׷aȪaHЭG^MQ`˒JE a1*A:b 0(8옇pKkG֐Co};@R\1%kvR1Z3Oģ5)9R>` Cak\"=[$m[n\g$ ~ʓy'mяGT &r.9g%߽o8sPbXɆ4)DJ 0D^2+)^mm7T}: uRo}yCl-- ]+X!_*Ԫ6lW^yOsJ|wN}SO rw ՀЗ`7,7{hjT82lV q2ed+7nBq>1fmAj/Qۀi>LJL{{GN^_ؿo<}Y&W,"E:/3rD#gwG%foH|-g@F&kt5MS.uI ϔ$ԥ94If.M]3ii4{"*q Og~";=IAB{ssZľٌ-#hzCU+9ԧp\3xF⠢_i 5dE!m=*~@JX+,׮;?RuPWR\~?1?%%48&6n{D>@!QJ5,B8}Gb"B*dԄEFp%^Q^JWۇi{$!/"XKHB [h~0^j(M@^h$t7 9S/p*^F arj*DY!%5S 2^򜞞 Uؾ^7[2ڢHʽ(ϙTt$k%BA|YVK)#/P,dN:qw}箸6efijSO_u?iȑ(5~MT|4)@~J8|EߕFh)ڔ#rh>(ٷ "ꇃCa$"C?o^}BĘJ0*qR]5eҳLXjP{'Z[ѓ]]ϓ V}MFo_tܱ[''bRj1Zȓ]q$l ]]&gUwϜ<8/BΕm\%ܕ' ,Y6wD䡆7޽~sٕ@dFk ӉSΛiz˛2&=z߃,ԕ(Wg+b=,u9R $w~~دZ$ r_(,IGb׈9JzTFHXC%؅fr|Aa@y5Fн^dlnRٕ̦为.q(19QT'NrvoR`mcAUܭe0RJ}z%cY!eW$ɉ.id>wQP-JZ]ƚFҌxzfǟdT(kN[VxTgu嗟};m{MVj-yCsW7f8YڿcN&%Q,}l]&fc%+e.ql*}ӞgxvsUoP{-ӵς^{ʕIgNOEQӺp!e렺n cI̡*^c߯~_tO~v5_{޹yWC\9O}m[o|)wӝy{槙(aFi36{ MI޵h\}u^gϾˮkٵkݻ󇓅fF j &bK\BÕ EZTjy !A֬QU,mگAp3CJ\{v0g-[V=*7 9j#˰CQ ^׿:xц0@ D ^:pEڗt@Wd^H&'o<S;W4E|#!4##pRWc0Bɪ҇%כZMˮ泟o/k[T*q"`o핿_<p;C|{3p UWw}v\]q_kvٳ{f#MMPi 0"k8HZm2IV6}urQo])UX){;j3A?"q)MV̡T)1VbVkk*iү\+`FQA+<Fw#QP_'*By\%\V5擤&٪W9/hKu*e.89 X٧"lCо᜝7+^U-g)L ;q?#渶ykXJ1["bcR8\졅/]?[~ZyF4.oA>T%yB CN7q/0/l3j5~察y'(JJ5Nꍉyy#jbI1`6BG撕6(иZu;>/G?yWZ=Cg>D1lM)86l}{=dj#;ܵ{*F蜋Ȁo]|pTMnRbU _fa2!ppIFwqTF@ړˇ%#gё5(DGT4Ey> 2̺`ѣyMH嘈,B%޷oZ9|^TuuL:'|"PVyD}* 3)*0ʃvj`!&gMClͳ(0Y#'^@YHۙ5PD+ +7j5T_~wzoFz{0:k402#DT[ 6[hDU_<ܳ]N`R&2Oa\'yb{/| Ȃ*Y.Qǜs~7L)G2*+,QϞt\xQ|\(\. {D0ƉIK_wgm=~;_\\I cZ23G rq 9*"F֚K u6aP%Sa(y;i:ld'*Y6{q2Cs("A>J涸AP4A\R7sdDP# C!e2 86,r?~˛^75QlXYB(lmR/Qm]h>)q]r_>٬OM:taIK !8v^Zm:Q# "+11Ȱ1#>2.ϰ.Q0 }+_juzVU[O_N* X8)OR7@DU&"/ 911|㬳OKuFl\%&%58.S^\o~泟QML&)"ȉ R!aR(K(_W.sww?nFѬlZC|ppubs{_hb(؂ !AMl9w?2Y>RZQ 4v{$ lGx׾:s}J6;77WgkrVS̯  AΉ'Tt|ǙgC 2F@&R5RD$Q"*Tm0rPp n0,v챧r1[ov҉'z_o{=;Cd s*&7y9{""cL(FF(XVv2 eF:v[fT]4 W;1&H( NdU3V {+I_ݮO27LɋPU48 ܆ eD 'Qإ;y@\YB`|?icWGsj4 9$ cMsןg? ҮTv:]Ðt+?}jSheoη0}r3qcԦfL$*.2މJmrw&M= gBxjꬳ;@ybuغkj:;lL%.@q$f޷?=T%!"QMR@SWB9 T@DDd1x!UDEwH`;St'|7iٙc=vzz"Xk|_2 FòjF5{޹ZGT-ǽ Y?o]T#k`KF̺n#"&iGBsZПOkxj>ϥ \k;r$NIVWi橀y.2S&FRZH^ocz+Kup}{c/4xms^hNbHsnO|ܣ4JRm^Χ,rdzyPXpNu7L8-Md7XԖFIQMR9l#U Rp3wKJڷ{ 'w]O}3>Dw3V.(L -%c'U}G7q\K2Pl LT?=Sғa(97'}Z*-QcP r TU@4F""⼈SBDsOOOtMN<[fz͛7w̱ӓJev,|{LNap{~O̬\1Ns{|꓍R Snp鷀muIa<yBOf2ⲖyG UQwѣ) cIZ/r0#7;uysCD&^Pb_? >v75jU\"^8^#V(ӑ,ek[.Nd-z^=y;|wUzjJD >lB:Qg`xǮ]}K>t; ~z@<¼D["&|o{;EVM|ݪŕtP\]o?dv*"gRΙ1f9As6aq!WZcDgxO P7n;N=oy7?[nZiI@V;O,[4E۲/_W{ŵĭV*@+I !vZs Ǥu+ػ6b 9ۢo XcQ# @T%'TZ%WoxmlɈьxWvcETHu8!"8uSW򥷼ŭ_\q3D"#n6ywUl޳+K_m0BZ=:3Pcw7ldMZ0Hsmy};5R2q{PQ}/7^`&2@edb$X7 f&8'[r)vi&7QZF$dQagˌR)M2P+/$^-oO l%,ej(` 10Ъ{ֳt[d(#Lu[6'>Eo5BzA`˞ltf,>)"cگ/r~ٿ#hrᒲX W_E!PȐSFca˖GWL閛z%ĤIb+ -$/}˟ E|+T9쁈G?nwWu_W5DFhM' RjTs SzVCyuZE"X6Z˥!x5fW=+^L#nrf tZXq -94si4E$0q%MKkxohi+'p-7t۟vک7;-[6mٺID&#{h0pk1J͆&L?k>cv^pYMP;c酷|%)8╯xγGAIG 4aj\EOz&`񈨀5@7RMҐ hHKP%"\GiQ29M(e,CZ~F^Z'ܓxF̳CLtrUZU$m6Ԛy/{ɋNO:G:BZ*DEHn$cO1ϫŭ L`qym&Qe?"qDW(vE[/K;e7m\9_g6Ju85`U>a֬.Z^ʥǍ>䔀2="47zclU&l HT$5Ɛb^ 囡o~3Io&8EFDK=Y{K&{fepu&Ge)eFſU*#z&m<8$5f C(Qd.R[=im@Ey3zo߶mKڜb q4f3~0GQj ff^/4;%ǹЀh8Q aXnsVIFN $F 'DCzr &̕Zȫ_xfqֹxf'{xC*UT ?)/M 6Y00_׳:17?59ShkkO9oy&g!x0FWk̹GH5;sXQى׽U/y *~O*;Ε|y33IN{S`CQ)b]T@;6T+bWOJUt嶽[̜qff'dov7ٔ3,ٽ3y$yr,SR _q{$LLeK aϋ_-N 415K{k^}РW[gH0LGz Pa!*%/_{N-s)Qr(*6A֊%KcbyC";~1U%+eI*j;#AYI+* BX$@ђJIPՔ?ĩIH5H™~~N)j( sVm}9g L OR9SU- vڳ;H*~Gג? 穚uZdi9~jdSˎRR&2Dff`ҋ20مCDĀ/W{>{[lHحb?e#물4S D b?`64?L!>}vliUU%ϓ V3갊pn#g~3>?.B%8s9?,Nlv#8T#+nl?_GF'FF/f6_ f=_prqu ,ae':W";a`r9 ?Z^)lĥ[W&ʻ= 7aEl\X8?C{S|{`5֪[=(̧vl͎;'* P'p|qs[~$o۳w2Bs- j/ R03\/C2~Z" )Ry>w{0cK@IE@, G W.GJ6=kiў;`.wҵkoF6.(g࡛okvǝw=Ѓ#cccDbn6^pAFAD섇 G8{14.ƥF8[Hc?Hhd_uKS=?TeSsO^+jY$q$ n^}1p+ѥgjS}Yư^zɧF0f~b$YTQpPfkeNf-19*dr'Wld6qET{qP.˚de*2A"  ~00,hPX;_pu4|@AhZDbҤBXM7݌sV/oQ%U}ݏ=-[gOIgLdAnٮ6ǿ90%'FRr+>3N>8 fHeu$@0q1CW@ĊZ"n;ڦ3;r.U)Ӭ˙ֆB;NVHt%F[QˑJهr6!1+i000<|]7*A1+   +Vm3&@ĂoBv{7~a'>6+U {+WY;i"댞gv8g^%''ُQAtU;w.Q. iFW7lyCT^`v1f^W~'V^52rH'̂&nP#1˫ UX#FĈH^݈9ZnW0Ϯ*u U7t9A4olF3)J%a6|~:҅C@" GQNslb3qdɎ:R.#L& Cmju3N 9 Hd-xIk#Ci2<3 P. JI<3%#=w7tEQa4MTET?ܞiɠNļdN[KyR _ m)-$+Uje2]L#i)D Vt0 'ahжWtzrYdN UyPW} _XX l? 4}C{]W¼>d\׎̏ `s008jv|= W,fM-QF4`fWv.9znjPٜIR L'rM{R˚o lY9Sv~~0>ȈNjn2 … 6րR\[2L{.,GľYW&" ?חxlPbj\ &+Ur\|U("K>(12Eү[p!1#K#+g44WmNBE/:Ga5$djsTf H|TЦ8" 6dSqP`k 1t?я ֦T⩄kV_; %" }CAeݼB" mF6>e-g@[qwSMc;+,"tvrUcہfț1zlxQbqVJ33ͪ w3 X;vWj~'C+֮phQy;"0"&J6deqD=JJ(G,/Y|O}9{@$o!jè8zA_o햁G T$=[k'kO8 amEiTTEփ#&m%R'1+׽sE'R\Yps2w] njBA_iAUT5yk kH)yo5,nN0￿mA.̽;Sa AT.U Bp'?u?a9ɨLr94 yS/l|+'ζ͙_$Lf; Ums6\h ,Yy줉v$ 3C_$Ղgldݖ>jUsqj9@۳Ȗ<|]5YE{\]! sX2^M*UELGyQ~mZn}cVT0 ʵevv҄y4?-RV`8ۿ-}Ɯ6#dDi"%h" ٴ-ohZ,r2S/~J5>_>i(e#״pF $s+y nє+r_T>!QmV@ `Í!f"-ܿf 7T)rh# 'x"RmJNv8a@~9~죟dd3 |"&{;n ❈ 1 <2*PL*6Ou)=_-cAJTbVeQ&LzgpӍ;?f8팈UUՆW;5~ը z4W3F6gJ3oK-;Gw.rl9'\'~zq:V$N<>cs9_Jlrym*D7x`gCa=cJ fD~Cg}#g `rʰW޻w;JV#3Pxqd$RVvlg=c67%U:k1/Vj\/qmG9-8'4/#A'<`:XZ,~g&2);VPu:(#2*QZp&2+V_A@*2"V%B?s9_c`&E._FGg/^t{ 0IDAT` "2E'Ob;Gb;dzu=(MymceU+vBvTczXXi};)3oOcêX{#?Zks) aCDϞ;}Cry6mrjcvKcmWlP!`00y:A%/əFj÷a;_512tY,0w~e!SL١jtc$ 9o}g/-'|汱qNy~}!2/)h=c68C~pwvn2;!LSƢz_W$j2ƨ*1a7{k!pk(LFbiFw;)%Ec1ֶ< }Eyh[#:i+f[3d1uRD!tvOnlX"e cH+?2ͧbsh 87p7}/Y25Hx>/ 먜ag~-sH: ?3:X#;Q#&1.*c QxыNY4llzH}L؈2o(%ylztؖTL4ʱeNLkQӞg/8cDQ9J9 ԂocM8SK!'?_|ʿnoJ{ tb(r Fx>g#Рs5V i2-!@o]Ѳaib6hcWQhlXƣkvġyiu!]GkȍX|gw~;2qpCiTPt͖H]z|.5. nּ tMU,(RN3'Mq;XA`TEw% g+W xс\`Y/0#ޤy @.y"t~g[J$ :wZUT3)Sh'Li!FO?\BK;#2l/W5 4Lό>nukWvƌ3ek-SdvX @_o\FG/\)$Y{iXNV cNĂEmO{=BjPLdm~WM6jqќ wU{ƨDǧ>qyI=Kל#;Y<+_&-[* *iVZ$T"j  Fb4#^FᚦK)/hZյ4hm(uLYf "Ze"rUU-p_AJ&ws]aÜZdۏ$<#ԧzJ ( 3 s**'I=Na'ly|S.j`_bB#\.`x?_~UX㸸y&]R!A;l~yvZhc;djZEẕG\nkV"a\I+5V 㩐QX*^Zz+O>;d v(Yp}/i2@,sR7#s{w7" Dןf<z2HBXaKs YiIcj"v͢%B,|Og? .*J6"QH,#M݌cett`𒗼K_Q9'Ǭ A\4e:^.n{}!!{'z`RP8/O?,Gƀ92)<SmfeGbR jyz_7`Fk6g %&yC"T/jpd>RzN!1[Ziod0$-G T\< 6Z;ozKlc))hHBYc[j>~yD+0ʎus]a?#|ri,*ٰ%xL]1Ð| ذYVlw{ߺ䭯@j:S2:o4MolxL qRC ldU4G4)Fp=>"UuhZk:|-&BP[hh>gғ6^H4Ia( |y~x}y>O }SFPWf/R5,]sv4VJnf_s<?@PHINBvxP`|~؁5W;^zMvu]wt˿FFJ#c'_r`h`[/]h}|O}7XP AP2a9I'Op"C=׻qJyOPuDQx9?"CD4vBpş"DC99g>[ࡕ+VFFF=lŖ&nnӎnvlȃTl )i[U^TպMvNDIESDy|Sn5-Vyؿ7O \~$ !rihx{}0Q*?c*ȕR|#c{u9}w]`*>1CMx$!a.;n]^iţ/@ aAIYE҄!~vo׿5oNnӲ:42sQXr{ss(Jn'\TC&*3$TvͷjwŊUg|7x 39? ߻gr\NP.󅀠䇦QS RY{O6f6bODU=ߋH36Ep?Vñ?Ʌn "q/eXH Ujx]z{jFaAMD$}UY_ 'v&_3pfTUՅځ"/66*^Ãwv҂Gvߟ~}<퐃8wͅae/HL/Òj}YwlQ&z&_qoTU@6j{8_II,ʩXᘜ Ξpޛ5~E8`1lpa^"U|!p΄?]G IXI.ZZW.#"{frg(PU"$fL[D`T> N%FwҦ/zox9ӧ "h d\'zz4@7]:My:~GWYZUoVQ.  ~~S//?oE|><ʥ4^gf&W '{?Bs!>CSO=oECЈ&8IPzd )B'INX8uuO_߻j \Wz D$Vۭ5$>vu=TL, Dzpe,.|᏾ϾLgjℊ6[y[󱕫 #J [UiLQ|׼Ax:5rc+JUͦ`PH(TA SsՉzp}bFHGoވ%t0oF39Gjzy,<߅ᮥzdh HYD#{O.kO}K816b ĪYnerP</s܎54 MNa |]€0! =wu@hаEP/naS}:}YFo"h6%4ǬԔL:+B/*Hhi%lb&"`8t4`uOktm[XfP@-[r|_fa6* BTʑZ1 2B ~>o[$@K R?[ѐ1OEPEUTW7 DMɰ+0'qA`ўQ(%&tc޴;L "6LD &"35+<Pqn;py_X1 #ѢD^9?^W^/Q#VwS*q_Sgt22t}um\:0$%$&fbchZe&8nPW#mc=&NBrEDd1"e%y;m5F ynW8eH)IB x@y9@,"k"O3wo{ Pp)"DNcEJ "4 Ld7M}Թ:6%Hғ R B*W\S.+#"bN`j_d3c*S2YULʔ'?ɉ'8>n}4!a КozW J!C) {8ʉzkmA3L$L6sTsu|nFC);+2z\ѝlfnN2:1&贓t. 9-L [R!ʛ1rWOyaЭy)u6u"rֽ}'Hc]#$\ycn|~x> /JQޮCK~>⨣oBG\<՗@%mѿbV.tNָZCg;d<$zpϲ%bbI113`f2?U3s|v"^92ڵkSQ:]oahKJ6`<0 Y5G7}_~G֬Rq,B,NJ /<'Ƌe" -j*,BM]4z$%DPMnzӎ&yv7}3h94[Yk[5\ZOtŪ+׬9{}iHh8KBJGǕ gqN:ql|x:0<`E"Xz gco .php#BL}0Gװ6ɲmɫjii']UiAcEUmiԛxip<0+AGgN^ﹺ74> iz1$ ˰Z( W3RKS$LKL+^p+WuԦI~P-'@هdʋ9χh a~1x@rX|.6~] 9 rsu"k![ &YHg >VUhJ`e{fڵ0F0Sy_g m~u䢦xͅ,Zc$M+˜j0Ƙ\?zvaX^g|-l w:HnV݄>֍pCˍc(+d%w뽛oʕ+A;uk]>MkHӣd U5Dn'|G1ݩҥ{cw qAq{޺]i ]WeaUK7TO,W`pу<<>V 6 9#T)6='\HݾyOBn<1~4/M[mD,yg-ʿZ$r`@]JDM=HA@ȄNO  =o?s]micV"UKd6~#cH9 _?AY+@piW]j:]uft7~l|w.W$Cj׸_أS|ǟ5SRuvgj|}4님ٍ4A*o PD<Ȫ#~fDD!B5M`8耽oxŖ‘@ԪZM|b5vE0|*QWnu2Ч~?>7! YAmc^MaFt`b\4dbJgnJBT#d*{젮"pFBIIRb"vDNQ8sg(Pd1I\ k7]smqYA:55q t& F4jlz#(YnXPR7B/4)b>g!ʭKL4Ld#7/[[4&q$=Hlw+ZѱԄĠڸ+ L9~wÍ7,5cWi0UM6&k-Jґq %Hwu.o"vR1:bBDnߞFTPUmd[M=}qMqvХǁ_. & @ߝ ث:+vh&_Jӕӳ Bi=OpOCr̝x,u<;:cH^#˟>8 SՋő34@+SP9-u^x_%Ĝ畍Ie "ATjcBtYK" è"Vُt2huY%6X>ஜSzHAGPWL:P 6_WF,++ ꣮V͓[/>sC<zY'*jc5 ,]v呭g(ƚivv Dɾw3VLԖ9"Z2l8:gP"`K+=8>^.?Oƙ; ahc)sާ~;׎.pP"km:ܟ>.܉ >sA]l9ҳgL c*ZD/Xx_7^2LڨI6JXyNUER"?mBPJv7)Ǭ2S"Ԩ^N&vAFpjDIѱVA S}n-o;3?Q001^+dYoDDLhF >Ct6L h_-T ar ?3\|+DR`% r ASbL RқۑހH3J0Q,P GsSX6k&>^"~`vht]Qi$),k"jLjWZ_7h ['dV$ NAE`RU2^sapk- lMMjMwn;,y& H*l@xrŚoҟ4\i3}j_PYFdX`*oKS]rzn4nMy.Q#b2n;p뎪VBQ T!'E\ȒgGGP\.O({|;xbi]Qd2JTO-4RC/ejoV m~SUQ(<=e?mbwXn1LpaJRoWmEZ;'5 @ߏi:Ug6o$<4EU hҰTW%:Cwcvu7XhтCr rh2Q~92ZG l'Q9"|ΟP xlX$$ 󍧤inI R ϊ=DH&4j7'u.| @Uc.ֶE_?O]趮$\{4Fη枠}JQ1{8pq*;[=OoU]s*1gwZ3)pL,a)A`+|a`xppҥ@~!9fL9'L_*J95} V]> AT,*8VnaBDAX={722Rȧ%2ӂf~RZY=S=gLz$TU(D0a߶dтԼ g ev FN*6ѱ5#O>wy7{c1 1[p!m*0:,a}aq<)06+\6 DD$jAϚ:- ThͼR;MU9S &ob~ߣC P"] |>x-^>6$X$5ι﵆׷6YEYU%iF -{}o^QޯOd 'b(1rd<@P'&~Ϥ cT. C8:1ueװcB C(ZIAqƀ2O5S |61!ù /9* l꟪Ӽ |".4uj'qD6Qrde;vP`#. '۹ʹ3t>ڤ$$J(]:gJς=l xk$rQIhV2!@qNLP']96UMIᴷRHsL; SNݘdb""zݟMTu"+Z"aDALIXj>3'M  bk52*FjH]A~LNH]/Y@{!0K/ :+erT+rI k]9Y7cĹwG%v2;iJD|lge2B&<3+qei)lU>ٶ djTMUe&4"d@Vddw}[2 Nh=D6ld橪DBDIX˒6% .*:'kݬHK"?c@ ~yCd]UYHDj!0ŀnM a\'\UޫӅdL7i-H)0@ C(\OQiB^1 6ӥ+]߈ -ޡjtTi bg<"&$W 'j#5tIٍ]*SYs-kRf7dHg9u2e-Dqbg roL]H8:p}Np*!]yl%5@t^o̴³g:2^YD,Z0Ĭ2j =TDdT&H|M'sfPV;ALwUze]a p9+9;iWa5Ƥ~yC4UTFW|6 ǟAY|} Ycb&!r!E6u03[p=^LǤ ZI_=ܧ Ժ˴" y`Tę-96TN1?˴fѷY .!oLR}0,@Dik@p󹪜 #hDc/jZ}oUO>ΟFIñچו>6:l*3 WQYaT{[ 4OYt##V-dyYZ|>lzŴG \0`UKD.OuJ~V&AU RH4Ӧe׳ҿElBa$CUr\%d[qVh/҄ᄌbw @ .TA Cp45?l%`&r?ap]Z@/ۮ+UʴG'Oud:upZQ*VK d^ >qԻ@[7}tȪ  ֺ9f ^Yu*NМd03 )f#H>O7DQ"l`A0P r~} Z\!K_pj5Nao>_(Qh+CP)r]z굣kȆDXlTZ32jm  }Ljr\.HsڄN:IYvyh,-LU[BAQx 'VF2W4 _6~ӟRD%cr`xQ.,ҥK,Y888/Yd7\`m44_h X!R^\rD$ Zr˧ߩ؞T"&bQj] :Ҳp),~P*8.;lVa#@-]r]wݳf͚;^r5cccER h1+1Gz,N#nRTM x AS 1Vs~@( Uk}䑇zw?R)뮻"+E"&6*~DUĪQ.wɢEy߷YI1mӫFư8zw\ъ䌬3ۊ?J{:- 3 -6t{}gYTtZG@EXd@ƅ>+ @{1&FT.{ǟ\z'D Q00`<^4`…l&lofl|-^8efu! SFVۼԐuW o3rP5$pXhTGSݼ|:U ZC Z[{{[nĊ'3"\AlľϹ!x^(9[JF_"kl>o;Za^6GDZ6ԷubԢ䌔 b%( %="V"͖/G2/ h݁"U w(,T C 3 ֊ 'Uw4b3A8ݑ}& \WUZ$yCjEU}'|6p -[ۮ;^{| u@9E}ê_UjPIt{Pj^EgQv2K'$;9XMcT(([RQnw?׽ÏGƨt`-ŬKVɖ_|@.PI߃ƞQ]&s.kθ\ `lv)h5rFLg-Lsô\48 E?Rոx DweW\Z`")ʓBp'T'Rqi) +lR `bb$+Gx͐X|)O9h}vmrOGlOQ\I&y٩%+&׆; ^ٮ -X` NjƁ=e*FF'nw뭷>C?DxeCbB#ɆW n+h&~ǟz}tl֛/YuEOUh/ᓙAM7xhxpl_P}LMwu27~TR6B2 -^PH#U\x{CzA)8Hi ۊT%&C, V7Tֺsp'W([]@1``q#Ja=GFxGn?7߿n5k`A"c( KmJb]**vtg3fmo|򱵫h=-wߝ ò\MF̎afWDlߟYT ,@Bp }Ð:QMA\A,U0YaF0~[W0߷ro,_~9oc7^,"{VW6&Fw؛7d\+]T/H\,N86C 5 @`6#uW\?Ϸz#< g rCyQf|(|Us-%Yk3Rn|3{`tHTyBuJe=ٷ+d< ̥nݳ Yk=KJTk"2QmER&}^8>:B!d0)`Dl DQw_7nzӞvS>pٲEy"R#ǹTU=JIl@3.VDFy{+_]wc=%ox+ ![Uk#UrӅ|"2Ɠ(&&vuwadd$X7Xk tjfœ!]/uRCcU`R{J{`ڱ +cHEJ"3DC 9-n~-?У|A0 "cU]'i&l-w]b'c<Ɵ_>@` |6Tđ@ +t>KJkȚj%"c 3E=Hѳ so jVZK=|@XՀ>: >@ĉ`ZkL…Q*M;no\O?dLdD PkKec`/ 07}᢯>)O9裎99DaHUՎ)i ei I(%ffY;aC-0Q`?yd K;[$u~P!HuGLGzAp_˜RDdc]vBR}tu-PR"X+˕C/w+eD cRJE~zDnǝUnq0l"fX'xޥ?'+ UUE }t#6TnkV<4 1:m娪{rX/#p 1SI(C'ta"U+~PmOuJ U,4R)-[Ubߘ à{~wU,h}X =VdҬ{Q G\wI pBν4y6_sdC=iK-4;GP4TU˲ ```Tr$B=ȃ?V)[O4F m~2HCLxI>g6BԲh%ݭ>RsFi| }穇'SEDDzᩪ(y4oNqЋ_r7x *'FlyӇY=㟿KO?طp!XB}GGי-NȍrlKz˯|?/-B >$[>#'&F\7.-{Ҝ'{Ȥ݋,eW]EC;͜jl\Ƀ$Ӈ&('Ogz%Ⱥj⩯yfmFT?2>ROQQd=/V(+y3auk!qItTITE25Lk ֪9ʰS-yL~M.c5M5Ļh.Y 7[nZXUUCC\wӍSl@A^;5ٲI r:KSj3SR|6@V}P!f6Ҡg 'Ǘ_hr>J{k#T- ai1yXG"H>s2 $δ׸7RVȤ]9U bYwX:q ưCy7 Y@MSu-bEhHD JxdRX'Vwg㟼mo<. ?oTb=\kx蘺o"`fV-.>sN08pY8Ē+NYq0Uffb`@DVDŊ"F-uaA<zy|>Od<[2ILq[- 3]ؽYA9Cϙ$mݍ7ޕZ*BB^| ?lXBTiN9唉 oԳ9yBEpuDd a=kkou!C*=:-K%Mb$]pO?&*Bw8I& _hҋxѲ%)c X(ƝA ILZQuld#8Q.RT.W]rU#k֬y'֬YĊ'rX/G#}(UF֊Da(RXpj;;c}C()b! A!#v?oǛ_g &'|AT׶dP&V]e+~>s߯K0ppHttb6 P5 }&m)DRI0Aheb%v| r/x  .]tnEK.rBC#13{INŊF -h_;+G,VHAL?/$*T$$;E'&({[޴KWz2<}~f2@ea #S~f @x [( ]9fVX8zcc´媑r/}߿4[jŢ/ fR[]|ѳq',$묈0Ԡ낹*: #W\裏\x'y\%, \j\QY[Nʢ-أ#J!bf{VZ .WcM]ٜe}W"2]Y!.cM7^~:06:jcc+Vv{<㏏BsoB>PHEDJZ 1`G3=lJ4WltHVAbU0:^,Ej\~ş~N|ɻN{7 ƍ6s eHA4Ezlȧ_jq.$FDTL/7?R\ͱ=cTĄ2Iq2xe;V[ln-re ͪZZqf= <ǘY-sR|UE]sw{A!k'O_5Y?֫1 yF=/T\4|{ޝR)dTIbf.LʇfN^Tġí0%"4 r9qUQZ+A9 Ko?7mQ28:y*Tsk`M7?XJʚvfDtm|exPٛfr3+= o`EN brʇ~螻~'nnaldDx 9cJ4^,f58{0ԥRRU\4W(F׿uŕ4gdÝjIi5T No>(C9%񛉤J%_ sAx^8bjblǞ{Nl%/Z,Z̞W̓jx3:Y9Jr^6MR[JY7H8Ǽ軗7@o|LrIj2 R`}=ȪG4 A2IPx6p6|{+0Z+Wo馛o疛o^v2@a jźtQQtUbE&8/I-Z؊oz;\Oe!u#5fkoDT/,>D/XL0ȊX r<e#JLJ<04 ,lvvK.]4|( IIaWD`B a? @J'+.mU ?ZSt 9y1{~8:6FAc/Mz'h.MM/s~ֆۆ\<ߋȽ?G^g㏳CݕIݫ "Y?YQ9@ֱS/0~ywac|QնחZ_ ȪK\x{1بL~P#<'VqeW, xR\UXCy3ng#LJ{7rU!|0P`Q&dw :M(xt|sÎ8-o|>~6$UhHЃH+xI)gu'>qv$Z*Vr`/w4Y*?Yq  Vyd @vN~^{m& xL '3Y2mɯ@ڔ*9E%SÏ5Ոz'~衇tߋ! ef. w.[d|Xd#'6CP l\V.=}rjQ6/dͽ.0a@T&c2Es\p_~)(Y?ȇALE_t%.z|3GNv!QH|s^PeAu>?uRv+u=7p/~[o rA\VFeK_FgonPrçI1K#;T1rXZf;3<PRɧ3Y`Ȇ%l vuguAN;,]tɢAV+oTeFVH1e/?¥rɖB5~̌w%[]` fPnqGFLglXFs{7E4'&3բgz鯽"7{Tf.{ay el-z!o>uι@&Gо>!Bĕ"`!%,YSYHR2"b.;m۾Y3v뭷^k~_zG{1-WDRC K6wq/9vZ!ÓLԉa͂o7$]gNu|+E'xӿ=? PV+K3ǜ -jm/rg~Ȇ6MU>)zcN "W?@fS,NrRq">{g7_# Jo 66zͫNߟ > %yP` 0%Q0n;/wOWGvcc FGG]# ӜS6xEK6\f[O{o~~{U/ PI׾  .^N0pTfY+x`$֮G/8g> l"Jsr QI5 Df:HQXgt(ڸ` s#nȃFؚ,Wzwz1 ;{YFg>%Vo@bʼn jHmw[te|&xY.^g=_Wu=]_7{?pRT5e'R g"EB&"km0\WxӇ?׼ £pZٴ7wU._ꗾkG-xlTpڙ_ՓIwUrՎ 1yd^g=vJTe2^]|(BQ?m*&R γ[XRWw#޶\\PvhΣ~կjc=#>\be.tqs2>:̅ G[[ƧVyؐ2Qwܲ>w:%럘TB!aiͪG~.0Wȍiwz؟.u?==7ɺ_.k# 0cт¾KkK y%<{X` (Bk q/ũYg^Vkr_y 7*MC|2\J񔫾BfIgZ<<+o?e~I7^Z1 QzQ^|fUe6>ȋ>>#*MVnqS]Mox#ބmgZ8k#3EvOMU7(+s%PUkc3^4|N@E\&վn #OԻQ&e-A_6eǁN;㟯j|l\"Ȩc0&$ Ezlc;gySQ!٣.t:>UNӫ\oU|э6Zz{q ec@Uc*DRab: M}<ʌ huI'箥{{qt  yʢxDg]R+ CTx>0o=g% ŪaHY]-oF11L`Ϯ%uws+TW+ڽ?AۅTpW?6BTa &>9VoUmkG Kp-o}q/x@W,Ya1\0E/xz}X%(4CHʥLrW\{_^;[l$9nRO&\,Bl`#UK=‹r7Q1Dc#i &2Yφ Q\*MmN>_^;{džkdVd"k?>rW76Y׀oI¹ {j\Gw0*r9Wo~DYfr'}LE_PUJw…Kw;EKJs^xߧozkOhhaV(c2~@5טvxkh'7uE@ت9 pLnnS/W![1gLIϜ#~+ A2e[vYɾz"&$SfrXg7q|tm<繲fr=ŊdcDoW} bQmdѧ=_߯!ממ-6]fxn ?ڿ_{7_ai06h{bT*'Ɗ }ū^}Wm ˈMcV_g|L\mO9䰿 ,xtZeb̆Eg jAh=`!WKˎ[PadmǮvj{i\"g {V T#A? 0Fa`з7 k (4h9teM}1ji|.T$RcknŦ'[*j<+iNyi2ͺg]Di Bc-_鍯rM<@`=ߨ~oSZd?8cl|?v~syK+(  9g< u "r11E2Q䋑]}i * (kKT) 3$>3$/,͊Nụ åD;j.+Uds\`J~ riė_ }mX]l M@-JHg| /ێkGjY`w^*D!V4.;\njU}|T"4mI^C|ðßy  kVC!0Xl5ĄD8 ?urfYkxH(6t+~>{~7G( \.=gmoNsQ*Gl/7.y㫲| FW;P_TE-3YD.'pbhU, G|p``%vdj_ps|w/gy^!0`_хFd-$[[m}e6_,>cKoHibL%|ዏe~V <&Hߦ@DZ:@K4/|+s\80+2hCo߽r6p0x)n91yeHXp._ڗ/^46 ֆnY_$BD/J}gHKL#"[fAa91KE^|MQ[JZm}C> H :h662`0aϝ=܏^sDq@!&}7p2(ʼnDi`hn8ᔗnDB)_>eYaʾռդ f.riy7s剢¢ a1JckV,dó?qWŹW"蛜%{֑9Smq|m6Hh.*pJ# !z̳?{+N[6,ut0<h)f\`lfNi\͎#]~Jڨ ܇7g513kF6 vu#8jÍ7n(<9k-4P$߸e2L^N5\ Ud_ReRc|Ԗm:$ TJ% DT5J}jf°IF,z3>{=s P,Mi07A0䨾Im^RURHq0fZ"X(姼;| .VƋER*gi-u2.a)-sz{׿c7-L`&{5SUI1?6N_ U Y=!6LDę,"5aԸMc =u C6 BE%b\Xa{y~]E;XQʵGh~'_'FL,!pބ>lK.gd'i -t J"\+d/%6XvӟÏ QwQ LT'xÔx i)Uu4XtC_5'M#jhM7?f-j1 *TDHwy?~uW <"01 -b/X&à =أ/m|6hJ+@Uz}Q$k %dTu)-Y76./<+%"<3'u;g$)|9 b@ GaD* EQP _D$H yfGuv{+W+:׬2=k_*VyBxYjQk֬42咠ům8F_X WkwɁXlfʃ\rG>.}}ǵ$s&xF{}77z2 qRkS` 7f{fpF&,s~aox=\T2z~JE+]Iב-R02"k&z AQ2,g.̾mhZ_T-tFs4Iu^ JI9g~kkђn> *5"Z1XGWr3WCa9 X&bH8*hN5 B4^K3m''umMNH(*!KC ! B (kbDW:}X*"*RƚﱉJ]]йg>_JSLԗY3r)O?Tnj9rl%yndx.TKfWB[NRXzcD :,"EDJ!0ZkrYʃxmt'WmprE<$iD("ŸhߠO[d)|ƒm#`#*c_W\QFzk{㎛}׿O/^hps9aֹ@+(R wpe6lp:' ut䵛*E<ߏ}{O=OjLB'aeJEY3p/O5P VRKW u <_$Ω_/"z~aȇ}+*zW˖BHdrHg+v]qo] BG2gpbUXc('kzaaD E) 2.h3wc77Ȉtظ( \ PcaL$Kf0⌿my'd@ HS 7bEߢE|O?3Ϭ[AF$ taJHƝ& cblv8!b}iehYUBd9c?|3wHf2n}yJDJ,rӻN9C%~{?y`$k:;aFB+~ӗ!˅ctv$^8̬d]* "b:vNFSCh)5+@2/%@zr> ʖtjS5~9IktN;sM_u&8BE'a(򌒔f2Pw=06`-#ٳo6;n>{m U*!wXCD]L =u2/=I~fN 7̣$l1觿(0X"@HȑX1yzy?Rih<̋/tg}n=rT.R !-̬#Z>GNB0s-AZ}2t(Rzͫ_{f˙O($} P" 3_eEs=_h PʮGD0\ Zm (p%R@,mXJ@=}N8ݟ4! h(I *.ӌ`r:ߔFIɏ>:fuR=G,+-^Ehg gK9) 2k-rƌmݎ;V[9TL__P) Z\.ArבlDk/̬2:(`C6rt^I?I#9ZC4.Xv@Fd < lX5!Q.;Dr9 0*0,ZPFf$'J6ZdBE:}': d؂) J.D'C y;ono힋K E_{ek +"BBOH@XlTnfp&>1G6=JaD &[ic!n08 MȘYSR'"ƺ̧7I#7ԲlXB$T#Dk]:;UZ+?NjwKvjB̥ W"BgvlM,^23O"JDLŖ[|Cv>;b|)qoo|;e^kKb,Bdm%*vts_tTeMDbhlvdQRNyۻvOa}ZÙ0,䖁B"_}3fͼ9Efd =C+Q mez^ZLy4*8]2 LWI?jw;HDk\6+5zI61Z+5׹ &aS'B6ZP|?i2S QLD,[X,(v ka7QS r?Wl>p{9ׄӺImOEH}[necFhwǁ> !Dv)@b9gW}k, Mԩ d$#W|yqM*h!IgQa}?~[ PTqj >%*CmD;wATL2j}k2J깐h9dEDR ch#<\v .k׆0KR.}r""QPYܕ@T(F5ѓBT~xiE1 r~^y}&UHK'"ORZ6bytp#ӞaG]N(0B_+rA2)\o^I.;}OO)T?O~F92@#)@$[7XFd!dvS\ q_i6tA#ִР8p'@m/+Fet%D>@Sɸl *t ф.f7߈'ccː7t<,jV0 QGO^.S BRT)twȇϫ \qsx(!0 K+g;q&0 LJklfXꗿYg#tY<2rh>r?hƌ_]pv{$)L/q$.b v]sUK> f< r[o\$D: 1vLIHQJc^nZFQMeɺvS2JcZkm؝ޘ-5 ÂzqQ/@$s/E[̛YDBef_b |K>{ph0ZjQѭD抭C|cO@(L}rZ7# Ǟ\t; 9`eRqLH5!vy'p(%Ү@uS2i F@  }/vMkf(R܏x'.t%1)qSq6ggK֪ӧRBD2?O+h2+Sl/:]\,,6⏝ :|BXDJ=}CŶ#>n٪>A"֮8 >IN~[Jbj,>cU.]a~޸O(l\1$ `(X},TR@tR|a䓍eyм Z ̦9I\UyW s7M)M8>Y%1ifz1|Tq å5Y&| ,^*`cSxͿ(+/KGN8bT=㳟Qd5&}C:] ?G p҉GΜ53^"?ȣAC50ݥϽ𤷽߂"qHX.ߑ-yO>?!b!eGWs2Y㌨#zenEa;FJE7kuB(ȏ->q+1.A\ 1 ◾ #˕A"lZ])oVm &_*( mMwJ+fGMdG?i#k8Uca#KU0cd&^bh!cӂț\oDPi\h"+")DZ 9H~tܔ-)u" ,79sf FcJMFLl]>r"  Xfb恡Rdb߹5L^%rpK^m Sm/'IqN*]zɧ*65.iDhW] 5JCA1TjVXk*̬RJ F)EI^'Zc&"Wv4Ct(0" Gpgf#VxyH\*wvt?\reEyAhS&$, Sqgϧ4p );ns^_YcP!jWmzmqi=g0Q4T*m7^s|=cL Yf~e\eԭH|b / B{ǕDZ /IޥNXxT?KFKqhAGV>K9?"cfOɤ><!%QA( #6DY^ـBFJ+Ghp4ƒ"gAZOF& ¦ !T\yӂո|'>s DU"+@h}饟jomQ)y ^S4.n]m1yG|!|aDX>(,aՀdٺ,̝Eh3 \u``D\B vu16; " ~ge<HJM0%/EW7(n7y]^x< R,EFRpmr˭^W"ms&^m۷}x}?hDzҊBc%a=4f4j[jiU҄6GhEOm8 TKe<BDWEb4=mArc!.G'ێtXrY 4{/]|σyZD.lDP#EaG?oAJPg`N<_"b] 3|#{/J$Y% ӂBFJπt]B4ƂHDQW tW* 9pRI)k?v>z: Puʴlj|$}.=@#zB"(lmuGlèX;WyRيXcm=++4i֯}ƪW;G/ NF$)Walt[yڑQ6-܌l>O\?*)r<1 hېɳ(Ln_l?h@ChCH mpT\L6KTZeCx6:u >J BDA$ BO8gdT8 bB?MWcuwS=ݍAF^p9[ YP1[T P!K~sԄZ @AL/Mɔ@jwB@7?x)ґ5 6BMy5j>4ZR]|op H( S*@ 0kMQiɵ?{ =$F ¤ovJ6]4"BߎH"4eB, @0VX߹lf__@( Ÿ(g b$ Toɞ,(@\4"(PmKoo$ uJs =J:j!lfݾ8abmm7RJxY. *%5i1u!Tq1ԧSUX]#wZ.gxUapB bXH8}}Cɰ.drJ#SO>Cs+l/}?_Ws"pE%1%S2\RÓ0կ/54 @I˗{-]QaFf~chΝ48`풖)=Jڳ..s*5rrbz&kȆl5MLEG;c%X0/L4a:/_+~U(c].e@C4#WRcoŔ|}Gt(s ϭ^3]RK"TMTlV\*T)Atqyk{QHe4z +=w}\}ռ9 5 æ3!d  ;l%,h&T"$)s'9Nm/SNxhgL~\Ql ?gr&ɖ[ JUIE5\nKLPZy(iaDZş]d~IA9-M 3ttt8*B4Q$H4,ҚQ"m?;lV2bBX;eME$f!E`ڽ/ibı$o@JADؓNzۑ ˩ҦrL&?//Q|o_Q66^[D!+@ڻ[;B@0)cݛI%Ywbf+(ٓ6B`i|e#ˎKр:e er"E &> رN([zrek) *"$fJpEI>Gղ&ucFMIQ5y]wsAgWOjgkY+>jMaJtmzop!BMǨxb|RV`N!nF8@Rʍ_Y'7Jh41߽f޳ }Fnh. adKi/jb& $cl5T* cwߑG_ji @XL8k[oūeĹ|f[Kؠ/9R0"?ſQ ɕR}e.izú.F)iX\ -[Oi+B5' `ż3ςTh"!f'`,, )B "SlW|y $k[#ZiЩ/,HQ3q#P4T2֚(201g%)CEl}r PZy)EDێRZko(= ͘,:Hva|y.Xg{N~'ad-#m |oo?)׾wXy U\`@B1L$1bGahZ,lRDQT.0,J35yP(iPk}#RRJ)(-""*"$t@!6HCO17(kV*Og#+wu[rJo`@V^:5F,\ /༁^`2AOqqV'ˊHT< Qd4mۤʒREƍk&5"Z0֭|F)vi_@\}.%";}Qy }M ۋ~q_J:7l^r<$nn}I`f[ ,]duoO`oOU+BcJC偁RiT*c#YD$/‘1֘Y*aB<""}if̘U,;;Z[\=wӺBKВA"uQz ~ug"5N K(&}}ϩ}S-L/9ӨA &=*B_ӳYtiOϚ˗y==k-Z<00`r*f˖ ef(]Hk6ֆI+H!z^.yn)s9wu]]bK>_(b!AP,r9פH+H< ZJ#kh: NdF PqO?2P O*hrMP |kR) c5&VwNfy:UvŌtȚ3h4 1=ce'$Ѻ1@T.2dZ c9C>A>4&2 3Gm~{5s0UF2gTKi=kz{V\۳pᢕ+W/\pʕ-**5QdT(`GĆDxT͓!,Y^4|k.$'̖J'"*lP&,X ζ3ftuuutΙ={Y͞ݶΚY3IhAxjP҆_5k |_˵ 3a|+ |UC{O~Mw/f^&vkW\5/xa͚5 ^X4T( ʑD ֗R@TDHbmpDC'מG6ЅH&0P= Zr  BGGǬ3;:Zg͚6m֌i\%ӾRU7V`։ g " 7qE FvMR K߹oۉCCQ! "l JƸsAup%^tumz%In~`L/x7;< x>K ,=rZySە;I\bFRG!DO~|cDR("J!8 b՛P=:" FgM{zz|իV/\pʕ.) Uz{{3΁Tw'AM$d?5M%^jP!'{Sb #5}zGGǬY::fۭ;tO>X,(ZV]Kը K3rS6e ig;J@2 H<3}os$ <{ K]| QVY<FB K5k֬^S.yg}_\rR*R BD!>R91Ft ڪOe+b-E1"3 K.(l2X $娫Vlz뭧wwϝ;wڴiӧOhm6RPb)=riBFRq&/꥟r {# 0ʍ__jF̞}p koLD3++Ɂ2m s 'Z6449@QM6l03k`x~tyŀ{tJMMD-JZm?_\bGwVpvPf[xiČΉ7}}5kV^ԓO-]^X| edRtvk6o@c䚵[VB2ث"H2۳pl=oΜvl[Z 텖N@u/U0\zAka&"ϫf=c@zMUp6'4@(²e+~ <>Oo+VAli}joIctK-uǫPC%&F,=*@N\mH*i6" 'Qz#AQ(\.>}zwGkf7={洮fh>}z{{{>}4$MD"5?v"= ( )cX(7u{+!Tz87GD$ 7́h<Nݯ M;9|>7 )"p {U%k-5%t͘6}swy-qfϚ6۴ ^5+`- U?̳e|0DV l XǞ!"ܙEԽV䒱Y0 E=׿/^<̳C<&Q\vEWq!:@kו1;k& B)ª=虝V mnGR(Y.BD+]ւ1 \{Gko>g6mٛ;;:: b[k3nI]"ȈҪ5zV!{qu5ȘG?,s#> al}ba-/%ZM&shFPsՌ1F(3gtkW|cT^^iFU-` |}}q?t__>bU+׬Yӻfu/ĩl^>oL1,阨>DžWx䙐V$GD=i܍F\RdzQ&DF%r5 SʬVJFTJ l`ڴniǝv~=ح-_HH%V*%Y; dp"`kgieiܘ8CozƈOtjPJ6XߧY_D[qf˚(JW>!ڳ2nM}n'0ȲbAUr~|yVs6) Iwutn6s+zlMuP0&EfL;6_HT3ѝ1NHnT8j4@o~~AyJUZ'vQt"Xl b;lN;NӮ<}zW`@rhJXBM J y3)Ǟ`b?BIX5JeL7*@Dd?x%(P (L`6%'@   ]N(Uە=ߓO<̿}3j6*_гehb1TRK$־ӾČ*Z8 !H\a_@C"DlMs +J$?Ai s]Ku\đBJ[&D4J& _,N{.;m<؊X7BY6 uXoŶ%5Qw1s)ꚫuұ[ɪpVN*R?4 ^Xywpq40OkhHŠpKZ6}"YY IܩgI ,O U?l-oZŅ %LS]u+<% I"WnG6$ E&2=&?wѓ n7l{` F,B&D!@ dAдvOq+Wڋ_mNM2nbTm\3ul&~DPsSNяM$9{J5ύPH0<Cb5™8i7(,]1i4O2E8dEIơ(DLEL"QIe! Ay}k^}_;sYP3蛸B 㞷mCe#@ {:$Pα^8X>唓/rr86-Pe`O=_']d硟~EFkOifNhsjTpA8iB6ۤ/@gy;tyb?u@pn_l2OWT_yM+ޠGְ Ɛ"C5(@$(*l ",a}@z39\ }ߐ6^R2NYFcg'j=h0,33PkkyQK,V&/׬3-f-y["Rcg<\#]af`VZza̜V2.&ёH"Ph:[(=Ӟ*.=O+EgM 5==Qc l\QDZ (( V|""E2WJ~.'?Zə}QDihvlŒ:p8 TFo>{uZ$fiY7!wGm lTD4{hm7_ްמ[[ou4>g?0dBLdug '8KdT@-܊͘M(Dy}_{JVZ{"ru}'$I2LHp.Md,J9c5.L-'QAHB<}&u*u僧4.2 4o>߿E#Xk1Ù6-иu]u ).d7 ;Xt4Lɍ&8EDZr)<xa]4 lҗu됀LlB`Ăr~KA[[[BX,[[[ B5 <}A6)N E wfk80 +J}}}|R\f```h<84Q\^tҥK(, T* РSHix"+TɣLU*& AT)x-w~o Dl4M@8u#o};<|kR H%HYԞ@$\tG>u9 @ڋ*NNIPǟo~{םwqCϻ=b`VWju< kK,嶙Yĺ]bpNj DQ^P(rb%ut[Mnkkkk+/ uk8 aZh"ST("v(, alJ==k*J4Tr\. ˥rQP"FX)^7{ke q=]}eE3 1iz6 (2N/RW8_896$?5 C @ OR]e55Ni.M`` DNwm\s7|ӦήbQkR,("$qUPM9?БL\SZؘ( Q( EZ1P*24X+/[`˗/]4T^x… W^q$D|2*qS3Kɉ_HU{ ՞R塜S"#ޜ9|Gy~5kHR;WcQR@Ƣ | _c%ZD$)4J Tr}7&{QLIurH7֍v 3`?/C> ֬^zJ@ \Acf f.j;9IHZ{""bq O3e*2P Xj .\b3<ēO   kLdK!ʔ\zkMvc` *}}ǝxZ ;2P$/Qh1`uݛ$0 Ӻme6qcjrfI-:1Auv_~ꓟnim;TV_N0ўF #'/*$ l0O #_f͘6kƴz͚ٛ5kΜ9ghomooQQ8BXeﱽq,aY~o:c0\p|bK_x~?O/^%򬴯=D$Oy.- 9`-@\4[̦|\nphl?_}w%Q XY!>EXC]ufx{W3?sm]ڂUΓgvW""{|{3g)%tո(1۸ڎh ƛvKW,gA'&BU9ޭ4DX@,Ɔ~a؂ҹ|4,,W$3fΘ1mrܝwqo1on{Gk{[K@ҴjPQa=u jfWs}DYӿx{aͪՋ-Zzս=a{AT^YǬAFƀa@k<)IV(3eG8ms9OW*%1!jUT HIhц1 }&eR(L"kq&廙l*/I&|7fUT -{b~~On9jY F {`@:}i3]k֞1{ar|'ܼfΚ>K @ #7pڧG^YU5mCcC,_g׿s/G^|U+V@T vHɊ8S[KW\ V]b U󨣏?-)`?)`U:g1 W{?&"kV\+PU]{'Q;'a"X%3[^o~cӾoH 'n>`Jlr(DXْ"lXnkoommyvcfrr֖o4hbҎ6,+g;;?A2OaeD]pu?W^`t5D 6@@@iWiH $(@KCGu:*F~.p՚1z/oXlY^mx_KZ0 =1@YbKC#痆.1О7{ԵIhB }VLM6avroLQV@*:d[#lOr&3 _AbEŋ{G}V,^DU  i{@2 =sOiϣJMkwljo9C׼rFg$zDI0<@Wh #5nw""M*8CηwslURVJMŕ_u=/Rlm: A2bOFB`Xk+۶n˝wyvqM6o޼[M(}T :L r dwG?|$d8tz9~,V*~,L kbC&gԩN:µe`5=^\=fDtugk fݨ%g^Xy҉3׽f@1 OɎł>,iz# HAпzAUWn6X(؉ DFy&ɩ+{zo폿mQľXkm*.)'"ƀ1@a,0]M7oa}_srvM,haq9[沵DߧM1կ~wNJ +tiR*ο/fN a?Wgl6X)ʑ01y)o[l`&H #N;FP.Ad(rR)ʤ(ZLMJ)֮OM1<[PmKTD{Rapk:.W/K< RbN<&06፯o~xq3 = 2ب4? K@5ff x*^?%>8f`JGu P9?ЊˁH+PfSgHEY#a}^u[A 5SS,N+tއ>sEa z^7|ʯoL%^80M"( d-|O~ yh/0Q¦y85ݏNGԈC}|!6[~!>5sf7 0=phQȸ[íx" ,,o=/~5,G@( /( <_@DǬk5b^pG?sX3g=D$RzR3 S r֌YRv g%iLZz|e4lQ&41@c\.g]{ÙM\!ocv>9Joxۜ]ZeЄ"j1}\ *_,x gLm7VH6zv$x`g;N~lE.Q>yo{{Z\&2Tէ9RHv&laj`ٔ–ܞ{nx3ft@3LwD l5{{|XLfʥ/_~{{ ذ%5ʕZ~~ 7>#b/Qr*aEXս1!qEkH+y}i޼STB '%5kg֫tР Һy e4AB8jS= \!<ª#9*zQK?%V hT`l8P^rw^M2F(T*:::01^^ pRu2 C 6)p2Bp]"wОq bl3Ƹ4+h ls5"o_"Bݠ 00+@fMk~Gwຟ0057^ߛ^' T08EјZc|Wdq cƩ]'H a!o X`WʂMdʳ5*(.?4Rs yni DTff#d"*4MTQ({wN8zFwAZ wU[&  |߹OV$"Yޙ !٨[gnMd<$nNX8+O??zD)8X?zX f `–=v;wmb!ӱȭu'Ƶ&t<""ugACn 5 bJD-. 0!2mЗQ1MU9w9?N~d H(޺Ǯ[wࡇVZ*([,c7ƀ _whuY@qcЎn03}{u(L7{ͺ@G;HjlvjXkGM0sɅE\xuםr?G|(54TyنOA@_O?mJ9FБD;W^?!B W*>e  pS35JGa @da͚5Q-[gMezz,]o`ʕ=}-*Rي1ZS- [FS' B!D7־+bk--ŖbKWgW3wnWG笙3;;;[;Z ОrS,)N XgWtCyoOO@\"Da^DZ28G" ..j.;};0BфЈ}+00Èܥ_%b{z(bGWw^vƫ_Ȁ֊R(KWO} "{daPQ$o+~g{mlÏ}]Z| 0a.s` qU_8A$04X)}}ry/`ohpgYkX[.EXX,E1KINS'lk+N6Xl6%okk+5Xq(7y)6b@SO[rec_nwHX~_=-R$4 k97Z4֤KYJ+GpUmlbk6/}C&Z&'e܃FKpk.b3U8i5i~94o;Pikh kUY~30eSG-o};f 97\W,@d9kBG "+re˖.Z䩧|qE XbU+VZbS8JɭZ`BIZ$aFBv8.3 ,6, ,IuUZΘ1mY;wιsN6mڴi"R,uRu}˵aUՙ*D:J1c H】PII֚0 A$W<5gOgBJ(@"?Yv3 ?^Ґ'd^闿wOАsUxlfleq, )|ϙ}<#^}rsD hr]K3cGޏP`5.^tY__O?;T./^x⢅ryՕJdEZ*e>#Ʈ|K1Xc%s.0ܧSą4e¢ Ѵi|{{{P5k[of̝I@? P=h Ci + սG{lYi&ݧ~˭hOEa}?}?9]n2DT840mFϮk_Woo/(JuPܸ)XO}4O 'Wi+6XO}B" >>E6LºI  s ̬+:?uEDRj 0i+e&aF}򠃏\;JXk#`3wY[o9 V(A]kV_ls>x_OZfŊ,-a `H *DRDbťxƦ,DseR53n.jo怩)+@ hB;7sO>}Y|쮎vߋT "JuJ9\D!e5k?[}BI)ky:EAɘTs\0M>#?p9;lW @k|y TC ND$4jOkc[W/?' ԕ-sC[[o[߾;B 4)a HA03WB?3~dzr{k /iNmguNsl)\ pկ% dUV۝_x=+W,XªU+Jo_m$ "Ww$vɭevU^&mxNbl3G?0@tuvΜ9s3gΚ[̜5c-ŖbSIu,[K\E eˠ~#<~ɋ/vk mT;Z@B*}/^K (+//1 o?wm~SQ%T*6;'Qr4Sp!7%Ⱦ j߰D8ѲQl LQ&Hg|fuc㷲~ SlRIS vA3Qd|߳_^w [Vc}+S( UP9UNe  v聿:dAD,Zro> H,[Ϸ~~O>n(LH6E Be'իVZs-\… .Zӿlٲ^/ CBDmf|&6Sِ >6|ٯdJ1{J\ UD 9FlUWj) B!=kfwwǖ[n5w;ϙ1sfwGg{?в>RI $ AH###O_.\kƴys79cƬf7oL%׸zo+swim( OI\% ĮG< 0 tE~owU Ay Y 5EĈHˌlIvԡLZg[d8ycR)s>zd<9ɣi[M^`>_+\A+/B rlA,XRזB񴶑16d6s6}ay|3gtP3&RZcm j_'ظgTE >3Kzg}~TZx)h @` h<ZR"q/ZeƵ+e4̹$`_磎+q]kk`4_[[mi͛7sviy̞6Rv WʂJ'?_%+(ɘRGGݺ9Z6ZV./i~PmBg 2g?f3{sȎ+ 9@X0WӵRhmHd7>Zyzm'DA H+ښo:=àfwa y\οv]| q|Kg3*!h ,rGv?TI0 xCrʧyn{ >+V\tIYT@Z Kq}'NI$RԭGSN["k|JWk;de Q@D0$TZ{SJĆayayݳ7lq_=z7gM9&gŁ=a` {_n⥤{&1 Ψ&y.)ʴN<>k-7c%æ8 g/|[[f *CCmoz> 0?OL;/^]E/9@ TyZYfF@$\ƆTsO|qk^V`X4ZDA;'U=֬y~E^\OHQHiDYˌ9a6jL Zg|tI" Sf5f z8eX6Tqa{=iRq6c }](̙3{ٳgϞ{Ϟ=s-͝=X( -ɧz1p鏿6)0O-:VZ!p7 W2I_3)\(@bۈZLBг֋1^㥿nI#xq^ƫXM0+قR)8moc.ZÊY(y <w, 0Z Hc9Ϸrb6w}i%{ ׮M_0~}Nnw|迏!*Hs wdD֕Rن(}Oz7m6`z]C`62Vk^a=/.Xt?xǟ~ϔ@Fs9s&9Q*I/[)`?qZ  Y.?.mNU ӰV ְH:cO\v2Zyvm[l;4}i3_pRah~!֠څwc{b{/jp%FFa_QX*[JbhsU>4&~3`׳pƖIZ]9Ѥ %q{F1-ղ)0.l@H8({'\KkdYnX9vLsϼObF.IxŃ|䊕=Y6k]w]tE * p RQjw_ߘZ!c# 9T}ԠaßTNs Z(g#ٻJ>`t\bD\) p5sN;on~mwi'"0uǺ-h J-[nσ^Ѱ0jhY@l*m]t^{h#f9W/i8흆] #yT.쒳N{WK<W{ 0 j{}?Zok#-hP5^F[nJHj}?f$`x>l;4pG{|{}KW\E 0ȁ"? hGIM}0%pv],͝jW9R_MOҵ+moETZ X1}f{WA?/e `|Kw~Sx y@[뷞?j@q䫍hL8t_KJ%ӞeTJ!M&4u H>h@k59cj:L*>gYZ]T=|:T*bc=qoYGقJuSQ7]63kxU2DB;]*hc0aAX*Ĕwz&`t4j}eD `k} +K\-) sFG5ocw^. EQ" )"P)PKq]v{>iZ[s'/蹊tqW"߻.A r+|ʥ? p%,+Mq1 e`, l$*FjYߌl-;<2²+?'zP) {h+Fw79]ւܖA G*ڎ9 ;1 =Qr.ֽx9Q<#]jJ4'~k]Q﹈)+hrk %)vϝoz}X*tz|C*8l3too=3}O\j$L>F2 JSX;>{(f<8zIIA\@Wxy A8i}%^k$e_Z.U\Sc%.ޅP%s)[;:͙Gll=[0#0/{4D^BT@QeBT LH"eK.j jd48bǥ>&ChG\Ör@2#p"cނJHcAB=f5rMq*Jմ7at$`LDC 0d~RZ˗|{w? H8l>pW-DQWggÌ@D@ȝz{qw*cW5:Vu7;8EDŽ<T8HH,?O|Ꭸ4)"F&ffg磄(;b Kw'fT1H6~g:"67Ko)lvxëk˹sf"C6*Q; <~qO,$4&BAk#1VDviwgN񼧬%ǏNRKX`GϪ #20.駽+hZcM o/ZT3 VdA2V 9?X. *ver[=1T)aN[/Z02y?OO<%KP)cPx2t7Fp'Dy+q ɺ߻RkanF9$ @٦n?kIĨ%zXCRϪ4.n *q> }+ V=C'4)0ViȈH_u[-[/ZKea{M]$4f:]52/4b IDAT>-_m2^-q Ŏ>cD3~e⮴"TZyiwZ"b'K&}U0Ww=D e ۊϯjrqE<&@v|JjvEDAM!p|\5[u3&>H%B\劒z#pBaF;,-GT@Bn59|i",=]bl5!{7l'ȑgX# "O?~_T$?Enk\\a̙r4u9 /:牀 s`,}{W\Z 3gt]k}E'p>x/˓VArI(l?@e&* a?O8v]W lZbydg_;n-?}C"@bkc Χ jk2=jw}a9h*"t9SBD"k-;bM@"٘ظ]R:!ZtO`16!zg9 R.HPYɺ.6}d=3Kb AGH'"6&qT}M" c(I"l(F gۙ5J{5&bkhf6( :i7\^A klM1"Vڹ5cVe" RRDr6[m}!o>ccG$t\]X2﯉7I.S%;/}λeЊsde&AlZEa6nq}q+֙ʖk.ɬ~-oSbLB̃?kSSSM#E]rP0ȐVƠJOLǕJld*靧2ws ipzy0#4 Fl7λ߯X2*e>szbVjBdLDL^/N@H^Hwk56eM :QZUu T7EҢ1CRVZeanqQ57 o'iV`J-GAXy޻ @H[g=ߏ"l;Tqg{_!F/е4Lb0MƑҽ+䅙l*!@է 9ήUF@RCe_g-w^ݩY=x9>+$7_{w~D" "`R9r\kkk.+ ёb>wwz崧|R1d"@}Ton_ygdg(l@X>k)o{˧?q_ꕌu}愷} -K/9g(Xu%վ.+{tJf@:[@ $4C?kA,&PTxYEL0<`M7ox俏 *OiH$0~<ũ3NO J8lr2_ƟV*j7"hkgΜY( ymmmӦMkkk|P(i|WZyӞgD$DQcr===aTvukz \.W*f.U*R) CB̬V.qB%ΕP/ jÄ lk'NZs(; kؒ0 C ~Q3a.>Jbk:]mvi/ .~5ּRSjយi{6+i/ctZ$k뉓ͨ7 ˡvv~s3Pk{JZȅ^hF}_^+tl)RUJӯU\*0R]u5E bdLDӜEZf b D`W-(BWwW[1mZsM6mbWWgkKZ[[rSalGi~x5 ==}}}+W[pŋzEK?==}}}`(֠AiXfqE22IkDTZYcCe {#H,IMfDTDel$*ov{'[ ,pUuEq+\wHs)}~kZ|uƠ8pbMu/|%NnE{hU=c;zW |m^UHښ5Wt:K?ş76l5 `|"BO{) c?,^T*kλVY .C*g <)=*(E/*El61ւ5`mR,twvgΜ1cƌΎ-1cƌbZ\.y: @NJ3I:H*JEW[re__ >\__ʕz@J643Z!iEs,8_Ȳ R[˱i+`AY3p >Fqtٚ8ǟA1l1JRuYRyaǑsV@ TT  EOI"L(b "s8My=3o^6[f{z:woJy9?b^ϋ#Mz"qjq_wGJ< f'Z@Z%O|`liLjX3"vtu]z姜r_(6q Pl0w F4نY9/vVsQZVI>H| ?|{DJ w|ȇB0M2y(yXRt/[nq4FOk0bqҶk uRX4V|BUSt]_~FbkkR{ugÌ$ϰuC9BR)-`ڝ c $"E0`k @i=}u]gstM̝ނ3f̘9kf@E^TIzk2-<\i P'm$70V,_#=s{?O,]<P}gXX9;wCd497#bLX ;#>{Akm7Z@#2/;!^j$(v 0 ?foG @(B\^.w_|\*+g~ƮBT,-Pcr6sλ^|eR^ι'2fi{N\#b@mږ3";ex~<J҈c}^X~9## kXʀD3{7h7rMg̘3f̘3sQ"qMY|kT^B{g/8>}ܑPA#uCo}:'"kD0i%Z'~P.VK%ОGkE fCKQ$ 958ugsZ\,mj~aüD(TBi!a ,]矿x_~O+9qPAL), V:Z+c$YÚT˩7Ǐ<VHM4啒]7' .{^ RƊT=mXH#5f}ϻO; ToS_ʊ{}?H"U?|,Shђ3 /rDpb{݄n~wcj @9E_Ȧ-IFB믿G?oR*t@V0xs5V+Q(г'a.Q%?QrgonŖδiӧRQn ßR3FDɩXOe^4iH"DF$ȑR܆vo7@(q.s \fER,]tɒ?s>C??l_*`JQ4"bz 8CSPxo=+B}N:T0{!ׯzKE+ N?UߞЊ{ƤzaLvyPF۞&ь*L8Zn* :@T4౺ kLk:W MwWϝw>8P*9^,,P*wN8s NJ&@>K{,O5-!c!( X,vtΟ?n937xys̘1#_r `g.H\tMjz^f\Shu<Ӯ i"m=\DPNPm1|Ā $vҥw_Ï>/s9"OX<@a6FFB+(0Xdw=GoHɚ(SdMۖx*\>׿<>#@Z+i߂XÐKcŚ]׿כ@goF/Wnz.W:ȸɬ4,hS+G;mj'0Cb20 Ry?mzϝٽS`y:+u $8.m݃<\4X\9cDD&HA`@'%Rek2eЪYX޼7pۭa6|ӧ< Qx[M1Pm/Z)vQ6ƪipu$MO 9ǫ2Θf>& ju'g~1(2,Zg}{衇~ɫK@y<ö|j Ă"41pp9g*P hC>|/F$}a)?ɏSmٖ 5vUjC0d]Ɯ&rvRhCNiLiD JPbg^};ula:&v3w2TguU?wʅ֊׺2Xf呭TbǼsΙ7a6j-f^wu{ [NzչXpUͮufj76clA& °R  A}\D9{~>:{~Xr 9]ҞGJkR1000,ZIj"f"3"Ex%O>n.z!@+)vwW*Ǩ$]V; a|1j ׮ajn8Ϸ|!䤾xB$j+=Sz֛7뜳=wnX̪ē}ԳN=cjL96\ɋWyYUPŸ;єk5i݇sZ+BrK-tV-]˯‹/]uzteJqإ w$󵵁 XkLFmrmt̘]ɖ"p͵O =u{p;i0 T*U*`R) ܓ 0y__ְc [62[Q:&/A<$GӦNŎBA{^.Cw랿~n/IJi؎,+pq_V B˽A~~w}OQߐB6 ry5 'pNgB:<kO֡zRXEm.@CM˴ 9^uhE8_pcz'F DC+>׽`%ݓy;+1sg{/rˍ4 "*P ~/:ScI|R$`6|6 o]N@HX3rFvWHB4@ P.a,[bp`w`p^^|٫?bʕ+W.YR&T*&a\Z5麜,~wi2pi$BEDL+˗-[+{{{,YKXW_Yre+A` t-@0b?~0RbEZq YE>Or&LrQTGGVZM2sƌ===3f1czwwSBWgWX6+F 8ZĝmD,BP*>G/yGЎ4W2;lu~5+)޴< :D%,LP^X) zigEcjx:*W P[԰eV ߛEΟAfMS%FGU?FJ[*v7pC+%!rt?B:\IwiL ai`jWW^v{9+moW)З('X6yT=S>~͛k!>X3ɰnѲe-Z%+W=08P.˥RIDĤ`i返bȨ<#Nq} jMAeN Z9i1Q2gƌ̝1cƼysv̜5s)S8FAھ9!ɖRa)BŤ`hѢ;'TcdP9;STe1lBKng?}|ONCɹ49j׫d!${X9 NAL*{zz===3fL?uϟ3wfϜ:{: \.iX׈]lIP1gJ~=X4|g0ȇ)*`cT{8=<VSaGճ83:m Eu_bn]x+'z4Q@5Ep*h.CY0j ZH,J =O 7csFGDXj<ƳfW.𫳫稣>y/\'ZT `.:Ga@F}e[޲O>M~^N.?!&( >yġ^lT+_z[sV,_+X4X+ 203HkdO5xƖ]_sDSÜOn :-ڠaQF$3̖mHSbGsYx̙ƛ̘1cYż0I};nthp:iqÏ>qmX`R,*&x +`m36:Кd 0/k_?TQ{ƈb$g`0,vOz⧏;鸣b )Ȗ?]~%~C+K@h8뫗\vEdr54DAliR3w'9x9%hǞzK{~480NT.CĴ3RRi@ĀH.͛7k{߸^9¡"}W\׌eT#W_}'~^|'x^|+WZAiH )Drsix;'a P fG$ga)OE2PVo-3}~}Ԟy̞={K3f,HQT$]lଳ.bMp'?έ(n`|?:R"(Ll58 P.cM*9tl|Ȥ02bf5c#Q^ Y% (c{u6=[|^(#Z^FQЮ8BgO '~r,xA* 0;]|K(y{ Fe#~낳y+Wx啗_~W^~'zWK/04!(RJ'U] TѦ`[R޺Ycޘ'5Q"H>3){ e+. BOϔϙ3s-6h 6dӧϟ?B'5b(Qr+Çz_~w\ BZcA px~N5}S;?w'9c77k|W*߽_;r6*`B1ahG~o|˝]ٕ߫xe?zGyc0)11X#J+O?jd :D5 z'֕fϝt{ೀ zΈf .]vՏΥ="*tR: ; mPyc:uuWQ X.)5iT)}yTR{9rJMQ,J:9P,VN` t?tKBSa740E$`e6~]eXkӦ-\8yoy_p:֙N.EWT{N?er bx^ryԱÄ cD$O V܏~޳_'iRKԦ 5(:1@VƵ@.VD ٵOhl8nkF D%M3 9f2Ih*;y]:[S-kXpƗ>2U뱬wa)]/- `ɒ` 0 H. ZitX3ȝYC5EԯPMazE0Et^=bV=#D M*%δsnɆp`_Cv-e NY=0x?z-%+\hʳ +Mh5*0;[4R2n616nϝWI@a\rBagyӍ7H/wϒKgLGlEf 'k I*Gxonµ 6#UNhlߟ{;A]=V0LD[8ៃ~n67=9|t⠁cGQST,Ze3l#}}K,[OP,ZcQu:q>}c}M/բ- ԓ-$zYնYˑF4{U H,`-!^WgG.[wu֙ճx{ :lΛ=s0pe?8 #*b(ZSPo|A1XҘW#H5K*d)H0tknr󘉵[pgx4R\ yJȝ=^~Yg~9Am `";[;׿n: [f"p-zB'YcN5͓&4aP!c|.EIPq:keX|R_uQ|*1B#L @]jQa#GJJ"a%W(N3gΜM6h_޼mzƌS0vJ"2]8`7:=uش>Z@J:sB\bcI+6!V9gaP938u4 '}kj9 Jr^K.6Rx}}޲kw#JcCMrٙ Q/{>[`%]JH0,uܱG}?GU|ɂGN#1S%W/=쳯BJ+_iP0;7-C MijOm{w-o}KOW87j{_"YX+e\nxaɲzzGx%K-[\*;#ELIK9:k)u 3}= MQxrTPvH# qUWsRypj)ϲͻ .!4 ҥs[pu]W s{@c!Roθ-_Wk[DlE* !|2i+U*gʵc#A|6"5&`ZwooaM. z'/ׅ.k =Xc2":gzZJ_ (T^A!_*Inn45-q=9ėJl5Fiy3gL?[nofmP@\Kxej,zf,_'?}o~誄AժL4E! 0f6>w{-(Bf`?ʀDdB˯o]3:Emt3e&P,Ki_`]gT?(UG!gLnC '?#=~={Ǟ|R?0(va M0yK28uMj$xRKFuj)S21͜U3d+b6Rc[)'}c,B>'~]= INj­z[1w]s&n?QpHL#Zsuf< 1X?2?L CVJ *0_o'(%bj6˙?{N~G}py0!$39A%msA֨47' :bK$}˦?YBPWn H7DuH5$QQ!9ryP չܴ)뮻nrͷrsfik%ʬst0M@E $<~owܱrJ\6 &HHi|pP{;ZwZ+fRD i,83ӟW3-a\9`=}=M֛SZЭO}]՗~cn^Ki,r! ~mfրR 4MVb%Ǟxk+O@X#.U,Xٳv}yo}S)ܱjNIF"^\u]wuS<ܳzQ狅|}|fV*K[omP`l4L ^kܨQTIKuB&y$$Tg!P,+/?={!]ޅ%w}ǿt0] 6Y?f\21mmaM(b)cxz}H) Cm5\&UC!cBήC?Իr@ytnQtMo~P<5@]3=b\fH  eB c`,43lkF%@e|/0SFBFrZ[e|K\"AD)"`afD) *63mޜY[m.yv?n.kL5ٴ%xmXDC>[JPn+lr08bOyNQP ߹7X"z|<)m*e[X+{[XH)yRo&T?|coEE8bL DPwWA:p.ǰp]`FCz,L5N_|'[;:EZRE I49'lomz}7gZ@PanK?ؔ}C? @)+:ҖMC9)C,(ju@ZV|6 w, ct=r jMHqJʡ_Ns?~ۍJYO|!|'h2C@w;{Vypb`$jgbltӟ:+o戢cCՁpD[N"<' ڀ[lp&Tʃ?/vm dcXk-MM@^L@Z8qι:_R@A{J|G>r؛v@cO{ߤbV|:tm;>1]z+ .7 kY5 }fzqn"`#JH)%`dTF̈́ۆFVإj$cт>@0 hm)Y85) !KU?~ϔ~nmȺ0Z\ɱJ74Y@5#6'&PcٰiBӯ.^ރCO|6&s:yaiC?_U"p5??A~tkH1`FR5F%Ig_#@Y.@YִFBwr)іPcuHTH~@QvOh vmkYgB`3D8_]=x7߯|Gф;JiA{9_4QzwVWXn-}%Wh78H:J {^tWן7# UO:^׾yeWP.D %.IgYg}cGr~wyDB`@}'z(d#J+ lzx֛-L)0a)|n?r=?lR Ok570Hl.vIվۜPAj!)yֿX:\ѽbP=}}o]Q!D ]?"_l5/eM⪳{;>@\g-Z^khL"sb#Ѥ02r@J)"4[~@btʃܛ"Dl2I~ m-i\wٮ\pLrsbFLWN4T݉# +fAR{j8tyЎm>/e2^m¬TbYaX(7x]w-o~vl;gTM ذ=IJBI?x S{>)[|m8/-oBS:^0O|蒈D)m)u~yEial4"8S{s]VM, GuO99cr uQǾ Ryf6HN&v?|"32S>T<i 00XyoOO,W1|AkU) =s֮Ͼ{NӧO=!%=dD&kO_s͵^~B("B\S<>{QBCpaz:5އ?{a$H˜'U$,^~Qw.W-mRܮ+ y=vTBNy WwG &ڣ$ ,@.9 }&Ri@ 7߹oGExA,5a_oo|~,=y^ Z !! !,ɥ{|(PjQΌMQ BMa4FG6χQ P\-hN1g8+ IgO>[^]N,D)S }C:0 h۷*^S.@Rֳ~R02)YY5m@hBѿz \vtQ ˂ ^~ŇHUVJ%4o|= ($tIz]"$G5, ~4>)+RqhAhP{Y"Bm\4nKfgK=OkV F]6" dB7.V;&I2KNHU\(k("̭7޴>;; (LI(.7+_^w= X B(8\ͳ]TMZerEhf1O{[6 G>FZzH8+N?L$g+B __칣7T߬c¸/I]}ϼik{G{X@}O΁apcO=_7о [H"gtsBODHQKdEF˸lG?2A4l>CݑXgz^QhM5UMޞ)Hqۈ1HVDhN-e˞wຎeΑ`VϮ~A{[DLJ{woS*BmF6Z0%`TJYg9зR)emCv,L-n4IG5I{&7: Fi;-& %iF`h*0VDީ}ᇗ_Q B:5&<㋧~=ZX9!"r!33X/``\@D*i4"jK q|!)sRFDdž* X&4fNxҠ6 Ckm T* 1oD$!w1RH\BD7Ԥv+76@0qnƭuZF*t96D rsyo߸>l6>c]D/ @K +\6RoZ8zHYP :cnk<1xϟ)Oa0y~OGuLD67$Bݎڗe#` {^z{d˂xꙗnmХbyocO=߲3!8y)z=gY_(@:&ϯ[\|AD"ry B ( ]]bX(֊s](ǣ;:#A+E!NJl+<H$ Xg(d_n[[MH5&k LhJ+W,J+^]xʕ+VLh1hD@hMJ93K,To"IXc12ɪXuTNS!~DC`P#" lL=Ӧmƅ-̊T~D3 ͍~vX+z=v$Hdq ]rq(4!&?'?(iHC% JSdP!v[_p׷dAS1ӕ0}R,9AOqù)@X `C7) ْ"ԹMC*S%Zf^n_]/\>W4 c4T]5 z.ę S HG`"ނlrB~GB>1eʔiӦ_(s\GŎ\1)PӯkO4Iu}it_Nxb%uRTJ: Edb-H}CcWx ST , Єa-keS.%oSJ+O)du]>x{[#*+B wrFDalBMx_@$! G'*)XZH ;CJX<F6)9nbi+}t?D+TEN 4eJחr7#AYQı>RQbwz" B; SJƱqh'. $y~=و7Y3?훥,Qc:\v aA?X*V˯ʊ+.YϭX^^bEJ2P wtT=bWVOBNI\og@@+-"NB R ~Y3}.;)v BO~]\jEU?M:Θ>̧ ) Ρ T#>hQ1G*f_ysh0Y=Գ;Q L".LdM_[-!.K@5,oSx ZA~N۟ri' F0U9TCn=֛o!ugg**JZ!y- :g&'B-S)pRcrT@Vkstww3czww̙t:ŎbG.+ y?WJk(2%`EJ"޼ͩ9h~d5 OR *Z]VԵ*)*a(ؾ6+rR-@"736*a%r9ӟK@M@a"A_V\ 'mvsK4&DW oZeȬ+MT]EBhB6].{_r]c(FIs)aem/3(V[zQ 9zڷzjˈy6T%D⤛#q碔 ˗.^E/Y'~Wy^] $4@ <]((EZ{JQ5SЀ0y)h2& ݅($X@Xlcp]vz~mFSq DBYj)C'/ǿXDt}9gwjt€P[ZYAp؇ƛu`+a| D Ͻo< ! C ""`}ǻ{s@h=+T}Nos7b뇵ʦ2;>ĕ+P@}|OƝםR ,`$Ę#4ɮ Stҥ@{tsaeBB/+);c, aP40PJiDphmA`Ai}Ouf̞3gSLY f̘z O_gbS iK~'&S1>Q,"ڔb-˙_9_=| M(13?e)$0aJmeJh!n__jWqZ 'L7IRSWjjٺf sکgg<<`p\RIΞu n 0;;o^Kg PY 5p5 DFE[)?5őaBT  ah/^tGy}~Ѣ^x驧^dY_D@}|R 1̜ 4L031"*.LW.3g[o]viux`UDU f `g->o_ 0o;Ο&JEV7w\˯ |~|\ b(@Vذ_z촍?4nxY_t*a!B +w~_:Ygo/ g|WF؇3}Z^PTJq%H"\ ;7[}VTJ%zK!PlHV]T+$D*,WHr}\Oϔ Ι3s5km4{ΜuO="@iֿT2X7'y,,@XT9C= :87"G/)=SuK^ED$q!(v-k=M VHY{et,rL]g |ZT~RF>6Yw}v64 BLJ\?x d;KYW,?V[]Euh, #edE W O>?>~eK *C}"b 6bI~3MaZp F$J)ݽ6[}}Ё[l90WQbmkQ4 񯯝uö[o) zJEaB9/ԣC˖  !"BXciKXWt!Y"`ˬ<yႹm<o}Ӯ5`^L|0&0!VJW\=o.b\JZH&2p '^gN4Lm&>S?# 9#rϣx{^YByV҈hޜ|++V#է}JюpO}m5Î joϯǞA"iTC9Ojsy!m<h}GY,Ο?wno[̟?Y"ukql*2ٰ8 {)cݶ"TC AZ8WS#@F ]#( B8A2al{:; // wO4M@h["L5h>xQS$㦔`䯲UV§@}~%}/oN El_t}ng (Yp7fÙ+ D7beơUw$e"M٤- (OtUAHjJHj].&(V٥YA _z|'{駖,YꫯX+ 4h.@I\猬)A(֬3u{}:O[픓؁*S(otEK-&8+?W̙|⨏~ӯn(A0„ոVgk|onod2ЁJRS1 Gnq+VRROiϟδ-7h6| _sg=Ť~f! $Hs_AroK/[@L5&"p[wkSnݝ~.etlfgNnv0bwO; XjE&Hiovö/$ωpR)WZk1C33;ƷVh" IJZtRSd}ZRIzjO@ERPdqGWWRݐJ_ߗ맞x*Z=IhN4{$Ǟ]TTx9U 0ss-p.,M"hHѢɗNICC5b%9v8ܵ )-] O<ԃ?C~}5]BlLX.uvw.?iS:"6fT 7tP*vi[W*k9 dU|?IO>BVFZ.aWݚwJHb}\xR2mcBAr)ވSo:?L`vC$Eљmؚ9F ϛ;L!Q8VE^<{v~SjRSA5Bsv^3կsVTrE(?J#"fKr\.Lrl9~Mf[Mj9  Vs %ιܣMl"DFZQPRb3,Y+Z 9ОB0(W oz~;c X!\*̔ {~߽S>aBy$ qWS?Xa6tU徕q~R5E,{s_xyUD;]ް{ԢOs0l[u|}r ᇺԮ:SY5}~7׿f y}|cL-vL,2΄9sl[n妛m6kJWWg>_+V,T404MckES0t*Ic=1BpZ 4)"$('ߊ D&5\PH9i$DM3'CU-ՕW'Heǭ<mڛ'R,8X ;V,GZ1\eoU?rM6K('0F+6f1l|ך"8#8AZ~fB uW;w]=4Y[H}=Vzƌ/< 2|$#a;{ְuIi=ݖkBB0li[3ɩbl s&VjKtDRJE7)8]i{JH#WB`EU@:1DjUǬ!C9\Nw13&!C^ov n8{o.[oP8 yg  p_`>x H1$y3fuG>w‘>W*JE7/e˗ۛ,Xzy5^) S p5^t@ S2BXdLK D @#%NFNzeP (Ϋ܏-UxշZQ2OWk@RniIHP*o|㛮ӧ TW # xh#^HQT渍\&mKp |3~"l`5j/|_yҾč(51'RB1ߔ: Գiצ_(*͠ `#$]4sF'Z)\fxG7xDu &R_z]yu ]=D+usڒyW0hm꒚Q8{P(ll8`.*(:::ay?&4)u}Vg'h.@ϧ0VR@ΌT.gϝqqYZiDp*0~e@DN lcw GCoߧ?6$טyS@DEaBK½c>=wµhH Y~oA::lK5@PH?_8k+W4Ⱥ k4;/Zo`UU / ӧu| E]ƽY+kt q{s/ׄ-tvb%lY<0'6iRȢU⾣1 9EV!@h*ldR81VNx򩫬Aþے4\Em)՟ƋJ1"Ȗ;z~N? ?_4 X4YfJɊm 4κռ*.*!*!&u8'ye8 2Aq8- ",(z׿mӦw9\Hw>8s}Xd)@TnՒWw KYH I{/cs݄i4V41JjS +}#K.D*tR߬M c[)4)d NEHX2 ===Ӷz b?Fa$+ 犷ϠK'qittɹHsZ}v3q_[<]D T &iUXcdrnmkHI(DIZcuF› * TJ-0J?˦Ƌ_OKxU4qUuat)k|~A뱆=9}ls4G~$13t!5?m>?R53r=(-mV9TDZotY@өICc- (Oo|ٲۡ-A [lբ^<3 &(.$c7* D%LN}r_"{4Y@lB|Sǟ/sb% AoyDA=Z̍jf p&y.e7m8%̸j*Px{J'Ia+n[mKZI,:@c @u|("fc&qɧݱ,Щru1F{4k1s趍VM+ov]V^k0d&pIV+{WS, HUCix;79 `RAP~(=D|o6[mPNnQ4A\n1 ,=‹/̉Twx<$@~+oނiqZ/ NL] }W@y9TT|"1MeQm{teNݰWZ ۯ\AZE,co}mZBkcv,qh7lm"NZS!ӂx-Ƅ|Me[7խ7:fZkch! }[!8rAx:700OOvtvAYh_i%k'i& X ,H{/GOB;s 9ACj ?~G;9 X %9_PsFRO m7av`mpur\GGhT0|yPg&i$ "Bǒ%ON})a8\kZ}#?eطmiܦjȲtuwtW""g& И殦]XW5IjbH_ULfh`n욲1$mƟ 6;VCZK)0;tϘtaIU_of%DeW^uq|FtCW:5>G8ëӭn(! yO>obP,Xpo Po6]"2sUcǿ9qeyFV3F =3k^& DF3+W~KP"ZF`L$UCăE@/WN?5؉X`6G>dmbGXry^ $dKş=<$FzM4IC/}=O{ZV5E񯔲Zk۲b~:ħ4 !ahЌDD4z)wV&yI,'Ă0Jӿxoo6(gppGx{794 1)R0aQjR$W*[FP_W}?!@y@*L$MRDA"c;r9/+a|u9ҙhu rkBbnDDDt5k.GO"F)յ+Fb$ȆIA$,wuOYϝr*VĩjZ<`άΏuw7PY 5QG|dR\xgC^ NI)u.DthLbFЀ5imw+a,8 y[1Q=o2,.A襗_x'r\$U9One18O≶γG7!<>Q Le^нxʣ?yu?f.h80x~bA@x[{aAbpg4X y+9B'lCt+;oWgЦa "v<ΐn홱ymϚ=ךה]s"EK^ @{ZYv9~5>c S2jt={©v3 a诲.rQi)q$/I^ % FNȂgIϜIC }#G}|w.Xgzט|@:|[w~BLżǿ}8edhdI~_F8*.8Ǡ96xJrȁ5|Z)d!ݵlL$j$0 }_mgqfPI Z3O?7S6ZuVHxOw|iJu!@$MPdsE7knu\,[w3Kk)pZqte$iu$:}uӾ/$Mk$E.c {Z J@llX8_zg$x;VM$D1J :uZ!VL-RX`2"0ډјИ7!₹!_(T\ފZ7.lxOr(cd5QzSG$B ;:^] .zmO @c'Xϱ"k#= E$Ϣ]|,h$~:6Tr8f@B{'?i%`oU@AjQNCz`ψڟzNVGO[ǰ|ͻj;b"0snᡇTCpk`4k[v RԢ{ؘ|7O|a=1&^#KDHNX:tzdGo4gaP<_\mыTkkEeX49Rdh>-nBɦJ =/x ןK2P2\uH=&]-#aTo7t3ŰHa'Q▥^W3YۦWh>1_3?x2ܩ"B"$N6sH7]CY,$[MDaCȰcyM s>.knJ(55E0ht7Fi.$_!u+w*vNy챧n>{Q4,J&Lm ""Ć;]ӐȚp>IUŗbکi4{U3NՐYhSoeXtqwd%[Qx:0r1i3)]"Hs`q[o DZHK܅ZX;Ș2M tu4$^ q<26khwAz6,i7D_{9/a-oysgp1i9fP2Rz_Bj,Dk$) Ts#J;*Õm8Ԭ䨁6ičC]WpNAfVY5gf317]eu[D$Aoç;W-*7D- E!\V@&ȈfFE\X}W᪟w_o/ #]Hjbg5 e\bKӤ bQWbT2 *X\cF_k2ӳS|bRaQ79DֻKܐUKT+],@"(7 z^C h˗^r[y xӜduʤe}=S 8 `f k G45=.P_GK84R C}#ɁDb(W>#k曉uC"N57K궪m3( [m~op}`C :q\.@DV ФFLmY(y>D8h2Е+]]]ࡇz{{vQRafܓR~ҵa2286D$Xk Oܚi+޿\P H[GbA=Sk3V&V"&oc|3֌Mvj)hv 4Qpn ,[UΖCƐV "JXefFD%D[@GG~`v?^gW AeZ#e,֥> *,$ZBU5MnD1+$%"Z#}K.ڧ!cR{V̝;{5&h|&ҧ1 y1Mt28(ݢn9owնNrb,SȷW12J1) pG/hu1i69R*!ci=C B1t6Ai]T<:Ν;ۮ===X@l_lh5 ;L&^l4VkV%L[I٫޷ Z@rpц~7F@Ğ)T WXQTİe ZkXƫ:]nJ}R.J.''ش *k5 4)aйAOejf-%ge"w >U&7v[j+ꊪQU.%j!Ʀ8J'YnV=Rw& _1CXk؆A+ Kk) H4z4X׸Ͼ{ 9F`f0 e6aZcٲ wlATdpjUz:;A&V>)""?SJys~2|~wMd- L!`=}7B!4a\lTdp z:VIi/dEZIԐ%4Ո' Wv4Xx^(\1肛5ESFtVUYDLZˆZW)9l9ut;;;֚IQEDAM"aH{ uRA{Di-h~dؐjW?) ;mbˠi$\<XK,vʞN J+ Ƌ-vDI,\#%I8$A@irWiRuZ^WV4mlOMcc?˟" N3nʪz֤|[Qm%P\'5Kƿ~|ڄfWK_==ŋ^u bס]QZJڟ_ qHqQ:fqJX@S,]=hm# ME.0;g#-="$ 0r~haqВt:j=pBؔ\e 4梦3Ю%m9٧יH@N@fVNY3遪(hfJfo*G5{e!1w'XƴDHsL5&O_VZ)5&iyVkhA7"-£6\@̀n[)5ގ1[_)Tva9sT*jCb?Gqwg]sOI1 5[*?L8Bc!/5 C#rTRY[IG8a}vUJd -kv X]AK4$ {YkLw}=~滻b17F3:T[ 'mƜvD0k(Be?40M5gr"-,5ơZkcƚAGDeBKZ((GO;ԉ[jlm5tܖO$MR[Q~wGOgD#i-$H(AP;g[!UffVZ){&ij(>Z r9zʄUZfv;Ư/oEU ^ d+B\)"VU6ǘA0!7kFbl-3޴fyQPf5(9Ehov&TQPA$fDt~ڭ'Ug1 sew?sRyIpLm5x:Ɗwյq`˜wRǤ1PU$\8 rp~ODͥ2c裖IDVƘT7A<"c^)5A\jVwvU'iM v␓RchrJ IC6QQ[Ckt"ETŽ'9:[)!<P23e .ω", mTvG@cE%A"A8 8;KV⒬zZOUjw[n36e3-=Y2_eXHyęYAn?|%{*H#:ÿ*t*&kXlB?᭶˹3 #NGv|AV8l6kHj3F@&'Dy-얈cƳS(3B{ygj;QW}7f|QfX&!hݞ26G?۽ oYP۷&3;+L*'&y &'L'eRL ɲ4lB'|S3WI& H@ DSg8!{`&1Ƶx'}\톎owi=Q\ &idv~0,Ř|kjrA׸ Oc⎉ܖŦUӯ5%cʩ0U$M4kEL__~O+ /Ӈƚ?Շ("ElCC7X3I1hO'|jlɑFY4%yvmu@xeP2څzڥ:3Nں>=UhTg<Mwl~zO:)y ]KqFDIvB@jR~l]UB0zo_| [H㚴ڃm(kkwyn\E26|v^R }q6]ocY뙀YXMDl1 QOD (i9ݿv$㍱j8\TO?v;6_z^]̢DmGop٭7V?eԎqE[d#8ROS#J-6ı;}OS0vǧMpMWZoeg1;O~afӤjs^}r\O~=mr% !ĉ!a%}k9 lɆ\3gTJBNؐp$絹gQcd_n>xc?wG=_U6VxM.vnPT!e Efs&յ-~5dc) "[O3K%@YxMvP QR"6\T4y3 9XZ爉HIݭ4Yi0 հk-_[COV 0y/ƻ c䟍csJ0(:Yk܈WwNJ.HXr:eYeN2D&5;{Nq_U (p͛wZ ư6 \U+T@GWIGQURZR8gfݵ70"*E2k10oc׈LKJ ZELPiĸtP_ ra$ч@ XݣV;ơ,zuRьըi E"q'2 Pm=n32& PٰsJ)*_,t}-Ɩ`c($r錄.<Y}1l&1WM #,Z@WQo5ko`;<f99SVmXmv/Kl7V .+COY0/ lHw%_D~_LIE9z@|_y6b,B&ǘ3}}]]b\T%Cf)d"g~i3l`x 0H`1L3~p50ӽ^c7K“:KnƱ1 +No7Fx03`RkX%X 3"K0v[Ts6\e>xN\𯇮f[RAss+❅w1ΔMTUJ%!t3JAe?0~y9㚐?BV+o [_~WZ7X8r 6B>˹e4ܱLFmCBǜ^N%Wۡ*}\p^pyh'V֞y\6@!WZ9C%vt(^=.ܗ)>Nۧ44._ n@dboUaОoo\z<؛٫=~|q~xF{8Ζ;?yxJl>T0.lOѻz%0G|R ٍg/ $IП˶ϣ (Ci{<|"+8p1r&^exjem3ynf<"_?͜P=ͅ~'|ڤ]y/UnJ J?~H)}yoTnh&*nۺ#/XnVgH踕ƭMq;zWs@0H%}բߏ}~(~.yn)QVv[ }cl]؎QB=pިn~B u\FGnJޅI(|&tdc0(S6/UQZJ)1O1~e-~bGokn䴢l%qS- 3{ƭ5\n{2ur:H:?lh;(c9$޺\Fm@r-J,]S+nGXkHB"A F./FkPuj h ~r({ȘfF:~yqlUpjY~_'Iy,qʞdpj{ԴU"ಲw]e,Iջho$V`wRJޯQc e ҂s ~N~My0k]%we^{6 C-諟TCjE6χx+LX_qS/SJѵ#,_^|\vg"U#*`AJi WZIQ{-5uT-ݿ]2p.s\V|*qvW)s>fhC3V}G~JlQYzg{[ZC+fsm5҅hD|-]v^ݟo<}gŻ^79ךjB8;qA0jxO{H@ =3dv#/vFa=gZBKEl> N2Gr*c33#+i`o*$vn799њ&9ҌU,m(yvcTŸ-4l]%cL3ms-4Z"[AgkDam3̋v<ƘJ,s8ۛ{l더ʰ:8jr&<H~]F)|~bwS>n{PvL/\6D{ K 1C+ $S `Oba;{pA>2[-b \ QmxvXIov|\/;fJ+qO]|&Kk?X B9ٖ(T$tcxuwEH.R { ˉR e3}ujONmNݿՏcd8nvb ƽԨR4DSҵdp֚:y _l ELJܳe/uN @NN #H Q`őRBR2'zꛦT1c 蓟sNCX([2'.AFZ+MReLqF[c4=.ó(n|(5{](vlטZ'QNj`wJ[r c Řh{\@d=ݟ7-CP)BKԖ;1> qTA,$R*Coq&=ysߜZr\ԓ(2# >f 5a\RX3F7([ 5=8]3% rt=+ֆa2kAOiWމ0n$<>a5۶Wg90!o~,ZpSM9[=.8=ۣ>hcV>,6q[Yl2p)\PF:xv]f`Id Sq"QO>.vͽƃ$lrmIeq/\./zN='}(\~`oVJ)[SB/gfٴw:پ!#I }}?'oZv>[R*8_WIY"gk"1쐤wQw8ꏿ~OқKB6[HpQ$xgc{n1d95ʦlߙ/ȅ 2J '@s=ʀH% |E nk uλ腒A?R*kM} RUB>MwOdѻ"mÉ7@+r~|(31ٲ c>]`O1=?_4k3OB(R*%tI.&*]*U,^/Z}w4:n,ebMX-ϴ{ݐXyhYX qjoي+\ ץF!F= ]JF<櫤 s>?X_f( DHqe!Ǎ,OyÄ鞐eY><. e!-I6}`1T(J;o2&ÄqKll@ 5 rppk8>-`quM RK~ԟcTV c®! p_. * 6ZOi[PJ~%@Cs-{93Rpr F| 5_Y#bW~+tLZ1$χU>u2Δ"4k(bOJ&KA0gvˬK!jcqP+|v, F3&Qw_~>pe䫥 o  O{w_SG\ksZWN߂{#WH/hXV)=?.9YF:?k}_wL~j0ZUFsmSJ?}L$a&!X}@F9#6,jrdAzZ=vN9J)SaUB_w\tuخX[ɲu2]FśOs7V;9~B)%|žꙤv^!_e }/&pY*U}!r>,/T!(E4%m-þ2Ӎ9۾+7{a=Fr|6bᄐumWPN,1bR)J)<#/{P0řr 슙Ͽ&u4Q֕?~^k].QOʌΔBMפ}B3}Uٵ5;B4ȣ>\}COǸk6NMJO$s HxL\99WwEY _.sxLpL2NRʟNϻw#40ت}-8 Z/ d"M?!R)J 4ÇMaטX/jbЫU]A.tQ}>Y"3i>̝:e٩%ՙǍ T/@`:TJH1tHDNDs '>bevk=-|ϗNNYpJ*s WcRzwlsPj訣ߞO5^[(|^4IJnV6]ȚkqY76fOX()6T꽆 Sb 6lيcuƗ${ /:xm(,Բ~?iÐmqF1cyKGϕVG+sJZi Rj)}˝}}qz⊸pX5Xe0Q_=Gc>_"*=`|'_{mhfrh"!=-cl]岴 - y$ _c '`(JRPQR1$/1~[<Șz RJ G{#}ƘeY>O5P> &W=hK:{!AnKN:'#oz|"_9Hae sNaR}0l‰$jisNc Aٻ,qQTܹ:l~nk۾HnpJf7>Gvo oC>8E}lnn{vvNT総n>T&x^ޥ![ .x-x3װN2?d96A 2=SujMD7@HwM Ij1n@l<'hTy\:ݖ_~ xU&u,?fl)~ BʨU+V7g3Z{)!(b’hVྂ@XVvd\X-6`Γ5ګJ}1Z|u8N(_cF 8 BٌQЅf*pEdP$ȋ=@꧆r iΣQ cA٪O=t1n!hvW ș,AtҏLU0uCjƩ!{IvvkvtD!$9W0a";`UkX0}*asv(3ƨQwX{Lr؜t0({T\N,\ܕ5qH52z~7r^7`e_gsa(i ݷ1Wa[Hӄ1۲ A2FM](9b6\t1iգnf:HY6ZI_l kb!.@ kgHK!lϼ=u|킛[ʍ}ʇ=>FJ&qE/ծHwZz6Ct4с=ï3'?tO#!aw7 % +v$7v9>C) iF[Ƅ@g"cLIҺ? <4LmꕛƳ~:8$i2 Xz 7~ݪoqS++sVbg3 k֛J he)5,9[GQ^ TRi4KuXٓㇼ~3_D3y|yp!J^lDa-PTr(_h^ GA<VSpOut^|`JkukS͋z7:GD[W uVXc!* ^龻#=.@WC @wKǍ`nnyn.ʘ8hf4pGs`M| K(wAqAgV) k(gpܻJB7u&MҔOߩ7kxMߔ5/WK(O.=>;:5$ݟS'2q8痚o 'EN9SF .bG{Y:hgPq; Kr \ngvy̽/Zҹ/n0ƈEHܑʕwRs˙RA= {emEݟ|, f݆ tv{5!Ĵߟ8c}T?v|{Y\-gk|,."Sq4ǏXBSN&(k_cj0'yNNHU|79\պ|n?(ջQw!:P#32i8?G{w \ JRz.[ boq1>PO4xq=/ZkŹ LyhSjoŒX=d˾v6꼶v+r0룛8R֓W!em!W+Am;۞stL)%^.MU4$%KϾ@^߇ ֚7{>*ku6{G[;՗cb{0~_9X7 B.ժBkc}w)AP[k+O  /_S~kmR.֚Hظ<4bBf62ᔔEU9=# 3{V͇_4ij`1bY"nq_>0!>w 4tAk Bb%էZb7ǖ`X5\ptp@ۼxJlSXoh8A.8G{s(b\T-YQ[:"s&$j'BxJ1JBXd^!@) YP R#=gY?7[-6/ZRbfGJ)bx|8\BTUgd"\IgT:ho38S5U Xur=炓S MC ih"a5yj.K}`¬ y>;Rr %Wh|7~k@~m1{E5T+M7agit :{I:JWk 2z98|0N9]Z2[cLxNBO}ԼNo4|roQJ6v=cic8JiL6Rwx!FSf||v'봫AB # zo{ξGG 7]t;]{m8)#;wل?W9Ԗ[*E&`]\kidmJ+VIit % _m匬A5dY(0i(Ļ:Jr'Zi<\sAO>oSD+}-~;O !09 ,a ۴Q29`q=ۛ]spB ғ686uǻ]>8xYEE T)ydl.|]%e4ӿA s]Dhi;Ì|Ix8_Ǚ}-K/JgMQ."yjdM }{MU;Ӈ)cl#z0W ߐik_)"}?{Wkk=_lsEf Y 5Ct}CH[W]k BQNfcW0v2+5N+gHz+ͲV 'vBlGK0fMٌn3~D ;]SF7FX/nԠJM;+^_.͜|qkO|HG`xHMwC^* U]Zn LAc&j,B1(@kBIE )7y&Ɨ;—]2.tLIZm&ˠVgb眎u\%n&XA3o0M{A~sz#W'0txOykyq7n#V˿ߪm2$r^WI^~|#K[8݊ǮGg>=JyD/-α*6P3~'hPң͇ףjW>dS;G?Gg}^h]>y-@Ս1qww\"0H}cn ֌3h~͘l W TP^v s˲|hPj0r)ާ2Fn&Ad\T,h*qƽ;\phX.\~/By0 C~ncXsQ;G?Gg}XHxw Vփs$ʆ' Aj c.W\+sJ ^8gděR1RJ! Ϡ.@z83a߸+^~ĕW撜TQ {_5ӋD#s˒P=:RCNP[3,Uw77XR)9nKdIA|kd%̓:boe(iQpk3/=["}Ws*+\ןwYv v]@szM ꬟6 B X ڐ-1/v Wj4XwJ)"ۓ`ūR_1=vG$ɷZKsB!J+%;/~zlVvp)B(f<_;{̣"oJ_~x)%F@̴9l~fz.7M{; mGr@7$1'~x.U.?gA2Y`?OcFSJ3F !0rv;з]: |wNJ11( g]}p>B<̇0NqqT ~8{wp/W@|{\w?gx-( ;IJ? OS݆*p:øƚ32(>[}ә8$ބՁ^cyׂ{NT12➦8gDܘL>\pQ wBؠXo} c̲,s~֓KR7"eTH:;./ dQ#ݬ A"$>_ǜ~wK`w< 9_!D7!)i 5-WHR|m1,R$ 2'-quA^}^5/C-ރcV Zᙔ7*Ql.>Ok+BXrPOznnV1m _GVl?nptIiύW}rw2g(BR55qJW|;W_ʨ 6O9c7}9>0]aFЭ#f256 Ʊx)b~ް;;fO~RιM~yeS!JGRg" h|z2Uջ8 ;R݌& }.ڧ9!T)a+9g4*?Fոg=Ó4X@|eAbnAQ3F4e-[> φǼIGu>7*1p^y<݃qD@{hM5e(syٴ#LqC~C nLtP{c<r~ylp;bN_,F @_E -rPKW38?cmF7D֠׾1\pBb?GKv؇bj6d6,) FyKͰ_=ݙ+4~v@$_݂UrY!k?]pd 0<c]?|*q^%E%GhYxc*j_Y,F̀]|P Adv:W[k_#4v~ |Q:nl6Xٟ.5212.@W`!h$ p970!Zi3R85!Yrn!20MeLLB= mޮZJz|E|co&Bދ=\n]'f!ZŃyVoɎZ~1icAg+91Rc(uF+k}= (ZI!o1桛bso~)Mγ$, ҹnN8[m6 CyP~ GdX@3GIa_vjE w WfRTsC)2lL=jߥy8PU>AWÒ}uk~-9H%õ*A:r2+\U>ENf60īqZX+aήt>-38FX)q>(؎t!OWLc=Kٌla[Q8š<?fEJ (nꇍ'5N f(u[ߗع~c#2bڋ}mUʍ߭UOb8rG/eo*6<3NRZeL!lNnmfY,`+ \Ds9ˊ m๱{ w-n/Rح9b21VRk ;,B)Q!]yr|ȌOaMQ{nKl(BV5~VQfb~%BN΢A9Uׯ}U8 `t+0's7kcuȄ` 9F0pδ>귟?w`:.Ъ|\k,%{ {%)|vw-}~΍3vk,rgTߜ_-C8gJjRWӷ>Çؐ7]O61 㶸q?sXmr0T@H{E>Dq~(cه)D|vc#>Q9x?mۓfǑJtϦ|Puׂ.D9=Fc^qqʟj\+;.[˯:I;cs ;(v=;;0F<A±$,oMA(1G]h2#9ވ9q -N3VcwC1KNuP:<Aq&(1:/t>-hsT9 , 䰺#0N@b-G:~s4k.1]M&; tOx8ؾ5TV~SzswՁq%"~o|CH/db R 6%=! X: 9c 5DPp%=+S9c㔜hEuCn[Zr>i:_KFu}s[.?uA9-zYĺ[G|a6>_{SH_b. 'LZr鲱$ǀݝc4P9B3Iީ(ȷxW6yRZY:ëDkmtENe)PLH4X-/q@s¹I<^`FWs2)(cH0?sh}bcKN)e$,0߶R{&LjǛz@քqybϗ8 &V;Kq]1 dvbgϡ&a[ ")dCi ӱ, |0FBFjPį{\J$hmuC7!y5(8\c94l1y9cJ*FJRN${ܚf{U>\ͷA~݁=>.gU3,xm斞e=v #[\̛#~J՗!3^NaΥχV+W~3kl&ۂF0LJDO\__|jB|i$5ʹ]{}~ΙN{ʵ?w_|lvrTݬ4zJOF v[(\݊~̂埍kϥibB~(kwݠ^R_;L{2B:Rq_p'qU-)Wپ~N3Eca׏?fژY s;yoaGF6Ъ{qF: &=Vrz!VJ瑽Z+׻*ݟ/v6rmN4R0PdѲ$ox՞KӞoX~_;!kߖ_&v W WWhԮ)~sR ߘfVy;T2*< rպ|a-RvCu5W*Eyڔ}U_w5O{В ڋ3㜅|d|6[X ,yWN>6y?q22_d\M$+CN|N>ش@^ɥ9jt5 ڄvew?Kˢ(BtmLܼ 5܁05X?~o5s2FQQzeSU$puBH1B:#2Ǧ@Ik@fx$3)>L1 5)_%dCO>p Y\Rï43ŧc]T!87{nNΙW_bj4=ڔ(eBftcR Pf\.tWJsg&T`!f6@f&BƹYd(ܜ6{MyJT{6J~%6XGAap*h{ٜ$00FrRe܋䓳~-dZnz7B\"Ao @Au bOGm"y e[67C;(jOoԋR9e~ nTyP2 Qt/2s|f@[R3|P%uYrjG6lfSjݫ<4`̽-9SIĘַ/!!Ԡ/l/F8# {o3ᭆD 8_vk:^ ͐9{yֱwA"(Dl%G_?sCh&Qۥ il0KfFn+!DdqfiW̛vw47‰ X}R]x>xSLs9C24DE>jy+0z1T@u@)AkoR)岤( J)c`Nb5EE_.pAiA:,b]%||fg@7@*gP)eTKMK:Ԋ95r~;c Pfwyec?GR1c!LF|V17^ui!DWSƾkpwl^kd~\7 -U_#e I^PexΓ9?^p& Ulns~hDi|ׅ:$v蜠'~$uh`ݐٽ䁯2y/:t]Sk,7ۘHqjdJfM4[d !Rf4특K$ Xږ% mk^c 4]=wO9ʎUh|ruL@<5)qL :AN\\ Qۤ P)&wZ,8zZ2fmW+3p5EENncOBckR\=8.@E x;fQ )?h,nBᇴR?x!79/eBnv{WutC*>\>v"J:_Fl"?_rW;9mpY#`1Ι_9H&g4b]ft:E*8e&f4~5&d?k!pK[)d  {fW=IPϸ;WncA]exo @#8A2!B^$O!>9wrHҧ,uʕ)2mzDkX@zCbS2GY-ڋn烌vŰϊpJ|S Tl)o|ϴb!2ܒVJ%}m`*e?kc o087?2cl43DmE֙|XfD.ZEb3 .Z+< Ȉ\=pOow'pe)=_ c; >8Y l 3 ' 6" c8gZ_A1 ><3 A㓜Sv0`Ƹ݌sO A)hZ\TikQ 87)KcYE)e#=ڑ٢JmҖ9 5MQJU.2TK"PdfXN6"rz|Zn~ȥ1pzA9u]O&`b 1UIENDB`xandikos-0.2.12/logo-alt2.png000066400000000000000000010143171470075263100157360ustar00rootroot00000000000000PNG  IHDR cHRMz&u0`:pQ<bKGDIDATxw$uދ,f.!0D+At"œh$zF#O"O;Q= abzݝ̈Ȭ쮞~?6=Y" vgPt<wJgY^e%||Y}DK<]+;Vo}BSpI<#zFUN_2>b Tls4n:x^߲fQ7k}_/ʎߨNu=+v\Q69a(N7((5𾷫3+p((l/ e9cg((T]@7Ll>:4z]UN^(((G EQEQEF(((ۈ PyU\QƋ3[i`4FK[mύe\tl7ƍ)(((6B EQEQEF((( \7Ͻ1UI1B̓|e?bY6LPbN2S_?WZ>דRa_kkT~wL׿>,n1*P=V^ 1h=Ɓ['U/6۽/%_( {ظ8f@0/+aA((-'a=CHQEQEQ(((FXs!^g2".EQEQEZ-[sUm((P@QEQEQ6F( PEQVw`!ka[tQEǽ#ht߫R^r 򉬬^v|X_v=_wJ4IK[:BB^5۲C{onT$FVΪƱR2:L<ᨤZ}ֹb%?¸XUЯ_3w)1ɃSXq<̈UReR|hEQek1((EQ)aU@2(֦*YߌlQtl6__QBwE '(`ڿ((;(((ۈ>d`ʵRKuW'>ZJՆKu7o@wT%ĨW/$W\5ňƓFn@I;#JǗR>b0_^12㮣_5EPnad@Jj[V((l*TEQEQEdh ((l#T(2 e~e>:4Ge8EQEQEF(((5EQEQea{RS*1F_FN*W|%4LN]q9+ʌ(Ӎz̸(U):](JC_Y݂FwEQEQV p0o/H^Nڨ((A$4K> U5EQ7L'NQM uU E}F#e` d\m:8~F]FU9骺5i zcɈWgyFߊ)y\\u ỲոUu7zqo-JUK~_)1z>?%/QY!㨈5eZEt@Q0קn(z+B EQk๨b/`EQe}IEQȴ}̊(Fw}wM0*~I1#˂6B،zi|>+=)J(((5EQEQe((l#,!s"ER2!Q%+*䚊:PmF6!gE\^jJ}\ޯ*JsW<%| tW8~Q_ǽDmcU}}Xyw(0y2Õ7WEQEQeٚB(( D EQEQEFhEQe,X1d=rp9uTEQEQm((P@QEQEQ02_2Vhi*Vty,| wֱV![b ڧ|>K:}fX?s_ o3D>u}U曪zm~ּ늢(( D EQEQEF(((ۈJ1ZP_EQeu[:3kZVZ#jEQEQe((l#PEQEQm-W-5עg_ yKtʗ_cZZ]P9o@Il~lXq/JuG/2OF+a_TnZˣUw-Pɻ{Te=GU~*((R 5EQEQe((l#.x&GWE)>(2NQEQEQj(((6B EQEQEFTX) XK~ U}ЗPԉK}E< c\rmp;WcZ| Pxe3S.[?siN#\}ެ(((5EQEQe((l#F,Qs&(ʪ(/T>껿5(ɨc_QBt@QEQEQj(((6B EQEQEF zJ52FS+0>R.4]yEuO/G+W/?jjtTZo+U:fnY;eTYO)1 c[tmt\sUysL)CXK}wToEQEQE((P@QEQEQ(lV1fIQe;((P@QEQEQj(((64TYO-YDWxD Uup1j7X~UQ󾴜%E1%3+J8WK_IleUGeŸNqxϣQs6l^T~UQ *˙;((P@QEQEQj(((6B((FoUEQ6((l#PEQEQm((CNe(Z<½ʗr*NV9arb~ |&v T;^J+E>qewʲzQ36oۭR ^M,1fc^1*K> y6[^#S婚F* vA{ /EQEQVfN͕62t \PEQEv*lAQEQ22 #((&fx+[UREQEQm((P@QEQEQ<*QQ\%)wQm6H_2[L{psW/+i~;涽T̫P@yΊ"e nUpżT͗Rq<|oTòqs1.*R2,AQEQEQ*(((5EQEQe)߶U}JH fxF_Qe3ęTFX%((2v(DEQEQuR59W((cBuL&d32w2jꟺѬV5`_vm[̥y4unϲ< :T\Wׯz2* 1Z@}!W1o;7C_l7\Ҟy]3<UYCތK[_H]EQEQe((l#PEQEQmĺoxEw2D@QVVʓ(2 (((5EQEQe((l#JcnG)%Q곚j)Lwv4gWek*GX8ie}5M4T,ϨGF*W\GZTWP\TlF;Dr:?fi%T~_lTޠ}((l#PEQEQm((X<E4'((l,(((5EQEQe((l#k uTzD);|5QaPr6 t5\K+W/;^~Y~uG*U(m۪gG_v hyJOS?.MƨާXs|((l#PEQEQm((غ1(z ʋ>([5EQjY2;.D(ʸ(R$\O D>(F EQa0ڿ(ʸaG[UzPo(=F/"?=fYg({iŪߗ_Z-vHQT[UcyD:PP婜F TI<]2qi)CZAQE݉.+j(((6B EQEQEF)OQQ~j:|jיL)mEQEQe25.oİV ]wFv= Z[%`~נn8:1mQ@Qek2h,#﷙JZ}I-dE2@7pwz|,/AGTU2}nM~qYW֓^+QUD@u~x ,{QmT?WqZzYdߢJ-:Q ζdrυ;NY`v5fx@ރZ 7?">ٓe ?/o\!m })d=DmkSu>SyS~ۗuߨE&JY~U={MweY1x1&b0^88[|Bc6+{@к8g$-u$@)iS{"uό^o`!ʹI%ZFQj(QX[6WcdW109*L7e&(=6k:d{\hYRqdWЗNdzHGJBnIn|wGPytNPJc.(ؠlZ#d>X|Oc?-‚}+ @@caȝ-8}Oz5$Unb] Pe3OY[[}k"Aр̩,Ed>9"(,"^й{Bw( #3q6# Cy-@h,`w`6K91w"@d,("~$DDY 1;w Hda$i[ڥ;g/" !!1U(CEl6$d@ۇ "!Y2DDy18@",|(MDh_(P1#vV|jP,QlѬ$|$M:N$GZDZzb/ DvqlP dP[3R͒ t"BBXk%-: )H@$-a7$%iw4MwH6F&Z;7 ؓsDG!=ٌ)}q{$$$dI#XF\k-.̷ۭb'mVM4''jbh,` (6gl]t:E%[^<{ԩvkvav^pTā"RDl7߹w 7֘h 5_I_HDF/_<}˗.i-u:|hFcb*5vݰviIP.sCNMOGEεgf/_|.M/V8jd\k馸9=cgOIK(ީ$@H"|gqҥ 8d !F95{޽]ӻS0 c`k/nbe]`ȊF- n"ϟ:i%ZhaLdbh;v{Ԏ=wl!I_2HQ18UŤ++mZ_(Wf:*F4uZj-^p+N_pZ6l Aܡ%uIR'SnΛo6ۣe b$^dj@d&\3PCx쉣^xOu S,[,x^ jRwߍܱ;t9K7P2WP)hɭ&*TfW\gO>y|+z,Z⮄L,žSϩXkzhRmrMw7dZ;M1岛=u4-D*ՄoˮA0?s̉Μ|\;M&"K9s#kvn7mcRH"d.1%!35BPPШc/x,snA1D$,.0pb< 3D;;n6Nx"/b qR6Jּrw*9.2UaeK[fƏ}Z"a@@~}"s˳[5ǰTO}罯WDɹm2ED;lNTQ`Xw B|5u5D0"pv\3$/<ș_YnՌ(< R Xs罯Wb$S_LR͛(\bQIvvAEK<@;|f 㛕T%C&;NROWq:2$@ِ<RE-w^~{ Zsv/Iƶ-w{_u/^Y,! ӷ+r] ޑxdC~j&<-_\Ӝ_;0N%2{nWamw%:,-|o?dp}>`]˰1LZ\9}sO\\HL3J<ZXX%jӍ8" TS,3 bZl3Ǝy[n/D)L;1$.&a':! a]PHP:q۳gcGجi1D6saC-}v-n4qj7|]s 3El~kmUsAHłG~¥s1 kćDDf^v̞ų1zp4wWyox2^7+"(^4P<-gϮӬG?óXNE08\Qw#b؅øqǫ^wO\ܴp( b!Fѹ̹c/QgaYC`{@1ZJ=/.v&Ihb|zcQۡQsF e;q$0IW@ɠY_R So5?h2qlySIkn221!<Ч^,aY?}ĕo#Y#@A`tkr-" "$t c}CntA3uHvRY;E66pC sNl'n1Bl#kA/Q Nzta$9afDW AH$"!Y>zo:М\XlK)%Cdy`yAړ58O}ũk$ȩp.^j  }dхg|xܳg7L a.QolY;d|pAFD`D&flb:S/gm*)#0K& %iǎ^ݽsg`@+$Pa[R違>.z_X9U*SlR9ʂC\@ vQ@ !cu&"h\C^8Ӏ!=0{p B03H _9}NNM\I!8L-0  @DȀLGg.<xz +d8gK  "f!BOݷ\X]y  6r 9mT MJ#/=h\Ӣa :yn^0,M%aff1]rso?95,Y L: ~d 2tRl_yCvг' 0EBqHA晎ضg/>Y; `f0k\z_BM,NHC}G?3Ai, "bd|eP%{$עKΜ>}j9s.d#r/Fl3`;Ӈ{1Mط8j\ݑd3m-"ѰO~.5 [qtweOW PÜ[ E=vٳogAz( QK';>^pYkF, [_`dGJdD@#`;}qff];w YSxzBv+| k qId/7&&nܿ߳ y#Po7EptǞ{>f=Q ^4o 0AJ-Jc2ٙv6{†p1$I;O~/ϟ 3b?y{ce |ȡl[г.qtmm;D䝁$8.\/C HJ،l YԨ(B0<~sO͇-uF|V l]P`FGO:tts 9n,A 3FD]{gþO<:$ h#23zB_i9q7lLcߋYK=;_<]#x:K²wbp3Yp]nv  aR؞lp;dB~pAkE^deG`Tg`qv(/,Z_yJ[jj(%z[74+?"N0tvMN>c:tF&{$,en '. ?(]eP "oE"5 ]9tAB@@~ bPDSf|~H؂0="L~5`C@h#51x#^89u(X(i-OA m@nD>zS];3 J8RuoVx"ٽobjGlȐ ahjv)7ujp񥃏~Qh9%AB4D$dafzՈE%  PD$쑈 p:8wwQd%iy-X b OlC¥u+$2=dp;vb9bo/x# SD[<{`#n:I*Pz1ו.6/P4m:Ha[g 03d?c_K )_s;RCk/ìRl0un~ /QUK@1Ѽp#`[ H8@ gXY}\z=LsvٵƎ}jԍ-ڒ&|h܎9s>\ ÁD;~_ q!zq~~չW"'?:wdl\D>WpmZD< 3^roc "fG&,7CA\:u>` D""qg#nEAIð.WF,EsvڽkaUB(׿% W{ɲ8ww22l(/\b8fΧY\*^}fG>} 5b$z _$@(d9LMWgw HK9fT@͘=?\،l'kMA8*t6n ӍfsM%nIň.AqY≘>瞞X"ej*_ۥ;_@XRR.^;kSB4ABVR [opؤl2`6(x`6‘$O?֥S5J; fw>"sѹtagLf/ph}8 JM'?SI:ȲA NBDq|_q$ /y +5bkS,Ie@\5#?ugjvd8kVe,<5Ki GDRȚ(]Ct}n=MX]+e  auct:Pͅt݊fC @!0gp$ .ao V.s?ne*7i ZKp9kw: >$˼x`˲ȃ1ćx OqE.~CFO7, @< _rλ@Fb +dóܳOP7BO}ϡPCFlvVh@@-w7N2N`>a`ٹQOO~ci;`pZv2Th 0{8{ݯvj!t~-^<dPpp;n`0}}/K)ADDwso※?,ukYv;׳(݈ 9?ь.9cCD)!gbЅ<;#naz#`ǿ$zԱb&@`1WC׺rgjDaoc|~i-l8 $;!  姗C&ps3g_x1 T]:IRah^{/ň>IHj,PnYӽ]Wsgy r)Gbȉ#;9RȂ}="2${ʼnZ6,X8m#/_g_9sÓuVVrVڱbK?i@K]]eMEVV7 N:4sdd5u(W\,NӤ]Gx(L%e#'蓯psgO?r` iIXHظ΋{ZI8Y4%ow>iE뵯.?{QqZ35cKNkR2yt31;IY[l^:Su1Y-T9#aH5SYFd-A 8lC=9bHr@A5̌?SbIߢ:3G]=w*~u([< ~Z.{\s&t=(XT;~DT] [Ûv+,TwSya`:#YNA{P8.E l,` }w|3KDH|KA KڌK (`H䚱 ~!Ke* :p]{\K? e=8}oūwpV1e3tA!0""scsgO5) `SRB02 -F߻}RK!Z Mw7-=h $X.`YB&6YVu;b~O5 {"`B9De#On>(3O"Z_~ y+8: B$>$/l%og=p\lM=3 V0!; P'!)r%CW R4F=/{w$sbE0HOS[5Ǫ'Ի?3*8P6 U2Zw3Gd3"j+pϩ2>J$J cD`"]_:<{l,R7?e_ΗBҐ%=/qF 2nq܅/#;Eo'r_H-Ν98E`n#n ޚEh-8|е[1Սڒ"ʑB_@kq B^) ĤEYlc^0'ٖ$F?ꙣ1HdʏgN C, <&$_1 1PvOVU,a уωkY$V ]EDc(t *]Zzv>I毜r(q hP'( Sa_;$A 9HOxIPDc؞9u*bWkˮP,-(A42dc@ӝuW5J|9BwX!abL{M98ue0_7y xF`a~RQ|YrG̀%^͗Ҝzh;O-* WmvjcJ2CgqK[>xbOcr,F_~=sB?'r~OaZS^ʼnb6\ +'/\81K/p?.9^ 2xs'^ rsNUHDDD @$!8AF8{.H]#!FӏLF$?ŔlzWj)TQS(ֆ'I*I+!O`9ɗk+5̣w~E"AKd!t3V f¹F{nb8oovR` i-Nđ[zQ ,Hȴ| 8[ 8V~驩Kϝ:q`b2Wv#*XxМ|0]r'٬+"ZkE٤MubTk,͟?q= D3-sžB2A2F`&_gE,PAX^&gs-jA%?1WɿK-xU]$N+Iz԰Z eª'n?}ߑ- pERSOm~5P@QMF$ > ≲i`@p6ɐggMg_$˩АF6YA C>q5K6[n;f'?qࡗ.>=gWI0{kb)P2B!+l9  PyI0<'r$BQxV{s/CCG-( snZ.Mh!$dC.ڇܯFC{Do; %NQ#"q6z!"';C&VY;o4MϜeaZ2Μ֛koZ0FDR=so^:zdz.c0f1ًgl:+3&) Ϛ!#"!ft3ų222aw7ᵯ߻w9zz衇g[;<z:v.crX̑&EEC&uН͝ΜIgbZ'w}q%MWsw= S{'wޛNRcU\ gϜ0;!"IooOe ǏGȈ,"luZާ//7uwNc.\~ZIqt/A!OzW% 7EI߁u)2Y0^\G`#c/_<7{w}]pjZ)|~W7y1%PXX7≗.|rb qL >uzEE^7ATE (gJ^caè@1ƨUeUNѪ_*Ku ! b#/\:Z_Y#/'"",8e/UC|x0w[GxiGDcD 'ΞLR/YnSh2n[xB`MB'"׋wc*2'ѭ=jmooy7{'~W~Cqslމ }'=#y$68B};2!LBX%AhIhwM~7v8˿ _%_$$ {c{6qԬNQoj~F^-I} rK^R(v!ً/u?"Cq;>O? {eCQcth{BM`񪜹aP\%HӺOASbo`3~'Ǧ'j&:KAđl6'\/r9A6O}O@r,dc(3cڸ91c瞽7LӘ%Ɗ|@ V9#Q0K\Ygu_uwE|AЗp cog)eδgΟr indpNPZR#a 804S +Š]cKV28WiXfѥ)xq ~c1Q-^<u=ph/29yD ـ;p}w  ܯ/|ٗZ`bŶ{NxahT'~40|vܕ-4"(/ֈ,g[(Ɗ"x1߅ P_n-̿ (֤@1 axoܽ]zaIQNI#KȾ@B<ck?i*K k51˓oH##xn:pӭwᦨd:,D֢5AZ*O]UmqTRN˨y(׻^UWk vMqކ,@LGfǏ|KF&"8͗ýo[\?;rH=O&rq85Y~fB&Oiy輰H^tܙs/>|oWYz: ]'m3)w^J,Apu^F~'dckZw}!* b7HלsSSwu>d J\V˿Koeԟ~C ou"v:75 HD{ک)XO xfAaS'Ѭ5_>qG>?=c׉ދ (B8;m~T͞:rn^0“1@7LVpAag!1R֌}^;,5Z[37|G QeAxg,ˍ! |W~ӉM2"b1l4iqu79@DNќo䢐!HeNd!a4暅GD#8m}0!X{@*4!so];w>sQk2RĶӑ?Ȉ=X F1g?}goW43nLyvL-ɨ5oʨlk bZC⑅'-ZA3YeH򞞞޽{w:B@:4u 6޶/<2jL7}/]qs[*3|kx^;vSםs̊26l_NYxyg A["H]Lb{/!E̛v'$_=jz/"+LaN͉fsB/(iCJ"fΩ|;=g/yoo>l:t(D e;B(k |y\!IRZqmVD$I2PSfomq$+ݽg]w3;0"0!dJ0f}C*ZU7g|7Ј=@$WF=A236Qd '1ZҚ}恏9quo{{۩#1f*WEپeO>Zʂ{e:z%T{! ^MODOpzjzX9H OE2F@`kg@ t'n4Mwaq{=(.6S$ Bq7h|]\02v5vTJ1R^_ jq 1V12LokHH x.`+_qo-i(h` GiU"(Ԯd,Z1ɤEdL"Ϭkg(ҟU]x"AdXM;(A@J,DЙC#ۑXdΌɚV2Eя=]?1JLc Id $L? ljKr&Mֻt3cqMaU퉭[հXf^z>Mc8<ً#&qSc_l2HqlݴO_G_hG6J҄הsutR|,Qu: 3c 0}_O7v=0u%Q?٧d/~#xsOjH#.s.BܲN2<lY<Lv O=q{Q+ǒBd, b}x!Uu1B 'ދ!L uQе:bX#8&V\ j剎 `jbϸTvĉ/үjy0 XFTpS0?0ﻵĮleiNfsSŜƣIu}ߓ1AYl޾k\c^}M2`|kg"RqT mB\$3Du^v&X :ɑ_~akESP&HiFQ~?SJ₃3ŢgN%>dxτXE~~?}'g-b-{'$D$)"iZv-5&&r-W9z2q>'e*%K&~dHKS$t:dkuYd8=kjnt%!B1 Z (e([W9o 57>O\~^RC-{Ch0iNO\r286xG Шס=vF C$ggg\(%" $"H=%[7(@d:Sη|7ܵﬓZ^}{YԻt!yVLya~Hd/7DŽ@na<"NOCN}-_9JI1g%s*SEssNxk(sS$ݻw[ﴘ,TJFQ>'n!QvR6'_y۾+sggS ]nX"BD3uq'_za1 {)wj;l9PY$M1@fN=Qw@&lG <[x;]__wqA 0" H ,@!σϮJmgMo5#C5`bMIzօuR׈> 8A4DϟQ>fݯq@˄c$Z X@\i)f ]\J,"k03"$BXZ/xn:ކ%t$IShFa5;-i<(4XG™',7Y#$/l,@YKk'"Q٧>Kw,Y犔ռDR5gi>E][m/γOίگ9@aN3W`e3( ".(w(2,h!wVXsh^^2&0(В2WB3> Y @7ܳn+@$aqCA̪qV4pR5ɋ\~8=%a ȅ 0U0sF.Mɐ"SDh j3u^~Ԏ"{!,bu ʂz ȧٹӏ֛;_+RLR\([ 5ED$n3u_|%IXptNsiu+oҨEAT(BCY ` އ-= i8%/6'Y!5QA0]ֈ$& ݒ~K[E+=\2׶D,d YDE 2:@ijk6u:bټ@֘dENq$yU(cnv{z呛ˬkXE YuD(SK{W5Y vO) YSQ8D[u[?v[բF\ sm2lBD!hn5(Hn,d! I(e + 6& !İQH@m1Cjs>{7sFDHƣ`I_|ozn=ps3&ًOAϹlO  +=]Ǽ=ldX 7ǿ_Ch>eFX#%CYbb,=%Q}g&W➑([Rlud\G%WzVרg+⫂B-I[.>~M a8%#P._>GDd,%#<;;u_?o~ꕯxEX'Hb m9KWGĨVC$%F:yy/{ˎ@:P%ߘ!+paC}W;E`Py/\:$%v31{ltChIROIčY 1c˲ EAaP;6y9]d]%d ~,1՚x8DI?г}S.{?{=_`h4( 0ƄF#eՋe y!`F@B Ao|ϟ/]g 4aų1.:A"쁀=LX;x}4cjPdUq~};3H2tXT]>mdf' Z+WnO|cB6E/W+ZA#;a=샏}bJ)|'{k_;VREb"4Q R.^뎙k+&[b\ 8ɵ.F|?(s(sZ[*(#/0Rd!ƒ{{Ҽw$|xǛom߄kD&SJmC>쓟;7oN 8j!Jds҅Q0,SWOVF5YH] {tE !<fNp~O>⩳/\ܱS2Ȳy|k?g9zU11:X,K17fnZurʼ۳f5d&&4* +aO=:m68c;S{H3;G?_ozx[sMq;?D A;"mdfEC!/W}qchN*W];c/ym>vVL x(XEؠH.:ßjX rj$IӴ;?/~Gv[Lq-`Ǟ?>x̂'^40D&ncuDxg~=;Wکx#ʦ~ۅ8  ˮ I1$ۉ;9tqNv8R@0$I'ɣƟ|C/,Sfd"",2>$jŤұ3{:VzdVK=ĵ9?أO]WZdcv/ɵlHl \Q}~.IYk YD[q]gxtp;P@„r%`Q#J[1Z[]T=xCZiGB_d"M$c tUV7âZ蹫v"w;ؐO! |9E@06:pI+홹juF2kJ@F9TXV뛄Q3D %> XxԔCL=p bb ދGkop.=6Ij__}.㹔Fzvq02/#X0zV;xQW򉏃\W h @sǙ3gF";p,Re5 "{w=W\7', wgjl\cҋo}k>TS։22]X;覌%d &}G Y[k[r΅iD֚?~O͞IS3.ѽRIMAe;0>LLq$g~V`kDZ-g^{>ηއ⳵ve}d$$N4$-FLwO(%2dPF>@% gđ0[$K``"D d=FY%KhIOرe5v?A(e]+,9u #Fp128 c# %Pdz@P%@&`[G;wLmcdgb-_|EΊ2{Qn`> S}0BK3QKDֲKg/7>PӑNid rzEQd)#\z?B}I @E(!;o[Dfv ˺$0$`e]ņ]꽏x5s|6 Υ,཯|z6@!BY`u5`NoڲsCD,"39j> y㔧Q>Î;K:[>5;sΩ*AYWx3V\NgCQ*1RaVXrҌe!,Uuߨ%Q()|gNZ;óOMOD!byylnYKv% zˁenP!3px˂%u˷l4Ep eWR+ ĭ{Qu`-f*/G,edu,26HF#̣GzC& W\;}~!2g cݟՔ{^sL \*5 j u勗c`@Sl;,OVa{2}+LAqX3qoa{@nP7?C4pص=Bݦ⬥*#XcvXR?KOW'$?[@ Xc _T9Q5-PjF ,ΧN-yR|-Ϝ"wnJշl6TH Jd#l_xYߚH|Q9]|Ñ q-ޔ\;A!zk /0 o? 3j䋈/|J$h3٪uqR|TX-sD 2#a33Mz! ߮%n0 u^3v)B!'pewZ:y"Ojm͒ė7%_}.b fSL-`皨m=p#f(K9KncyWd8튆V3>]0C`hj^$"(=swI+Ί ?ydauѮl4ɎzP@ٝuc%q)[Ź܋-qk?k*rp,`B8zrsg_gN' ղtXk#c9`{*H>7= Fo]hu6޲|1kiՈb uݯ5}6BEFբC{كE yVz+pM<_g:wOעν(bty) ^6өȞ* ;s3?h\\$05Vb8D{.j.~DsWs m?0pW*E5%]r&f$nw8-!$`gn|+eT=J܌,<Ɛl -\e H썩a=UKjj52&"!`-̤#p-{޳ѬKSGg.զ'ycPj(c@X c|r y+ߖ#CU{-Q-h)5i13 <ı}7|ܜVDuZ W\vC H}z0֐8P;Xf< 0Pd2QŠr{aF`D5O!F6D,C pb$[[fėe'"D@7u dٝca8FCꖩc]fƈA'W\R^;X4Ԅ3޶:?׼$yᦲs"Y11TtCW K,D&YŬ}ҳ q,Ad(5`f&C|/\:3 ȐS7Z-q)ruS2eQ A(i"}>9k⯾OiGc(*&3Ϝ:q` ָؤ(51}2{S"r$U|-q PLHTs'A0 hX8<ԋ{s'/c4A]M6;"ٰū[^"`Hϝx]>ekkIPD@Zf 8eDJ1#" қH`fa}l&&xy1_7 ϽIDAT{$Ef CD&L~b̾~en3 VE#BW^DH 㳚 ׊0w\$ ewD\ +̀ٳsn ~ך~y"PpZp"A%<@hDQY B(NW(m< KFz""YC##bl4W\pش)LIzjF8bw҅S-3WyɐK|>umo JlUR6 |O_]l:Id3oՈa+&ǎL Dg[~TXs396et_ȼNO;ؾrn"]uk)?&DKȘv~%{;˿,r-䲐 '`&_z:"q?R:0-{=72s(l=C -xI:3/^lPs/(J sNDщ)u LpA" . <ǚ8Pr&,:{ƥ99g\6sf*9dK_fY @©B;·`G ,b_={t wNFHYl WW,^#8G9Lc^gf} R:ݒbؐH3[!mwjzI{nS(IRXCTP&.ԺO߯6z}֌AјW IԐ={-N+mсCh=ogY%Ibz<xY >$j.&_#W}׷~=Ƒ72,۷ 6c~'~&wZ$U=8)lSY.<~sZER8020k\9{쥚(W81!0 `kaۿ훿rvnf   <\?;Gg|Q1FwqQzFລ3>ۿ=εڝF̈ DH#G>c'U;ql5ak< r"…(C6u#8 8,~/žͺ%C.JMpfibqD E-?xlU\xP0 w4 jsgBU0%܋|?u("c,3k]S?>NyWlό>xخF)[XDl"PƀS|م秦\~(zͮ!8ZımwS?`N'ظ\hpS=Ȏ]8Ie@ G"wpuSӌpK~{vܘr2 0!EǏ>'LBN1 $.hYoEN'I{~cl셑s,Xvg$\ 8vm ^8}f  ̾.7kSO?JdilRWźt f>Pw)~)dSTq]aIq'>sB6 @ 4O?g$1↼JWdp|f6QmA"n`XʡY5#| ȅg|{^ӠaG^NNV蓷"뛦v9.X>Mλ+.Bvxȣ}-z}bÂBD!?  ǑO zmdhtoAƗ/77g/=~w{B^UȭJB`]?~Zۊ(l1󹳧,D݌s*&8Cl% uuv2U\,FtC\$~:ܮfݵZwgɒ0 0(g\wW꒚ ~ #ai>wyf箽'1,᙭1"A1B,cfis {q{'w&ak]r٣^tⰁŖQy0d &:fc;&kD㮜eJVv=* {Ŝk@RriyǀQ͛F'u(lp`?r墬F]ϋZRgkMnkŧQ!b7(J466yk^9&68Nb@kw毞8矩T;9P y@Iq`DOjY|"@Z~0 F‚>23ww~ĩ&{Iƚzm{Uܫ‚ۗ2ㄅ({TKkxMI:D޻_}7R`/wɃ>q5oA, 0VqH^xbz٨]k?oٻ8!"2{ {3>QkL%hXvGDRI4ڷ[A;'͚\x-hFUyT@UXy7߉VI<{ovSޒ Df~qr"@X ]ByA~~vcrETWmx2ouɜC1`l+)%b [oXOg.]pn~, %Uȍ+|)&+ C5_Oyr3/8,V",&eoL]nHurp xv`qITw7xD$1ƭWg/;&& xe-V  d@fd5wkwܸw66Łe"ぼ}.CVýy4&sk{ח_Ydj=aOף.\ 5Vu6(BjhFg?Kj-/qɜKc 'ig7~~Eӊ{˪VcC.}'>q={!+6>s6dM@ Rvb2Ty⪰)xer 5%t<b/ЃG =o4,˟"&4c_ 2D( K|1@0I‹g_8Ԝ`U@iwV`w@c͍ޝ<~ #!s#A2,IZ|Uv._T\1>'wyD)cUTȵ4tPcBO S5$$,z9:@a4hSTOy_4^>IЗ 8ql`irӂ x@?ƯG  8d?alo]J6V^i{qqq>1r `e0~棲p7Z0{\0+"VSo8B!{;v;{;ޕ{쮤^wǀ M1Bo!!S jLB75B 051c+ۤݝs~jWJާ~?n9{.d/LL cW$.p~} *"lC8N?aa~'+^Bf:^Q&YAFfVхٿ'hܦ\ʮpB< p 8 #blj1j  8`F 2 !jV$6ܬ*pUı i"`<ZPgM n.HsU¨P)0 D[nj`Y9n?SQphC9B~w,.<0y!{ݧ#?V23.!,_ͫQk[ jbe( ׈*wv;Z+[82îwa Q ⸕\SS#"W9xƍ: j5칐iE_ YUR)ؔ?}nt %cUa`X< 5brI9,ݯFԧջ5$\,W(o&t>Ѝа|Teס#J{VCȷ6[z 6;2kڒ:`@bbex9#%U+nM+^w5SUEcDm~'=('51e*#8[u3qvEaEa}vuTp(oG{nu/BIؚ"îPn]ru*B3ؿޘ&"!`вQX)[ 53 B ۼJ[^[Bx/|+|k}L1F/Iz%M[~ԷTO\yw]0xLU]`@=eٸ%'n(lvT"29.εy֬ w_pytNdn {K7n # /ffH[DP d+ĽŢ@ JZ4@Օ9k_b@)n3 oK]&Gk7\ēPֶ-W)RwuĴr_G ঌ@-5ym٦g~wwᜮ.JwBkJ=Gm/BUy8w(~r):n7rpI>!cw@7NQ؂caUr6lj0},^J0ȶV0Ҁ]d׉;w0j6%w XA;O} HIPUYlɈDzF*0=by1$nj% u–/KeIy mSlhsWbnoЦoA)*iT~>hk8nu&"&=%w[qB?u/9/ O4(UC~rն^:cA:HTKDNXœDDTEO(ih"뚴DK DV~}6h7o299,KH[ "qd_ V0& c"M jegz"& Qs1u?vėAA?\9jf{2J e!再릧7mX٘h \]xaCLLDI /faaZ{s40_'Rn{ Xe?,Of ͧ :/ш2w?ʑ?{ArmͷmN-Ka wc JۆRXtq>vrv5dj3QO*k#_is4yٹ+/!&t 9YqKh)t\*8Hc_y~WZ+;l v+Gfnݺw Ę1$Q ŕW_yuzrtX?=Ө[=RD ֢7JʻVs7ծ c|$mC(篟ON~盚BS}< *lTVM[s:Fյ-RZџݕ^ef磌K9,PhyYala995JR*\,J&l<:"EGi 4P0o[h}?^N23?y`g>s" F-h)8'HH4|]-Mj1EҶzjT8R\ *iF#PUP(A/Jх@\C.JD߲;i5;zrsz4݌S *B1Ҩu͂dKᝰ\k>xƬQ ^#3ObI.GLEI0\pzYVUH?geE]8\:.pI# !*@j "bϰz?G|W|ȼO%} s;ȇ+s3yNub**8BF,`mBځL<@\,K@T15^5꓾4h\6Sz;qaucZ ˺V+;'))T))ab&eIovNScmDa"R>B@Cp rq#xKnp`(&QO\ !Ռ|Prfu_Kơp=˞)qf -6iN]JŪIU6SVvL#.58z[[{vZk/s+Mv;ݤV{?\Q>ڊϔ؅_:x-kmMl(T#(VoX5I5cNWQfsWYk0 y$(}tP+e\ylTCbT|C߷l(iи r N؀4; N>udY+<^*X2EV3hbX%&33s:`zʎ]&öE< /V\K\7ľq!O2&)H2$`g{Vc%w|=HB*/vώt}PƑ_?Vˈs_P!uE"Pz#4-;®*c\C{oʖ%doMeGiqm-{zD u>nK.ۺu!lJGtɒc2${LKK&u cQq+^gV V:O7ch|v*T;2ĀT mZlJ,f NLUk|2&[ Ȯj7oF{V2ܜG7d&Ũ6. @Ъ$L2@wzM wh.D4?cǎCD(x{_5dt{Ev=BUATGHl<#~e˖߁÷.|cP,Q1C:YA" v;veRgUdӞ moQpIG 0`HժBDwxsPVBƂ5"tU|*9*aTnGy3?-U+o6"ˀ6fT_mm?]xhzrxl{ygGؤJ}s1])cw+1jLZnЄii݃V˜bS*@E{t>J'2,V:c؊0z#L V}0^z|Ir95 m\.ݿ5H%[5z5e.%N\U1WV V @`%I/rۇ. `xB;4BK*An^rkԀ2}.2)ynfU6jADER_H@RfxūBN  XŢJbm׬VO1x$ Q;_鍲Vr8jsDZi}YE'jmYw fm`@EIcC02Goq X@ngg )Zh RDص0rp`u_"}͒qh$j]lcm\= 5=KVWGqu prK2KF)Hd/Қ\*O&-%na<[Qr2aWg::%.d_UҪb- XlFd|qrbZ/ЌBc@.о0$V"VW(VdpzPCD~.5Rww^ Wur%F>7mbkjR:q I+dι(y G'4S /:QfIyj NNaϴۯyW,B$bH뷯mY"eD aoN*Ǚ) %Z@[W @BԃA'XĮa+7 [һU^<]\=SM&E+XzckXU2D+[hTݩ.ar8[UZ7,l [t/U2&Yy8 p^CT8#˄^ 1SZl.W XIܛ,7PgH_3wO*ư>klmȞ^G!EH\~qJoR5Y(j5͉` R¾=@p^NmC@̚m[xac'.i[okJq[s=Vt571 "r>BE N@nI#n8Tl-TTJjmlAC*h;l)+*P&bwLjVɵ7NwvSq*H8OOOJ1UOm0vܹsZ5ĵ]n<yPOZX{(b%!Z6L5s:@۶Q*2m2F& \^~n2tJjN1ULuorjbTFSKzW@;J,*sjC,vL;wVTB3M77ZfB 𮹹[ 0Q86 [حv!b"5fcc CũI&ìT<'[}1rN k=(YuqqHTU\?֨o߾}qqѯMY /zϖ8$qM\LKJ@A͊ٺ׉'߼~fA/O 7xc"LSV*{DUq$iv6XJvA.M1+|LIER1\l]/yً)&?=3ޫ[VE钒|灤μg[u Ӣ[r8}ߑ1ߜ?;,0xY`yU*19;zYʗ~i f')Th~vNG~fXyAP70 ,T%虯 `PBDDưKM.,cHmGE/ZjP.F sI"aX)!-,pM s!(bBmǎmsǢ6[GXYaW0JRI *taPީ*пmVf]NmD?d֦^01+a~YL;ۘ`[n.q C&1YͳdzEb^Z`Vn*w/pda^΅(0YA"֩%ǫ*ID31H3r}LJk?&i LlLv˭Q" HUp8WڸY"h GpTnLח$\%>wkgW]ymcfcOWcF1!q!IIO:Ƌn>L^΋zss!{AKbkbHEsWڈTe=:k%ICnj*AV>Os6^=k+QA97}00tҵ0x'x\ Q@kiyj V+Ҟ8T^!0H`V/A=?3-'-Ԗzy{^-*|" xumcXAhۄO\#hfrɅ|vo,I r!NAmD!2 1ӘbMnx~C LCwz@Qڻrnn K%BwG SL1 yW4!+(ES2tl!Xr; vD@#UȊTu.lYΥ)YU%U6FU,`\u.SYes$H\J2I @ +E{}Bylm VDbJ,^ D퐜nDUضp1.;X ݾ:KiZ“4IR Jj 8_rư\oEY`Emp@,JLBF$a&-D;.WkBɫn6rh7k{R\-D4ʧ;g'D+ Gw ldo=O:)\Yo.]l\TIӊ(N:gTiyx-!5|g5Cnk#[\_c1ɬh_{2V'JLmv}@Y _cOB \lnYh}Y覻Y\X]\f(9 ȝ4_%=B$3_6 U b& Q8Hf*H-3J(OUXUFpƪjY)a_͐xPAy$3NsO(G1OLi79ML:9ٻWWmq=hO3~˄R>&'[{|l?1 Zdf #q\ţwͱ1m.aK$w-%*dqi[{ʱ9VmN=l uTU`ew_QKCϾ uްKbjO2-[p˚?\+@Ê {D|zG)F^"iGF :nc?Y2s8v {glZ'mCe1S{N1˒2D^,M{W{|Y׮iw]nlkFE*dB6x`dG0 o.dOu5Z*@ PaL1& 9f0 )4ti^n{VgUmzv~Yi[UDorb|p[ar/o_!]}oW~ Vt>PgFl(]< JfBR'x#m=cGlHALq')@aکC+T`_*TƘ$ Ċ3 qw7x j\ X-w&"b6lܸ{T`4ulػ ;PG`qdp6Cv ,1[ﰊ 3X]ذ]*BPa젪 crqATUk&Lܯ7*jx/w@G%X)S#]!b4q%+٥8kFc8*`sVJ0PvLVH47.@D0޳?0XؒwGb $թ@I2\/76ᓌbh隈el6=PefD,=iA.잹qIKyYTtA| (9(KP1LDAstgLdtQdo\sa&NidoRU^J0vXIŖu C 968.R68I=4+E:Dcv@:r^Ͼ.w2' 8 8lnش<ј߱c⢈_>L \40ޔ_"v uOQH$aZ0 wjƔW l,#KK`]@@8izYpcn a^ %EX 2<ўUot{G9 e24AB5; } ӣHEY(T-S ۤ*֧`"@mN&حH>N(Z41DA6 ix>^-Ν;~oiEUAZQ8|>hUƱuEΨǴ#L2.*e3$l0z'z~[l o?{y?O;(ƦaH"*=j- j+1~@WW C^4&MĢ:Y-/s̝x>;Nu 7\ }y%s>1uU|i?-=I$~,,G{݆^hU%%Q6LW senj]9| g` zwQڮURJB^_=BW{2ˍ]qbJ+Ԋ*9&kH4eKhغa{G=-2ݯղ7?g?W]{ͺ AZȲr^ ;/R@IRZ|g\!HB$=3ln.YzaŃʗ;g?MqN n22 +ڳDIt '`*"j=m7wc_<r+Dc/}W_—fwnذ9 ÕؤdbUU=0lTdQ$uDu4f& VJ0$lDH(ŹT\Ε_"\K:SD\Rp{]PٱcuO}c3$!)Qp9c<7?v« G.i;OIOMS 0s,Յ'h?H AX*7s c= N{}ݎ9B rRĶrFP#`?A5{B-mK<_Dg= |Ҍ 3=0)%LЇ>=z~[6V ܞUNt3^V6zu}xՀqTaU  +\U&s\lHEDcpף_׿99teZ4υ2A\DU2$O44\B(!QQ$UeTqX(yGZtSYǾ\?hL@d\\`S0T7|H $UlvbUU+V}υA -?av{C<` @ALeAz@/} z4{^1EQ;UffIx~ǞW+΅&0ܸ~(߰VԊ䌣ΖmGNd*B0۰y=zw^ &(40Lp-@*=o/J/۰qB{t|`H"Irjt/2N2^ERX<'X eG0LY (6L_߻ؚh&%M3RPrQ@*VU{L ՚[./W(k[ 'h.'9ìPf ZRi[O3JϬ}+˴Ƭ6 @ KBuh8xrfzjfմ@B v֩;mZֳ* llmwP#Wn[D3{9xX(mP *^g'>0Df=}v84 OXaBdpCtb& 5 ixTIX :|beZ=-{o0D7+i'?> ŎE쵪s쟣@B foẳ^DaU,AsnK5>ˮfjz=4FU_]IbFR.?M+veȲ7NJ0XŰDpˎs9NHx3x!b"#DՌـ1 -X6KÛrIw$4j5B_Ĕj&^oϮZt0QP,.!PbaKADe2lحBD`cX56u?#=ERN `jνtS./AWw3ar 4}V1=Ce箝wԫ^ba <8dc]@Hu33yُ}DQ,b_[S55xgf-i4EH:.jˡk֫Ybcg 0%]|PaFT؛pG>\zafDj`6Gt/ v3p9! hax#1AXnNtvp?-\ٮ<;J,"d[v,4jbXSPy YXU0ϫ# |V,#/\{`6!SqӓfscZVP5.Z>}檭n|,`f&D6H'gjԃ=Q[6fylWX*YtjMqss7-žpJ 4*_]dJ&(AEkoZsm.mQٵ ˩^PbZ^5E^҅=P9wC߷Z"HrPa;!"/@(]Q9ቈȑnXAEL0"d#0p!'rj~&}}6o3B1 hG?+ĦHҧdos|xq/^֖\BuuDҖ$Hf_AzA~'}[?8wc+6O? 릐/: t{1/ 0'E1\!(yj{ 0z0G}DcRDlZvTu$E619y!0a C8裌Y.M:&OZul&[E_s9VVcmUL Par.uJI"v~BL]n* N% ]shMׯkcb80QypynubW-5"*=(Z^ၕ0 ̧p;?8of#"!gڒh$`d#8Jz{˻4{`TuTO3O jjE$Jh{ }M6...4&E(.>^6 =0(˜L>r Hw^>1ϕU%dSQ666P~㽍UX[IBa Aٙ8{BG!͸p$ `L¥Da?" IM)~>Q5 ٸ1T0GUQ~ȦeG M "8L?5(FJ߷c6]B)(T2}ƘنRdO(`֭Y]"=g|AD$*<|\8_⟊خ%m۶1f+JF$@|lwȿyw_(PxWl2wPw%׺K%zqbJ[Rc,icd_[>/tw9&%r&/&N8&*Q20WհqUUϋx؇B-`:S_mf@" ВN8f1QI;dBQG礮 IN=McdI푓bU?p"Lh ܹU?6<6#EGn٢Vvoڪ!X.s+Lǐu:ZX Lޅou]cO2Q= {zP.P(ݝGOo/@ge"{OB.r3G+ AzÛ RQA!t"z L^>%=[ V" ce ܲc'?רP\@zT&PrZ+s+X%i1rk%{* "f" S[&t H9@,}~'}Ʌ3}570G>Jlڴ1d~ l "T@bɳḰl>$r1ù? "&B{''qZmeŬ}(Iܚrʉֈ,JОw&ZA_ cJPa K o}3Nωdb4T^q0Ss M)2Y:HH]$Bt>xd&Q(kdp-7~KօuI(p6"\laU_];P0(`<㯁MUW vg"'?,6<w$}V^-1r$iC@eI(xsEt * }9nXA-ǥl^){ۀgV\׌`vW׾uȅ.%8#?Ͻx7alL$HD IB{!j0۶vƣ'>m~DPE᷿S.{eOga8zֳկ}nc>7 RJI-Moyכ6 P3˚CuZ5|(+ ,#xf ۬j?i qXvΝxK^ڳ$Qd})3B `\r5/}嫌x1û-WYlT`zK>+Ө$ wA+᝛]nù_U";|_|򓟼}uVb{5+,C !u?Ql6%j_5c?ױ q4wq6L5R6¾ ]=Wݸ(j5G>o7nlK\*j1H{TUD&CBJYxQk׽T5\VIti[bcUkq%w9S毛ssaEjY s VDa G`&V 5?vUh7>Vm lfmկ'=w*Xhf" ?zrfKU^9ei =*[d掵[̉0U|oF-]t/gFï~s>[NLͰ#&Rў%tjR FJ0fsU uLۍ~O=˯"N|δć?xY/{)V`ȐLaD%`J>.aAp$-f.jfm7bDC}<5o~ l8ZDž*ar'c}scmTjV+bUq[|TK8{ԣ{ׂFd5%VZ /7lu] r U?d**#l!.}D߰| dM1vò Uv]ɸ)8fD}\d*{9JsS;X3Ӆ~nBDƪl=Ygvka @h[3(}Ok ^/޸7% "HqQ=GQLHRAGDk1PUtrz_s9|Iז@ \zU'կzmb|a-]"*iЋ#E ]֨ȹ:w-7$irYzQD "lB1` U\k'f֯ ˮ~Ꮧ\ZoL̬w[USEX ")T8eYD}j.ѸT[1D㑪0Yѕ dbb26}^8x@pWG?‹ZV>q~Q%R=N4@̯I}Ea$>5 [&qEZp?1k=ГN:imܶꫯ~ݶïo 6HW\v.%qvPaT @J#t ܲbVL1cX[ M¹GA70׽췿jl \_G^v츭15)6Q6Q ,7nn?1n0 u"Zi^PmL[""/S%0LbwOcv4Qː~+_zNXlF.b963Mԃ/)O;ڛN˯z#Q49> Z~ŰjZ3L߅x*%ee_a#(RQ6lUՊĢd( g\)AqOETG79EU]Pq }OBK kRxs=1*& QK_XGz>D=>o:>5cMcu H2vէWwDVXA=l.KF-[E^WU R+JF&?Jk"GFa3/$nB,5&%Z/]@1 Rbbޔkp}gTvwG>{*R o|c w۵C1QC6g]|wDI"jq؜җQ%R3* \uݭx㮸{x5eET4Ww&UڂKv[WaZ+챐^1x9/y1q|3/Ϊ$OV6D?_g>낋.RNCNÁ7Fg/|ޙmH!%؆u%3V7`g`9/,ɇUX8l{3<ez+3bb"'<LJ?;]"0({3DZkEDҽ:ē ;'~SE '! cᖹ}/ZjlV(?zK'i!WU{46@*u {-HaEV UoǪ# 3K.\t`%UL/|  VmP]l@_m:9ըe;ַ aTTЫ-1LZ`?4LV%nuPfU X{ߵeb i鶹Փ~j 'gv/bw#, ]q*5Hʤ rz"A.s/*fުB">Ag;q>t%QQԕ|/3Z]3gy30T m۶cС`F͏DǤ3x z'=ѩUV͊ht~4:=32 RUSFe}P\slKnk8R*pҽ;(d<j_Lƻ+3xVl!l\xk_=ކjLΥbj~@ UeEk9F0ٝ;N:x]u05H? x޳ Y!oXtz`FVb۵c׿z2LjK' Y [ޭG? /f$µh^9?F,iM^W*&tc/t!'KY=7.OO}/x (X#xbYD1'[V=}@V_iXdU@%|_*κ1dw8{QݮV@G~qg?_nw.e3~_?7:q]~epC\?3k͏{#uX[>qO05μjFc1 4^Y3d9R%b&Zy+ut {?8nӠV+O. <n4)zzrͥPݎ;vlU4=n^l|Ǒww(fW_N;dcDm@Yx1۵Կ?w>g?a㤍K @``ʫۧrlc"+o|s^tyMϬW%bR.>'i<=G[,,,j&! 5!,-G+jAfgþIeUlnvU\ˮS1SW){P։Tv||+eSc6{*T(En*[4ں)7lWCѽ^P8]B,`ix-/G]E(*O-~o޸u9_?w8dqqEqt5iTOyf36//qaO'y\`RZy.8Qe Z@7{=_WK'"J>y撫o~s^snb&kѨ<-}뤂'X4mr0qMex*@Rw *޷M=Q^`p{ڟZJ%ZĒ4@[ ) ?zӟv>E(<c*PX ( xl?s3 aKfX"/K[W+V\^!-;?OC,6yZޏy{W]Ҟ®zTqNi ;~Cw~&c#Ihzu~3w<6 ~? @ Vaiz*]'|qwQy+95kOj)47H uɟsfTU}Q *֨YBDGpe#׳̙"ݡi4f$Q{@\?\§̽tPqMMvg]{eWmۮ?=W$EKN~0=BR bZ Kȹ\yO*[%U0bT#/;V,7HhNPH:6Bti{F#2b;EIeP>`$y% iu"UєQaI|pIcNeQF*G zb a . .{4Yc"2%} ^}wm[l䌤&tA!; puznjf;}U7jK(SoU3m7zJԇe=ND6p{ct@V xþBBCE{>/A G܄՝o!}x&;܉I[qxƓG=Ըpj^ *߼g<7=h_Uq5;۟u Zg5j-`5dpjfL\d/#jή.99oPyѪL0 QR*;,aġ3xzXub6ܱ}q xUn@aJd|qk6Jnˁf;vl&ܰTRm~mB8 >=W%yYԮ~Sʤ6j޲yR0?w>!%L a#,żĐ^ ʎϾgK=q[D<~G#7)h)=J(wy՗Wj r~~ b1&zdrS-q2g> vs;-zc6kۭq~6ԕAM0bVW7Į:A\[Rݭb}}j6MZD=(Dڱmιew\s3zZޘL!qTBQ)BLm7=Ǚ(I,HFJ<|Ą1d- fVJ HdAىVֆׯk4;ŖmI5Ya}l1̔ (""4;plٹ٭ݖ NO#XUU!h~J2#1cwΝDH)3'AA!amtMǀX)`r;U&j-6ȇ//@8/:|뛮t,K$QD>1ܞ@JogBt)*V$aaQM0udfÓ?~1Xc1DזDd4Ғd=Jq .5rԙg%3'?nEqm%?b~;oCW^,vBp {*˜sq tPs豃#+j6;g>)Gv͇$=.Oג)#W$f(IX/܎D -)GY8~%=BBCu9(y?95ũeΩm2RheBhL '+dE4RaE c)X_Y򂾡Dzkߨ]~.0Cn~e.~Xe]]狗j̢ξH"c%DZp*_Gv-;6@ :P\Oh!UI@`Hө"p@gԣdߝv%%WPa+_ؗcd,.1;z*mTnb~L +F?Tv~j!Di5ce]٭|֮:DwmU0,*˜TEƶ#8@GrĦQR4>̡c=o,}ݨ`qgt$dZ1a(Ko@t%ƺ PJ( uնjwvVC-ۓ^b|VaÊnke.{VQ<}#cx5""4ISЩlMmՑ- 98&͕Pa!*N ^ɿIeQ&}lw? oWǠs["@FdX=D\[9ǃ:EWPa@fMbnߖ0^ht}[.*#Ŷvk߅:{\xMmKO%_~42d)mޞ7帪Lݕ҆o8kPaP) 0 A~v#n>Hſ[~1S"NU裷 =),s{zTk:!u46t e'&G O.K UW3/uh .=#dosV0FTbODUJ WZ駄mEXGu e6aCMGf5,Lc$W"QP)ל2{qg3\EZ9ymllqG)pJy2rbM ;J IqY!DAƉJ|iXi,~> yGM7NLSC,rLFI}/b<ЕPw^2gW9܊wQⷋ*X!*JhtR!I^ ZS-e$#$VQK*.EuJ%#bz)ȳJd(Hޝ'<((z T[K_+U_W-&腔mڅИ@]65}HV׃ HlnU,UUM{ЬIRrߴPMy~6~]D\1ϳ`_!|Zd SGPa{@ 2~ռX . "4{J$T\T B3p&Bv 2Sz1i߮8@*$7kE͑hIDAT)\}OU$o`"`Ě$"DEQd%PZ`FYj=UJ$E$=m$E8Iw_vn,BCXy;k3f4QVъbm|M^ (QrM%UDKtܓ^dbzDq̆gjE[=ᥡmvv"vw8xQN0K4s̰ 4lTU?)}BɏS(`W#|HÂ55<5)*T @CVuY\:#̕jfnzUWFa-\YR`I S!x49٨7<  #4GqڍUEJǑ8Y2{[9=KِeVz=(hٜh~&n@џ67 6H,qn/hY QFw4SP-g'i,Pј嗽#xƍPh+/\v^r]r7""򀤶s}w'x}kƤ?fBǨ+-*""b֛OY\sz]#"b2NQٽ"*(wiODk]3xkB3"Q\PSmV%\Yp3X W5ul}XPUGR*ȋÞ\";m׿ǎ#-l_OJ+ kۨy׻?0@+iwpeW~M}!@/JBL ┄aw5Q?,.<ϛ933/|ntG?K^qց;T$''"byXGfRH)_B,uF] A,TX\k֎m ׏8jo7]r7mq#9ɯf&A^ü؊|.䄂Pro*XQ<8l.6Sη%WX8x`[Wp;g1`JfIdIZ)7?kge.cSBYwAY@nEU ܙՅVs׮]JCh?AZqBPrZﲌ=9JwBQk=5g}nT q :N2/jͥ!?N:miGAe|K_~^K!I=6 L-|K_]uVKf׎vPSNQ&-1%'g67lm#63^t-fBQ|c~ 8c\:9KaYΥQQ˔fZ0eOEԊH9QuE|sw_ƭ. \Wn嶛oD0F+_XNdINy(=j[w"{&CXE/_9{6 w"!"ff"u붩 j,pUQ 7-wdpS $ sIw@JWoHA})mu:[@Av[G-d   c4nG%Ιmq3$CTB={ H|% @&_ I#=TWolcoA n:7iLtI{G4) _N|7-*PYf'言0Jė_y׾> )dTHIo,:a":C{Ǘ,rJ%zLJ&ZxǮg=מ>PcdyG)~K3*{b3<D#Ƕ_!?g? U`.ͭI 7!x )kH]VC_R{XɶRVQ)Bj!$7LYr@Ic"mM"Q;v@sW{[@1.z(cf}r|_-gDe%p u|Ϥج?ƷM^FUuȥP馭۟3%IXe,J2Q,.p`a-y;xӁ[~pMτu~Qwؿ׳^!b]#Y~{?z;6~#Bֶn,]KE$~ıBh׼_wuomMgz1@ZϘ\WJ Y@ [.P̽~]@5P@ɸN }e{+)RE%_:`8 <|,Dz.g ]+TK* #A T~' TW8{ WװYJV#` \b_;k [K1L &XAev(2rM1b$t>?mI vuUB-Oj,`eBһBz^.RV^0yK^W>ऻ3 h!2Ϧ/{?u%WNO#(dyFWya j1( U==db}ۑ?y^t쑇% .<"y.ffؔ̈Bo~淼)$׳Rɽ4Mш|Q%z^>$Rf+c-)&@Ġ^fGw fҞryn4nR8A:C`]=ׅ)TՉ墁t|7\Ӓ'SU@]}y|G?޾m̺VxCs14JR}b.ڎH6?/8..~?\ܱ/x)= Xdn->1fbz|s|h Bmz^/z~kq3b8C< 53KL-xQ<43YF+ny{\5s7\_Ϻ⡧?vQGn\?=5a^ [wOO~z9\rS3E)˞"6';6{R!7|b&&!!8Rڣ<cob?ɼ[YA_|h4*9dQA$q}\ڹoPad {(//*/y:=˃jZlcRe7mzMfيf #+*qXA@bFιObɖUX M߯#7Ku3xdV&HyVs%_'&׭2ZK"\Z*p|}'ЛcM0ow=O=?I`@estITyl6w]uU81@,L$& :NUuU H>Fw-G.7 ~zJe=E "K/}r.A`Ϗa6˫^򵣩{;Ad*5nLB%X [SF? 0 xA%#w ^R ["1ܕjpe vYȹZE{Ta\Ά--v]O9m5e,!O‹9u۔«B S5,}К+zYlmH('/T_V62L@UdwϷh E:<8,Y9"U2Jb*+xҜӦt* &1 >,{IVl% Y4Q!vcg*zUA^6Kg ըQ3ݽBUj= 逎ҵ@dE8hrSOr؝t {I9@|i_v%_ Ѹ[WѶ#L븥U$؉}VKT=⨹xO0l7 A-Qv-Z5V X(*Dq>^Tc:hOW1U0vB**=HEa$j@mr_ b Yai WUv ΕZhw?MXfQP&4|Ojq%Ϙ+$;) QQZr%^۶m{Щ>EY0WAL۶Ͼw_Fa1̞qxhKLa&XQeh\)*FT#5 A2ީNn* -y{ݶ1z?S  Y~73S"|gra0K&:XasWrOG@HarPae cvi}G36]I~K1P%q3E_'u"E\.Td'eꞧѢᱧˡ yݱ_?nC֐TnI b0%D5 "rLȢVDl1@U fsQ#i[,͇aH=Ϙ(l6߼~ S6Z5  I! -o}g>0Gq~6` %N6c\[R|J= ֬nڵ6007^šR*Th#Q4]ǼgpADKsdJR' żAI-NjU!Wo-Ϧ!iCaw6ٸbTPUoo$ 5țݡ׭;^8餓N8<95UDl]wy ]jԴ6VV PI}ϟo~kdpW@P0Ls!<կ~=U.jZ6֖eXQJ-M1Put/*k $"sQ xqqIK7iqPLRTsmх#i3 Xm!u!M!9Zsqc ͋ј[n 496r\XEVk0wzcW~~7=TAЇvW^{|;듍ވ4o~75v]=#D}?~ӟjԧ bva:{Di{Aۿuht+CfҋG^j*왨 c,M;Gi!ڜ"+B p[ )klKә̹pgO2}(q #∽`f{Ħ7cyjzPWIxlb$k}?_VdJr? F*[ P%"gt{zٳ&@cP e@aE=hg~\yFC!2zn׿955n;z(PBL|[^AP7@#cI[ح0pBA1BlYe@|g9}]^p"7z<^aITen* 䜥2Rǹr/j$/]wӔ "ֲ۱JZ35+#WUfHDC؂ 3M;#]i=iY6œg=*߆XVH,z:#+{=14r2$+yf8?ŵf́_Hl%fm{~?m_ij"ZX0Tη\\Or 5A%3|wn׉'`c_`UmK)JH `Ä<򖷿Դg`f]uWyܼi=@a|6n>lz]ҭMHwͩ\U/;XUY𾙉@T ZDң%: nw{owwN7CO\p?MO= O} x{&7,4DFکֽ*j 36%N&"<B`Mhf Paa?F+ݴ\端i'jACJ -s4.MGYH#(BMl>ГN M fǟ|ʞFf!"B1 4W!~@&{ѯ~rhZ%mܿ_:5FFȫBgwu9Ӎ ZWl$j22EQ @$IhxJr(} 72~,l9+| 1Jg<(Q#.N!{M`nnŁӡ ȊaǶm=/Sk5ruͲƺS|\kDf8[zvj05B4bU]EQ Հ=4giFggg6TPDxY2V:dوUo'|E!!Ԗ"r׽e= ,kƳy5ڲ>zvy&XKHWꗪ,bH1<1?1[f')mVM4B21!Q!hߟXW3uBgCG~>{o|ko#YLi?jǀ!8G䵔BW, HAozpZEf(cxLQ-#;&lF7uW/O=" |ܣ:x8I>zqZ-XK '2Q@$X/cPa 4 sO| VG14JSa/uȒeRi΁@4h唉+̠;Fʕ D0lGwX0(޵8n>d \͢ޤה(D 71!&DA50@JOO#-Rmaņk^Wmn(ujhX`6H,d/m⁡EY `i߮"7H5ʖCT#&)3?_YiMb=B CʀtoeӱȒK 'dH b9ٸ1#VF7o\42җ80SJdu Igp k.땷q׺cB9Cw}vwa]:R&j:u'o~E]i$w$7 4"(el_>'>+M$N]%ωh9d!>{^4M tq6ۄ -Sk-4>c֯ㅝ=]n?un^xΏ>G㓳~g()(_KTyYH|!ZۭfL1OOE]X6D-GjJŊ{SIb | /Z ؀HrW_ajCLc|U[LE 1DId6̺qy?j!Rl &C**;;A 0ȫeIC\̬{{-نژ=) 8FY8KMϙq~ZfWQƑW$ #$w|~2Xa B%AX򶸛h>$ʑok.#)"UQk-͛_t?@ 0g1y?eW߸i(yE8lL{o]pO|qq(L*x^tX=ѱRԦTvPP0r!"Q`R!0+g?ٰmN OO:iV,ªPQhR YJTȳxvϞz|x7^a{=Ao'0O3},dj BfYl\bİ 8^ k]W7&.Vc`ػ75ZcXDaȮ_uh#1Y(8'd );V i5w~zY% Lk`fu0e!}󮷿5oP^| 7{vR_LaJfn<ݼ6&zJ#)4O8N/_8g13[\Ff`B؊m^}IRJU ~>{<ЃpSQTYgNB"V^^%viARR8 |} 0T q˗VjBA3{Wʜ!X?Zzu`mPa0:7 Ën{\P81*Ř8'jlh`ph΍_ң>xъvςҎoz/yױT@,uk3'4q.\Ar%TU$+jpڬ-^2lCVka V+jT5& IDah =b&@^hei2  "sKxkTC[t=_hmh>>pI>@6iD ^ܚez:Ì1P,HD1]ˮe;6p+w#d UG?]>e?}0* 'w@PU[?Y[M,{zE4nJ _CJe7`)ſ#]C[ L[Ys]~C7lS" k8DsU*<}]eTE09ΉKDxӅ`04Ҟy=` cNw mr^iYU"Ȋl&ʜ'\eŻ=6n@|#ɢvl|>>JfY$2ap۶n +(F՛u3W=׹vruKgMDy@U%0!βjRT8eŒ}ZRayQY$5 cxݲkt@,xGWkw"ְ,UlB7v $՝vS8HEl_rQTβU80|{lU|-Iʝo?۶**#Y*,Q2׍&iP#Cfܺx GdbՓ__ts%@.8x™N@Ge,p{"R7=RK V{;GP3="'Z.Ф;7@B__~F`HHGְ$a*(EYQ'5phxxh`͚50SR)LM Gm 裏 NDxΉ|L?C#vd,^` wh#D HS&a35w_yIɬ{.쒦lLBf`ѢN5ҌHg8VHijx ؊pdĸᶻVLСݓeoJPfLP{o.l&ľN{< ١/*m/0Q6BH!+^FW bD6HZ[wB#0MΓ>cgx@A[2\`&"R?#C܃J .q!AeYY/gwI$ :i"_;+[H+W[E,t=mSx`]`~t0^T~f4e9NSNxf_r(I;<<%Mp Z7HTbiIꖎ|BGE5eo쬀 Cݷ\^m:Tؼ씩LΌdW#MZz0%{)ֽ1&nRvwy6'0MhؕZɊǡA"p :]l;<}y>73}l((#j-Wd!>O@|KSGasR  JHhr{nmlE5Π|:5F*@9k  V 7գ(b;ga6:08p=%Rbߣ&X"DҞiBUUW1o6t4+l}1ȁz ߆DYPyB+J\e7 gR@^P>0^* W),,tugU#"ͻL *8'ͮw:UV3M)yޓ  hGcyE9D `㔔N AX80,jTCn$Dh6%1@6YNŒ'aL67ڷMDY闪dsFaD q|3We 8۴?zԓj8z:-YRWUmS1ft!)ukVkq6.uo4zòS0+;_p ߳b?U̓HTKaҘWUA[Q& E `zΔ" QFZ))jP@-XE "EF*T\QQr"IZi):O>39 M^9$<^^Y((<6Wltz\Ynaw^~ P {!2=/X4bzV6\Yu9 iAQFd*Y,z +nH`뮻MqUԝ!^MC*6Gpu\Y ;:rY|U~3ީF̋G'I j+ AhKn ;28c %~λV5P4!K1uv*<\@֯̚Re_-6U<k+rJ*?\Q{ZOz`zWRQ:æfn? }JVA .8PTc#R %;:ΆK7&gAk.$n;*n ܾKAoTd,|8h6bWA;Io޲yN|2cSTP;X6<2Xm5QX[YGiQ& @+vX՚*y!\qߠjuED7z!}FB Ld֬Y7ќʏd;{bA ~")X F~$m'Zi"y_uPڏޚ&C}+@ ^ xehVtÅaDCWաNO{@+i;ߏlAnwI nV37)7AXSYkd}1rf$x {zl{I0WUuBh+v"ab*ٳ"`TB[4 m$A&u5efJZ]S` ~rv|^K+1\n̶_ n5銤0A [70NZ|*+*n=;.VbH{4AXYum*i HU殂bbלܻvtݬWPWV,êu7MN(1WJTʥCG~7ln-NdT [b81Csi244?=maӿ(>%2o~3@|&a(PTAĩ0\yr$n޸&)T]6tYPs#@*vUwWCwS=Z1=L8Uz3H},@l@ޕg™TMWG+  -6dl\}*ԵxNI_Gʳd<9⌆ N0ax%~Œ, `5AD~/?G0uCp˭^x@ [Um HdnuσFRV٩3^h6nhdSu-iNM7Uecyx(D}5l<=7V:hA[I"K?.]dXG~o%"o鏒2{錼\(dP kמ~˙i\)m ~S$IJ"e@yzWD"*Q&m]>@裏nP,8C:I`H##[<|(ڪͶ5ib4 54LfX *3hk}kPV9kWVfR" {IT :V vT1-/a3qK̲'Eٴbyvwq'jC(0`*A4pȖnn-0g( +wU4".oJ$@0_ o}׿#KI o˶eā:h%%@!L5zt. Sv)"O0ae裏>zGWۍz?j)vZs8uɪ<|Hpft@W=ZdGQ W˜u꘥rsтd'NJhh6"a"w4#@fauI@Oh2Z3317Gw^pvXDI?~j2"%&$Oo};jEP 5PfDfɏi{SDgj\reV8p.]V9hCVޔY6[mnK.wW1CT$&PfwUus\ڏ!,TUokf+qkQ FBz틎%82V8 JdiF>d'S-lD?_OIE5T@)/ek@&w{_US SS!V#vis}lҥk2dL`L@FS?ͱ0 R֬aBu@5!Ud!QS)^[AT , ;4vn3},hLz o$ G"0{n%-ô<ʄx48tџ| ^}) G(!$S5Ǿs&-s+ix4e6ޛ"Xq+?raج7qqbRc)DP.B}h<+/U41VmiЖ@sq?G<1G01 VX xrC{ѱFQhV._òJE_.%^0` MSU=cݴyhhHXRmV')irǕ?O b'^Tsg-*\u?w\|eՁ!=B.s*JhjYj aI52i>4Ҍ^`Vт~@H>"TـY7q$[D_>` عx /NaBpNm`G5/~<O|>8آMc] .ysꑥ˭ if3ŨHtg]aĉKdlH6dH88hp2>ΥIwSUu})*3n}>006$nŋ>)Cd>n޸'u(nZe/0GSZ>~ 7\u?~_]-jSĹ2L)1PQ"1D֧lTaMEZ>fBnE(O Oq(Oi90HmQOpX|GU{MDҴ h6hYqZ<'4P3RFQԨ7и O~?'f0HF=c]ÎPWrO[PjBOǷUܸی9 ƫH8u5]Ufm04k5|k_*R0l.wfϥکN%'P$T`4iE}OyʓKsoʓtJ7V.Nڷ[U׮[}7 WFxh`Ȳe#KQ$qc|u׬i!-zgc>$*("HmŤsy}p%דACGJްTBP@'q0q@"f#qmuBGl=x$[Tu+ ~gz\`|lhh}ïy+^җkM!mT0 ƍ7ݻ$04 a`)SIô&T'By^TJcXbʄY-Mg 3[(}^Wbf*a!rDv2ᯓ&P@l\Q(1d@xͫ^n?s:RnUDNɜ 2T]|{IǧakoC3翰hx1Ѩ["eu DUեbi{PZ|)YyWͷ yJϫ1E}<nRÙ& fK%(OF<{y.BǤ1LʳKpV!]z :#Dtÿ/|R!4K;0N2݊JJaNO^} B$$Eh[ҹk)P' [R_=>g~1y   ]?2$n201AdnK3 4ặu0a%Es#K_ˤJ̰lRwÍ4z ,fYF$u)4`\8շ|W,;,$ DAT]j^evPG&I"3qia$T}la` ~5EC81$Ib!"Udi/D0$qU>xqz(l*{_?&P&۰DwYspec<=n䤓RD!𓉐VYC4g3G?{x^x$U?-7u>p{FKekmgMpQq""2PD恋}G<t|3>/Ī4(A{iK5w$2 ѕ+|_~9'w8ڸiS^߃!֖ ;./#_BS0 fِ̖q7[6'~}iI%isםwnZ6T *D8Y3YVIF{NnqFd m4?>'}ӧoܰ&Nǩ 0k|c~Ή/wj( |~3ݴ> r]l0D%qW />)QXaV*9K+ 9ع6l6%eB`ғou r^m3m9ttc`eϬ퉨P^脸yN/fjP ae`z=5|MaVj,&~ͯ~; 11T}1LC8eqr~Rmz7/]vG#?vi!T&w,8G //n]#K,\E!oy)/%'O'GT$ x 7-_p'=ቇzP T'Y2HR}׿Wl֕p2&J.@^uFD/| q6Mc}>xFR*!2<mHS0v-GЌD裏1C+J|-a^ }IT']-Gb: .@V C+/_:7evކ7/Y,eLaiv_y2Y9)Ozq=m}w HJ`6o$5;^zU{O~ͣ ''ͤTA3jQҢp_>6PG*3niIZ. Ѻ?ot$ZG?3u{ppV}wmZ{{fV#K~߼7q\`>gͷV+vNL q'Hg>_zAcDC7\<p BWf:~!?S^ڳ7zYSs)Q"4ec'=DBЈ8MZw׽W͒e+SQ'+'Irw]mq2UVAuP$ \`K-k'l_=kjit[Qr{8LD߲e%N8IH9ERNpFXJHU"CKTNq|.{TAdׂtv yE^oC}xA f0YA>J/pjISt<Xc\lp~񍯜uA{!¾D0,m0V\vQePAƇoo'[6 V~t챏ւ[RxΖ^ikT)ս۩#qMTRf :4^x7?X U/9~ -^6 Zp=IRFՁEȒe.YRTRHUZƷw g # /|ْ+ %'wa ,VjhAY,?Ov|#vuon^OЦ7m}m L}1wQ}t&FN}< u ^Awހ *k{}- 񬈮)Yg=뒿RRKy|P$c*/N|޳/Eo{+UkBeQRyA`d,u}o~Cc|LD1l0Pŋc͆4Tz\MrOO^ByXs)3[+$qq6㴙(*2i)Y_9~ 5G'44VO5x3>ԑ qڦ+n) &}¶&4裏п^vNa/=k~FwCL)0ظnͣe|xdMsMyމ']+M* 53 XaT )>}g,[<&M 8HLҖ( EubTP䓥L!I?S?Z%@"i4꣛6~;GJſeڥ_x͵7TjFCY$OP50kNտLT$<0f 3:@aF@@) !g<)%+gtToX˲4) ۲y4niʁ}zG$Ns; 8裏٣kb.2]/ٖ1鵬>L$9:ɣ1xjI,0#:i!(wBv{zuhw}˖Z%\fd{M=p0@HFt;n2ᝲ5YQȟ)*wZ9}: @ih lzB M6;V7lX1 g88oyӪUw j7n:92>;_Lq`o׿ͷV[l((0]xx%Wk U}#, 1E^7l8S0NԐ\<47mY44Pt5`˟c/ʻYcM844HW p:6ig  !|I&N0~~tVREU$8a4:m톘%J;?w"֪Gs5No(u61;qֻJi)\iSBM緻X27J *kY8\6%΁}PVIT+hս-YKEInm[~p{DJ }Ⲟf&" T 8 sݬ=='o}k'ozYg5M1 Wo|;7n]4)I]!A>%( 0ƝngDT ĄS^;^-b ǒjC>W^+;RUxW_\uuoD+w\qN{ܑ&/̣KR ^|;VkC8F_l꣏>&GoB)YL8#bP 1O|(TEiqӦ<Q=n=7C58E?_\oPe܉Xs'0Jq,O8C]vwVFY*#@Z$f4E C`Z eP[ =C?zK_©}f]vQ0:G?)oxc6ƄAǓg7x @D86ֆ$M>O[  9aX mo{ 4ezܑ|{?hၪ& OtlaCx!Vpmz@Z}q@1O*ٱSYPgi:5+vܩ8ByPڃ3p>wzQ+".׌W5tpٳ#;Jf=G4IfV++>u&t\! 8NM"T͆5k7=ogCDPV&RJDP 7Yɲ56d8)+pKnL'90X۲e˷[ƴ:XW.iaAܻEr56۰I9 JX_/_2tȾfŜF}h:}<Ϡñuz .Xxs*;0 T͛>!(2hb78+_iO|L6qTbP)p貫l7!P@C;80(8U VT6__vav{G/5ĥY?go4BV8~mpI[[9B%Ox1}+%LIܠ2;n[Tb%TJ0(+aS4L[ow{G@F3:HrKZ:U-z.m}AemD+j&1h&)D°bC_jQ%a1W,:(1/j(k4Jv3ֈ⏗\?ehxD(P}]MG:Y'br3j+/D[G)@ \'$Bj|@efnNygX:we/~!ԕV `(N=w /!SX3бfwAEV*ƸB,٪ 6AXh=t+I얠\`|+9Aǀۤ#r;y46׬wb h[`'u{uy[h9.(w05f)jݟcz|n0@1jH]Ҕ-wv( HՀR#}/~ ?U5u kbCx{>3ϮVA뛃`?PQR?@-V̥6 `4n޲oxANBD(T]L|W|.Rxl)#v-rRgj\ w'׾}$Q`O:G"U5oo|+2PUUYJxhwID>UY[=?D!["vH8kp& B($*Xcˣ@HUDa0 )Jo|p0h z䘇5Z 1MF}\ ')d:.ޫE.9?Vhj  *z[J]wBΥDO]~? aPo&Z Md术}[K@2]e[luF:GC@L4aRKtvn޼epdi>"ҫ:{x& ϵCYdd>)q2cwxan˃_!Lj%Ƹ #ِ2i:/|I{춋ɩ;9B!zA3sVA M{8^q.XlEH/506zuW{.i0T'/x+V,ǖaf֞_]r;:Gkr`_gpѝ"VݱvW=@F8"M8N_⯗^Um6D ɷR/su+j#qh q3 Jwd7s5h4Yu׬[McQ#%#*C{y~{!s6{e};)\(2;!ckREsݗDS0H[wt~#B82پ(ãE]d ңw+_'>qace?qa ^nK!Nz~z3**S_jC*bI1d0/8xj-Q"|7NDQTE7Yy'=ҿ^Vjl& "eC洇)oy|5iV ޯhk-D:<8p/2DJ Q6 YMGhLZe0"_KNjf&q\[|w>$!O>$N8‹M(f'?9r9C:%(6o^FM_X2n[>so^=-ZԨjZ*T3+B jm䲛Vt^{c#* n4NZx)/uյUIR86VG f8*Yp;9y@(ԉx~ةM\%*XɲIPUPg '2}Ģ*Iէ;!Gx`^h L"HO*2k7iO 05Bgk,+Hr Y̻Sϔxm[9oH A`[6' l;A#⏰)k7>]{ݍK0y')NbsCpáګ=>d$6>?!M'5s:TW޶/WDpڒ6 "(F<ү.}a/F웉C'XhƄ :6O( =1OP0 8O:D!ˮ{B2.mWU#+vm$*.,hZw.x =]U l- :튿P-8|;rWim*"lrv')+ lժ5X JثEFĊT eI[CAz4fWXû">6v!f1ٌc4]'IZ:փ+aEl6cZ ChC"i7,] d2OډMAuWd!ptq5Dh4c7ᔝV.0sw" vW?V6GjZ(;Vws?`I{ Pǂ7Ac|}2U=Y?\BVE[fuӋ_$p`( _}'DƧ#̶q 0)$ c*aԌQ4m񡳼C00922r6Y*38*2sQ};FV~;Hq?iͶ4. af}z3rA=d_o'LnaJMTJJ)$B LLDy=?MeVf"26HhIl^T-Kgqk&q u9 l2:G*']܄j~K, +W,{ X kĥPU*ƽӒ*UjΥi^FvV9̾EYam4;࣏zɕMV)mD\f#L<|ǿߥKJ8>#oU(*P.εٝeLqÚ#HD{ vay0ebyMLKUU/*/$DĞZܴi76,B4)>Iv|nfjѥ?Vqut9v!zp=0mj"~绾jҺmG{^ 裏yμ`.4>G>Y\\ lkmmi-m zv*gT_d'7g^pt 3dF0pirP4yBP |!eV` 6}v`f\`ӏ=?;l2"">PJdؘ|@:׊NeBatC۪fkQӹ0vI`E9c&2;79'> D;nێ9x,1t{)6 ̾^e"gtM?CEupYMnVl+3lK,) ʷw+[˃FI(iN9^ S0;8r/b߹+0o_p`#Sq|G:Nh{$IvRu;]\8T2&$e%cP5Mn T[]m\¥:p=&n~HjּYһ"eWƔ.fny ЎΑ.׶mֺ~z%THHf@sąAMMkyxiSc $.-5F_$69~E|uhWYa˲~P҅Ν4"ֶ>u}T5 ql*K/bR⒐Áj480vF2r=YUEYqw.,YէcGくrc5kVrQavRyqz ЋEuPH Yg!@4ab6:W研B5+sNa:=%H&dT R҇M_/)&J6Z.Zԭۺ-Z@|%fcU0NݹM'T$3s05Xn+75w߻f ;h.bE-z#E;Q59O2Ei>4k:qjE6!gS * 5;2sƾJ nZu_u{Uonw+ͤ4fάaBD&]悢(oPVQPk2rZ'J鈸;y1 :C~aby^ YٵYّP@g*g[V6Cc!x^f8 s%*&13$ PK/Umbnu!Y}s yd_ ${sR_21u0 +9A<3˓9lTx'~;X#CQ5Y> vRDDq!%3@d ZBY}/J? Vo[918`1 '&/oGa#ݥ pyު̼4wFf[ĥ.B ̲(R(7r\9qBֆH%{ n% &5ˌ­۱Sh}$ ξaU ب CK>ǕD00-V k6Uf}*1APg>XX;}(w>@S?<}%WɥP"f%#v[B#Cέ+y63ԼzKN>)N% S`q?5묖U)Jv-6 yf~a0'T$,}U0D֏+o/)&$L9M\L4þy7pZ%.;~d΄iJdzuh6$I[>1uZ<{#%4KҾ+CcAKQ31r9f!e2R nMWC"SuV$3=t:̊J5$(k7+ N҄ Mjeyܺʥ׼ilUZJ[{:=`!!~Ӻ$ki`+O6d@һLrVTxLr-i(t lh6D桢U@Xe4ϔ*ԫuwI#a>zE{379M*s{B=檔3},hkU`&ԋ}rֈt2 :wYyT\5~ PEw{Փ#02ŋ4A/|U +^T*$VB'Vf.eN~;lmlJ)<QhVنi$vWռ>芾Vn IjKO$uȇr}$=$&)z .~|[_0<~3Tk1ωY:eVJVNI-1f߶=(3עpV2)" J\K뒣Ux"'f$:F6y:qdI2Axj EV5}<͟J e:};/G &'̡ 움uKPxE ZIhFDFC7LiMǽ=JDR*\@L:6Vz6c}e>nFZY˂XC^fM 5i⚁(9XK٨.pX8M,#MkuJ/-3s-Ha)5 ʒ\EnsP,2T&Rff*3#tRxTWcǼ8e~!꣏)j zEVBI{ԡ*X9'=Wc @:1^m3L )VXg2,4H.T;Uva.pwJ]PUIS"SP?\$а ԹO$ Mi|<[+ʀs 0 ,'(_dvc-{ Ix8T$%q:8 XE k=3g'EK;;&m} dG󈫿C3#N2׫amHRgjt{@X ^f&Ѻ{4YMn?Q\if&d^kw.ʋEDQ! VˁXDfS-9ggE|&"v ۲\ۓs~>:@DY*4I|_m f6lrhw[K"ʬ`T<ȝ[nKx|43ej0?Hl?'#w̟*.o TQ%k]v^2*Cn4GE`Eb"}G'x@DD].Qy/lDDZ"j4Ze=LN8$M 0x;.[jsR*W ~Q.͛T k֭wJi` =dӾKxSrBcDuЃDbÊ##D&3CHdQZ_G<|p 9כH&.M]iKPDTUD"QݛTOy F-Z|yl0Hw窻Z47a׍TCF8qk_xW=fX~`anǶ}o}; n$)TE/ '?剧t E3˯+^"E*7DeHI"V $ l2Yo4߅2NsIҤ9^?>.b2*fTq׿c0q?3ݛ\rri{t)\0"̹bb{q-7_WW,i1kRU:;Ԭl*=xN*׾?oFl`\\>hbϨ ,r  "U簃hz칿c00R$X{.d `Q;tqČTe3gs׃bu.Mq N\vlhѪCNtHVa|屏~h+Py[<\TxqXw"Mv<Nxhd+1df3.p0>nmsZd9""uوY`d1Wzv7_~FD0!߸Uw#dSEA%qܶp>&X  N BRz$οŋEO9V/y-_wl8i꘡"\n&C#4i?gG!CU&d"R6޵zZ!2lhoQf %C(y kK=WZVM#ďY5 F=eW#NLc,I'l<'Qˏ|Hpx|O[.F148n?^;ci>2;d߿\}wꀷbs![> kc)O|>+&]}o}ܫ:0e|mgw1l­]e7:UC~"yXv % 4!caE8L׻ǻbvulu_f#pSAcuf=.c3M}2eƳjѩ?-6i[ωKĂDnp`~|ITPA3a4 &4AebP,mxjA=AYiF{Ƥ2SXؼy'>qQgZ&ǩ Z;׭5oc}V(gM1\lnm{|DtBLA8uw޵Z_DYT:+r,spԙ?yudyVo$6>x2Ĉ@v 1k}E_7IAO{FF:ԘWt&d] 4\s@K.\Vndx^b$i$g`v^#?c 9IHwϗҬM`5nxG\ ʊݙu]hr>/j5s0,->ju@K_/$L`-G&űM(O<PqrIfowY(;6`J^Ҏ\F=˶j?t}.Svz'M0vNE]}> /]لX}SrJ"JC87y38u5q'T4ńMEҫuJ^vxӓK>z%#_9?XbdkΉc})u7~3}-w8%qU:o;FNĉK8i?0Uef. 5 G1|~.M͜%G,`,ib5yqG(J> 0l(sBH]IZ|k+-:YmAH!Q|W\pͤX$K-C]h@(ᤍhBCR1xI/? @V$\ԥ~_ن6el _S eNSTY*NRTĩ:-M{x%l" 6]XSǩ$xD vE?пX蠮 } 2*`NP@axC"Z/mm7`$ E$ihl}]>GR筕iӕ8Xmpof(u{=Ĺjz٥[nS_/{VT]̜gmp[w;%8ℭ#l(Dc'w]6 ^D֭OEslLAƒYyKt&U63.A*D(d}s[a=O$ M]}qvm&-hiVbìL¬`kR-<=e[D@du'TX{ͷQե)ƏL>)T>m'"iDf04.Kgwsl~G=?y o[)=},hדd]6FEaZhw,y":Y1g~1zxC%ldӡ!4+8ءF13C$= 1͹:70P?'@K򞐔݆Nxg+D:Jy!x>X_VJ W]nxqhOYc6мjb~7@ /^|5לNykE4My#ei}VH29AAl޼g>ُ:d!MX/!O3W]u헾|BAgcYһ.3MpP >QVcEw/}@0qO٠ #@:;wR$w tt"*ʆ'] ;Xrt>\%ԥiAk8IJX=_?V\:ibf#LhXTT&P 7'7nqJF\oj z>A/8O[4c :cy'=xCD ߿jU9RAbLwĥ@c47=1{G ؒ{Emdy~i8Y;UE$p:qN/~TcݰaȢŏyc"ju xKG^+_ǿ]sJ0 X8}C^@ lr- =_u]C#9Z]ܳqT: 8'ɾDdF{ɴ~MJ RTt[_4T9_}HBq)jfYT7[Í7>,SnyL\QʪjAX ŠPg"JqJEQG> H'Ŏ,KUF-CU~'?b"jz#o۷zpBU}죃-@ 3`ʶ#I'߿A-aVXfܬ $ Vk|<h10 Mo~|Ԕe(WJU8!e߸_(t.6gCr D ^z#Fؔ0{$,.iq1~1E7rͼE1VͻYkJ;ꖣՍ7o:q -kkeJ8pW+783 X:0h:HkY2ԩ1$ ל/Q5eu5>ALLLTk)Ԃ}J B`dM}]a!c; NG@Cբ{~1]J4c{~yTsK݂Y;+3{ùUA`mi egǺ+(j%\{57{T8Oz~zsv[\ ͡HFVd0*FhJ,W68#t1 0ӳ!dܣU0Mve~=TՂ 8845@˫e:HaE T0\j@F׿6lae!:u۔(e\ n>~u[%z]#mˬ-==˙pfϦq:X^cU|؜ 1j`Qx ~pIZ dI$ua% ^מ-^rظ,*6$"ָ1Ϟz3l&40۲m/:>Ko{:q2V Ibȉ!u`Ȣ#i,/i!t g۰8uU\ngycZfOx1gŝwXU 0ANTdez6Yi"U*ń\o|eZiZW! I3[}l;}<1v؎Z\ z>]KC>>D/U%i6$(9;q˿;/rD_㋟?'<;`5VP`SezÞqni{7lٲvFs4(jaʕ>{N5-,O {LTdLzſ7>8M2`n݆'p?ف*kM( tI% 0 kLc*T%[>z 7\k$,"_ 33sym$yvbCAV,Vaac;},8jmgyfu\*Rz~ϏT x*yK+.KVyid6xf?rRnSH}=ӥJ*Ah1fΚÀ s"Ce8`٦Lf8*,c @(45-s5Ӛ9m2un6:xT2E l%{͛^̧?E!h2 U]>{ bm?r V&c={>yE8Gm:JJWJ%m{^ LݬJ}ɏ'NXZK"a8$q9c R&* Q` bU`!,J( 0l`|LOK qlMrUk4o|kc[A2CD}2'+K@U$l2~׻F fc@ j&qW_w){_/^]󖷼i;DmzP&bp 0 %04_:/, CVU Jiq[hy*8׈r˲PJ*?,4ͩz na!ʮlKܾieĩzܴ:?y_iq Ԇ*77=xST7b"CKSݚDžU@ؠۨ}ً4dPH꣯l9u@,&Ϝe}8BE"?="*8 LYIzPQ`wЗHccC) VIf1O}x<(q6|kŋW/~QG?i>eA9wVy{6*qz-7O~7Mk*-ɶG7K/gVNoe|#_zao݀*@}ltLL3xvYHsa2o{{8qBU2ZMw7rFlM en* V^X.,'Il|c}+^&qH!_W^y[ &( <fnȄ D$P8k˗D^4fwo{&nLY\"*B*IڔOk^2XHÀ298UUկ~UW]=<2JqAX4K.gN=VkE/fA䀋/첿]qrذax#VLPM3ۏߺDL[/d"b, چ}LCcpg:Xq@,P`H+ R4?i, ]Y3z>T[jlsTF2y٫EHEK0W3y;!j24%N3{&o50e;\"a4-ʙ_O⦱)z/17E#CW]sTGVaJtqÍMߢA"0zg " I8Pw_7\Yu䗽`VkZd>*q$N L%s 0bg}߮ áOWj9K! j#?4~Tڤԥ."@DUDHD$1ZK,"A48NS"㔠 $.i ~+_zD,3*}C0@ ^z/E9@CFT5s59H V߻f˖vI] *DLD*3Mr; B9yf@T&rB0ٻFwwf!^h@v5y @ tj.m҅們7Q?7#6qO9oA"Oىv}$K+W,g߸9lNW$!qi6p߮}Qb%XჅ Jk5 O?3O}1Ԁ$Cgw?AI/YvӖw?^'XǛA:4D{dsJ?5ڪJD_#Orࠃ} b@s~I:aNz=NӦ״UQՀ7Rgc.MaJ ( FùtpKgcL4 WQUzӤtE,PV.U*:e\&W}9t裏}>: ,g&盷=rEŐsS֌r17=ǹ'͠]BF7oA{|>pߵ7U={`}Bʟ/=UZlQ*(kXavJӟ3 R3@HD؆)g>J]}VGC#˯Ї>=]wu=v 7T YB`,HE{Vo?_zͺPTV؆Q%_wo|\hPP$в"S~ĥ?կZPI8mD,:䐇va|#/_E$nݺ7|x=8AX htYg|a`ESux51-Y?qŊu +v ,"0Wت!um}GL>*$j{\d˥ RD7_vX1:6P*EF~ _Wj- Zayݿ>O?OA#b9UP|_ klX !qZl{/eW|K_k=CgCz`n%#xt|윯~ktlkyM|czVF'6^[,]?ū_wʷ5c s, DF)Iu|~?jNRl|!>>O<@yb#Q0a/~_6lZS *aEÃ^yC 9G H ߵ%/;/da~K0!^70*uo3+tz]=ݾH qGC:7L7Lڽ:E`jvq"&R͊'A`\OZ'"%NET?{'Ue}}'*ڒ%g#6 K%- ,yMXrdL6lGZr\%`㜱%YiBwWwǫ鞙 D,z{n<Ra@Lj zcޱcI;K_VMO6,8GD`0/Bj #W5 GZ%Bf[Hm+=h[JβJ"fxߚceىSνuox?z|*y' X28w\zԋ<?6e5DQTJ< , F@^D@ftl~?O|39e8*y#6sֻ׌~d6m:/ycuІ59)JHkV=azCNj>|{b7>yGha fPJ`\[gZe펫D5/GLfR- a@s!UH3:V0;`WO>%Z J=DeND5aQexdt՚5kW(r^ݨMOܑ֦*Ie6y'~nXjΉX} lD^J2< ZWW4C"Vz?22T׽#N?{5Sc@w}__elctk~^SBOn[c&++`MTWUDD`D[K^)6s?s'"CqO}90^rm FZkb9g>«^#8/E_T5I_>˯\f}zs.yE}oE? .$o^:|腁0JlAB 5ŋz} . ń'UrVixԱ6Ma<3b3[mk."~gy;qi @@ªj~ы(} uƪ!E \IJmȰp'/ez?~1EE&zg<;*0( IE,8Dl4Q{i{ `5$s.ВT zddۈȪ|G~c23Q ހbbf<6D#ȮEZqd Dlsϻ*LC9>+@Fd\'=k]51UKtZ8uge;u%: Қb[|{xcGʢx8Q[mWQ,&֜Zٙ˅Myr!ߣ`@ `Zcs쉇pJFI#s;҆w b^b6Y?e߮en8hUwM SD,0׼ ãغ3, a7:Idјji#Ǭk8"S]_<4 s&X#I횚|Ӟo}DEmB" .i[:lt o[^@|=>UrAm9_A*6%r\YhibЛi@uZȥ"aOET}Y%zw `,Y\O}d^M}P`^9;Umy A|6$I-1DQ$^ B@"RQ>$9t<|( O*@T9vpǻD#c;*Bd -r!8A¢%\s=6wN DZW'^ zm͚R52tlMdm,giJ̉1Vso-{X&ebU$ HQիN>ԣ ޽o??9{)<%D߼}IO~~u79(BjXH!^ MjEC&(/ %F@J٫Vo|;O7D(xJb&:cM)í:%{9T!&fflFqcamVpQe},%)}sB[gsоdXhEgʍ9:z@D0f³EDI;(Ra`yrz*rl8Ǟr'>,Qs*A6뷟=1=nFz ǫ{w^1USfV⼗F#J(z[+] Yʰ ,WҐFcA>ڙ77m1c+>0,Ok}ֳ_/Xnw=3#*-ȳrr%9❢ B48JdLwmIp9M-E"1$A !|G0O~ /xKqߘܑPF٢şܨ6FW[ 5+WULDASEm8xӪ뒑!%x}o7ojdd͛6vِ(L8yB-4 ,MPF4q~ ?0JG*Q"k׮)I2Jww.sAz,Ys0K%geYPf!xv9_GL ԅ:vݩ;}0pVxmя81hV+^w$Oל&械baΡ$I)p}cEd_BҡJy*skǎ>ַtipO7p}"q10Tė]y/a2,vk"6m1s5JlD λ9KcI^֓42ꓟ3jK;ᴬ#0PBdQw,,TC#'Q3Hdc||ӛzn4/]jPWOgS[*"M @ÑI$ G' g^9C?atlM\LէEU RІyp%ޘT " ѝ-pQ!Ӕ¥=Uj^錝_p W,yYޥp@8~0~ VzeSxD&?pjI<11!P.iloɐgHTRnyySd@NRLD_\&]6v|ycviC6:2<7"8Bl_u巤ٯ/욝S~dj8"ptm>Cw#W3&"@ߓ=xۙo{Y6*&πz\`*^3xW*l6\淜0؉] Ern%6q漉d-;xh4TVC@M)&Ȏ>]cFHIȇ.*a!,2m\PIep;_PSkwX{o DkL^ifFTrW t&۾ 0@ WU|+1$r/"-Х(JB F9Z6L(J⑤q,PoJd*᨝ m7qszڝz!,(/<;2+}/~n:Rb&|?`;5)"N HR/-_ш ޱPR/E m=TeL-[/wvz Kb* jU J1^2L 0tՈ5U϶fmCs;i0_+~TQT{ߊGG9U/(ylsM=xb ^OQ%T4uE 9옓N32!c&+;!c)/'>޸Ć(УtGYNPFQ8[/p.*"J57*T{6ٗ7ڦm%V3 @A]!F V:S|ћ 2HU)P.M/A'ג!b.̣Y/?eq/Ud"$V~VIG'1ޝ1 e%{Б'o8l+ d! ԄB ⨡2J=Ah`hHS"g7ȋͣH-WRvi\*֬$A|޴n|M'kDbߺsIOz/~k^'I4"LLlmX̾R-(զ/{6R!B I_7hxRHEOlJ!vE~gw=vlL`0#͛;t )CYPDU@Ճ(4Bőf`-t6aR:Q[䱷4kt%4jwy]fL 9ƛ_7pԦTjRxM0TT6KUĕ(#{Бmx5--M!HdWoZȉNMW66d~W{୆ROz'##PmywwviRP@RfB,nyZ w]|\]Ϣ꽏YKYgP#uR\oL=KBپ#~:k!"] !@ ˀCV9p\Ϙȓ0ȨKbr\`a{/ͳWP:2=ƳAf3>@}R5=+T m D* "fi^N{GUOh 1J@efFnx̾PfX,MouG^nԃ*Rڵ+Hh (ީN8J gDe0Fؼř"D ^Ў]Sb[nѨo8 fiP^K$=:xM`/"ev|▛K'\ѩ^<;4|8 o)^04jSo>{\6EGn=+D|((vRake&.r Hq>ʫyo38x$IX t5uyan%Rc1g/O]Y#Mf58ׯlddf7.qKy5[w8ԕo[#h` {@|m=`d9ȅ6j)' ^UD# #~:+2TV>#u(q)BXײAS@%m7h4YW8^ǕOnm^f|pUt:oTֆ򛴴/Y䝮z v,\2{[ ;{yzzdX)e-ORZ*_4mް*p UxE@BsSJPn4Tfi 5Ce"+jؚѸBES"G,^x7;M8 "xQJLek6l@R\$ے4? [~)m\BpW ȼ_3:zrK]DQp+))Dj.hx" e0j_NQk=Pjs¹e0\DNzr🊗Ȳ9+C4m&; `T,ft9ԝ#w?!5A]uEo L!002PW.9$$"cK5guO8vțn {n^gt~+ĸ-];uÆYU Ue)s >oEs^ !3)!vYo<Mkoܕ$I+kTfաD}&^T $pi ɡ:\yKjrvi3 Z7kCm|Bu91'\%۱`0F->Ф煛Q<㇅0{F16˂ њSDU:h0gA$Hk^'&/-&5d"ŷL޷OD¶e2\0L:T;l$IL`Ҭ#8Oo> pȡF#$^E:Ty1Wz㨪y\TN8vƐJҺ"hQKcTW9r=e9#VU{bLehZnڜrdϽ&ij+Cӓ[řCަHض싁qGBTXAyXnΤXmN"@3S78H뗊Xga'4.@r]a4@$Y,gi *s.Up 4y }j.IQȊ@T#ex|Uehx1HuT%&U [w:StY^b}"Jb;qdB vLvttZߥTn%322rYGeFz%SQcMV_5zІ 01H >I+6[g|!3`mG!Co{&CŌ46[swML/k4Uk(v):y4eUG*"l@V RϤ9mr  UG،9$`eYH1xt7KԞT(@fbyHt<~0F+8.g vLyՎSatIy˱Ci)qd#DZ qݲr[xbKð>\I%uv#R [ؿ1pX`{ݴ[O:c-]7ܦMnÖX)o3l̗6fCSTAk1fOO ).mA"#ѴХNbb..^T0[c]c" ͊1ipq^tieϸӵIfYET;_VԽLfjb9/XTQi6Kq;]fȳ%ZDD(Ѩ/ɚ(!/*6FFw)I a:nJ{LGc p뷭8fp;.p=Ϗ)ңx4*01y?b[`0JA U[2we25i*=U(w0gRrz` 0r6]߹s;HTXjgݚ!16\ft<D2CGɥm&06sJmf6LH;oy͵מ|g\$ݻlf5OQ ǝjn*\? +rsC]0B %T ֍oP]*Q*}AAal.rTwmv~KMhZۘm6o:arH) qDvLoӯ*Jܟ+Tu"2dz&7-pi /ꡎ[ϵ&AY$I>0DM'/TDaQ~OZ*iDd#&2VN55Nuaё^I\Y*eKuq·h 4~Zs˭t=RZK + P/W}!dҼz'GKx@iwUU 3dCE +?4g)\,#s40q5N MDawjQt4s ,H*OMOY9Kg?^6oXbgT[('\ZTٶ!""V{/_k*#^LrcYt"0iz7y [Kn,լ*Fp6[I"ky/fiԧ?g=q6S*991a(wkXp]=[DDLᳬ19˼qk˄|}z 4uNex-4"Üh.Y| 2 THڔX mQ ᘜE SMP˽0+D:Fh5Y\ yY<]葛JR 䱨UQ%)'~?UGF*w (\ 9Acc㫥fWQe]vXk SH%8w?QjtٛUPH^.Z6E$S"&,sڨ W \>tx&d(. &R"R돺U-AUťãwݪ'vrK/u* QV?ё4jnT_xF6]k4NZP.1|v*m߶Ū+E9NL,Ank;tZS.%icֆZQ*-m޼3YAWfW+.Bn" fCddmf۷2cK͍JE[C/zI!i"ڽ.7@(@)*D fiNDU=TZ4)OpW(MǤ/| 3ȸF*Q%1 Zex೟FǼ/N<{z3%&G9JZ"Mx/,dYf9t4(o g[w Unf8tj,Ol?>^'cAk޻_zɽ^O%v,gSUsP,>mT۝xd3Vn O|rejWqImuY&0p)|+hLs< w{v`ba0"$IƉ*{dr $E5;&/oZ ߫v:"M͢1ɂHL.* Q6ruqs~`aZ{.R09R(%DrWHԂW .T$rˍ G1K0B7<Ҿ ID;? NEV)fvQ/ShD6 !eYReU&D>k1J%Sل́*F 24226V9ԮX[u{88lM] LB"cۨM|C{n;]tX5f|O oq2<ȼ*9\{>*JC#O<&<D6 su_?^~֝wlj/6Mwuʦx EL8P~?Ȫ/ψdV\v׍HdZσ)RRϨ.kQ ~%\-]>04I`EJhd kV &If *ݟ|ZEN'rmE9)'@:w D/d!W_3i^;/*=jYeU͆ݱnBi ~َ VYcmגy<AR)GDyd0Ŏ x !fdlDb\ r0SAᇔW@Bj)*PuUGM`Qo(Zz``=EQ>CHBLmij{n闞璹eq°@ VP 0Y&Qk9gwzn#9$IF6j 9?q݁#8V0?w?Ndl子 5_B&hvHk,PZHwV؎ܲuǹ}oKdeisȒgJ|+G~ʣwz^04TTJ p:zi^oM$&+CΩ@Zwwz̹[ρ0cSqvˏ%Yv^ǝ 7` Cjr-&C#K;Mt:TÄs]TجV70\Xm`6IkzU d:g:M?!?Aȱj>%D[6m>fn%X*}^f2{-pr!.vw? + ~d,2ay_[fmr#2 1m_8PcFFsC2.g_k>@*eeh?_xeZB^oZ];nsC~߻~NLN7P >&/"iFF/Ox?ccc8$NЩ` }^2lzP$y+1l8Kx>K.T{I᝗(JXW_o~n=T=(ְϲ|%U3/7?AẸs9QsWܟ)N f_`8P?9,oű{0 لN7lJ,XRIR0 Fr!x |? W\2]1%gsy*E9\hS@f޵p,3qhw8=|HV L uܼu#_r٪6g/6n<ÆGF29kM7dVY ˠl*‹۷\ srĕ׽wQG̤~*q.N+^D/}{qȦ{.I6Rw&3Zcd&/.xmT=cf*ZTERΟDKEj ,kpEW^uڸy՚V7"[oM׹TŚXU "%܇o);wMDmŠ Fh[,wt^K|\ ԧq[^Om$05jL8y86u$(ѻvv9PEYA0Ǭ\&F"!ΐTY!)a+0$GEux`c A3ᙖg3sar%N"567BT֬VMh p'HB+?qӟxoذqx| 'C#IStj{W6:"fAV)Bs2*|9Kϔ}$"ҨzS>s>hÎ nz'_)O[wekSSIq?ifi\A9VYf^}S^g=QذaCxTWU]Uk~/E\R-3Dˠо2k@d(1V\!BTEP )pr["&EI/!`1pXU=)^e& Myi!` `*78>nVsʡ>iz˯HL';`GD^ 1dtj*,jRW7GM&_|eSǯ[,Js[D{;zG;wӬ1:\%YP^illMVc|,oE3L75SW5_ןaxxm.*D)!^s_?Oӟ馛VgTid)0[B+4.oh3RGz^d6,[IH4Qk὘%(hrG%`âoeF$/= Q2D!,k a] :\9rkw>"0lWnC r|lU#VﯸC&5d D-3, 9VPDPg±y{eim.8 "mA WpAoL ";#"q vnx[޼ Y=؉PRy3΀b(APU*S i~.Э˻s{.)JPubmJc1FK[zB0[d^vd́<ǝv?YEc~k5EKrqߏ5 er%ODc`VU`u`U`L:]pM E=VWRT*.^=6{mRF68 ȭ'XqmX v^ ң;q>dXXaI-Ag2d7I;EVTWrjSxMZ˟B t$17\r?# :_xgk3 k^BzƀIƈH2Tٹuta|^TN2(I8Yz:T]AQ p(aETzW]ǿ{G>#?#UGGGա7x^yշnqEȸ: .7H'޲WZqS7Q@sReh!`:%ʸm`Q8{T(6`TbU*+L*<~Qw~R IFp߸/]T""8g,HAfE05;9Qh4=ދd G7^GW :_#'͈3`T^D-31E6I*)W]}~#/^|dU5Q$IRkXgYe iZθ/{N}=zݖm}K=|3KL5{``LZQjtH @h8sG{߷a̧6_R !X.E?pËn=6^&ŒӜ@MIk*C|yHuQƠ>ů>yG sa k ֪#:h&X h-(}1PZy͒t [mV+MvIhiɄ|W EGlCX4akxk=r,0I<\VH2 ~5Ra5Q 0R"{ Î\5 G! 30a*MI"ڵq}>tsYIqDQ6rʡJ/9}~<4Sdc*Dh|H-S!Py1Q5/!as^RNp. K{rm] ye\yg}O.K;a,NP {, )cm#i* {f a?yu A;DH+^{&(D@Rag5ӝ|gd@b4^ObI:. UaXd hO^ a`.hx<֬o@m0!T)R{{uv{  H/%Iy[޽lCH0r gj*i8o|,*45gї#cLesyuVyL_S2GGFU];Iھ5-p,xEx_Ph-ǀ{;[bE)s͂<8K9A 'k+uڤ22CI;O9uܜ*zIŊ5%?aj&-mkH2HA*F`TOM<)O~_+.U︭D@D3f^122#?!>-2æ^,] r õ󾿝a_N1 b2{3k_ {]mZPer. ܤ$9!+,PZmzk@hF P<\s5_ۓ4U1zlD ϯZ;g5X+-d'2I9HJ@D2/.M7um֭@dOUX>.B75!ldٮ_Ë(1TI)o+C׾moyۇhK!æ. 兘υBU$"fY槼!kQR)Fh4$ j;4j)_Qli/|<=DPkl2_Is[0@~jSU&MxbFͧMO<֧wR(m_Hww2S_|?X /Һϒ뮻nn39]+XM'?8S R,i6ؿN i,/C|`6x=q =p*ZT_ܻ]{W^~< 5iDՓj%oz˙oyA$"26MSCޥӑaLzk xW^[Jr>˰PD^%bqF9C 9쐃vM'G8չQ^R5`=0IDAT*JW ,T*?'|!B);`lպW;o?uvllވdjG/חj:)3qce]JJabu_wnw6qM>~#ڧ?_׮[5sDij6㬑qR6~o}˙o]Z(qis+1= /z^γizNw[\`Cg`YYWuEq/t C$ZϦ֯]s}p;t`]te#c5ڸ s "Q ^iO"9 & 0+e5jxڜSfbb v|{SrjIXUH= c㫟Wmo{|CoHk*gJL}gHdcBl\o@sdYEQ'y]ա$Mmջ U^Ʒ\s"߅ZuEUe?>!/~N< kbn᷿]ISӓs oX=c,P>dSO=̷+_F}sf*|Wl:/ h?wI f]JCD4ze[kLQ>@#d* WZ^P{Sg:?WqF?sy;7HQ"&tA>iڲk?N؁7_^~oؓ%(1!n$Gˑf 84;qԬ,Wզ@ ͺ :ŲwzG?N:2Y:"nuUFdEQB />$5265g() t@y䜒sۢ2\TcfW&m42 ?%/|a5ilQa ؼiY~իs#w0>jwh^2;m]4&"l,11ܱoz'ᛧ<O"ƺUwsͪ8 ngd1Q*o]աpo+۶jvbnFGPꜵFHXC/QYKA_Y .)/>0w ae8WLޚSk`,6 3V̱98>CZ)vSsgnteĮ(|T3_\:_>ߏT4zD1HXx~ݚ|ܓnw4U(}9(/*^,5l*{sɏ`C3u-"]j@e_Rm6j.{ ?shm /{>UN\ 5ӡ3 .ޔj{9Jՙyrg9ܩs=UC+^w}_ |hX_ ur MߠF9V{ +-ʊFy q-.$IcS&Ҷ{P(Y B!74uFpa>d`XWǀ t`tL fR16#;sE?SxJZ~;μ}&ZQ6W:ɌFw:mo~ _>;n~tt[86Yk: !SQ~ybpuKU~CThwɸgHW7񍧜t`c4D]L) dys>/۰  wi5X6O>+W|m%$8'_'u牜^fsDe˖~_߽kG ճ]4;#\ڔb('Xi X@?W敭b8ׄ¦̨y֭]~zB(y"eYґSoq_‰1^H(J'я~#pF^ΩEBy}"B7*H[>zp{ z}ÆkVSUQ7:q{_GjD|ٶ)+!b fʇ;寧vf i}O<'@ 1/?xoZb{/3隍)s2zsLQ7XG~RlŤrfFmё|irCO{˶[oV 28|VтLLLoxWk,q"%Uqf? ~ g*@W!չk==-o'2ն}kٍ,h 0JAGM{o,>"f Ȧk8uɛiZ;X ^3/G 8Qu)zrUP8 P1Bk0(\^ԫ(gc9i2]'E)HI h^Ź*K8:o-3[W ǒE5; />c,JAf0OeCd4ҴOWf) vyb'ʋ-EL\0kƆ+#++-=CK OnJ,+≆-!e;`?J -c윛صkjb[@8Z^Ҭgbbܬ/m6ULצfPe-*G4Ƃ'Bn(+B DL PZdAϛU^(%V/Sk*fhDVi{uHrvfb@ʱ| &An3D#phkAwG9u~-, yDK+/*!ZĠ=0lZk)MY}6ΰ9%B-E=AD5*@EF O| I=4;n= AunXϙ65yQC2"n[Zl]  -ָRbv8&h{ZCsjh 00~i K8M/aVvHV$0BO򌵃hCy2u,M0B{5r`V/ iN1s yBEYD]ͩρѯN9Kb=#^8Letiؾ/0k^%E t^ZF@窓`SoXfs}-Zy׮ei1ېp^PU&"z}C|;}gO-8=@m59U濟8ƭ3MWg˃U_wWh733y?q~YZ % ɲ眗Z- 0{u3sϻzcۙد}OwWDTO8UE8*qzLژ-J~H@ZdI RNib f6LD`3|̴@C[mSAHtIvM/5e4+376'_=gGt/TL/;Cy%.mės//"KuyZ*m֤^;֪+U\B5ӋV=mk^2bZUiiJRf?|i?m-!&n?ei]rF/2 2sͬ* XHIQ)s5KtGU ,=m6Y~OӞub5W؍'i O+{ꦞK0lW@xX4wQ~׫7 ,vi8Z^ruEt1 GbA1/.n/kK*md7ڻh&>S%湝+Пla_ߚJ"r@UEĚTu`8 Loeaff鳳WWQ~Ό.^QkG"ڱOn@i Sl>VhΌj{nbQJ]7չ}I9ZUyR)?ӮJV%ފȕ{Pe|RM&v(o^%l~.=D.eZ{{a=tRe4lP椚'+)R"^w+ >?`6 $XTAsN2FԄJ7`.4, ? 9Hh@po9J4_GV9Hh\(֑ l_O |2.MޫQ5p@/勸LEnj̵ -c|.T&Ts+4'OѬך)|^w1֠%A4%lB$ 0ӕ4CD&4i6PI 3Qk uMԦoں{ir)ũ!#}T=@ kz-NVkL[zK2́*%[|.-e[\S)("PM` ]<#aƍ*b.}ξm 7m뼶6 hVnRԶT*wt8ⰃmIKNm""Uh뭷NMMW*(KȥQVߖMߠ9G9NټQʕT^hz5 70 :N099=kw~pϽfvvN~);m+Yv0 % *\ ө:ݡ~j^׭5}!Vp2I\IӕE;~~9/zg 0p|}b;Fm>3D^(x qwéc&6L\PL\w3n)B@HKF`f"%s0삄@@sTUE`=*Յkn7CnBLU wޱfH1:|T~k|22I+i.ϥ#-l̥}?EH3|ߖk$PA_'#zI!:Uk, Uηh `|{?Ї?\̔ tmYlDL !7WQfۣWL(?Px6CqlHt3a"bDw'|;=댧dN!( :~s֝;k.u&gm+l&Tit>~%wvY.>ɤ0~/s'}FҔ|5_Ol\m6řX V|UBdhUJ۲z:̷fnbRM >~||Zweerϡ0J{d(r=j^M^E&&}Hx}$5Ĵkŗ^>9](P|u\nVDđ #g3ȋBGO )fqPkƤ2D~m|@u (TqW5{m=,cO#2|[?x[߹jͺ 6Ίrth+ *r?$tSRRiJv SԴrZ%CZ!DS1qc$duePy/Ud,!qmpik,,wY:QA{V{`߶e!𢷾o|k`I>VAb`觿8pRaPne6EI53 UawI+ҥ*V&UHHk#ɫ^'fC0f<\w~O|r݆6:!RaME{ɌqZV ?{K`jރ[ b[ sT V!լ[c,{,[Mihze5,, 8>@χJ 1Ҙ{ E}EXoֆHiڬo.Ѭ &Ѡ6+95Ϙ s-fd*8n3܏Çqʖ k r](~^CT|nc6vt;]-&/#^%g7 ϡ(,M;A) aUy[޽{b||qw"̸U B'ԗ?I<<&PXqB3[ch\ȗNhWl=-2Kס[Y}S%/{+nh:g~ 87%/{m;h4_Bg:±-廹v{A\!]HkiHC>e d,Kn\7#]Vc8}V=& A𦷦^#i3K&kdmII߅n,gY5b(+_knٚJgV-+ _ҫ(an G 7}D1ToG=7JFCM$egIP=ܭ;v >Og?g̶Of62<7xb%%KC "CzCO=$Qdla(qռTq]__4R~e\"@_F/v>]wLgԋ }k{}[mb^DĀoկ|g-F֤Y^]bgZZ̷-6Mұ'vwyωɡ$i4qDlT&DDfR9)0?ы_]J#f\9rNvcB R>M_)g%DҸ`A8DP%trG2UӍlm5㣕'PNu~ox+cc@DByBk^}J1FXXL1,*[4Suo~7K.Oʐ"2f?dUVwO~lG?pђٗ?OFhPF>=㏻{;rr> T*-*2Dmd ];eBՋ.%/{ū~ Qud+(GK"۰W">S20C/Pvݙ- /Y}>H:=q&_ uvxd8eb_/Z*ĥIOOL WX>3pƶ[F"CFVMg}K_=9u]J&cTi_2m繊['W/}6?~jk܊+صs~Mdk/V#F 0n'xb5" 2q7WG1㿿ky?<Љ P)QCQl*6Nv/e)q>{N9E][ !<.Q^_PuY<ʒ P~x+,/ޓ+y>@ R`]29XPWpS/e{l޼yll,Ȯ <$Fm |SC<=9 Gz5z}jjjbbbp.?;'߶pŕW=W"u'{ 0B-pRr T7qu(gCOr d8φ'wI'#tks,V`6ߓ yMk8kzLMMO979]+QKOj3Sޞ/-SP@ RUS| =jVڕL؂T WJ*1Nm+;)'~㏝Yq2{_(:缪2s1Ķxea}'J2^ c)tU5s]$gB=yyo>0׌ts6E|_1cw@+ U*R]jQVVN] &5gȂ/Ű 0~ joe{{JQ}K,{yM6nx|6mH! aSG~S?UDe$mA09Uewp9Q2&ܘBޕזz=sZ+$HK|C " {-yiza$ȓuL )[qofi!;vn?%u$8p1vƳ/WuZ&}dH)[UkEa8q BEAAǁ$h"4a/TtJ 0 J`F^!Tg@B9 C2`8WdֳTf2t[V?ivSƍkP( Q!Xuo|˧>qU끏f% Yu\{hKy!r]yI<*Q//Xtm_سZU `%d\T/µkF BkX/䲽^yr;N3LTxCLC_ԡ6[I$d a~ 0@@SH˅FscGeVBSL@)Q'!@rȚU`hhHDD`a&_wsʐ2i[n0S_O uGaE2=Ow6U/"UP姿K/tdx$MJm͸ۇBS 7c]#J^ՑEɜ&ӌUn=V4CZD",# rE!n^|\Q2y"VľAl`(6["Xz "cgSw˝?Og) PЯLj@Dl|߻>{|t5xIkُ%L^MQU*DI1=> {KtZ@-sejubjzdt?SȨ@ἧR: #@y~m,J"Ī J [?@x z`hF*xce7r#x8Z +H$ƴMAu6cdo+I,[!65*]``*4U;vyI8Ƭ\zꢤFn?D Xy!CݑB!R[{m?>IP{#5,"w_ԉgeUi:Tt>/f+} 9f^>V*eYVVjgSFR >@[ݖn1+**׽BV#K ~qwō5l=6錭8SlCMp GAD,ZIa`ECBDjQ6(Et#\yU``! o[?>j",S$@M:^7m%J, vyN;_ƥj UX{[z]v}Y 1Bf\|#AwAr. B>lv<ynYկ~?5Ҕ`"${tV~ә逸YTɜ(Je+J^A^U{W[۽T& (Tի)6T|@_-y6= PJB/bf_R܏wʨ2TY2bdEd*([`VG6 $!Ωj5N~& ;s?| &O8+$rN9/|}ck7xKidm("zUdrmzw ~sљ(̋u$:Gn_)O%} D0lyT P@\N_ +V"e6/V)Y}dhVMoQ<$ Rgl%N^ UFM|iBrS$q^KM[VU DkIR0v[zxŖ,kk^2:4%,SN?/_]u:۱}ƕhbhH83Omk D^󆄞aR(@.rJŽQEV>+-|0pX9ESfR](ċCՋzPXQ<L `|s!3;^vG"IQc"S6QLqer6`bjGJ-!"JSWb/xS+_EaܳcR ]VhoN7n/:3);@U*:{x0$ӵxo9Μy~ Wp+$InُyI !'h>526>khl5V()u6E0HGb9"d&>YcHoE)r n1ؙJeUueT$Z xOXU]=0`"c+R#W '1 FE\U3Ͳ ^OiVۨRXcme`6?O|HAV4iˮg~^62˄m7@Ӄp懋]Qa;wOFMNJ̙ʐ&Ih4*r)ͯZ@dBD@lɉzF*Bd3Pj .e6D&1PᑑёDP/;%<1T+&1%mf*!WگU ; H76}RX%KJ۬Z_r Dw`ttmn362-W\~ݓwOx5,^J[8>Y-Xˮ} ^_j՚ZnPA`r= toIs;AYY#eiIWZͫV IS۷o߾}g-F11:O$GC'kw>>VGq^gOgdJ>@ZR4ě(ri^3Vp<ȣns֭[o۶xM;k\m,Y*4$NJUUhzQ[iC{Q``b"%uWvLo FDRY+Kƨ᧝qĤcCDEKyφFQ=G>SO=Cصv7]>O?$Պ8x_ON_j0j$.~^rfYM!+ 4r'&&'y=l5Uβ,c"N3֨XU%gBlrcFh!&vTxaH3vUTN='rO>i5G"Lzv-??:lzkTUXK &Jpwy79kɒ sqhy΋^|rhtu;IΓ KDQxe9nwO#?4IȐ ~񪫯#&"VwF Jxb"VWaqޥqb.b^P.*3bnUu}v~ r9<-QzOKdmݕr}jܴZ5bn8 NŌ@D`;nݺMo}Y|CB@yH!:2T9#?ȇ?kй˥qRDwRW㗾[XF6/f:% /y"XFE"=-(;5Qհy,sUc#y#~d $2RǪF?'=1?O>Oneyj%>Hkӑa34TgM*Pg>_RulMTK1{=2J-ۙӊ,/+@G&v WW5xS7@֨5v!IrB'O8Ow~jmR ?A xUCNhOPClD9Z(x|(L tz A`%2~0ŲV$"JeW:m2 24+ծ/Y֞EuDl[nw9?JQ)lz=dxh)Ox̽}W?~LM׿+_}}W_WONdãc\k?8*KVD:eªI%X{5RĆI!Bh:F+FH{vʲ, 8E23BU!zq=>?ofڦM{էvGGL $8sዣk֙(vo%W'މ(+~X-E08U$;߼{woq~br,6WUdU#o=󍧞zK^Z |5Nh|cաjc^S_=l1=~T`Y0p! ՁY R$;ܰ}ǎ[6yBp\迲hCDʤ{s'VMNbk*LԬAq"L4{Fa7|SםOt_WnWqNd4YelM((~^ɐ(V0Rd*MVRU֯Z{Î#h2ԨM7&'&vlOꒋӉ 9LpČ P %wDQt([&%#YF9VU9J]iVY%L16L20m0ߏq ='2!@rNqrz{fzwwv林驮n=V3 PlڲϹ?|WjooQ ðH!%ZEF~mofbR̾0PǠD[@1}N\)/?uD@|P""k 0(sowB2F`#]Oxߡ]OCu' QX=EXrP[‚*9+y!҈j\:n6O[gϻR`cH,ufPGaARɋP-I3:W/DM0^*AHw}o. [k]%$g@ֺ"[ό0`Q5Z'IFz ,nA4Xl+`r6UUֽ>1:\ zzwc>yȇ-NڿN@jut=]B!sJE%\(˥OL9wù-\Y2lי[#(HG>rںa6;#Gwǟ򊀔5" yoL qTDasl)TslK(Ա5ՙiT\+/^On\ʚ Q#ovD\iE7764C"ihÝiEP[^s.F"BֲH,@e=²[Yn}9H(0嵫V}zAHY 12M@9dH8]@"IRk< Moz@yg0ZVVN ]Wi<-3[^H\*zxxx3: Z{j_zu׽ QX@7EHU7jLl|I#-Q ҧ}~៏{6|>0JfF^q.8U9JR`6\ ~+w=vv=KVha QxσXRY,HHƚrj7 ˿BE.FODp:"zJ~.?}F A"Bb+#ZB-g{U˕#f+M $"*W,a8Z'C{^%->Z%uN)H""(h?N"PARI葫˩^Fj/Ȯaj L )_sȿM/.Z j&A:e u1 q =F G=َ/7{4GZh#2N(H0=g=㪟Oاxx'ar8PԊ,+I$ r.ږ$.NetDȓa $f&*.)`U|WD9֭>s,8v(,Ξy^ǯ|Tixzq眻+.cߝa<%YD/yK\O\<˅O>}O`F xZ48HqSI8ͼEc%9>Ehz9N5SS3݌bC_Da4#c{|#wlO_n5[V$ܜD R (W,Y<{VX8\)ZMk"=t.lYXbv3q@*x4\A{dr|b9hWPZϡ!skȬyg≢hy Db#OBt~|; BY<ػr37ݴv`q|nxV(2K_}=z'rauGaLԙz@`yWZ.y8yT̄r%(L;zr3>`Erl $AE I[ (\jXg]t ]Sr-W;-9f̤fRG cөhXV,Y̆I-s1_nU٬p$!J@+fĊLIHμz[1Eʻk|B͋K':~ɹ[^+6.$5U\. ˿g =UJyrtQbFaVbPL 빌xy("D)qTXJ4XOIc]v`ѢQa5$L6Q(hl.ˈ[Ic썉 bΛbV@W袋Eg?Dik$ia`-6D|nc Ij\m+V0딙AICEZD-Aܠ0wE[d5cn,1jb.wWLX8όhտt  UKs#Cb!df+1G}vK=w/q*hr=@8u|PoUOOE.:\){-BQ+ǦX,LVҪNccݞqLyI`Ny-˼vF!qx.N`icRfrAMo4hY3.W%> ,ZR 0.:'}]cZWfvJBb#cW|a(H8ֲ6 &D!!s`[{'?0 }8~`aDBPfgJ+.hˆb [@R!tc؊Q^'6eP9եȠ-c}cSOPVЀ`N#|sVПKRa)UxK;Oe#a~%*vr ~ߑ_|fy#`bCUկ4y0⒙E/| RmtJӀUtJ?:&(kP6r#DRE|0ϫONdN~]S Zp]6l,X=ZҴgKU@dJҁV~>4{w󴇨,WWܦ =jDet~`b4q]Hl =k֮gZ%Kxs܇5VD؂B9ָv>4ЎnqY]\;J Zpl-[ǰ DB*9nB+=w=g ҄n^A((hKs7JU$,5wo?\8S9Q[+"Zh`۔h|']!DLfGt( NبR @B6+@rfr~iBjl6Y+Г,Vx2u(ӻtU AXypY۴@4/Rͅv"so}GJ|oVLYZ/JXFS0X#R2dn)O]@ p!˜\;&tE' vD#ܖWKbOiiE鞁!#֦'ct4V)&eQi?:(=롩tk0/YD#HM!`T3hWPɬəHpH% }Xv$vLՠ81o~;EXklw߭[&f|F1q_;ncͱhɄS޳wXxR`]]S Sk?a-inB>[N|#W;i>;&950!rbap΅0$G/JA+/W,2CG:D6>Hj>wL-Jy`fi]3"d4^6LPIcЗr?;Ϛ'\#c޽#_̹]cTZJ)YU/|?aH!wʠ6َ껶.Nt.(Cwe.h *"I[g{ׁ\`i]ʴu~zLJ?+WFn̡(F@DӤ}ArtpɺEjv@+UVZΦj \($&.I$PGQ4EIcDJDDq;洆Gk,)RZYcng{9΄6? '?uw ,XD 88On ͺ- co}1>jUO%Fmn݀ѲY~zWhhHv!f:5Ƨ]A4S,@گM%Ɣ֮nϚDE0<_z:oLEp.3 i.cbWcqO1DI2=Yo5_?`9""b' bաN$5 "v,D8= )F!ؘã# GZ [a7WFVD0%kZ HbSP@@I+Zz8֪>7kmILH'DiKhJVA [`B%D^36m~K~)"bPZ)"*tZA3\YMzh?d夽Zf嶊b9ir:n:=T*;Rjwd9;w 2,IܫiiwN&R]t`@oJZ,yQd0V4BT@HU* ҋzo/yw V@yn9 zTKR6HH b=`U˥l9F C@2id)CqeR!$ ,Xu l8ٗV/ I Q=y"4D</|Jk\ ܈Q$%χ>eҩ"łQyb2, ,n~;ТA?W0A| G =}.Sr͆ð4>ϥ~4v)[8,&,  ϴtvرH}'B=]ta3lAi!]&Ob +k-޴qY-xvG'"ڵZ*>|l瞽žbMJ\ I~!( O<[`o'_7>gm7 |5Lp6a<0S.M@^9BW1"a%t*, w'KwŊ7n\r5kvCܹ'Ph ?}vNt7;8\ON.U-)a:we+= ;WkMDLDzz+G{V&Gdž ijk80"c̴l=ύhժ2`FV qB?PDZ_)$&" @=+/X~{OD Ų0 '~]jP1Ol~y1䦤D`J/h?wv@RJBacv"IWX8$vE.Nt.4VEbBr{Q @V0\TKa\/A[SociN$FVO;8y15qub|-":]T(AY_?j4q^}Y ggfJ"8}DZfF@oyl"k#cM&EႯ$ =o}+Z&OH)Vϱ͝rAE:|۳hђŋ-/zD0UEpHh2xcND. ].+RPȰRB"iVsh>/YdYwE>q~YV>4SkG0PhJs܇??"@6x-!@`kRȊ{IQ=|P~^fMnT,l@Z[/b%z7K&sp}?ΡG '= 4 S!n4&Pz[+imLkܶhIs~]yڶKC+bFD8sw5Vؘ]) mQ)3O׏a_k^Ome.==/| wޑa׾'ֶZ"vP `U^~u',^^ *z8f׽ܢ%E90i bA wdW]oKyA.'tfnMD\2+鍊oX2٩vLwfmC)A꡵Q#Q(]bHo+0Cν-X(Wμ.[$V~8|'OޯE)/K 8HO{O|sО= zv=G]"53ab[~+gnRTD5*ղ1bZzFc>m\4VIc[v-jA\/;}x]24ZtEhQ ?<:磯~ڱsp񒄸IDtys!]. ZZ&H7BS/>4ЩWHނovf $YaF%b&r^`ݪ Ǡyo!"H"fN3b"B"W 33-Nbg>}"v %aABE`VQqYAPhmOQ Y}οI H̦r`vz|B>86&*ɨ5\&@~]7 /[2T. A4*5 _j_/WyYM";+'EaRO욭_\EqI~`3;ʃj;˯|ͯo|-v1y@nU'/y%KX$: 8''n&=[q"k"kgP悷xT6ӆHdk4{ZV;bMqeQ$UUB"YD8&63-I[ VrTuj]hO_\jawt/(EJr>;c(<:ڄT !f(B!X |O% ҭX;P#>qlEǤ_T&l)GB$J*!#t/}qhѢRy$) CBQåU˿k_{޽{}l#dDT|EDႋ7DrE@00}Z9|@aD=Wz^us.5VAP,ja,ML ٳΟ'?Oѱ _Xj5leNi"*b^0O8UXp`(UgqFlY6XWw>Z80 xN4"t)!wN ?KžsB2qi8v`ϝ7GGȗF_Z#*"0r*'S,=C׿+_)JcY)72R9s9"D9}UQMcy+Wo;KUsy +(S<4>:쓐R@h,z*ao}?uq}}}KpA.{j4ɬsıag.NUt.haS=`jÍ0΄|%V67{sݼ%Ü9򫂁!&QK5=n[<1C=%1HDk\kOVEQE}{kdpBX9Rٺ~I}lZK*c=EDTP0tK "bA ZJ@OEQ(d.Os!M FDc깙t1}UO Y# Ā=phf`O6xVCښ9V!.[{oixtȰQkˢIiMc\zοӿAl"kkBO]PJiIff̉P!bdl(T6jY =T%Jbm 'yǷz(@P9݅ EhΙh8:0SSs<ĢtYPet"،?-3n922R*-|?Y۶qֱ̞11N m~n^q\0^ :t#ڲ;H'g]tt..8#MSImS#san޼xF ''J)9O6m߹go4b1W&ʏgzlpѲ DVG g_yݚ3ϚY1!!0 kaxS+sO F_#Bx3d.;Q+n~9sV;dJ4^} R&vIqV_F$+}3/k@pdi;otp0SG9g=̥ DZccm##W~߾fV_Q)@'? @.o&ɸrQtA`8ƹ JH҆X֜~^ޱ Q,0(Oy&)B>2PVosx=ЇU(h41v?|W~:VF= _= uZ6C;H4 "0`AաM_ Ft|ѨNԤ FpY&86]wG?_HEq{>p!Q.o;U*(Z"۩izbq]bAZLjt$DD)Rg7+eTuk ex--_n>|/~;6112oFZP8wA0y+a%\jHS 6!"( ڍ^E?PL(l=E;oj[{iV,m-|VXD,[y;C__z]{u/'&JS$ cpk^b'zgB nC]S'~q~Ik֬y{߻qaBðR{}^ڋ,9I(7:X$N[B 8KS $+9󜋯|Ay ie! gGV<9 c˕ʢ%C?mo}TQPME|1ɿgz{{s7vKC+cuIff#Z1ȡ}=x/1Y@ {l:[GA?#tThC\Ik?0 `g-y\9 1hCo?wyDzbT,[fvOQҖ2)c "/7NT{WbAQ 35h# 4Ņ 𒓣R3LQa}x;֮Y3Y1@J_8u'"T*r`yu[/֏'{Ԕ&@@}eG^APSq̌HA< %   άJGlcғ1.dYr NffV½C}s n,2Bhؒʒo᭯{o|[7?ށ~`_~jˆU#l2N!]y?@~{~{GU]mGb $qX)u8zo KX(TZHG`IhY) [080ֈ؂=wIDAT>X 9F7gdD4Bba;s/O?}5b@aa|i#T q^{͵;>>cuV ,"&r]/4$bwUXpeaii(HnrjEukaW\V+4UnSEm!#{}luXgvR\)d9!r#Tb?~ ' _ןT__bFy ORKy +Wd`pw}c'kʥ(&|P=xϏn҈FP B;5H"s=bc|O~f`NruRX#jS@c,~3W߰=Xt@w8(b"2&뱱m(nEذaㆍNkRd-6d4>,ZD`,KWPߢBIV4Gû&"{vͤU EKW/Z2ڑfF~lmm afjmWw @gDPxp|SsWj`TX-Q4ReH L?_Ha??9jUk2zca*N KdP!AŨRZXs޼Xd(^&REuZn)8*ޡ{!ZAFd@T=**]e]ܱA-$QIR^̡g}?dhYkJAfMNJk垀UXyjNcQ=<<2&gW2ƘlX׈[=m[{ߎ-/ZӋķ[w}lIĉ i1yGcik{m5&j)n8bS  NrQ&a-v'WyC=S- K@{@^ s{ z^ws8b2 X??x@T"n4 RaX=-֯-',,I͔JXS@+O û$n@ @tY{(8-7xqǼ@w<4*S͉)1adT+ ,_eз@ɑ:1\>GeaI9b[xˊ+#f0B"@"!")@EQQ5 Dd}Rdc DE< *0b-?kyalv9hÆhQ" az2 4= G:xHqi|;(.BT~q,TνtGKVTނHlH)MGS>瑻oUHJDB$"Ą9@$l ;);Dm/?7̑ : O:bTŽIeХFH}F\hd틣D$B""~>Ï>::Q|d?9M22*P? j>_v )=Ҝo<ͶbZpAtL9OI8̃?=y "6  LZk#9 j7:)%W`)q˶U_y_b$cXar%,WCHjNBi DRQaaÆmλ?Ӟ,Έ))#"+Koܰ킊ARy+ur6d_ݿކqy-39'lYfDli=<_9r75Ͻd{%Zk-[+Jj|wz|#v2L8 Li>bɕ)!0br6 BlERIyj||о^ +( #P G H)߂XJd6}ѥWu91ؐ".Z Ub{dlbl(\z.Fm+eB`ܓ hhk9q餺8Mh Zchⱉs|l l@Bvl}A~_ƲԶ$L’c a$(t=O?w8V'{h$!X:FY +mNJlWp _t굑p5lR=k$ã#~1ֆuŻ"7h1"-[~wid& PqЂacg0s4zÓQ>9C}ﭴQGFlg1IRѺODJ͛xLjD,jq6HX'BD7ߴr\1vbW[ΈcyD̑)׭ H.3q)׿_Ó%E~:zXMjo*5VLAɀUMZ+siYS'{ ]2rd7SLa)$%J#ۿyW^y \al]V=1MvQ8W4-}gm^l*,7FQD Bݻ?o>"褓zQ)U!aa[*X @:i1}3=0 0&K!!8j"( @>[ntƖJ1JkE.-RQgt R@gM<|' A@߀Up:jՔiJkjs6tL1*44HqU/WbaccPJ 78X2a@31qz/K_5&fm{ DSmXHEvNOq.ˣ '3WB*ǁ5 xpʼnG`J< wCS81H(b}^tE]Q/EQUϳ(i4TVvJ=S"׬X QŘ\ PRD\a$=5 ўriʕo}{NaW2UU;qa]!d jDI] ZBGk( "d!_C7n>csR=7z^ӿZ+cS-AOρ#cw0Q[.TEu[%5x,pxl2rW=s?t=?=R!$tS,$ȊoÿD D)Bƃ\+190(PkKHj#e)\H:CmTWY:bb.\9J9b3~pŗeD S%""(/Wгg]zUyAGCRM[dNE."9f}TTw+f^DZ" ű;gv=x:"ZwR8<[ "Ǔa/.Ϡal/ C(?yfOfT0@W+? ڴfYil8|!ǵh.,4mMO}zEƚ "3o} ";eɋykޞZ:yX ] gagKiRjŖ1-$vjפ7N M̲Ծn?lltʨ^Q)Ĥ(w螇׹mn |RL5:T#!5FF Kz+}Y@Zc޹#+q-BN\vry/',1".OT 2.tde/ȔՈƩ7qmk( AЈ ogT!7.6ϑ#@3}9 ?cPZ_teۯ|#\@+:TϬdf^KHGõQ@X{|ڔ&8}U4YQ7oG>v}(ut4B4Y ]jȮoޜ^0FE @  .'yU7NDIkEaܐ0!L^6;?11H\>8Z5+()Ob5?;AJD]2adž@bFcH*JULdaϢW_xo*qXci%ںHb% s lZ?UlDWҕ`xƁ/@Lf@rLNua *W ~~``Z(>L=ȩb!#ZƀI\(L'NA\s4hPY+%/{뿾Rpʚ2ڑeb?ᚵ믾էo38:*R|!ƒG"ϨUsJytk2{aG6DP4|F*j[nC7~cݷo_HkeT!A1(@l` l+.H0}D% GVɾٝ_Pd7j+ ǖEa>#eFcMź;6dM$ 1XŽ[T;e!ZQyaXx;߹̳Х,DQXTA'ʧa5Vv^]xf{1"eH bP0Y"^25`#ᡵ_->wi$E<')91*pj]+*!0&d?Ӹ\/o\' ɩV? Ą1@kׯ*f+wE,t+IJ/%uz:.o+ b N (F7ff8; 40׈br$B?}(81B R;EP~lع ZP{YAd!k˓ iok !4q{l∙Ӟ"$?|> càsXS;/Ԓ&ABfK®ԎڷN@)UFx[˯|eKոj5Yc`S ]|¥J""Fs_S߲2??9jH,4.2sb2qh BkIJX6̆% )AIǔ^5&ֿH&OyX]F,P;(s;k#TJ\˯xo-/ U勤Y%7i0O׎2 F#w0zy+ֺx\vꙭ۷?f͚j:q;z"LGhOEvRm"\GL7>uCOMXo.ł)Bd%ʶ>3벇$yEIPN!&W:e q;~ؿ?$^8Q5}w5d*сn%ϊeSKM|SڰZo9[=wyO~4|hz SV{&M @$];zO;S.X46˩}3o>n n7HEҘUܞ d1);E`ڈ,3WjeLO1BsVfbX6ӒMVk["eUeк:h=M0 =CD[ĔCOSp)='X,S,/e+O'GFqJ= JGMDi|C}n?ocFVZ`:Od}{я~4TQD!KX~ :GvR8M19fԞd#@]zX`o2;1P@ ##ͬ H;Jm+Ho+)!"3kv^=ϟ/qOG.XbZ8yF *  .|s_^~T+G|8,1)J;qQl̅/4Ϟ?_O,+qkZt@ ˶>3ojɴc~ɤg0ڈgMd$5 ֍ :byz[^|rSS/`Yf?OMM Jc%6:W+Qi/ E"–΢{wyЇLFz~gxȽiSc]<ԩH֭ũ~#OV@ʈM2ѱ0К"iFr5Mt1b=w|rE(7q[Jʴ9) G yոoɐAD"$q#\Pd0kNӎ͌H-GV%/pƙwǏ=-r@1[5V"{7y[uGD[c{pi4;vJfyT` Hh0?a<$C*1VGƬ@MSdFcSI& vE bl,$ #֤;\NDݯ.^XQ4~zGA!{ИiO%Z/W{u[ٮ¸]n q56Ҩ"N)2,Ve$Dф X6Jryz!cimL_FTju>_ΝPDq: X HhX{9x,t~ZJ(Ff[YkσhDt(q͟hgt| 6rX$^+;L$&!m !6GA|s1)) 4kƋdFo󆍇#GuZн%MIiSthf kN6`],PXXk6b՚Ûo:t`?J\sرpa?9挭KWycB5lytli'I3T)^w0U"H\ÇK#)k%[h$m-_fiO?Ml9+!q@ H6>k1'W3 3h B #Gp9'9s9t.WM)_zdH^-8clA.szVybVܣEF+%m1'KZ5HcPO7\xEz;??ȫVQ^," CK-8-dV͜DSI0cGfpt*$EMUXp{o|@fhq$0:6o2(eN #"v,op=RJ{饗~)<[[diR/G!B$RTޭAQ 7cY"T2MMJ" hPzj6lٶ3xѭjgm2* S" =PŠAueb;Bي`Byg1O?8 -"%4`_>{nӲs2QrR55-<6$ p?Xa rn2m}t<.ܔiEsZiMˁ #OnUsca8buW]3RFuk ROpzW? 7L]C8W TjNCO!?4пdp@oob hPT/o'>ÇAy6a$8V =c9Z5i#># C>$@JUUf+ m$-XZDD$Ҟj}좋E"b; `n0M\ `S'j5Ad6aY" t?묳&'&J%a*B+"j <400'+.v7^*WT:Z:t`~)[VGWy*RW\>}oS+aleϣ*02QU[Tؿ "E"ܤq*\~mUq)Yע Ei;vweԾΌmZݾ3qHjQ*գ S5LrJg(te-諮{ /\)2@HjZ")\PŨ>MW*H.[j'_ )%DZ/W*0 /ې 3+lPzdIztOJXd@ |O=q7<>)sf@0FϽy> k$g8}0I=d rW"hKk%4fOxeGbXڻ{KE>Pqb>J<$6+ֶcs݋z{üUl+ѢO~8FA{Pi]tlOI3D@VЊMϿ5kc(% zcnӑRWj"R=YP "XQJiŰB +U -\xQ>`+Ĥ&ZSFӨ}D& a'qdtW_32>pOo]JɪamU~H8@RV k; _VfT)2# ('TR{.$JRi|?@?*a1ILuA85(o|p_D;wANy*c_`g$6B?`_ms/U4\v(!E {;sӦs2YBa$#ze1#,J5rwI,{Ϯ$@" 6L; Fls B68K²JpF̿]}գccR1x _+hu d ژE+s8+} e2. @ą.`#"gl@Ŗ7l9s5#WS( }Tɖթu}4ל4FKaX</{/*&P0I{ZYiTҚp&bk%8|\x~@J3OMi> `{[  _Y!ߠ~k_M[Ύ-&_0[kADiFDfSt".PNQ r9 بrV,^jŚUK{Dx>GLeft]R:"@4 ƍx!m"B¤QJEX8vP*,󴆘ĶqBE-Z|&=A GW l*3_ @J>(qxRjai;7YOX ߿[Šm{+zn\rsI2@ɣ r'qzo1O ǑinbX㑪V$DZ)W%@0 AA'l " ;;+ava|. T6V{BXVgswxX&$جYI;"PC0zTLRIPZeݒՕ >`Pҁo$<e':72rk/|o_ղUˆ+,[+MD %S{t*=\du+ׯ^940 LqAFLigr*"D1e$Z+0%!"2 "_~G@ 'Ow4hiui Uwв([WvPJi@e038"VGE*2D`Pc0QڕK7[tqo!(a01Z&qG O"~'5aCDHD,[pEĚ)-R I)XrҡG{Z0eȂ(!Do+{1T ;rUQKNҷ|ul;y<$*ߖ*mC0yhOi"; 6tƅ~(iqTWUB[̈(5P"x[~^)}YIZ=hVS1`j\'B 0q(xZ -\xZ.EJ(6R:"b ҰN)ZDл^!"%@bϖgٳk|bB)EFgt<c=iTQD] *aP(OĠ'2+r5_ٹ{ 7.<ۤOGItP/_K~OaTcEH3EҜ3 z}=׮Y6fŲޜXcvDqSI)؃N+l|SȌ=lnܸq`{!gP0U5E)?ggDk:O^u2\04d兄0?tUfo˿tZQ8qpMH+jB]" (!?49rH'.J!ZMkyبTP7W|Z9VPfLI 3"VHxwْER²֚-[A$7$&L#BM&0# R۪F<-b#G4%`AZ=_XD߾E$3V(5V*}p~vG!_`gx.d MOG0*=QV◿z-UC%Z7@gn6JDV}=,_rP1kCSLJATLv^.Z"]~4n:뮬q@RȊ0VֹA ,yؓP)vCS[!4ty ĺ|YDZ*CW ]`KWr6cOH4׸FVҲ5kI@ل)!9Cʼ@;@Fu77,Q%ewRDXcb3`]tYc 1XDdm, l1U`yz˗sre؂J{EyG!U\Ob{:2W90"gR480a&$ڷwfI5tPf-DȘ QopyaN"I/4%tm7S}<0i Em3]p5W+(=I*WS*s3S~ YPlerYbɦV.[[P Wꊶh:588ڰaJ)uEiu`)s\)[~ʕwqGm\9Ka D88y2auđ]$N!hO d_3\ڑkhl媘QDAkjE&5yE1|`(M?&Rccc+/W& YkQS|6H#5+ dCr+H)b66999E @p'B@(B(i,ͯ^/CT(rr+. X]8VzpC 71#(rB+ou6lxrdza=OA@$>8za+n?1Y h"wwO 6zs˂Z:N$ݲbT*_}c%?!HҼf…B vp,³R8,AB$ߐb$^Wxf U+E̐GE]gځe h!bl"لaYY|pgn\|O О7 k[M}_\2ѹ@lX<ƒRu?@g YH+۸iӊ+~vϢ(ha"Z@Oq K% $HDjR BE 45Y9Qyt QE_tM4>Y+Uhj)"N Sw~'wn\bժ-8n&BZ?~7Mr'2OXgz1S",zl/"gQ[T2B$4u[?Cn!4(7\xi7[WٚR2[jOD&;! rDF@̖H5 R(zT.{.Ӡg[5Y (q_>g+cwkbGa5ևqQ“#Ni$Tў't]ЎXC Z뜧| ݗG-I{}3R,Vzbg(Dr|E!P5ϻw;W2MUjB?&fa64X2JN|퇙8&rҡŋoU`.kYP&Pm6yV-2 AmܴP{fǎr%{ X7ٷfZ3WX\^{jΝrEMj;s-^!8t5ĝȿО'"a7/KUadUj}O>#JEQQetI`[o+SbqGg}ٰ Ϟ܎lE뮛/cY]t26uj */b #.戮aaȕI /\9v@/_\'~[WЗFk*Ձ=OLOTƵXF`yAyilL>%i#ҺZ{o2Nf6]qmp"vj4m9I1[LšL)FsQTH  l:UMȰyHPb !艐C˞—]?:>h`6:i:iAk xZ*J[6B] F S ]94XS ELgFb!*]tŕDB r "9ZӇx*IJ&T_̯]v~BqH@ `p.QήCsP<6Ԕ .T0 !rOHg!QҡjD!a@`JC84D`A~`лn]Ǔ 9%z/ @ .32 ,_{Lu8ՐD ؁=XŒ ,@Ҟ7Qܾ}{~<'f@لZӳ1&҆F3|}Ld UQpF&pdİ(O3 F;e %\]W/ո=}yVzLXͩu~BJPYA53h BT T\9׾p` 8}4Xk+*k϶.|H(01ҥC+/ kR)\?G| `lM?{$GTH@"*+.6iδ !W,wGadWGd ww(C.]uWeeeVn Nw7S{@ '!s555={7a|g4[H2 wp846Q^-cdg.sOsyE BvsCY㦶N[FI=_I@wCVZ>vĈoG/IS+ 1FD( wrSHȊ_ /v6;et`$ԌjU{jd PbÇWϟ9\YA1(]4,|w\MμݍzqOH(1qswU#$%3Ѡ,_~_|`c= 8JZцJīN*8xw͹"s`.5c%l'S Ȼb/)MN~V3œ8iD/~Ku;eˉ:?^zն$&@D42>k(: PQI-aYz5ǎ^~7KRGURh)$B2BfT V߼ V_l3@EEeȑ[f,_ճx1d{ o ѨQ7ә&]-4Ċ N=}R/Y|Y yZnWCCUI\aDD$TZS_32!m+),~Ժcw()RUb6.<8y@ԽplrڥXyO3ezJf * xl~PjLތvmUfRQ2ʓwm_iN#6n1 /7 !ZkTBM}34!X&_SߖFFʽX7Y BzqǞ8s{kӵYgl)}F4 %IPr A@\g((z_?}?L/usREc>󠱳- ­ U&<{S^gTsAWnyXϻלY۔eg@*/sF'掉(ѝ]Oo LϿ°/ˬ)KOfvmvӯzWt0{T:IlDc"# vϛΓ*q,:n!lg{PcSJa&vM@]g4nQXkuo?o73u_8acT$Dr&D+s=«_~!D%Jh\硣.pd)AYr3ZvOn tz>cT%xg I&U'ƉS}{Em U]2|%!ffS)~HJfcӺ(OA<"j([Z+̈@T%Bӭj HHB(䬋`PKW{z)n0U[3u͌YU3G_Gm1o5Fm7Tmڍ~\  4HA CJ֙_?_ǹEZep^"bZ?Gkݯ1fcS_kV 5 )Ua/TF*8B-OsӅH ȅ)20G\lWN9qq5љHYNcBkzc0˲phM̞)Ɔj:'q2P:;!=Ie4HXd_:ı7n͔vNR(=UpsځC~kk=˲JRܒ>=:q01yn+hh:/Уfsl|_#Ϸ>cyտ^m6_2Ԉ$ 8k:z҅GVA`jb'@d"bP_<kKz =Ҙj;%)hAsBEo)Ϝ 1β7?#GX[Z'쟖7w<"ӄKV O/\6Pr,ط\ё0Ϝp]n?Kׯ}Rn1QQٽr`Ǐ3[[%"QL"!&퍛gY&CLDh" jk {y>OV Ѩ>,;Z:{duw߻y{_D$'6;()P  /9s?7Bb^ Hz*tB>ظ6(:#Ohc g_:̋}a(YQ-䉣ΞY= #Rc{ᙹ=7bn~EZқ~( }{xjJb|vB:4eY.~{}}~3X5N\xfe.]=~eV鬽+^2OG;}1]| o?""x%dEq0|ųTէɁ(jgg 4Y LK:Ȝ㕹-|UM9Щc6aHH eƠ>1\O15Y?{K}* 1+4@8-z/H6Mh3t^íW&Ggygu̘?^/5=p!-?|Ԟw([-ˇe/"2 xd a{.o|??m1WƎo.;r DmR,>O`fcOfXk=&icPU&!$hniK"@"$b/t+!*ekNmQaS)bq;h0DM$hyPi %tWx~:;Ӊԉ#&3.\_}B3?}$mX['MXdK'^ʯyɍ"sJ͠fJb!()I 9ƇldO?“g-9W&Rh:yOՆA#CJzSPZ:0ZvֆEuKF:[gNΫ6Ps%Rwn:_2*Uq ϿoNmw|3%ǀJRd_ b(RTbz;{^zN5L>~67 w6h|@Ho޺!Ka9Fo? .B̻w'6ςy'ר$D;ۙ8h eyoazG.g:e4x-ޑ4Nィ:gew^>+,Hhi~WȐR~0TP@)[=q/D5x2727 {~AEȘÇV.nn.|z@TG/|7o1̔w4cBHnX* F!≔ (}gEU~ 3kXf1] 1Xk! ȑçOV HA\F$ I/2Ǐyƭ۾VEI t3iwQPPw eF jJk> H-/gT}iAsD "o(X7}p DllBĤɗ@ M5JA:϶1Ę9tNn GF5F(X&)=7ʌ{"p}qf3鿜v1m1PFgcGښauJ &AH`T*J$}NoǟP LN26{4x :Mdp͵L&=UF# <䳟گ$lL_Ҕ U BP؈xG䙓w'cCwztM(1V?&'+i`͒;}#1dFդB'cZmVTBEJ//Z9s'}'@aS؊xy4( eze(Va5W_)U0yz7$s^*s!W *ʑc,^đ0do}g‰4Y(WKkkk&s"\pTMڽL9_C6XK5kcj&PtƓDwgq̇ND,Wȳ?&SIETZsՕ7oZز63:].eWN9?~𑣟|q B RBtݹ^ ǟ*X½m@]"U1%+OT9^@M>gJ12űՃ_ Ou+FEL )g%6Jy{xo]"ycwo"1Ȭe.U `cbn~֭"QM:f.lmm=z^xw?#g:=ўji "}sCJf셳04H Gev@Ssnnkm;U_ u5{>xL7#d$h(\wV (H#T- ;r[p۰ʫO9k"aŰSg;uMfŪZzd>& ڍ+g=@P;kkݳ+ث-\«OmqX!bUa|h͊i4/?¥KG-eR a5s|D$Qw٬Bic$FԿպ1Ɨ%v/,.5Okh`_@Q$-W>W!2%bU@ 07>%T1_=׵FǶfwT[^&e5~ajX~~، 'NyZ$RLN"24/kky4!ѵA[gcDJPI)ݵT`LPjDUTМP"Ĩ`0@AJ3:F&. sARVq ?~TV;&@B@k҅knEUbPPTqTZ? %/ JBCp:\v:Q)+GO<۽U=sטaݥ?]+')72qg۷o\ e=$@@@TYU!!.zk?f$5 U1[?iA0S1;)$_xKZ)lw,9ˀ2âSV+TVE"$'coZD5wĜ>Mx27V jE@D"pڷ5%2qΊֹ*(}Rh!e>-A\߳.0a 1(Gp?ykTS55kT{yzyNUH>Z4PYB``@h'|T5U+bGjj6,=|T{+45Swh5?{5fƁ^GK]ݻ|G? H(9-:ݣ7o|JI[v=Kw  AJ_ί"!JT^ Z'ȈSMNy< DQu.Do O̓X|/oh29AszCjMu`IN[ 6Q(zB<}ׯya(ھJ(#͇2X=x1NbLrUK=wf<0`vC pgZ R* ix  <d¦w闿x=#Z2kf -K< ]*~Z!?dcЄxbCk1|Ҽ[rAqf1TsW[{E@숌?}bIMycy>?i7T4i9χǕ)Bn?8vS|۷o!v8rE&k )ʑ#ϒ>v-ݟu|=3#>w}G > ER! H1ntpukrOݮqJ-o_[o Ԕ՚. 3J=yh~d8,̓&P^%`Bs_f"YMU(5:rX& ,Bt]a#/l[!'&%Q(PJJ[mH9{빵VA Jż4>gv;><;zꇢc*nEfl^J9+7tC$Y)/ʉ'.yj":rYJ8 =̓ޑfrVrj㹹o=Kk $JCS%JOhu 7tsATU %<)3UUO5(a +;>+ZOY~5E#A@Df߼ч" Tit ?__H,1%νXcʲliӉd4@M #5jպ 5x@&<%4;\=6D& 454c֘XP}T4d-JbR +?4EU!fp8qd/n}q+6 jsw1tT&݅Eo_[S@$e˫ϽN#G%H)3T1aj-"|3' K $AwFr 8 qhqvVI bTVV^y799hy ٔCm^'U-?tqH| 5iB;ι*i "d7P{!uD$vUHC"Q%[zS'NcomgN|퐴P@"ϲh)r"U!1"ER1G:xP~mB%|#8z]E9,|coolܾ!:10MB8 l&~f$EmsB@je p$)2TШ5y5:bJ`<—vmo0)Y< O]|!@T!2Ųikpy K>&bcDZgYZSэk~ES/I}o zG݂5Dnک;8d>Ny-Q!"ӚgTy%2Zk׮]~yW E5Sls ϖ ]`dn[e) v*wq'~$q[|#]zs028ZY-ۙ3bBG/bC << *loًVSd :yӧoe `L)r΁#e|4)vЈXa1rԱ. i]@+r(6k֏Z+cok>e"fCIL{ eimG&zCHmIAz }?eCf3^{j|2C#)*Aڀicޅ6uF*oQj,|̙ӧOG?1e7 X\ܼz(ʓ'NBdbfw}e.'i?Lc2ca;uH!H@X [Ύ;s[O~}8 R]{nmWS!U&bh-:$}}wdžɎ6gujO }_Td;_R䕄 eC@1^ţn}} j"(/>us_r=%@% UuVJ&꧝)1c4PKRnD@" (>Μ}s_]X=| W:j| 6핥y=4JĘc۫q瞽th!#? rc}dL?kʟ1MѲ, ="m"T罤=3KBe "WR[[{Y3"eٔ6A*?RS7դ80*˲Os6R E,WQO=`6J3ŎKwsgQĵ[^"Qv{G>[Y܃0tO9'uo2o̐}<8b3&tP !zYcd.w.'%35:"X}SUU2 ,}FܲZM*=­@LDPOycPb2'RYYCD3F TH 5&W hWFBƌIÈ!95&AD [Qcm& DdQ56I`4ň*'VXqD@#Hc,/:l2 EbR0UT.5cJBiayvk19zѾt}o0$]zz pRJ%( bsOܥǝF#2"DIQkdN'G]JT cm Q(U 4uZ$*!bU2S9Ljubyv)IYW*(?q,k]$QteΓʉ޻T{I5?<&,!TE4JD$/|MLyn}(+l|u1M_ QDD(}v08y|z͵JF&4a?3z:^s*W=SgmLOȄA,xs0{nA{(ȍ "^} @,_]OzL,Rj2-ޟlWCdوhr̷jmӁ>BT"MVT$;Y/cs& NXRG-H^攆8]TlKvEO4C DӈD DHgnoJ^Z@ }!Ӳ8ɓ'nA1&D)+(@Bk]K|oVolHLP|\"Y@Ќo;usO?ؑشgk$z׾9T5o6|cM~465E?IgL QO5kCU]Zw̺l®:eC5gDUDqFԎ '݌ [k;'f"1fއ'. 8; x4w|Ds|pؙB @¯%ܡڨ/fǏ3ՙ_0= 17Q=oC!QL(s||$)/jluN;>"eV&pX_?G3\X)=9截iӪTTe9[(1dK"Z|cH '{UcFEU ovM™Ķ^hB 1Vxk !+'ͪGXZ9|PZ m84$b"|՚⁒z_;|}*)]7a* Xm3D ?w򸑲ls0se{5LMlCi(* ?JIIQtEDƲ1<ƋUY7Dv&Ϭnz*ԾogmWS OQ WP'CHi* M:XB ӀM-omo>v3Okl, ڙsL4Ec#D`W\W Zk h `ϻZTs`?d$NL!F,;߽ƏmMda;Ic+"26+|o?g۾bcsNĤlP"!"6dH)PATQ$BQ%u hLYl0)n{ 1C4FQ>J&T o@u@"1~'C$@d3[cl*}8`6-Q1R˴(j-5ZJ /EѱvbomlnJRQP@HA& ,,/]~O"p#IDAT&uYs^Ih(!"S& m\?sKs *) C(ɢwWzkIZ6Uy2NS1bq|W.d[4s1& /jU:k儜i*RͫڹjSسxډ=A<Mm@L &%&F6B%rUat'&b(eQa@Eáud\dD .جcluz3_ܫ^|?r %[K2*A<^"(}cS ȧhLϦ_3{524syEY%Fm}oșS 뽂k߷20~__}t&v6|0 @(t\&'R1*=N+"ƨ1RT#H*`?(05* F` ;cӕ!vg3J"++ U_StJw DUhgsIّz4g8 y=~;]}!4T( T 0[#'/՟ ZX ^@^Y!) %Y\,{+'͚)Xk16}MUUg-X5'%7Y<̺Т.mh*khsb".-"Yd\lH3f(1Rww08T(>zgvmзo}~QeY)B~?埾_ŴoˮgbW{׿´rBF \epe.=sy $2#6 nƇ?E EQ)龑Xb<Α޸ Rj% nen/˜MlkMo[.nlǚ<ϭn'5ݲDcb"!bQ[DdM._~. JJh {A<5]r$oc5Cl!J M,Cq"R9^D6I)ɂqyՓ_|??e\AmVN *MFhS5 B={re!6|[ X ؝s{"Z_yË=%3 B,*NQkedyz1Ʃk1^?FgXж@ˊnd ䷦ܚ((E sYiH-D2*NQH:}El 7;UR$.AA)b%z~?J_2PDPeQι,@zB05~d}*raVQj̾WB>f0{[g_2(s`.И"Ve[??vK;Q5]—mϕv1j2.:qԾF5]V.eY bhN8&͢IM*URZkqP+`%ga6nk ݞ[\\坥eN'2&Qc֩/ˠM^QUC#p~bOZ6wVm^_UMѥ1)!P(L1hT9E4 >88Ώ~8("T T"ɚKAw<*Vr"3|2ÓmL\v'D.Q6 &+2$?zV03VB X%?5͔+SEõSBwΡ6 s[ֱe`J/HU]\hMO[>~U QG v:]Br b9EQ‡0 JD5XPE`U#UM@ >(ẻ{.ﲋ O5ёhTBEϙSwl~p{BFc2+'R0[AzoOx1Dfj#eB\*ekH{`v̺͂J<߫Q-ɵ#!PC_宽O# 8')ڬ#0Mi ݅^d J%:/]Z}gG9.J8;춨"*[ DHPl IMi1hͬ,;-tY8p z!- LܐWꤎҵXkl@kg&NSE 0Dzc*P jhFzQuPٷ1$@iC*ÔIAdQ֒Dш/dhEAdasDDQ(1Z 70p8,J8"ը`% ca1P&j5Dk P,S1$BQȘ'~'z.rU L BJ`o:±EEB f4bj@k56 # tMJavLq dԪCzH>RUu)$Imr:0a@IOW "Tt@%Dv:2lGAC^ooll!E},"-C8TLGfkM'ץMVEթ;B6mXC]R"fp: ;̋XX*}<#,Pnzo=⇅j#A5_<ۨس$ (* G(sQxdԲUMȢ8y zR}" bbcX3ekӑ4h0&1 a$*gVDrgR/^7븅N^3{hU&p J>T2H (EM%rEHTllD⑟E[ꕙ?n.1*S"mOyT(ecI'DU*KU5$.~Go/>u0Ql8U CS_;kJu*FJND!&@D1DF[_S?P[]W/1|c,ETQ`Dz[孫9T?hTcWl4ITVE0O1ԏnjVI'[k$}%z+OkS]?R:@Rh i1TN}CV26}qyԥ:?NOB)EG*7+2b8fZΞ:Fv V e50ĪߔY竷A*ayyY8xh///.,t,(cEPJ$R&W!"venfYe03Y>XZ=?^~Cz5*c<Igf}, A DD@eb?yT1fP6'mOR SBc`6wwьP#ut߁5ҘTIlWV4U'83;2rjcmaQp8lQG KFu y@ĴaISi'@$Y$^ kz=Wq v8~^񵭾.ViE{4bc}LŇbH[Y#`k\< G]DRCHbM$*E-:N7>紫0!J{L|w>v4CbF1l,g]X׽+otB )>rpwuusae fj|'”VmYmؔeem@! 0XK/#I'k9?\)Ԡu e+i7@l^o[_[GOU4Af⇋ݥ/=QYw="H(LlZ:t|ܸOXC@)6&j1x~j&f2pSO>M.Pth0*l.vY :.+VHޅtnk{YH{Z$yd$~XY0Pp ^B aK@U`&0NUI%UGLe7CBuaU-BJJ (";Ð_ZZF1}/, +D4s!cxoS/^׬\S0k ̨'3f_?kaDUӗ`TZ"&c,`@ë 1FʲJ80e8/a{Pllm }큏CX4%X!gL(8%i(0N+P=gYM~nX&ٵ/rwۯ[]*Α"2+Y&Xap7 |V?ˍa) j\>8~ddf}uI|ZBR%M "$qde'??F %1=,IiUI`+ꉋ/X@ԼDEGaڱjT09DG%6ż2vk^_uFBn[Rw>HB\egQĕXAt^|͢ "V!B_Ͻ2\ۼcU&B>TGԋQ! <ŕ'v4jYHLcpksiyW^>zjp[n֭ahk"kTu0f"ބXl0eʲ\(btYAx}s{mcۛ[2H,JL24†6+zDhE0niJ.\ i.:k_N6\)2P >c]>(^t edEW:uzyvl+KýnkLY bRqX%\J3XfzXey1"к,B?le+{UrsϿaII)/ *PAjԇ^_p`QRN96bB+Tp8FTenSu.JU7w#%n܊R%nmGl׶nn^BMebk#2lد*U6s1REKylEұGW}Š_˪_܇)"]|wO>{(`c9܃垥&X0OQU6HV1*6< ~g,n\Yؒ!hRϬgSDA[J_cx_* 3sw*,a{kț^G(Z:qluCd.(db#WG#Bѽ$֚T,z}QXcE}o.J$-! ˫O=߹Q-A l s.QjS>U#'dْ}N:AL\tM _]}K24[%jf2} g+^+5H@PdB["E@9&uUHxSϬr6հ1(zŝuǕ~؜Okv^?o'!&h:"+ %6v y dq8˲˽ek-i !c /0vJZU#-vheT|#Zˀyҗ^|揿[[0g#XY@v3ұ3]ze%mA#4Q" @]b '?=|# 1H.2/>{J@Q!Zejֈs&5 Oԟ,UQfQ ATs+9_xɜt}77|՛ܸ<(T #KH "Q 8D*&ݐ܃?|g@B#@pȡ3 ׾o; 0&YMggcNշ'Z;tc"ef"mON|ҔKdʾA)Í7׮,+NkhD > EWϞ=t|_y(T|z7_G1:z,p$C0tq{mچQY|pa#ǎ:dl$Y&l8d((H @KT3ɲ1\ 7W~嫯ɷ_rk-_\( 5 #Trvӛkhm?wEHIՀhv++(=h`Ԟd,J+ HP7/_^Z,/9zp[~8 H;_`֨+QNc&+k]PpW\[Fu/?d/w>n/J4d(A>-fE`Pz5Unne7Q2a Ă hCSP#* r>D,q\Whv6%D[UyΟ3#BHI*Q}j?'V<^Q3))m"1_&LQ4 Y=}m͵kkb{Pe5]ME":ӺECƜ*4cȧYnQkѪ'z=ʴLTP=O?JLJz&z,Gи!4U4)W9]/KTy}F{F0٧^/6ΐjTw眯XZD X ,m^{'/~y7.i^BhS.3!E!s7n{בyGχThpv)񃿼;#_{vPLdD0~rܳ/MO9+*!"pe?+'@m{wטay)UZ(:Z_D1P}hhbB%>WPcPcw>rlwq镯ڞUc04L:7(s`.,('`}ok?{g y^Tɋ,RTsK_xq2[bVk&"Ij)#mEvBնV1tVu^iǂWn UBdPE1TyՋ*ۤw`EUCShT}SL@EDUu64 1%':6ӃdD$0)',+Tm*PU"+F4 RPj )Q`9]򍎥ǎZZ9h$ˡeK W?RLTW%ŨQ$Ka`3KQ'001J$D*@qe=k7,u,`rX4G$J5qfI@TSN~&lVL?0J*?15T4_Cl;>Jllߺ|V G5Y/*0upOM:RU].ѭF֒D1F!B,cHDh$(UUSն::JSs"|.T!蘔9ˉ21'Kr)P[R$RUPlKYtZmw'gzW8bSqtap )@JFÛWl6lԳEtՎ9DYqE@< +\W\ÛQyhH%{.ʷ_7~1/ɗ&6HȈ1]:yY>x{j( Gָ<;.U)pbm{RXyk]:tdiD ѫT!`HoP0 u@D q TZBq93+gUL2- 1KDd+H;c1ln9k\nKģRS$p LP5̖, kk;zPBk%s.&5 ƔÂEٵ.p{;XXzV_ٟajŊpeQB2s]]AAJ)hi!&"ļ3/.T@FxsO>q4AǒBҺJ?&3+J=J%QV2NM6 vcO._~kv[g\UEeD$DÉE1&B%:bh2`#E1܌,}2Tc%( ܤj ̓֗ĝO\0U*ac$ȲV𐈸 tzj=潅"Cv;[e&lPr}-LiL(K3KÜLl/QpHmr1@LmOc|q)˻ťw{`cLQU}RDy磛|rsy#OX=p{[=2XzbڏI+M`d#QQd/]pO+ A(Ҡ Cϟt}{ј.큵~)I̬IG!<*Y^HPI O_zwf!)!R$ՠs/?wYEVz Ja4i3uLzg,hX\#b>D眪9B7n~wnx^wbl119JƲZڜˁa10 ʲ(fJ?`0#.O`Lk];SqxZNnv[/nD*MֲIR٠d Tiai)%Cya@˜ 3[k1ƲuDV7la9P0y@5 `V -2#hJVUYAJJ 2x.mn/{ 9"HĤ!X^'_^>xl#JlU*d=B\TyDY.Ͼ}T =p8T(R /c r1x!2OJ?#B !=K0 %mĀ 22HU-U%2"!BDB B=%b%c DcI+5אV$C(.,Pkj ]Lܝcʾ':k V6`y5aN7_\4ygiey//,jD|@l3{tЁS'^9x`KMFb*Lt4-e2jcP"Q% ?wR\LNeEvQY1*RAA~WR"l`zZ/UA$U$=vnjBT5̀i%E2[ڼkW?r5uNE%wĔK d5[[,6j]>Y 1Lժ8VNʻ?]m,_mHEp1zu Ӟw]U v:l n%rp8E e"2NZT<1̖tjGɈ d ( @UCr"b"8a2{W~T˭e::h^ @x嗬]Ȍ`̏3IEyKQI\|;?pbp \d%`c2u; CYo9:6XRg9hTn߻9tCrEJPhjW%aBPY405aY7~+ [+5D("K/[ec3GsY|I\o3 j$?H[b0, С!%bNTM~ͥS̆"DUIRQ饿r:Py E+Zd?^ٵFYy쪮7KvvbnDLU@v0qӊ#&.|,e(`g.e}\pƄ7` \~+!O5)%[X^楛=rVyΝ{2CL eC/=l%-Q"yMO) rLDWܸ}cmLfU51PqZP ^յZ} DCjI )qLlJκa2rxvOOALI"|B* jrSIŭ`VkX"*Zs/Sv>DU 1 BdȰq9g\ [gNaqY[gLL֊!p"XH5_~W~?\e9$mCC?zuS|t{[d@s4.޾0ֽpg}drd5<џ!M2`$ʰӵ"|;?ε`J%&>, Fl#'Ο[/|?‡p8ЗFфuӘƉvCNmC*juv<!{IwWcB3̼OYh4m>Pb,aeR&L rTwGu /Ese,B,9y'\6*2dcQ^J&nnjAa~'?{vNo=@YN rϐzE?5s'H֭hپrd㓰=q{Us!p'|g6l+QhH;_|,"ClJէÐSieprpPN7֯\ɕ۬#QJ@CJFױeY(6ֶ۬C)yl+gFR`aiĨ18oAwW/{ڦZ||nZkY \5VgOBprd(ĕ(/<` qhiF Pg‘/8\, 1M ΌU&xXzwϽ/{48Q4Z 72~,s[C4HOJQ[W/_3%шp tjEZ7Vre+4gŵ܌s|kCu- ũ ")aoM{:K,:|0e0)M>cmHE*ڱ[iCrljdhϊr2³lot&(#VQa\|vp=7L5KZuFeΈJ^ cʉ.n %f\;P\/>,&8IzTQ pYnM=|un;|rV"rCjbg9PC1|͏V[y"0ufy;>uO}(ni64;*TTZ uff`kDZ cTՏܗC}RLFyAs0{:]SB+c^$};߾O:B(4}DYƙ,Ee A !K\1 D5UK;ϥL,D[Ce5=(iSox؏٢7?Ʀ[-\=pxoPؼ]/}rcG;uqg3)rY툃|Ǔ[FG?|'jr⎂+ gY ~w2#PÜ ?R-9 w͛  %՛ڋDZlSiSauN^|4y VXhUOMU{S.-K,Pk+U{3^E#"쬙X` DDIhDHF"+tw߻2W$$O" x@@Y`9mUeFYU]m>cLwUVfVfdúdy  _^q-mԔi͖M&m٨5͍vIX J#J'mirP%€Ŋ p4szek==j]/9R MhH ] Ѻ)~短h $~cEGei+ 6NjJv*H3(" fq^7/?q_^3.Wx򕗎xvh@%JJ(_rb.aj^y;hZM$mF;BpRtR!`ɍ8 ڧeJ_KUɨcҳnm$AL=wܾ07k㈒T\N ߬"ʊ#Yݸp乕 kF6Yöqt6jn67.uMš` -D)k5k8|GYD@`( X"P#8b` c y {Q}oׅ5+Eb^O@„&E .orgY{lrMuw"Š ǞzSOgcHh7yH.? KZGJՑulGȀ(F>̈׏w~>/ZQ~# `?j0I77õ/=[[[YPA6ǟzrnƲqyJG)L|- 8\,BDSJ W ں\#']]ø=ڸFxmw?waZPLVO:I [q,& 8ӯq,{"TMC7ܲE ld}l*wvda(i ]dƴv$M\.W"jy^pfK'N-o֛HDgmD[FmmQh6#1H'}V7ulN Z6"hAԠJƗ8 +0{VOx߃ 6Z"@Z~"OhH->#P z + ],v|D@5O}_(*`ISI`Di `b9Ͳlf=l'֝|Hٓ>_v恿/%&OvSu皭QX_Y[:W_[k7=rhiiĉvXTKrdb]yc@M1,y:^4"(fZY\\XuChYJ_V|,DD Q@3} ((BRH@ A!(ظհa 9F`\vIQ|I{oCD*]oWI#!$%VfRю"c bk덯>s/QoXM Qvc/~yhm jk6JFA`I?]GX|_P;lpЕ  [o]7Ş(CDHRVj6Fs~qr`e4ʕ+ &/<& ]< !M!Q#~٥$VHlٹc/(B)A8^_f@?a6 t(ˆAjIbQ rIk,"ZH_a`MZ@x/Vs {Ϝ8z;ouan.n#OQ GQ`tXɂ!Jλ-<|BkZ>{,\;) J,1``ƚ4ipi 8QHp7#rs3O?^sO!@8/|%OQ"HQ]Ք_4Y ՅMn/$ш'($A%3 (a1@͵ Vqչ9k#,Y%q"İ͙Ծ}{yʹ !D݋Μ"J<1 $ f#jM(4V k &&Rej @X=yo }QK9]3;nhMȀ.c!B^lR5/?3/?yltJJV$6M$f|0[YF4 @ߟnEUa1Qr@Z(vڷ) @@꧉[z#Õ3zEQ D>g^|_$bv;we fR#n$#.\)#G)DIH rs,R4=745WnͷV Xi*Jb kcO>%^0j-VeK6R֒0$E1k©›CiU`$gş H X}mnaI֞g[--޹[n& R($)tEEQQG ptOV4ty1f3_4KXF!'+ 9:3# 7#| sxEHЏnr_2 OO,([\ $b@26II rDVmC /fKo% YE@̮=7ޅePd_禢Ž%I J)k 3g|xGϿ"PXL% 3AR(mpeTܞvl3_IS>=Y*Y86xqkFb;R[VWZ^_]lm N4[lK" W4:|;ׇȩ-گ/aι!_pIDX g'Qh@.!,"" , XD,,}sg!)1 ͝9w.6q,j௭.D L 7CEJ'DUGfw.Q [n8{qD;>"=?ӯl5We=@&/ IBOq1ngV;-:D syA(>[ON;L1.yU>y~`MLֆ&{`P*lF j9M5EW]pa"o<㚟+U7 $ %% 'jKG.1;AwY3 E:g#EHz$]-) \uD$,`;ɚ eH7mz513$E(<kRB@Ih+gޚ $N &PⶒRu lASldS,af?qQ"D2CHe'EN93tuV*s,X(+vٍ8p=7,3a5 ]{4:ֲgN; s4Hj￀ Be!"`bV3 ؝aN!fc^DL i&"HQMOOk }@˕  (+U*U|SJW*eiI+HAiR( ia[+őVınGQaEplLՎ⨶h6fh667VV øl[D/h-XRJ+֞R:^HeV-0u])zo@ `>)4<3/p]w?x]#xlGn8їigv#K/?ZI]s($F{=",xpT^Ԯ#0#Y R6k/~ZmԔ5FH{D,܏ew_[]ɕ wHdžٶ, T*APVJyryvzR./,+J/y*#)lyOW+JR`-[ke &"c5RZF:6EQdbQű И&0㘙0 Mkf (=:Hb$t]s7 #@(W03p0i>S/WlȄz L"aS[[lx32uj%F)m*򵧴fٙ SS͕KrT.Jj\*ΖJJZ**AiʵRթ)Ri/ Ѣ93zB3t'S. tN.sXG@~l g1R䴃B-0[k٦f6Fl667777W/\j.j ZVal4(refn!ൢ绥5,&)u{-{b*^+oZH1( KgOJv!Wz| (@j {W&PȄۿ8?؄{>ٳ((aL^zxYu/Vuz(^A?<33k̞={fffv-ٵk1ryZk`|o4Q$,w4)V`ܸvZ+0,$8nDwl [k1lm4֘^o5FZ}}}VanjzݪaZ(vC 0TLNS̒-tW(Z*%҈L'Eڀ dU<%?F,h 3*kڅm6؟¬9H;u.6™$83|[fafk }B|i[-33SryjujaavzzzaaR*W+t\.jZ;YQ8-3Ȉl-[ y!gE' %+;S#&ۡ`6$jY%u@JBJ)f>ibVxeeeccciii}mc]X;wniu}mFh#fFD+)<@."5tcg=V%B"Q*^]O|oaVTbq_ zryw~t#`˘Mw BktuTyۍƞ r[t<_]>|ꥧ1Ѐ&p)i=sA^l~;kcAюve?c l#bcRtٳ[nݽgqvvv J3m̱'?- [q 5ԯaտ[xBOwÛ>VÒ 4a)1'ruhZa ð^7Fj/k q7[v65Պ"VHk)o"D,Ӛ(2: " Y "nv&P(3Wf"l%gfP-կ>t_cD"2+a`B·D9+[˺96#cklsHkJř]vLUjuϞ={}T*<jzZ+Zi,JOZgq.Xĉ : 70 9wJ_}kh˦z3DDh0 m'IIS[zSAT*R4775;7g]\\V_ TTy֙/u5#9p5-3} o]EdRe\Poͭw\XzlKuIxG \]3vHT*|䙳O?'N:ulk} u,+($"k žcriKWD%/r'v"C7|omD#/fs%26&d :YkT:AO-"Y[na慕g걣7WWTVb9Knl9ߓWِ癦B.+H,7 ":5w#7xmu] wrTVFy@8|wBpgVV $Ij@+eն o (qa8))@1keᑮwH40q?ՔpCa;MfըjzjGa5+q B]"WaA3; gdemA,ߨ.d)r/_4p#>vz' MȀ 4=~ Qc< #Z+6 B8pgffv=333UJjZ*3U8Zekbc8Q8k?CT6*"ځid)3s<"u+3IXu?~VA}?sS,JOe 1ښc>uı׼ʔ1ԳWjE6@[Nl&2P1(#Tg(޻8_Rzǎ=Ο kUOD.UM֕}sigvjiWC)0=R@Qu6uujjqqMoyMOWFO?3<}6D\h 8FJJ(`.T܏fOSR*g?|֛Ƒ?+H-ݰ|"ZQoM"<*yT9zϼm#RC2 e@\)*l5[_>|ݭ7|k[㶽9/ mj-P`ޥnglH!a0,Uhߎ0_8 B@X(Y%K"~[ro,ZYY0<Ƶ6oza1#(w]#J ]4ɛZسv% =Y ??;VZL4JH0 џp$G>;#|WR3 ىCڎ|QuISy"Ό6ڈo8r{c^yɧ£_WWWZʥX(\vn~YLz+{Ο7sMQd^8zrzf} CކT6q/>3Oz9n< !\Z?ܥ\ѽؚV G:p}G#7tT v\[ FE`˵u,s%$cPwu%G.aǒMKh*dJ%'峙^KF 1ac isSMO?~}ry]z>2 e﮽ 8vR&٪N&-Mb221E?݇(v?0ЪW>+Q|3u?<a$MJO?̱XADqqiJ.+?w&o_Vx z2uVk dzMfNix|*#X~XFܩS=fLecɓ'{˿|kO> vR}?)d|ϏcvҕDLŢܾCoÝG{4ν>,d9j,4hXC7wƪhbvW>)AHTd- $#DiaPף("wyϽh%qA~ТA/< SL4acfggooow}߻o +痖0cS|E fImv"  0Qa/]wQa[;756Jg|îavF#?RB,9N>D5OJ qc$y'pQ RnyGQXh6J^~moە"kMQȕmJ|;?o1+ $}%.a< YݳsKgxV*ca=_"ݪf־"ň̲H%Q:'  Ac#|ZWA塾TaQ7Y4r`ypRR ј䄫+<_csN@_cz H(w  UJEP"@-WG D뗜")1钿 ry^)E>E(<9< wh-Z^T э+z7 BBPa6qܨע0|}ͷxЃCmsseePʥ8Ɋ.;}3("ZJ4XK/|mcuϾvd {^kXFdsNm'%*{Esc/}~ij!XDD c0#ШJafn5zT:r_>7~7:p *At{X:>}jJcD_,g>W.) 8bfpsKYo]j.OC=j~f%.;YkDdtE4] [E*}ɜH@[o'-"z<@Xhnv[nwwݷQ-,_X]VS\ vP0iAk"S͍F5=UݼdbаJtE߻{(bүyC O鳭ճUdC—%J%W `:J9`80[G7|;q;l65)"y7[ =Wc@DWQ)favueBx @rB) )OLy"T(8V*e̝,tbt)}x^05;G~Ǘ/E. 4li{T uL-}NIDAT[=vE LvO(̳ [`]뼳 Ĩqɹ)"9l/|s'^"C#M=ʼ+1}YhZ,ǐ:p>[,sYzt9;ڌ1[j ǻ_Diq"'lyKg6֚GDTxg ԁtjSSS/G?񏯬\ r*Ϳyc|iZ W"]o[O-ͥ3g_~!-mcMȒYGGR xz{wO??Pؐ$bf MrZ}j. ,Z+yfc}]{=mmoyӛWT͚tp>6qA} H.Gy゘h&ssg s@u_nXᇭFOS)r,q0`>QWZU^ ۨtRO}}{CDDH_LsVD@ػo*t!/bhTa aى2#q9-6@-<(B;-_V#agP2*_ 8& Jvm^58M]䳚YEPKA |1Cc l~+#ˍH(KڵѝJUWl{>G}F/JY DGDZk@[*Xk@0t,#l1RRj+m|СFvFQ*ґUǥ 脩FjyjjvnV^NWccX6rM cR7>_}ǏđfV8:t@9*ٞKQE^{}??~=VKؘv "\ [ga,*`"s#WU:Xq}|7_X1dakHdIQ.HSᯇ☊b0x ցe&3xɅRdrى0:OΣǼx-7+%F,>JG=vF%lTr6r-06x5Ndt@\VwY\b[cQ9?<9g[Ey_}Z*RD(qInWq c.\Xs^!4^0B3[s>8?m-`@g132Խ5ukya~mzA8qsZYjd Wp|%lM\[_/)0n'`Ysw޵糟|DnyPľ%Xl/ӞR֚Vn5myϯ/[kl(lkEZ+K+x.$LN0s/EC08LQa6T%aj6q;7+ /ՂKvN.@bR^dbXؽяCO.+UȖlQ sn񁇛dɗ~e&-0> @֬haqי'P@3K㥊"@?uqOw{abs݇!F-Ej^8l6o]ZđVi蔷Km bl@Z)N@"îAm `` )8pͅ @?t\@CدIv 5-R⮏}C?Ykj'{Utt@N/S7$(h-3Փqӫ4` Dvv(Xm=4N=nYDި}?a+daf=c~=N;s{C_ JvmޗW v#\R#lr{ `f3SH7xKC9_VQn1&*23J%":ʉ??OR^xۡ=f:.fJ(@`PȢ kTB0ٶx$Sej"5ub$2k ϋX1  ð4)bY{j^;;w^emEQr9D4dZ훱K$Mh\"l-Ȣ)f&P)3lKoR P 27W0$mg*92a 8??Hs2],=11y2w#oH˯"36]OwkYW @[B ڢW)w-.,/Τag01  J/}+o}[9 Es:{'3dy{8-݂(1=qXpOj<1 <<- @t/q~aox];mccQX JlXzu7{1{]F$ WTҬ7Z:v0r R 1D(6IJvMy1e!R98 zo}˛>O? ̭vcCHa\1-S%^`{L~kUt~#?vIN36 2t.In`y> qK]^K1837'Z];dڌR]7P}ρ_; X ;]][`gS+,UK^wQII%B!د=T^ka6'[eD0b\!U#ş|?ߏ8cvu70BP5n`TzMu#&6fT |{z}kMO+$BE]7`T-`g{Ej@ ! =OiBBWA+vC5V,&.>!I@ΰ q'B"jmk#O-?nUضM6@̖c@$ק'+E(}x77,u&r Swe>g@1 䠨-O_X>|Պ3b,JۙPp)0?嬵/~ይFu}- *"j<&Ů[ժBw F6pp5<}9cF̫d ('.lO NIy{wmdIrby; ø}=|뷾nyVN@]E^; ǜwENvĔ-kDa;al\=Jkemp occ9~׻x~kmlB1V!Bp15Z"+q;'"5uUBý!wF,S>Y~4x"x\yJ v?~øR^[2sZ$Q,Jiaʥ ز?\}~gl4/'`ؑH%g쬁dFCY{ NW}4˨1?]NECqae?0aT*62",D;2;^rӘFDrIUkIysW3Y]~%rsۛ[hn䝍C10D@rtC-^Dt1̉i5!\\h|`P*앆Hn:Yf/n,E¸u-dSԉHiqlml~̿ZEd{?NRphڭ|8UH,3J狁+yas2Gaxԑ-CwMecЩNqw.(W^xMq5d>.rʅs7gg#(ϯe? E\џ_Ci]## סEݭLиyvc*ZE c*$hޗ9Q@ߵkwcvS:P4k4{b/O=Yۨu՞6rkܩGS-5J-C}|PI;ҁ#UXIR;9we. ^UpTyDГ~Tt4}e٫#""EHE,x@a v%(3p!ȐԐ*;\a bYزٷw77?C疖?F)U"ӛvsc)~ @w\^UG51"hwq//ďn4Q9I\ÁvJIWKŸ@% Id$)wc*$ɝ]SbaEQZIAٱ-~{3c.6&=*vd t)RaZbÁ8bcg@ me8a2{wPH:K~ F$c④J{Wr1'xFpeOs Mh-+SoyƵ\ΙQ Mj ?>@*~q$b$8&QX?͸ɣBgxBrg0cGʷS:Qo\¢7^47T NplɞwyAI$a)befHYBI˽p+&Z=&ʠq9u@ʽ&Fhcx߯y͟}/oO?   ִé׶cv AS\~~oLuDag3@:{XȻBNɉzyZ$K Q @R$NJ tҭ*顡_b^ՓX\%Jon(E00qfheʮGL!o׸}hX_gggWV(ttBxm] (-^/ Mh]azչ@j=]& _TV$4)eDbRh6[Ec ZY)+dN](dU| z6@fU-h(__ t$f\ T?2j6oT@@AڂX8;ߋڧ⽙9Wh0T ۿ_~Ξ9y%E’N4Kq~fS@l1`ٳg²A<ӑpuOTșh]׸IN\PJéڙQw"JKxgwa_ō0g2"-GL4g:ļm_ݿ2t"m8(]Y :<3e} ZZ3 Ҩtя/үl+ 61إ>ObP|We$d %_ySSSD\ 0"om+˛ukR~VAadfg?EQi -kk"x.hI{ έi^n&sMN'fw(Ozw!]4'u56P4[@ĢBPDJ+R<]V% <݁?(jqBUP<_LBadkFRb8&*gq4 8@DOS{LJ,ȫ 9qV?o?so~G&%>{z&HΓtg+/;k&z P)DD4rB Dlq܎vFQq ۰1GQ8 5!ff@b D/`j M:,rԔ2(@ Mf}{va(lXL.U6e#$A+Ȃd'yĆƖϭ5èވ҅VԛV7[8 806@"b "ȥU䩴23 "z"˖-e9-gzՎ:tjtvJ}V{'m.7AZ4 a;tiA]rN^ʜ Q_pF7 na>_ax$BdǑA.7U鶇=z}V' k^9s4b/XcV;\٘\x=>ydf͈7["A& *J)T^NC ik,ڿ@E%o9"',  vX]ȹqqAAԓ1h]$ݪ',L ēLy(l0+fWZ*^{qanka~n23UGpj;9ͦrl H86jnqzzT 3_?APJe\  1OpaٷMl,JQO\q_F~ʋ]#\$HRuхo :<$)AEQnFr]k6jFceelfmm#FI{^c@D!(o"49/$P ? nhss/xe% sR ŌS8;7)]"Y1Le«@dKC֗/!~(yPW`F})-7 {CK~Z< ylIg|` MСnv"fʙ3Edђ%"AI؛/S 3 s?Q3{0T ^,E&fcHwq1"˵M8ܙz\2zzwn vij$I!d,&aOT4;;])v-ڵ0?=wqa5 lhlZClBLBq#*P E }Ak{u"kUxpoEGdwa/܂"j#4)Tzዄ>sZ"0ԞJj6 {~'[ϟ?;Ut*2W\x}ך{kF\^ԕ縄@z OӾn M+hXVoԗz0aA!T*ArjdJi [a,9/QZ[R!p<}3u=G0|b0-IATA;yǰ 0I 6J(14To|[݇b-[Ejł~: r!( :Q-`D@8W^ W%]$(.Kbm+:ygK_\uy."#\nF ]bV\vzlrz:]lc NzHyQy=%z9*bS8e6r," Xcck&(yz~vz~nj_}@=F1ALMכЩ@vkiZZAP[?)*.[""5Wuyӕn:zh`6JQ#{-?޳qa966O^|rߎ0>A@A@{!>z^*jYYYZ^٨}z+$^PY)""!0 /`&@TtFt'pr^q'cِ P n0;LD7ڹ|2(WU |Bȓ$c5^wAu.q&j.?puI:FLӞ_4/Kklr'̌nͲ헚B\jqGϥҔH u>}ûc0C님HQ;ø"Dkp;ξʅ78/(j hĚR07U޳0}h-7ppFSL$ +LvrvoD P0ZͩJuvvSryʉűj0%o}mmn~w|wݬccϣFY贕=Eb@ J *A,^7Ϯr̹/l6 Zi!Ҿd@"&{簳VbaZ/(v[68 p@y{i̕=ö䓲]_~/ø0s>s`{DT꾻N3; sR$@@0&1# K#`xlJGOpS2P^3!HXvmwWv JW~{ȍۃq 8k+JuuHPDQo=Hj9 W @&j#0+17ܷ‘^ؿk~T'f}=Q;k (^fyu_gUt{Vr1@&v{}U_hQ7(VxIj!iHEX;_[_.1Ri2GQD°%|~O{Y,XQ'3?; &Q@aEP+c^9sc'.l[Y-rYiEInB}@gtۂ`t5zM/n͸IoyWyj 0 4y`6Ͼr/Q\0FnuRZjf7=aY;0:y`t%cS"_yyy Z+4 @8dJz;ޠg0*r;9Yx$4;M~sg*8`B0 vO>`'ߜ (dD(Zv\~.LZFM$~HUʕc_|sϽ8==FtQ )Pi~7雾Z䳉J)F=.[lpEQi| fh_>tfi_~gFIyioEPQ>Fl2v X>`P7u9i'=[2q \x-2wmu6dI n~&\gH_pEmb[WjQځK2`-$ɡ[߾gw ВuI.ٹLq Lw>dDNp%]`-)ovGn84hw Ŵ*3l \vvDIVr?3_9_ SН+Nnÿ ? dRVMNF` ABRjQtnug}3KϿrdQ*)hD"kEQ\)a+J(@m z06@+'_uElT.W~mmsqss p0"GOХ_B0klMizl*~O7ξTm%+^0D8+J{xRMBԕen$6IƳ5ܷ)xm_F|L`@BC(trIӀvPNf;YavUDy6 Lά["aF`Oa z[bQKCn ;Apފ;EpiL )_.c) -=DӋ kW[ qrW:④"!iTu Rݳ7ߝ󾰇l" lI`GOJ J pws=D9 *o6Ï|#_V])W \] ++K˕R^/y {ĩoʕвK/8sGWW⠚nty)TLm,`r'dRd"3>G>{ǧ=FYB=w>;ҥ$L4W&`xʙA٘gLt !?sK(DLPv EyݱE 4W^0 ] `tvfY~o|Q)BR@-j^>{_}'>zthīTR,[@i%$c .*(J0lGQtǝq_N>]L1lXuwmG1""0 o4 k 1uAz4"I %IҔ*M׍:cO?|cǖ6PR+€ Qt\d=hk$N) G# F8n+5NѦURQIdmAAW omm=^;+h0K;3:#6sHF+f( @ib =6>2>WJyb]?$D ofͷ￱%%b$}9!{Uf.^UU SB1 k ݳw|u-G| 4hT %c()ba/~9M>|苏>k0RX96&O{a{{_O@ЩPb>j67Y9Jw I"rHn7V*{vDQ$Ix]ֺ-&Y_eQ5Id$KH_2Zכ/:'ycv:Au&2E(=/#"-ŢS|oټ x}<ȩgyY)U5Rꋟ{D-+Kh0hyI)`f D h`JU6By3_o_ym b-iVt :q~/] dh0'ߏ#8{dF_֤hɀEЍβՒgOډ4bDܰT5nm?hc~UwpN,W&)+IXAKʬWQ5ɣX@!b($mV{p=ܮ͈Y I M)I0`,HXv+O' @͙<Z_Ε%Ux@RI#ߓ4|="8"bsM_~Ͻr9:b-H\O盕SIʻb6]}Mzb7p9g5vv9β~2s\,v/GϮn2{GFR[IeW(jse |dVp$ l!N3-C絈z@,+;S7hNp}efJ= nl=sTGۈ4yT;G 228\q??CY5hg\w(Tg(urӀN HlmC`4f >Μ(Rb,\ FL7SL-ӳ;P]`,jD IfqIU\Zozx8ש2 tRPU@CL%/RW{:'OVyi[%$$12HD )0Q{Ƨo|wၛo8B%qQJ9'N`<Jե_'>=U s4AGGF~~ ˥RIY}"וUMAbP'6'_8vjiu3c 16 &RIfȻ0xEf0J['j\-\_zSSPj) $PNy,hLNFaD$D"BD`jn?|rct͕%-猆Dˋo{MffwD;x /M5F7H.$^r b`+)P}F\/,"[Cq$%!!s"" rkm`onڽ{xǭGKGa"e{5%Q[^o/>ŅEhz j4ϟ @uZ !wCdܪ6ABΜ]}'_01ymcR(rb@; u/>=[:(r޶=v~M){ +v7)'r$$#*Wf+ znڭx}UV#J Z3gj}?Vogf-aKp$-lZkA|ӟ FW/ $ 0D HS4vPA* *WMhwZ[Z4SVSsstAϢq,f/Xs/& (&2|f9zZ9[Af[g!lɁ]@jsY\JDdp}aF9SZpX" Tlw*+oyY[ުq9(@[7n~"vVzHdB; k;"nw{wj"R*t'ZD 6I\@yAG>kylm0sq : 4)xcG^X$Lb 632X.~+gw[Z/H1h`I=#̭2_u v3/|ֶjk>xጂ;\,SVE2po#kGewN>,l:墑`z=oƦ*Ťu {vv&h1EY)š7g  =ԁ}3E[RϢucF{8Q!U0Bpkt/PApC0\'ɸ/Sn?&K@L]g2R01qmڻo?>i4"BIET VDyDDDA|QV)"Ϩy @DF6VD(n;.i^_O}Sթi4lFivmRxHIKD4/!>y5͙;4,.t)+s?req#YH]2VX7fM$,pTl\1x" /l.K =Ah 8l{RZߎH&" 8vX> BRDHat T)>ɕ -sKg{qiQJݟgaw߲cAy~n|s$mqAE]Y@E H˥ kO9soy 3#&xi?orCݑLC=nc@k{ @%Ͱ~8:A}; "L*x?=윧}@$U-!7v&>IU/HB^v#J|}nCfFLE}r(aZ=@'Le& > XΈT;FW|%noW !).ZFS}뻸:& ?BHUpTRveb?P蘞+ް4j 4=in %cyQږD^u|הx6Kvw"ub9$3rG$8<$%L(̉CۏFeH,oH_rCA@ ww^7+YVJOO| a5u3-7~VxPD{1&n.AZj"ɏȏ?l /~[`чr&}'' E;ht(Pzc2L;]C0@0wHe6־4<& =BW@^vנqP~d@RT/ijĖXVE&7.$ŀ5`;mX"ʻ&_)p+qRUTa\FQ]tvf( K%zҞ\]W}QY"*/㺻U)#('b? Jiq\#sU۪ R< v .\@faA|| AzumQi.;I]n2/u5% $u0( C oܳgw^7ia؊PX6׬Aؚxa~?ɟ|7}7nk KoMl ',=H-"ۿ>v:)^Eqw1ʅF(X Zjq?[_71 ]fbbp'=5;W HBdau'j T+g*W~qa k#favw?8~ѣGWVVV^Zݷw޽{|}{fffv Z2crE}^|SO==/+v&4!p:Z50FsiCJ4&Eͯ MRh&tPzJ_ Bm>e6@: & 񄾾ȕ_מ|n7~9OFȝC(PbV=ȍ7rWxQƄ+KH%i|') k6#kxA|ӟ>R"Ўp"bMz^#~oe߁QUЄC;k R(S?zL*#2&b]uy'}B;J"VE*=Hz{*TVH5%`ldlJk2)ʚ%"PI!Zfa@PVvg?qTR06+bWlH^ɯT>raza#Fi@#玜Єrڢ)e|\jg|#Clh #1AZzީ׏iO/v$Y;]j?*])BD j@ 隔qۃo=|ZĵQJ ])N$T5A4͢c,}vH%&a }+kJ; }mklj^)0 KM f|o7h0E ]34vVDtiz*_w{W|'ۍuVIL$ b@+.3cVEq5qSR_}tf}3%㧙ӧOɟ|IL΄M]oF 7[@b 7vS۵;չZiOhBWmpkzlTXz9Rh yYK~d?=7a!l  $@v>cpc[Er4{<"j5/\xϷ}["Dq]Mҏy`1n ]edĠ<OOMI6u~zi ԟV2Qd[2kä9=E\wW[ZQHLZg0>.~<D$D$Bl;/UjvFD $e&Kpq,l⣌+I>>~wq7MADRȈ {D8`+ʃ>^+.R686J)_–,J a]:W9GV-+@@q0p#}0k+ #$̷#r< jԽhBmԍ2@P0P=|vK݈zB09)S*,™P4qvw7=]jM5W_$i^|B*<Aje-c+dbBvrDDH!kmñUnfή"!Aw{3Kv݇}UufΙ p.H$[,I[ шayIF$Idı QHXĢcq$f(r̹|UkVwL]uٻkMXpr@~jLX]D@l`KL&{&AY&FG7;sg~;o<?3LHTX+¹ȫguŢTjdUͼ9^Zm̲mVN|m`N~N{o;:q|ݿ$bsB$"Ni\?X9w|쇯?>|4 ݮ'1f64\گ2GX_j`cL "*D[*IqO~:Wo<$E+fE4"6\^MχRwxzԳGGGNɈմH<$,6kR,ûQB%|ݾxf<}+< x7yVɤҩ|aEeXeAz^'NDE9Ey<'/AkJr<"%gd\ug _O7~>y!I_mdrVFYD$%[o7W~0VT˞* d”M*Nΰ(+r#-8t|"x"nCmp\f]ChB5{IT4 t|er*Q^y;s ]؋ ^wp6]K(oJb$AivFpv&O{o)H9%Ygg*t1 ]Edxv'2i*}q*r˗6$ T*ѹqgjCw&dOYu_fLf/#xL֤o5I,Ki]<; j&Z ;Wyt@?Wab*.ͷޯUFD, +Bs[{FX8!&UCI͛7_/ $MNiDY]|4dPID* 啼!aKI|yS[˗_6fo\_M5q;l(g:0Eb]Na5߳QqP~kaV# ygm|QR.JwkBDf33QN\W8ijͬ8ѨӘ\9͜^|NyOZg/̴92^N u3)a.z1#VGNnrуH3SQKG_W>1o+ Kn<<_5fQU͢â/sK>KtnBTT(7w_ZQ#cTy-5xA5Zۊz?Bx \hO ,\~YlMDL9WefN98/ixnU[@v&2[g bmSvE\tcW#y$[鶂B8u<|f[Rl XNٲZ_]XX:_\+O]\3ܹXgy]f%bU T$6ٙ~O#P&_O|HH*hlEμ:qfL,^v~?I SH)$IG<ʍ~cƽz.M57zߣU{.YwL6@1԰t&$K'G_0xཊL[?S*ܰy. Y{֩59oxZa@qU+ۮ>mfB.k"by48{2O43*)qQ'Y({\Q?4*kHtzVz ȿ'Z #Nzqw˴JGS2ci?؝O0=T#iq>OϷb©+w*7>~᝟8٨:am{/l9nlWm#t;SEޅct6Tׅ\gxQdž?s~\QZ6eÀkE*U qPL%6@~TċuEUǥl}1'b:Ui~kLZ <>4-oǘxVdzuQ!b)5/dĔ2Ms÷>O'/q'N ͬ1U:9Ģy8̔T$4Vmj 6b@[-Vz&"~o}'s(#&͵M}ff)l"$N[Iƥb3Izco_D45&uID58e _~39b<k K߿GUYѲly?h+ŁY\3U)I1FX?'66rNFO3>j/FM޳0:.7l€,QԳgo$ן~  +YziVcf4?_  QI¤izw.}QYӰx;FD$)_\ͽ]f &Q"ޛoO~~^y+Bjbgqh\{s/?{ieFF_"!˯\OdT0P+U5!eFUϑɵbIxbaGl34V%1|0OടfaZQ5DD<ˮ_9/_ _/>gJ, ii7jNӪvI#NҵI~+dq0 k`3Q_/7S5g;,L4<<{[p8$GG{{{i9(LÇ`0{{IZ ^wvz*~VLXFONn?gʴJ4;=T1Α(UߋC_'\?faM mosboAq}gǦE^kܿޘطs_\9 :NGs0.رaV#^~iqK e*JDb1o%Ŵ̙ SovoxdTr33b<ID$MUKD$]ϓ8L2J8>(Ϟ&C/M~Ifho.U9 Fo[w~za@'q-smj{@箯_)B@i"<ɈҔhR]IZ'"$rJD"iA?j ӑ/l1\P&t|ㅟy?$;}`WVz'ov; V8x^I%'ʓj/\K 5"Rc"t,î&^m,mEr@ʈLSzf0P"*װ }0gJ EszDґEΊ=zGu8{?sdžAn&l"C E533 X \?]{'pp3OE,v^gK-pK?x;xo9r-E-XFQ%) ??ٛPnX=$hb6WdL)OzCi_s9<˒Dz2:1tȣ>Iqܽ^ޕg RQf7#QubouBlE:_zO{,fLd;8Hƃ}ٟirq_YĄ9Ho3N$ hhTIΈ?Ono䣳>fZftnc)iFsj~ާ>2-f(QFa*@ 3$ ocR)^{͏KJ 2\ әN,FqqsfI$䋪,V M)5֪8ۼWoy HDv'ԯjNɰNf_:|B^TR*+P\|o*7AU} 6;o9>^nU%qًsh+>s7oov.ڷ\)g͘H)/" )s4m%ͪ6.4ÝJ%ĤJdOn?<?_7ǻ4h]iDpHA׹[d6Gyo|A6Hd@ 9UV#O}><!M*D垀M!DǏؘ)˓ǟrs0$x? V(dDA"|so>ƫzۇ=&DԦn\ WHIIĈI!VNaɞ}O&9zNT`иYZ-I¹Opp$= eDJYΜpDlӻQwgUƁrŊټB$ʚ3 \qO?'OiO-*z?X) Aa\͌o5 ֙We8uL_۸Ab&Q$IQ>d'>vhzpN2Mt0xܿ8tIt8 A%twFDÇp3?'x?|7?:}ԓP&K&VnkoU0hO\L%lhtx<3ݒ!Gɾ>zJ1pRo,5;b/^gZ.- D}oj̮ƽ89MKccp=/}^#WyޑvϜ.[Q뜟2i%obCmld=m4'?0Ln={tޛyc4:=닎Hx|!٤<3}Lnҭ(B/~(aff1RUUT,'#9o8͔%#Q#tDI2䞯RMZ<;nil~ /żz$pnTWsɃKpb^u'i-Y= ʿ.,52J`FO{}>xoxv_tyND"0+\7Xy‰/u3h<ɍD81IT(SՌk8X#NYHҠ= ƭN+/(Jo{wh;φdyB&J"9q&V,&m2҆Hh>J`2ŴeDrxrpǟ;?i/'>fBs"OG4s 42#NG֣_u;7Ow}taB9*DlE}ey :S{ ib,Jiߜtppptprtmz I̔@5PNhP#2M$l&Lyr"G?a><=}NrD3VcV"b FHL?7\UH^eCk(n&`zlawi*3Q,q#˓{ʼni_ 1axes]uPY+fziՙ04#Y0,aUNfJ[Dd%f!%Rf ~L2b|>Sa0ŀidAVo֍r RMTǚo.gW (}>9Vσ:FN Q}ci;җv!8GϝbdkpQ/AbjoiR%R=ˠC-%Z ővP3?a7?C/Y ms#64 :Cj59:{sV[j{ Ng[xXx @@p P{-ލ7%]-ؔv.S >/R Z 6sq3m k-g`W~bbo,%/~xrhyjKVj~ڮ0z: ACtuƜ!cǤjC C4:ĝKp:cDM3]wV$XނYÕVo70{sK$rŻW3\ 㲻[qT'XiW) zs.s?x-g[j4: O1j\?Wdz΀^ quUmgs${^_otyI6}t`bرMA"0yU`pKfnm~p,jfDEW[|j]'pe033}TkA$\%{oXݱSI[<66;qږa}LCyձ1̪q3ChXs<qcvb3m$>l%/qIX{!BD6y.W5Ҹ[r/y88o7׿sΜMo8DI4WY5À[`Sb﫼'|ڇ̓!!sUg,]+u#T-v_B"qQ]=+6UomwSW)/+xg*%aMbn"7c.+L_" Tr @;'gE:~pe9o@=j2q͛G]#s{M:;~+S|^|kqw#SNDxd1v;ѳEKсb oȂ!s64@c-'IDATz246O^ˌ^m@opMby?:RZfl[M}ʃ!@c+xߍn] J .X1f5:+5˜֋/f@ƛR"s/qc/IX}=P@q}!W#-1p(׸Km7KN$mbwPd0o{8GɫG ZolY0N_qv2HZ[Yֿ̻}/~=2wq˄3}[FUwK/YNYMb|9%vT'%2zۦl?׉TZ)j- S+@6nMI䚯&mQ:=̮3bpxN(׊;ls5{cؘ0pMA@[AWڎ+be6;ƾqoh_q&i|ž¨ٹmF-ߗw/•EŎ_|>~fu{13/ /@3]=;}|qۜg h]blfRtK"ٶIJO"n7Bh0RR#U@YCQqނc!lIIX'SpN6m_i0n?MAK1b2̨Cٵ8\k}7,I+5~p"[?O\C^ۮ{[Ǹw(ĀY0bo]y]^q< boƬ/*#ϸK°Za-:tIlKC}fuXL: FI/|du v[bP:@+R56m{ӻ{E6YhS0ڢ^T}R67 V'j0$4 :=]^4uWx噮rnD#֭!Fzh6zk9r )h =:=rhXp< %~E΅u_;{cvٱxjhW:/6g㎚Xdn[P|u_9p3wǭ`Sl9 V8qGçIe_7<6AUtict 1mho9HzABrkI3 7B36 KU5pD6ϾM@աVZx` Y/Cc9Kqnw<$m{XH uny@ ?^sg{ϝ^/Q~X~k嗈~=G5u 9g7Hlޛ޵='<7>ݪtF'|;n7\-jI_yx A4f3ՙcPro|akB\fF3XU"{>^MXwvM:= 4] `ԏ׻]obz8"ol.lXq17]S"z:9T v38xJvny08UbE˷o+goc7d={x3-{@4:Qv74F| MVG©W>]c==r=yvc4@>2t@x#2YƮ~>fF^|֥pA s:+rw wpv[=yq7ל3:cyouM|f⸷=[yt^gX:O>θkt@ \Ysb8mzYʿFC4:$z@4~| kz]e3qcd 'n71C`ő7<l[qy ^Yqi*~(}Wۮ_|=n r4:yZ#Xdw]ػs?@|ݻf.Njw\]]&@mJ4mA @;E 4׈)9Vlހm[bh;mc,wo!eXqm-gmwwDαJszSl0@jCs1c%<~'8]89/E(6SֿAm9˛rAC֕#.ݫ =@!ȝka4u,~돋-ϲ׵;8<27ċoe]~X9 Cpm]|q{ݏܮ8/n0?F8} 5usmj~lY0`+mZ@!WE3a]S< J74Ce!ht7G--i;o@ڮV9rzbjs6̋-.>S݈\߫rF>߹}.̖/𖎜Ί/.~\kyCS]ɛј<9./\4: AC4: Icz|e9.iqac n/3f|Sx8q߽xu9}X˹my!v&/Ac;Ƌ{?y6_+-7k('vqsi8=@!s65WaWʳ+~T3 Â{ehj\~y=xvz: ACRRg<ٖ+qk{|^2Ll.ϖ' sŎm;:m܋^7\{߮|wd[|Nm46w|:y#4[w?\fS/\t@ XXf( ՀAC8v=WH^{am/]N[Qty$#D+]BƍNa/l{{pw =O?08'nxѿ]sqvMGTݜ{4~q_9yz`;q/HzܾUga3 `pl+TEEaShlLps.DetivPv y 60+?%llj)ht7k7ZSCN>p>gy8ubơgN(}qE9f7no?J䞷ϡ48u~|[P:o[^i>>\Dc畫^~>SԈK_E)1ѩo4߿x\O3yϦo$sz: ACjҸs uf#t@IJ1Is;h,o:Nx񕝶7+fsϑq#&:N|\O~o=ƭ'q~an[yڦ+['!]׵:cŽc+/}dހW{nn|i&/pϣ|Vĭ'AC3Te&vEx}u:r v`ߡ=cgS|8Ǧ2{wv:@S)z: AC/o*޿w?nظű˳D-S"xnh/K3cVo׋~̑cE˽Ά=:t⑿K۶-x<*_⾇X3mǭߌ=ٚy.`[-_q9I5ŭ2C4:Ҹ%5;72!?"t@O%mN<<Ny,2xtbn.^ ѹ/8G c{'!fǽqؕ:>3ryN\vЖ^޲9o#౨,򍸛wihU]mǚZ5zBly!hty 5.hJ boۜ ~slϦ晴vACPBIENDB`xandikos-0.2.12/logo.png000066400000000000000000010123201470075263100150660ustar00rootroot00000000000000PNG  IHDR cHRMz&u0`:pQ<bKGDIDATxw,Wq:{fwoJW9g$`m1`l qcux'L0N3BP9޴aTtLnٻG;s>:U7I0U=C=HMDeڷszYn6Ϻk0O/DӊjI9aaaaa:0 0 XGn3=aRnC_ն*\0 0A+]/꒢L7 0 0u)aa00 0 0'-(RQE\@gWv]闥SrVnrI%~vGܾe*UϸcFY}_6_=%g|kܨydڽ͛kժ^k%iqv 0 0 ca aaa#L0 0 0upqTaaa0 0 0u)aa00 0 0Vcu#jyS[etuh,z3j?YV♬ūV޲z#m/̃U_qZVՊ۰ӯ+?e2t 0 0 32 0 0u)aa.aaE[Q[0 c<0 0 XG`aaS 0 0 cg#q֗;Ԫ~Ou?OuF۾^Q}J]˃맮z//E=qcZq*]Jײ|ެX.֐Z"k}.?T♌[Wv 0 0 ca aaa#L0 0 0u0 c(.<8;+\7 00 0 0u)aa00 0 0/2W.Wz:Uam꡴%u4+KZ().+(#QJ)bzq@*g֊&su)=RZrpϧGNqi|Ƌ>VFs͛`aaS 0 0 ca aaa#,aƘ2q U .l0 0 0aa:0 0 XG,rOv߲G_y~Ws]~m ^֕jQ9E^/{K۱QA7TTA_Dž(C z߆~aOq-}I܌Y5@ח*J6:F}q*OƬlaaF-`aaS 0 0 caqjf$6 cmQz"(׵[O#v 0 0 ca aaa#L0 0 0u/*?*-WFkV֑XA~Vb8Yxkg?zu_O:k^aY}y~ƥyjjܞk5{{Q _*|UQ89nq b;aa00 0 0aa:'?`ulnW c,cv 0 0 ca aaa#L0 0 0uD3o{ژ?gW^qPQSsY}VPү+>@UFWoޯ[gaaH10 0 0aa: q+V'Ųv uŋ0=}mgq 01v 0 0 ca aaa#L0 0 0u2Pf]{U}%YQyW~P}sZ>V|Y{/r]q$4?_f2/|,vxf`ǺTebegZ8CyF~UJ9jzܡQǑ/i0 0 0b aaa#L0 0 0u05Fߙ02\z4fl^ 0 fu0 0 XG`aaS 0 0 c3e}%p_R?%W4'+_ֶZz㪬?U=j7FT[_[W#^O~WTߣ>_6N^_|F:]V!ޗR%+WK43VX{37V"B*aaa엘`aaS 0 0 c13mdJڮϦy b0_G=2 è0 0 0u)aa00 0 07IjIhؑPԟk|~Y`\s3j*܏K)Ǔ0rk57F@չ`uUu*Mg6XM+[k*vqgSR:ޔճaa:0 0 XG`aa눑Xڎ _o5q`߻l0 0 0aa:0 0 XGT>PTy繼J&ju=ne#O7z~J.?CSZƫ)džr\e7*/lF]ѨVk$>Ƹsٷi5Qh/O=sY=aaa#L0 0 0u)aa8q _[X/FSW:~1:=]?aaa#L0 0 0u)aa @]JSꟕ_N?>s2;j~|2sICC_WP?z^qi5WmCF. Q5L]qiFߦJ y%~ҧf8:]_G0n3P5}0 0 0u)aa00 0 0o;kڙ1 0 l0 0 0aa:0 0 XG$Ŗ܏hCqm~sKwբr|1k^((_?GjVOF~Z>`UKz9̼1pqx*2nZ}Cյj@]j_ro 0 0 /10 0 0aa:cAmܾPL ~k?־1Ǫ6^/JΨaaa#L0 0 0u)aa4vjv{^տxzyկzO]Fm(z=6kkL7`uT^:#Q[\jKX=>'#|eg 0 0 Xaa:0 0 XGukaaT<,aa:0 0 XG`aa__պajGG]oSZZ髦_W=Ti۪~KҩTWP\Ϻ[o˯yV+ePy5j&5d-Pol0 0 0aa:0 0 XG]jaak0aaa#L0 0 0u)aa=vɟq3fZqz|KRz}D{?Tk>nk:W~J%_N~RVoU6aԌz^*9a:)?\M(V=laaa(10 0 0aa:baak=}Y,>w-/0 0 XGa~Hw*[Ap,0a'a&5ȣ/F*ARig40_TK&+-OtWF?ax?E6g]˩'S!|̗ d5{Zu%~,%oNn/N?}x/;FY] U.¤i#)NByD1c, *a\S/=i|P2yT9|}.΢Ϙ|^"󤐲 _|A,.\@:hV29/}P&.a00*QypkU,\2ts/lufP^CoG[Y<P[R6$=q.7B-4JY,3CUl:T(aR{p}Y=3 2Y0uX,uKFCd9_qW>W(Bf['*:/.aɟ柵W=1w'sWH/Wy\άSۋ=BG۾{N}PQj_;J|/4_{4ak}V߯TEYaD$QUgbtM_)1AU^~ffrgU,ʪpeQKҧEF'}e(Z `}G!EH ȳ"_ y ,f3ny=6~yp@ZPeiK,c:@ѹ?T .f,^D jWK: )V믷yZߺZ,A}@ 50J;?3^Vc.>H$QRǮ7?=w׿}{W]=sssss*ju&\?~y~C7/vTT||*y-?ӵT]<0CDV?@~7=ȹy]I}7 \A yOtC> 5z룸BYbuP@zH(=ZW>{P\RAL*Fj,4`*f /$7/|n@Z(H`ggܛVUHHHTm8c>~ǝtܱ$o6-ܕ]W0`A`:)ˣ @ߕ;n=ߺ}7f[ ( h*IdsPaZR܁0X.o'\\}정yO_Wl\R((B+u(b{k.Ltp%߇Ǝ{G|t޾:^L!^6v&iS 2;3MQ|,f#{3.yӟv<51@UH.h}E5`a Gu8S/ˉrWw?~c(eǪ*A@ a;i1*je)?L&@ bH]>E%lEP_X(*WJ^,jAWo9TdQrߖIhh ;*)Hx`0?%II0@Z(;-yφVzy3 G!U!){&ICkM'r_g? olzI#745֠0 eOLXLXev:199=\?ۿk%F uDB}0PL4-s'PEGl\MQ(}&@}yzCXL@vZ3rAqz?Yu秖)*t3e) ~ŕxdwG E[PȀ@41.8__S>[OҔ!S,+[3`yTR8 q⡇w{϶I&>PE(hk9EWi5 {9hQ^?UTY.ɰW>KaxY5)00†2X v䧜EGN,$x.$4++ S,+[3`y,߽w_??g[s= B )1͟ ahM;txQJ0k:R#7.ejRçQFi4_8WnIˣӯL7 0Bk4?/E/፷bRi`X'`yR9[~_NNmC= 0';4X)6^7]ιBR0L06J v zOܜm]T@cao(+[o9!|`ܨl\{>jrJKoCP Re  0y Ps_ƕ߸Gj0kf]WiDmPB`& 0 X'%ܰK߼OQc"j-S5IwL~_}ݹ{brvLN0Qj? c5pQ#Q|=#{$c]PTۗ%}*+jqQ1ooub潳\6xbfj3]sK7ǬS^bz_~:Pf^6W%  ?;ύ\#H*"܈vA*&Q 1߾?-$YXAĹfT:'0T>ꈣ|)͈B*"$YoJ~0 c !=' $\u ? `Mwa_T͢VTH@ 4ZAbZ  UΨ׈1@Frz[iZTj>iʁ?YPݳ5Gű;J#YȺadk2k(x?Je[!)³2鿒7Iwni/}njĎ~sj300܊`ijHyzKl@l=KRgZU}Tsv;fe`=(3yjD숙HҐHSx Sq4wj0$r'^"}wg:o U  %Rf\'/<|œ-,Re! @xsLLVX(-O)S|Vg&tګE :[zO/OQ_缚(~LnΣ+㼵bJm}e61ծ-HB:L1+{}}$3sv4 !i$IZI;ٱa޹4 )ewn92uo !vN>GsEmqD y3Lg!okeWx>M9bN<-*pF4kq?ضACȶd'0;:3G- ei5 oo M-)p>ھu3^𜋟O9߸qJ$jYt䂲ݼl}K&e-͂r~ϋ0X0Cg"{.HORIV? Ѡ<`;X.|NS۳Z)h&ӂ%*3 !C[ q.{:CUum`Fft:** t'{V=w{~p?/~k;wfAlH !)Ҝ+7 Aa c+M* |Wy3F=5Z~j#@+ #W~𧃺DRvf}A˶ꁈY 4s9G_#O{O>ɦ -f䴤Vx$ؔ=D%1O, %_r  (|=}Fs~ߨ*@ɟ?5d)KEnfZyJyeQg`vY-ںWhpZ:sږy~Ί@ٻS@K*%xx>]u58S7 ]<TR$slS? !s'J]~uLkJ>h` @L"[QB*pB?}9f-"RBg>U";n>ܗ%xS6)mi4FĨC{0z[۾3LK<{͘VsL.n&| g/R$E5xʭ~g7PֻЇcu7ܤv&%,#iUUx MîG~5~{ $i6IMNSLXS T}LnHJ$1J&7}yOl65s;;U8Imnr҉{Υo3f@l:,);`9 Ŕ8~=MAB}[vvaj?O;D14"sEZ!% 062ip;쫷T{ke+A" L4^2\] 9 ͆naJD@pgt' [u1 yGɿYx~aX\s"$ݻșg4vacXAʀ(x]OysfDP8sYy?ac+2ֿաX՚ܸٷ 2((e$7#nm`kti\ a6B@+* g&]=~߿읙x"M%т7Lu Wv9y%^s9uf.z:Y/#xE:y>K'a4ݪrWb.h]> 6HǀRu2ĒFTEDpI6"95mRŹ'ODQ# =h;$3 wuUǝ63Ȕ2yyuyClev CQߣ4#L`B`,;~ɩ)_0\ɛ">ǞtaЮ;t<@)нB?gWHrǯֲOxaF(f2'0 SP;0aх[U8CoY\LiڌJ -2| _l ܪ-'h3أ_W|VxFMU(r^[sϼzۯxp&1^tFL@w1J$HR)cr-ͨsT 8v{"Rh'p6NfKb3pQDsɒ(X@D 6NF{տ˟Mw5gD%\8׾qwy⹧Mv0SqG|h]V9}/|ۯ÷{-ш-a >˿k;w1yff4Iy#: K.>]y%P_y|c7r{γ)lJtOn4Sgvϸ̤:؂CU%bO UvЦ?}KnIY˯hURUO/~sO㸮'hOZP˨WJ.a33ng&Icroy3?3 h"V+ "fJ9SoωzsӡaF}dx&="BLx}iz@svoNjv>5CX/}ɏ/c_?_D3u(`ٿ00;hً/z6N:IX%g_(aծU;~猪ъy6|M钎R_~r]"4o}_WKU-B0@3shF=3}~ҧ?C"Ulb0 @&gH _|~=3sA74|sM1\D>rQ}>&qg\5'O~>Detkb昝WG9%Vb'(. H o<==zA'|AH3K EII3/o>vPZ^WPQW6ɩ\AhPhQhOyfҪ ,@<;Nc1c5vy_΃|ٔ}O|#r9>YO;ma3{"jУo~33j*AnKH%*<)!cvݳ?ۻBvѿ׿Ml7$ $!Hi&!IB2;y .\`!DݭwYrW ~u8df.Vj̪cJ[n{<8ӌ^FiZ-jk+K^ KI^#ooo]EFLk.H+DL`& sSM}cN: _L@MD# LQgN A8 ͭۿo}#MNCW!f 09N8sG_%.,[O ʂGW]s݃;g}TEY\r%x1]Y0[I>Xo^%̫JGUI}ړ#=kI{ g`t_?.fɱMs`מ{W3,SgSmwݍ8N\ Z} pSAPs5@"Y*n H׿WN=ę;UҪFQF i kR"1c5DU߃3WJD iN4vj/acK&zছnzF<~%O5̅J[nED*Z_hE뮻"Hر}/zbo]h{7UFL1Ɲ޽3+Ms_xҞ>s=LGk܉aVUWZgffٓݰȊwٳg& ;Fݎ=޳{ 4}٦M<; x96][P^dc(VF_y QnꟆwg 02{~zHJz@(H3\kt%"Kډ"i9?ģ>t#V{ZD8Zxhٞ cx(7g8b]]nwUB$agm&g8a7Dl֖JDɉ۷-۠`@E7n؈IJD.΀d={y_? IGÜ(ة z-;Ị c_b 1p]wq4!Uv5QGq'Dk=a7TظqcKb*^u<&>#<%oTt‰'͛!a~aƍ'x +pΏ"N+^Gjw}L$!*8` 1(yZi$!Rb% 9:~ia$m =n;BG㏍b6BP)'r!;@s*5*N92/c8cmQ_I7Dnݏ>(!jKGƚT(*ЁW=U5>@ѷPybx6]h47,p{vD%mq۷mGp2 cL6^?e&"O"$x*}~}{wzFY:Cb7{8!z/QIDZZt|UDmHIҜ̧v6]7f ذq#MZTjAT2*¹ - Jη^8v.jF-qj29w0 @MUE7ضՁH ak'Of5E9,H)XhEIHf{cN= e!"l't.>9';)ڭgߟN {K]ĩ8MuS7vZ"}ZH?{RfaG䃭M0$rx/yï10!h%/ =ѽ;;;eVm)#8@ ;?8pB^T3ouMe iqONNve O@vAcG[}ֿ~w?pC{v egK'h n|k_2dQ|~ /=1Gz ¥twI uK'^S?} (U}6m$I:SHTysL @DqnEE c1k$ ޹F**=A\J0ђKDP9ps^=Yt5'(EqX{H$!e:#O?66st48bร{_}eϾ̶8%i#}LZٝ}7 zBY̗T~p~ S&"XDBh0A9\d m1j=hLHv>g k D 4,Y\aUA[0,w)%b@ D9EvJXFyPV )UVlsk2ި~+gqPNz8/Z/V8O2q-Fcn,bͫ=%@%̭/"tֵV˰L~ZXW-Fcѳ ^]z*ϯ7&…zréW3Qs@*:R,쁩0q&L:ph{{VMޫ)?㶃dҏ1MEHٛ-rÎbk밝C f>)X#ADD$PXNadu(V`-.b}sPYޗe\X =&FM = 01g YY6wri0.MCvWTJkV#v(hi&In5jqCDT]/-).]$Tw*A=[ D3-g6Yä_s! ĔE{]19kYVd߳eW+ˋcd',h74)s:( E砼e)HW IS<'_LždO!ڊV+>ΩǤ'3]? 1)gP'ȋtҞwE< UQ0x W&˲9/3&t0̮ IDT|8 YUDn1mRKk^A*kGb~I =CNJ(/BE_gE(H]$oN*Z*I-+5qR>s"sR'Hc@t cY<2=^!H1i"a,c$fUǂ@w7"YYPЏn;vE%DdjH{e0^݀{-S3},@.}w9R Yf7dA'R1lOU!ä_WwWغs%Z3@Ήj^6<_yOgɷ,i#<+βD|AETݼ]=% U` |! rfpP Dދ=ZmRN%0rRW9KZY$sZy6w}K0g(D %l?xyO &zyuUg I" 2UլokbUϿo|.h$~izw`"`{$߭¹|K~p|$5M(_& U*gV9uyUO-9H3QcA=dSOfU}v8Kq " "EmWm^'[&T 9҉Mo]榾ߪ泪/ $oy7ӚNBH hr3}~;ȃk&͢XpmCXZ!='+_>k7MB nblo󽊥k#HtńD[L *شM~ yD1pu?_+B%Dʗ˄bEd ~eUZL%m`!v^ӟ|QaDF$ͷo>`%&Z.=ۅ-y?Ν;䗿7}uv.Rr!L{-ދh~)JM;XE2%?0"e i ȥ6 C&D C4VBBU7a}FzLsPMR&8e*<oaS#ȯ[Nqph9 }cͣ@@A*Mr^jGb`YAFio܈'f*T%F+`EA-" R5 MqB΁ZSQT-"S;vۭҍҺ] B@YK^J)VSU}[nL†)V&etNf "ى@$LJw]Yƽbvo vvW b՝Kƪa a䔙Ol?SHD6mٜvv *>-= Cwc_l_i a?Jjm"2%"ȻieV"+W@Y=4\ѓ=?Wx _eCqd*GY(Qϖ5*y22+\!D*Honb èS@^J>ѷ̄, g˹) a0A*Ђ%'DرjXPE`@Á;TАIҁ! /p 8J<jhL4fjD™\(]nĐs(􏾟y-X: W _N}(ĚaϢJd|W~?mSZ%;Lʊ#a(g-Zehf.%;5 qp"p%7bn!B6JGt;5IY^=n#0`XAIh}v AD-5ۙ_ۨy1XԾĹΨA@2pqt `gilڲHU! kτ@5Ye`@Eqǭ/$H2"Rh%@݊U$YaEٮcsOhPa%;]+q~䬕Зiʂdk硸ݩy%,⌨b!&u_WHMAb< >sƬ ͺ_oAe ۨ5'JRǽb: 7juQ5a`(+U4A߷J,̙h.!O(9wߦvι8 t-+HN  3n*[-󹨩8UR4Ll`%8[oh nAw\{^yK_Ql(  yPS!*?w/BD G2?kfJUMeRň^E=ٯA&@T?ʄ~}P~&a3?CBg4(aFs(%( )nZPJzepU ?^-mAqFj_ZWx-&_al4_8ֳC3eґY/dX;tJWk\kE#z >dZ+j/;4MUoyedb1aql{GW?*L<{"}:(][{,f1ں D&d3na,*L̬)ttBe_arLLkљg.5-\EV/(S&)mZ<0\j>0j:1HA]L蓾$Lϥ?g1:ײ6͓'cd® "Am!}+D봷*6 Tme0=fd5iHEu 1a|q@:AagJjZ*u&# "Е[5O< TU7rr=#"r<* _IoЁsy#fBg7D@wB;@r鑯j`z!WjG" :.;.c9櫪>MlX`#@{y)y-/,~$jkyb2i7+>_`GUUR1;" I=FAfJbVsb,rd`"RJX akge$[#1Wv3t4_% j6_4@zu2U2^Č?}+㳯B57kbf h 玦UKcg殖ceAB {8[vQJ3)Qej^hW.Ql﷦Y}rcO"BiF/.ptE^xta[; 3"FiۺK=~__ˇD1nAyƙP%J0ꪫvVzZzD 4ƺb*yYMk`뿈l)~,7 ՙiv{si$^bVk_jHC5:֬\ھd^AZV&>+af8p֍ ܼn9ve3O?>z{VOȜRc砈 1ډoN?yf磭{9gH!HcByX"]Q!Yu襞gfg;BD̔wr.uwO#DUD]̌ѲvՒI@:a55S cԬ}$I9F':i C&ioYz`YXKaX)I07v|/ާ<<lw.J2*@D {EկzeeHF(\uZߑjL_=V+He zL c,\Щ_d"b#ru񐫊gt22CUd4>iN8߿gzS0PR.XR:Ю wqԱGäբ8f5]V߽g;Ӆ`[whLz F>]Kj&WgfDı}<ƥEY&ahw:+@9KK@@HΩRhq- VC( M $I73Y j3/^Vp*i"A39%t9֜H'uS4LaI$")u%rgW\|;Jz/k"RT#GKك9;w ]6"xw.8oHBH,F.<):>Wז/BTi$)BʠԳM}}z rFvҖARiDRhLj1aC!΀B\$r^Ҷ8)ko R?Yvat)UJ'ʝu^hG}j1|V_?%RɾKzwHYUBxT;M9ٻd cEλvdz*oQL;r14E"|ZI&IHCpF҆h's\& W-DŽPo*F&p>wHNۭkLPf#an#'bj($i/B0ҼQV"OR ps{cmԐ[WOT&&#ziVq Pun1AU&'5i~?K gJhhYW ([J+檫_U+oYrK]<Vc;9րh/z΂Fi߽?T4}n0=سҁ~wBq Lm3koh>xnG8hJT~Bh9"rNUžG0Nٴ-Ihml{'?f)9 TH$m}<#v47MA9ٙ]v߮ܞN!( A;^BHw=fii0gvvs>$IQ8׾~g_B. 7p  xa&๗>m)5n8[sYxM[E6} n޼9n%M8'U_b#FSTЭU]*&!eDVމV4}qTc*jhkvnfzffv137MГ@;o?GRxFZa4Rlߴ3N}O|O>lܤwHRiIz=}|;~އd% bUavav{GFcnvGQ-SQ(/ZKUZ[YZ~L7k]]<7~LBL#5lݲK.~~Θ)|I@+b˯˯rD t @Y}wu?"91i{5˿'?Z 57(U?TrG/9O䐭[;dD%H-[߽?HAJb.fU<+v1?;e?n-k#rH?~#DNu p8CvnkQ, \>X? \M5Idf wye(AB~*[R8cs_8"0CUC{zV HC{*o-o:ȃ7p~ 2!;ՐW ۷o?syM7qevDL%Aynމ@@q\DO<MuP( 0ab":W^.ӇHI4L/?8;"ET9*$85p|+dEuEQ_|^Cm򗥘L^5q/È83N:gf|悶(pE,^W$TF3 ?}ujs{=F*rJʮp^w?3 ;[T\B3+D (>ᔓ6o?+_Z(m`*~鯿鍇%rᆐܚ^47FdA1pQG~#Q^11FOIh#*<%e"۷z߼{N %bbT\h5cko|ݯ+H Hqi/k^26N> ㎛o` ./ |̆~5yS4IbD={BfN#y9oX*]D#v s)oy\̯WW)$[/z/i2 ǝ;k:3&"+yD둇8??|; TkG%ϳd%X4p8C8ϴ[ՕHz?|T9ʷfMBY6%t k"pđG{1_WN J4|"H2$rQGqg}<#q)h7B!c @-Wy>GL(^DT$u(!'?'sB@Pl=Rg-- Ď\ 0щ'_җEȃ B٧~q"$8heRz4$!9&N>az.׿Y~U B=̕9PH>ٿ7@DY{}> 3H5G\+_j;5ڎPuQ `~uSDF&tAN[n8%} @rж-8` z*>u׽Moxwv;1\CWDJy(ԫ~]'NE>7)7_@3o|:[.\h4]PY3R*sITo}S/D4t*ޢ>uNY0brC}K_IS apFiRp)'u1.wG t,Բ}+QReGg:|?By˭źS/yWMe.%%VE(K/@ !1gO>Mۿ/t:4;]# )uUKB%ߛP+O0 *_JxQ&Ss ՐUURr ^KiYdmQOuD8EDLFWϝyq"G7e,i93ۆ?/첧sof9q>&sL JBiS5~ U &R*.(vF;gDgr>RGgI t64B sf54A9p¡^303ܽ 2{+D'闼'^‰ &LHш+_SMik~ hzᇼ~vK#_GkwK!? >Iky&oEg>I'#"h:"u¹&MO{f -P ՁٻDHSFjz_̋/Ji=8#89b ~7v⩧I fG/ǝJ._1g1H|3?~S'CB+T (ZYxb^<3́D P5.6m` @SH m#m#*BH~5>( , ]Š]1E/|=w1sy8OPpQ9S^E~QNA!`JP&rݫ,}~ϸOgu$mM*I~dpRk4f,Dѓj/VI(_bYA\1v6` Djz7|Hu&UՎ|Fަ ގ8.z<R#apa`!)p[_׾wF==!QD{^D𔋟']QҖL3(lٟ~~y:;ZmydR֭ػGA9dO7F~U;'!(.{wAU]ܔt-; !PyΥO _>gOt:iG>^'f,a3c?<4sk~ i8-/(4'J[Dwc$0(WPl TH;K-q -MMo|7~xNndjgG<;RǞ|_ ~ڶUzWʤ +dwy5+6K_ra;(xE[Xo t3~g㞛/! N[-tܕC=& ޣBBh/j*a,SuCfԮ.^pcqٌJڛFyU3B- fO3=q#Qv+"O?;[m;(dNAP*y]46g/{E80[`eAULDϸI~s;N9uNTTwwLzj4lGq3{G!4ZS/y2;`nIt.zES'8v q.`%b}Tg`8d>O{|]66_#w~Mn IRjDDyQgyVQQ^;ڜ"E* m̂ 촲N=yWC'$"jgJs;HO}Hsp @pB 07s։O9 ׿Nl yl0Po[&$@X3;/:DȩIDAT'2Ơ䘁 Ͽ'^swk܊&w66 + 8kHœ|v #L) 3\#0]L_". w}/Nc C&nCN=4pABLe̦܎ Dp1G)_#N53SoߖٸhR=j٨#p{㮼;y T)s.6V(#SΝ;t!Oz3mI53ʍe8Yl|a s/J*)q bFlVs= Snz ;CdYUPF#Bk;v[ ciQmݴg]vO(dRaej+Tj{IG$za7=;yg{HJgX(ܑ$4[ܞY`:>&>rG(sι )TN0;58*guӐ;|Ёʵ;(RDԳaS$Y8IZӌ-!ߕw G?|׿MIo Rt =7D59{Bhs9&˲8;;/e*<҂|ϞD]\YQ}."jx`VbG į`$cN=#vhJw h@ea:mGzH`H%mwԑOv~#؆V;49Q%".w9/Gm󬺼 >9BcyNln\)C_Aш}}yQ^"3O;3?U! (H6dw&f2%w~UJ9*Omv5)FxK5 0'(l@({af_AeF8׵K4̄LA@`'" uMg9H `-S i+n37a(prv*(-[ՈqiGvt6&ǁyVLupi N7o۔ BWτl=[o=jFe["4|A2ḱ8Syb2⡆ N`:%6m mǩϏ?6oI[  GtT2" "}b@Dဃ6:f&dg2:q=MDHB209A|2 ԩxEuF}/11Q3NJLcB3@)#MI:{R((U Sq&!? cL>n`֑nNg-4$mx->ּ>0;ۚXn.*cֹ7mE8|'ZmG'BߴiS!+el޼yvv;N 3ZrIB9O}AGME|-+IUEBPͼWaLRU! 2R#/H01US]2s[13k(('󪙅7;Dܢ, -pʳe@hFtKDIh3̸_ XD$ض; <%(ayCfVH L LW۰irvvBHE!UkF-D(Q?tb孊""R(+ Yuu|liڽ,ӴlƇ6>c3]v1=A@z薫w=Mm&*X 1` ч>bӔW|zq$"YBH1cf{bZDj4)nQ!I7`e2;kc2ZShG3=ȣ|;'8/!@TT9d>'zcN[+k""}}ufKsBHp2an~'vYVz?*MZr $ÞVA+ 1S(jW.zy}BvBwgF rw"}ų=hQL+0 U~]/"n`j9r'3* /;#.8>Z*v WO! (̎{pX b5ʃ*BF>xۚ .B*̚*kOݠx>D0sڱW_ t[ĥPBn; ve+ f7ךA7 * "UZanv}w\y_3E5⊛5#PRdA|#)V;Z=Bq $AH36\sqiϸXfvq>~l r d7Laf3-#tC|B4 臷^>!͎KYO]Kp{ Mܔ)'}UnL OۂH Noh3k̖ cL0XGd«f!i/;93>! mvH7"Sf323{p1%C 3Qڵ֛o># =A Y833KBake2K*,JHȉ"tjnλO:pnPՊDko[6UEs(6I2K eȬӀQq#kзᜦi<APRH^5KGqHf"H=3`>M >5}=s-S>2ѩ]W]8Η37AD[יVXPB;'I?B&XV@@?xw q-S[oCkndH%ms$v /}Q ۠8\H.}+Q={ G9=2bf=wwy6˫.#vH |֎舲3i\ Bܵ?, j1F]֘# ).Al̉&_i] Wv\kL,_da<;}_ &&Zv"!RQHH qPՙٹO|")o8|5)QD—sQccmp<(NyW ByL@OJ5B_~ϭ'?Qn'iHӠ!h%,G@첛?^YvIrmg Rr b&4YfMxMoSnG~nBHMW]}׾BG  [9v`NCj%vk.$+anTۄHj&J QɝJ[nWiv&ow$;{DM%NcvM mͯHK3Z-w-~SkeGHa #ZD JC N)s>(pMl|CH\᪚e׿977K&\"i$$u 8',*BP3XA%!BV;/SuAxrSTNJ$8W˿aLCξ [nBbUP`T1~~ΑSn%C9t%@,`ܻ%+g8ʱpݡ/P,w _]w-5bJp.HJ3%@җuWE?''u?@Z| 0i u}PbK`'H* J{(g3%.6SX,ʪo3 KE#'ƕP&l`%́;\A{%ٕ&AZ"ի)^tǿ?ȁ#egg(s#_җd>ŸWk#ƀVg8Yp4O koa,)z@4:{X ͨJ{Wڳ$:Yn47 B '**VV[@2UۿʔH*g%)sgͰ䜑d(bੈ(3|ꓧ3+gVT &|YA%(9ggQ3s޹{\vrwS|_oʎƢ VpWɷ\HsB)\Ǎ7\[Ͽ{liqhG_]q՟_gdO 'Hrj7v=ZZj`X2&W2p*!@*?//O0'qRko;`Gxephy<l"Z5wW81Dq~oT*<Qi A֮]/|=>F  فwDHs\__߈q:јNW8{-cng. _O+P@\H@>79ԭ`m&v]=t7yhrg[ogWB(YR&uU 3=}^zڅm%'47|_%)͸-z1Y4lʟX1bI,,0 վ?G)<536 ހ!H`rϚ>|'WK]lJ#Q\*>K>p]wf|@'r0*AQW~sOc(L?s+B<hji꼐?s8 ƙCYngxέY7\Ki哓&kE.e7 4IʨMEHU/}7THua*R` DEp~3ˈ/Ui|1A.‹_d(0LJ4 *3Ud SvcmMz-ko9?|DFAKe!} \E`$lK#_Cuա/}g[%)0kEتN\MTp˭3'""PV"&'qdİZ8KF::x!w\ٗ0@PJ*u:|?e)@H$KqC#B27vxOMYZQA[Z2 -R Op*He_7Q|Q!0f'&"f&/C f=;= d(d`(]w)rex7vYO=,9r^7 !u\HN* FX$6 }蓟\5039E:TE/ԣSƬ1)2KKm<F%cz]bHAgbNсZ퓟U7 $( 8mRd#Ȕ5kVr3D-v|ֲ*P0 ko˻{x8ꮦ)ŮM.I6 Qy׮;#c1\JL DB <50}K_>ʽ:~q8C717dweGL 2^e1 Gu[XuĢG/D (|͵פ^8@kԲ5<$ٟ֜pUsYeI:'y'"f&_~z2yӤ2ݯc۱TkOtu+J 1^vX4ntfJ1PT8"2^i6n?1(*a}?hA ˕O,\?(#x0lM]LRՇ}翾o_0LIS#7"5T{r%j@4E Mt߮YE?W==>M؉O:r|7~;fK7E(\XAN{r{?~+jl'V+[ET*|b;̽wchI!a=l}骄0G*HY:2]v2d7rEIX=0tǽ]?xbSl#6eZo ⽳>u?}zz8rb8I$/⚋~UkqP-MMGP@vTEm{)kOف֞(04THjBp?g\*^<@r@ $wi?grQQW;`Ԛ瞟]nuي, +YrsȆT VxqP%ȢM-Xė翨UZ4k %õxwM]BdU [:@!AՓe4K~9o~sUT*-^>貁ի~r^}p5-ӆ"Ď(hѪ?* !S -^uW{СX, Zw_~w> ]iPw^9I3Z*uL)E=[+Г}ɏXe8Nq%X/xa]^%[KVtW꒛kY!$ڢ6}:` Ԩ,=^GRl#PZF-7t=> 21{Dwu쇦& ?'pG |X|_갃rFb@ QKIfmƷ㞝BKW8d|}k=؊ZUbh{L*af׿gsɯ~B>-G0N_J LLֳrw*=tOե[fJcO'>;m;oIT*a:Q3 G~m=.~r9d /mx%Q@Rg\2U>Ma M.dXDM!3媨t;Q]-| d8~(P`**vvZ\DytByc>"!wD'gŁYrflhh]!5'"-9RG‘QbaQaDgJΐYg,_?SZYֲӄ$^,j@* Wͥ4s*Lyxmag"CBD( &*#b+}RqBv|ţ8嶠fSܹd$*1w>%"c%cUЈbxBNUHL]Hβ[+Kû 'P&w?n2B ,9j:݃rB-_mg=l]SEFx#Pxpxq)Lp6`b Wı?s hL.QlRx #[O*Oj'R7ɮTHY90yTS/D)$J͑wHSX47(FK KFUx$)d8NjRU2nos:lTE5Ш2AUm lw|{>tWkwMSn\1{cJ9zo04$RQQfeZ\Lw&YCPE0yC+:YDfFS=+?ׇIw>AIB|ڳ;~+>=Gpo7ÔzXF/3̍vֈ+EN#ݿ=A׆N9rPœJ2 #_Y7Xewz xzg: s"#"Cw<&j;]^j"w[3ZD" qG*3ƛo{;KLM~7\]c8{8yj_]ri3^F݅L4{"CR$_MwۦV vm"o ͞%۟.3(PQ8ڥǙe4fi1;"];mi 8,7^UDd poWt[(4-Gig#խ߂Jӄhn@S(0ӑb@1`>6d(0?eRؓ3@j%b"Vfefk=HɬvWUbjeY+CՏ61PyUe0ף!T.eox淇25RVu8%W~\%;MKJ* EQ4fGb&ך4MCv4^4u:_#/c{LFNU$xZp #;`w5nq~o ՜5;A,O TΘe>Oowi@e*!,Mn׾ם{zkOVI9LlczO7t$ByjT ((h@ t4\Z:򅟙XBpJ7gۨ&pi߬;1 QS!+̞PԬ"㧩mJLSHHMŸ^1pƘ7B`kU:{-I b"Ekȿ0)X&"![Al1hASʄ.o5/;UzMՉ;l0[j@z@}ճU@ԼssMmf~xR[B;Η:*`Gh[9id=TOQatuM,֛G 41Lzč9m*ovf0nW׹jI۹= (!\Z! |j5#3gʆrY69+?n"LYB&Eq4!~'6Jy"Q@:N @32BL~ЂumjF!wubma2UP(v z4 3I d"!q; JDx'r; ߫`$|V0PJ !n4 r]N 5Wr6 xp @)M`d&Kwid,hƐR9y[]`n #Iv*D,@ U"˺UŚHT HSP6^̪Zn(DQ|UU&3^TQW).nS$'22`U(gB 6n?:NmH:Hm.Tf5 (0-lA̙j|B3gٌ 0I__I=JIo~4b ukH: 4l<)P`.(`e:/%2Ime, qӆlnígEP櫳#k' %0Hfܮ rsq+(3ߋFb'j;Y0a (Y(A\`n`-!JL lf- :384M4wKs)e^UH` [Y_^5ց#bB2w#>\:(yqCc-ww|Voy9i–SmϛT )"e3K,횛*Fl $z  $]j4tz\iF`>XmXGIjuSDMYM=& $NI ` .¥Aڳ@`ݰ#goXI d62!YHYV@i=1=^QOP T6ݶc-e<4%\Վt2mΣ3K^AIP *&@5!d2AΥDd:"CP@2 ꝶ!eD#.G<]5>?ugaj.5TPn e+x Aa(0VPuz$>6qιP1tN[^XG`2S 6Xl(P`|2gARRK¸VlkY)"ӲuaL)N < M~2sb`=╳֭iGٷ hf.AlK+Lw)YoG$2-P@PtD3 f*VK"H7CBPlXJ)4Ig>O2cՃ".g f klL(r5\)X"xIb$dƧՙWQ8Q|ptNu(~@Ӏ, `L`ޫw*8UU˪jsY^Ot:سS\O#-`N}]4~fƁҐ,[لp,ZPpU|a/Q6jQ#s #,\0)@N2g3ԳSqD!U_SVPjKUv+DD$9 FKJ3 jwޫ2mل)FQjطr)>" pee!?6@tFBHc0LHGy@f_QA";lw_Ow@Ӏ`ְBy ۈ>96SVI7~Bx @af^&Ou.d Yۡ i+ƸJxU_pxZp t:^eaR"&i@aUT29b QB=ݟ`  ͼiXe~fSMV23z_N<}npO2T37[<VYiKA.S8]~95CWWsȓ[V@+@džYgmcr#CrG3e֕F̏$c'^vwAR 1ki49'{P'2 23% 8RRU!xc9VroS$JǑ)G Mc ar[X ;-AU~2O3^V*qS\ e+i 5M#@DdD.M@24*H4I᫉Kc˥4LiZ`iN|CT}+h35 $^j}fm-GFdXAd6xU0!lwtey8Ƚ0$SOX}q=̝V'GYhP/54ژaH:,v((mLq鳲h.UcgzS}+W>ϛ)SQ"+ %c#~^9xpEP{[HF. v^wޜzB}s)$R^2(J[U?K~DjYS U*ĉ±ERłV[.vtί"ThݚudO.{>=I(b"V$I12xG 3)5׫)8һӱ6%aJYpirG)E֥i__ՆdJޑīާ<|U5NLV rO';:st@DTYnSIV:v)$-d3vckݗnt9φV,;{ο|˚k8.{qxw$PMJ9zbUTY{zZ7]Z{袅KgCT]npŊ{nyڵUP2H:Xz?fgC5 BTBtnFmhv5 qͺ{w. Ô$Xj9-{Q{|m|sQM 8@YlRm! ?u]#uPl|ZMYy̱fTLdTW*!jwq8xkڿ]{[eD`bR/TZ*YfPT(K6|_ixR>p:=v1ML=Ϣĩb& wt{}ͷ_ˋC$!t`6Df&LuvQ 6 {3G<RNe V[~X=X._wwue6B(su"K\EĹꠤu=`qw&3/qN=}K.'~7D|%.@1<HͣRC8ĥ %$ƂRΞFUԪUSs %yݟyԑƧ"6O1>@!2F Kh^߷(n㟯ѷ:cĄ{ᔬXFoNs_x։rib7sb4kTK"f0M_UvÜjRkT忡:?UUA""JEhӽN>ydmq`D # {/6RALi1͞yĩ8;>@1FTbٟ~A!ԟQ62Mpڸ/^~m &5Yj#O㝥d@ D|dy37ɲEzlv;57^EU:MVk:ҔU/7q09[G#*\*HdovY:↬,}ܙ'b@#P~\@7*HRuno1|{{^@@d:Qf-w;#tU؅ Mdj1hiҡ`0&vaqyGʶꉡlJů[{B}\Z]}Ox=㢯2jfJ;ߢe V3%0v;;vD\xtmxʞdɠ_#ji+{ZSKO~nxb˩ˤELs28C>w?w~xR_QղăWq؈K <ͩ +xhQդC h.Q_sR `'iF 345}6^̴0(BO+{s +hYd!=4#ryUUJǼSnaG b6a#‚ FLsYcm 0=}\ghG{\QE$ əeIӑG#=xҍNոH+G~Y8]JbjʌT8' ǮV5`fK ؘ1լ׏  @c@ZG|5gm@$6 zk>u]uPN= led YM z Qf[m󒷾g؃bb"m6dy2&U1qTU]׾ 6WK83hK9 JBKwx"a9l.GKA11Rł/w9#I{QU"&bϢ-Y|"2F P]vg; :U(P UD Q-ѺʵwMƘHa1GDAͫD*TC?d @T6RbXÜwwmh&R&]2)Dj>B @N@9ɀ_˵5|ً?p֙]I,SbL2!-=-J Os3&n d]7nϾC)Cu"No2P3:]u#ۊK;clp٘Ik_R(0( %QE_f;޷iW;roeh&k/"N sʈe8XzNo è1QLN𲷼unؑq[@OgҀ~+N]Vi+~)2xw=^wN|Zv8#_:8|LJϸ}n܏# (Mٌs7Iu`6}{ϬD`ŝH :d 8:eQ"E@ QFSuv1Uouvw0y#!{_@sx)ft.F-dw47h20 42EߵY>!If:O=%/|nd8IzY*5xa'>%(f5FBC}R!ҍ'Ip-e Yf@>V RXs^J?߁ }A=Z4jx*f! AeR&eR'Yiu&zB2 F`o[Y`̳!/ՐX@pg$ĥhormCNa$D  kR^Ǫᩅ"I@B#f3 9wJdsށUY0 & #aG6b0E'o~0$uR`KT+?ۈ" CZD<g-bOM -?Tl-nj/4U(Lln EV5apʿ=9=vM_d5alyil30[bM5"k"2fcb]kLyEpʤNqԢI\5Olu5F4sӵ&揘!r%5q:}V[`mIn-]e^Ub".ƀFxw͇zE(϶¬Ld p,.lIb>-Dd$mpCwĖwKؗͷZ$@ @t&P lﬖ jvOjΥ}f:=f/"RwW_l;y8&|I!bym3KP$kIKLR|ک/SjmN;GvH24h ;blUDw9J=f`ĔSiGDWP(A0^E)kAy2weu%e ȦJ) #81nx+_tGK@ t~ F #./\m2LPFÔ#9:o+^+ &%vhŻ2hEQ;dmy8?6ј!L4=Ot֍ lx9ʶlD *6Ɖx8/+ͼU ϡ ɂ'7P&O #YJ^٢Yz ,zKQnV:А7sD0K%ەW]zǬ-Ƥ'la-Q5f *#OT ;֡.0C @Ʌ5 n;?H(n)3jAkdQZE3m0RLܓQ&T6d/3ƿPeD7w|913D!I ɫ]`x,dge#' ƈjg Zvk"ag\'im2S=<=ٹx[>2U#_w}?ɏ𞳎yI+(%FiK#DL䕄Mo<'ia1rP,b˄ fxHtvnjIGHĉHn6:<ڞnē};}ul H6YLa+X7r&e}kZ+aS0e\RQ0JĒ[3E6~;/Wr#Ie\Zu^9u tG]ӟz}E FS6@R3qz@%P~dc/̞Ik|œ0(0Q84O=f.MISPUE`hDo@ӯT *JBB@TEp0H1A3vyJ ĜD fbV {ww'?^v#v Z[?vA7)l h&0lRbMvgTLHݙjLm K6œIEhj,E5 {c5NT6Rk G<-w}pM">|Q+sQ J(8<~s !Y\.?\܏Jzw޹\b߽.wyǶP&#:RSh&qW<U [h4[kN^ynGU?t1Kc+: 0JzR_TȰp9*@id *4"鮑N? DmhăIO< 9Jĵ5+mTo*^]vݩ{yS޹IEzvҤjl׭}:e][@CT Y&PzgDD1|$%Pm;-yD&'졃!ZRZSj1ܢHK:[n>|t,w*4wڀבg:xRM b;#6oJ G8#晦Ҋ5k#3R ֒yE{wէ5TrɈ|H< 2a#l1%25_ qXc(qxs8xLuS}UB:lˎ;_0٪"iêL 8RGE@6k.᜛\BIrȧ?lfFR%i^* a@C[T Ҝ(~ݍ=\爽Ѯ4UEm:rힿ]Lc9|R\d[:q^l55]rzN?xlO%1@`$2s\`+DkGΘryU%%blJkzG°qj%. W/'xhU,Q)|E2<' KRsٸ~sHJv˯n+LGrn1X2&@ ΘͷHS15"9SM"ħk, ^z;pI'%uB }7l47anz,!2_Sk&47 ' f(5a:+|j@ȩeD -ә (Y"rƥjb)-3nhRNc"! +sF%{l7UU5ui)wqP;P.]:o޼j n;ѸTlW `b )UOL`%Zy|-ۦT)'IX o6@ RF4c~&gRv;@8&ZZpmC9Ĭ,0KRu^sv>8QdsLHal0ք>CZHU𴵔ю;8a͖.*uE\)4Q6Mᱻ4dQTԐD*2`zHO@ 4BjTx iKCg>FUEeI`y8!J ˛ojMjA_6(fQQ3{]]%(R(Rӷ1fnGӻpQ y]JVM2|Y)4:"*0h%&vardfLxjK[ LUPsamGq3\!&=zㅽɪBcARD1}g^{/=" Dj9TD4Hܽ7^u7@JB* $xz"jxES+XpX;H,+"7vRsMtc޶a$-Eq9n< ei3ߦ@@-[ U$<__ hLW,TG|H3}XL`m@Cb'R ZKr3Ϣ`7+blZf-RK?eüAc>^(U0 3m8K垅S4hBVmφ`^PK]/N2 ASPha+%U6"Nc"q܁E]RW%TՆg(@aع@DqHd'O,^Qq/KڀAxrY8z jNIRGW\ރB;.ŶRJx ql"*wU=3PYd@<֬`1M}*0b@#o2#Ri DF5q*ydY-#-a/P_PrEfvR+*NTUTNfJW+ )|IRqNeChb5"d,{T0e9ڛP^ 2;6ECMY2+{KҿBAX1#N(03I+k& 0*L-^睪*ĖRw': "ޗRڑBP&\)P21̡͢Al|+!+FT!r96PVIR庺9#ʩ3 k:&Β"/fHxVBvJ*Lť>2SZP]/W1d%IbXmScNOfx@UV[c'NI:v& gpd]='ש|謥o=ϽǩygpIMUn^9-HCJ6|A֧~n)ڒ- Ic3[BƑ#@BjíoVARVzRFR33@$UO͸ )VM[r9P'F64h\_ju 9.+qd/q(*`^{YETD/%U 73If t컏 {7UF #{ģݕ]t=un4$AdD$e>u=t׽8nq c(YMbcҨg4#i76 2nj@}dzψ1&MS""Xdc/H3+j$FU[vm 2ن! 1iI9k_ {Jˊ!ԇPr-Dlv%Y*JeRP]*`6D,2iCN'EUV/=:;~}w?ܗ?ZjmR*.ʓج5˖n{m>mCWw٦Q*!q CyCrلs4mD488$( s=õPeɒ$qF4ebq:* kV?v];m ،(~b=Ūʀ&quqٯ/A(͕_1>u:4p߶˜HUŋF+=‹)W<# ӛ^+\s?aʼnYOhݝ A>hI%qOi f9㧇FiE^q-o/sZ̜&q8(l!(|v`= tn@DԱOAl0XLFo7mxA;+0Eԗb7v~jAo'?g_6;|>>,#iG]D`bU~+H`4'(ǥ8[}US(&"Đ޸<$Ohxz᯾wAmUY4 VQI#2qWz1Gt5tqn (N } _lLd`U5΃,D0$Z)"ָI&$yH81U\T?WvoR-)D*˜ql@'cY3 ʀ͢ }/^4J ƞ Q:k4TkWV+FV]wĆג2 "5pgl8]{ԪQl#[BnS NaDҡ~򵝷gǖ]f ЫJMdA#e7jl_$Rh_  ɟ^QD0"ȼ-oҧ?(L!R/g_\ qx<~}^E@@* &@u}5oI]g4,9{?C\YdI"Oի^.C]rAm ftBHa?IƄbェdJs`,L FtaKF#t1T_ ؠth Թ4rIc|SKPv#^v{0slrB{$a㪫Īc8`9Y45IuޮE@ ,]u|}-7uFh9QbQ7j/8_x޻nY ` lx .ٶvIdMO^A<@ʹf ]DzKg_iIDAT©vST;@)ʠaw'VD.D9sDci((u۞z9uS&y:A JDReB;qzbuG?ygmB6|s`PZ;_WIV5LP!r\8GsnH3#>Y,1>pͯ~5ߒ$Ue,*A%1$T?Q1ͻ]wmCQ=xO/5ޱeisV\JDXvz[ks4=R"6q o[oWW`zՊsham T:N]7[oX띛1U>_H̖pRd`=vy`,h(ڻ)&k~?W'5kPSw`dJ"턽CsIr/4t_p t:rnr)_exbSk#mwVp+ZnN.Wnd+6ӾXz0q٥xrCD&-{nx>ЕpK_//]؃dWrlϳ %qk:C>aTC<`uOaN eA{ze)JuyqpU$/=}/?R` ۀn2_U&γ~_ÖlꈒDP( fQ'XW!% d`3shsX//\|u׽oOKFV'jx w~|s[V0Q%@Uz73=ѨPs7=됽3}D~e:Oow/$Ra(A\#s"9&Csn4F"RV5[{x۾oky^BF04U$"lW?..-V4%o8~DE\$<]K$Cox!#pDpm7}t|7<(5ay@Jߧ}҉|SPˬ^GD ,~p%Y1Y adZS:?F*+ `U#\lDP&rdP:CO=h],E!BOXz]i/xu?^3~V/.1mvdfc3|K)Ivџ|Ry/l~ |ml2HAԙ$q@] -}m ݥ%kSE_yv h%dB3`i^hvk{5.e?xk=r;<xhyjge6z@,;jЪ "V& MJ޿^{7pG\g$Z ՆFOK^|{d"p=jh\ $W\oM_w=0yh]7IRX&"TRp៷}E?ޏ~7\E<˔&@ۼYR{/ԫ bIB)4 :oӸMz=E0vT:;n9gJϐSu`&4$p`EtqG'?,5* {Bwuߵ7\=ëW{ϽswoDI8w~|LFU̥;o=9vw^j I\SxQlT4l+ Z F p>s|k_jM?EMY4hW(@d|Ʈo~O?e֌])C{GlxE>V*)[(6xc4s:֨Zno_&RgH61jDpu?c_{_lapݺҦ |lJWr*7|)/}αG^aW@a=aP~c $?o- J=TP S;)N3 xp t:F;4 wM^@ ^5uig8_\ra|^poˉq^ h%P+not}?|Z62o{zUox>򁗜tVs˄@`Z]es֙?ybJ= S庿 X\􉏟8 ^nD> Ǒ7܀$έ^/{'& o5г~1'Q|:7tA5Dd32IԿ$sCEj "|@x_d^odoG\}˴~;_zkp*{7O R gH-{J?ēo'<Ј1U5%Ul:E;7fw芩Rm3wbD~?"V<}[9>Ѓ8wf+Lp{ p&Y?嚫GuNmRF @Ҕl{C8+8|(f^oXU DREKtu;lš^{od'84VKW]s=^Mws-#OL\{*2xmݽ`.x??пnݚad88T^8 zWٟG>==:!>@(d6]}=9|Ot۝qWT5y4@F٩i%x`O<칧k\x؋LЌbȗPQ!Xk$%Hw)p#/nW ITF[ڂy>'uknʦTRզ; ͪwTJ]}"R%C P1L:Ȇcc>0"LHb@ F)v+~Rj@TB֗5Nׄ&k猉j-R 6ݮzw{E?K2K6Yea=Tɲj,9|C>s Qs-Ėl>&6%_g>}E )C?>uO,_ʫ#c{*=]l(a`p`pxxp`H D˦So l1M t>K8$%z~fxCZT <аؔY=?5CUלK%7al߼Iy)䧏Jq︛%!VbiCOz_Pp ֤檃eWov:f(A KkZ=`Vk.xpSNJ?\v˭>=t\!=1%2V=qwU_w/~q%`#8F!PW^'@a2 XaKZNm^FDx>ћn-'dIJ E{P`ưħTD bޥ)S" {́^_L9#2sfo}#uezA+4C@2.$ !m ({IcbuUK+KS'^4_r qJC˗w뎁( ZO!%E=z{?oZr[V f!o%墟r5{q}޶ї5/ Tv"Xpť>y߽W_08n 2\->|_|ԡ(GG'`xQ%b{O$$I e^2NZ>D ize=>姾%F@=)#R{`~|ֳ>nEKjK3`qllcAliƖ5 CCw_];`#r]l4e5hͫZs*!#J+jkVF aJQTV-sOϼȘRy~kN~΋S2#>!(4L;Dm6]/?ز`{qTRU%X)$*)C^>Ľ>V x6nM9 1)j^{gq޺$bK(b"I=NAKV WkbpxMRĆ~̼!y^Pׄf1=-&Djk[L<0US-٦z̏?~꾊N9p"i(e1W[IRKOL1Dp8'ޫWR{:;WglHdB(:h炳|3Q)b$4!c25ettT<)وOc߾bW]Kcw}/ut/XMd:4וO>y]s˰lTC!ld ;'U2s u߃o>%ᅴN42nl={w~>/0Y+e6ؒUwf3^_u)jZM<5S_M6L^Sf`ELIVv…;v[lSdM{q~`ox{7c2YEJHi%DhFY,nK^v ַlkOe}r}3=$-\EmO=2LJk(w$(C*,l+6S?(* Id;ί0u@(!!H4B%J]( \tKt:1&۔JS[!Yւhd4 C&[^3^{Y_`ZsKBu-]tW~sU[T5J<\]w$U=W6.9"YDd$眓 A"`@'T IDYvwRwU{?^UwLowBOիWn<yv1$PĽW(щB2J(F ( a-aDj c: g,zϜ.]v:l ,T90P^J}[Wlϼ9s%|IFU/:NetM6>S?SS8_.C\uT T@DsCOvܩϽ"6-JbeP( (ǑJWQ yជxg?F!DP:{ʔjtE=ho/$A%z#afvR8uR=oODĜDȆvXxw>[~lDH+q iP?jiwrevqgPRRK2phX]L~`6ZCh1\.;sYX1dٯɂ{ ]SNHذ!rȦ"쭟Y bB[{/ky#=pVJ &0*ĄlWWwNj6Q|DH̉ " kXc=}\-b#̬K(bb" PRƄAgXuqT:vIǞto~su*`#6\ r9tXzY{l8MitٟSV@ϐx3o/+,e@ڵL32֞嫿yÍ3˲6 mo\q!Jv*KE$U+Đa0?8_B>aزȒsQkEa~* !:mʮ;|aRXӖyd'ד8W^![a(9TOo&v"?pNFK6Py'::"d,+テ$N$A g6hmK?M"v.]3r '3}8` k5+b#JYEFH&b A)NnTC⯸8u$lLKw~p-64xn$(U+_t瞲= 2` Fj<<+Ȩ?ey߷D@unD`@Jd aק ~- huZ$gܥl? //_}߿6 Sr kT2Xi2SM2|qmi$iIdQj;|7=%aN04*j C#m,J%bB+陠P]Ē6Q 4QVB<u؁;}SEf֚(D`b+ο((unG," !1v_3 ӦS%BpMP[g\ZHƾ' 4(pYum1m6 B`&)Cةs/c혱kћ$:g)V[A,DcoΜsY~?e[:(5*( p/73E!JT*D1X " U#X!jjͺlQf$BaƢѧy};= kFOt`sǭ/ǣaDU8&DQ:*Ra?`V^n]v$.ܔ]kG;'z %5.Q&bbcᔔȹV\ eֺ׬=k"R"i߭f^9D` }\}(jȊϿx1=aǤCUGaW7`َ+.Sv! 0s@no/tPUAJX)%`^^Br-BFc;4ID^V!J z7w0OYjZcf\uV /Rq,wPs ?3;8唝w؆y'Y/R"G)]EgOnzɧ2υizG)`L$R b ȝd^%aOʉ$AX0!:ʕ/_㏜uIky¤n2KڤnǎzU|7Ga $DdeCw}#4S="Md7Vu/k9'nuI¢q9F#1&hI5"G5kzlTL sQmx{!@섌[_ol;Qd{58XٚbgB8b%CXlu091DyI#.A O~xޤhclY(H} $(wqq@QwJtp<.0LeRZa8J% AXɭ?o*ioj|?+(Zn]t՗_2QY+)Q%^T&Fɾͯ][/4$j}o)du\%n|ɗ-G 4r`c 1sXe,AjI 2jzezsSh|W cuFc+ɮ;l}om qȹ\մZn;xwc3λ.vƊzM fzeDPrJsvNWrCVdUOh6ژ7&'5-!z& ]P|wzSyY+ ^uWI&ĞR_ԢUYxoqJkI @"NU' HYY2֩V XkWʩLu^2T9EE9?W>;xXѴY]†s`6j_C1;/?ν,tM*F9_"TDܬO`zå;ɔ|T @(&y bHaы A8bpr{^}Nz_./ irBk)\7ȺaL1PkW_/G%.q\(|)$]2@I˯>ӦL*!%GB̯;ϟd2' <l BB$[n\ҫo:  @$!P@&ܯ""%0!q HW|Q_CHLdzUIV9WOJdk:%"gIcUV9t}w/deTn;kp3@)CNbڷs7[]S ZKq]U2F νD (Ab-v]a*jE"Ŧl(Dg09 CʀoQ4[i*;, m|;`oJe9mt䟰UQS {=("dlAj% ’-S_tW;{}"" DE<'&BƦ|G?gd;U`;q$B`7h3N=iXbڔRH-y˻h_ X @ā ^}s^A"R }$XYd, [fl0Oi(YY:HTon% ~fi$!d''>_y>Qe_ؗO /l*>g};KSGʾ迺,o[F9Vr\4h_$.IB,ĒjO$Йɯ@ 1=m1&ُcT'VFuDT "`~j-SQ3`@UN6WgΜk?q`MZ]=J+oMiW[߸^R1)xRu6 3S+d)X0%|s;qکG|BăFrG` _twOzHr>SL=6|W^3R̈́8]j@:+~;<ܧt v"K e2~?`a/֕I|_[.v0qjFo(r?daT^^=LPF "hLD YWYyQTsT$>|jS5<=Co m69_j5xxF q}1BYA "9y7 *G\FR_{}}fNsĉ,0%o|5Ooe25,I@8^3NDV@ "!ϿVXl];bqhLJj}2f T!`M%>uοr8irEXw,Wj (B ]O66&WząbT*8+ع;00@Da'.QDb f7E[_bIb Vo[Λ6D."u E M'{}?y׉G?ξɐ8Fl{|NCVW]Fu%o곶|9P jeήٙFs1!$x 8@TUw_~s8ӟdG2A43K-1jQh86Hb)\ŏ΁K0~|SdX8*HjTD`*uuBXF +&4FښU__8䲥%9ICu-2Y_<0UKQV/U_~Wލo7]SXdDAy^e`P=+!R}4*DDEZTҿGvTԗQQ~$΋0<+6N9=ۏR'Xd$]w/i%OݤVvhc^A*{on&mL>#8_|g+SP蔀8>GFiL)JLLPȹB A~?}cŧFOO!;~:̳x%PB&—D$P0 U(R`fp}W<Ù_ւ 4ԪK ֚BȾV�ǵ': N%:}Egu% tI ߰Fx˫nŗ}ͷ KTT4Lq~kLA#:D WXu>.v٥,S& bYwOnʼnD<6_@hs"o#o P Arwm;ppLp ,6?N~7'?Ma+5i&/ѾX&cMHd *HU:$9(F.Aה {lv=}W]~34,:(&#f[|/é?MBwԷ^ %TRE`xÎ>a>wԑbyiّ%M뇳X)}pepo^qX`M?kn J IŽ=|)λQT3HԌ6`r<$K6A%Cm6j4uz[돱إ2[eiyF3oTƊui_W~FN񦕁eRuM6\Ntu/H} 1lTǼK6wy|h.YsJݱDaߒX`ujHHI( |/;Rg]'j8ip7?:!gtMBLH~n~'ۦdmR0l WݟZFkrѥW̜ AqYWC__?! 8%(1MDd5ESᡗSvT}-N:~m7Q썤ƦgRNJj0NaA{{/~LHDU)nXa4E1`H b {|%TEMѹDb,Rhc<XdL.5˨' hU@&ĉ-ˮ .\ӈm$IBU7[dغA Sc(WIIZsܓ!S,f [sN>} -?N3Ho|ⳛ}xpyw)&Ӕ?ѐ*7#W>72F UrӢ߫S46hRm ox?ZO; L5:4@hdU?~Ox538mQ3t蠽w8嬳|=S(ڠfmHBD+10BhAǘ.p! ‰Ss]=|+.(BN*1^rqGQQLIdPG87ۛ&V `b" U]`"?;;{zc=p^re*tT \tWwz#nLf,6v,1[JhᜨjG B^qe?]wl2\8s 8 oW) j^6)D2;MesB סAך5q]eVYn Ŋϐ)p^BD m&i$:OhTsF#)l 1SuVZId !5;UCkB[N۝u铻! )&UX9~b6 )@L0 1U(*W (H[~bA&-~{ǎ{{U߮(b7oG Tcx{egt$0 eSĚZ;mZ9 d,(bZ\.r949=^C9ɗ^]Ԝ`i1'ǞzSt%;-:ɆhS[k 0@Ph`F2C(;;=SHWm޸ A HR enj~?[oEaʢ v;:JP&x&z 6NHj:ZlJZ/扈k'׎YN9dtuneҗ_zk{ lC'DZ8V F}c?MDKPo &@ ȤՐs>f4Wb `}a. JFߒm C|,`MiH udcf1SI}=d#Th6t>}w4!8bAE _y˯^SLj3DF_%S:S9o_m 1)`!Oqm DA@ƕ(BǯG\: 7lv?O;y ;*c6ƌ Gd1KQFbU!ʽ} rO㪯\{WV\rQAi֏`_f~ pY1#DBE'.;ndCjf46 J`bTVuKX3]^b!v|0&*Im!k|2RUUQI2q ]l3O=iM֫}PPR j$|66@|,DJA䣶u*I[|d"ā Կ]id73[;?'s:AeJT#}X˪CKBRb$665' Q j֔AdS%k\:N)aq f_^zyv8m:K%3׿MHنklwgEP";|'a C `VQGs.HT sy^H獃>޿yG.( @Gg{NsD䀗)gq/~Na1!18_ܒ%TS$ ^?~aA`!kN 曈vqd(陲袟~CCk.51kaϨKD*d "'Ϙ`4I~poIaUB꩓֢NOۢ;M- 5s 3*5i%?B!=AEPhX 8?0PqHMV^ዧ8قӪHaM6>{zE̠!de!Qnnmy|f?/H(uPBX; {(VeHP@>.Teb\HjWZ {7afSǙŠXJwN0}I/qWZWc"M@R*Tjq$Iԉּ( q HUٚW^-EqO?׿G>>ц-UkhFJ,uomO˝֪:BzU6ڸT!6B 'eV@GdMED!]ug,HE3(u;|6V,n|lsnJH?nG&tȘt9$M?уwO~|FgފlUWꀩkor>~w\mۃԮSC FUaت1Q<~*ɸBDiLSYȠ`YuO|~Wtu*ٞY㪁[ϒ1Kl+=+v6|S p% A8rmo೟}*;mٷjX{ˠs sZrtϘ!,bVy]vӵ$ę!JJ! M1R^ƌ$$13 4RbTX΋/~LJ>{z{/"!Ķ4K u_o_^w~$iD(AD-#8@]{Wx+,gâc0Q]U)-^ >kv~{~)GϘG"~f)3;c?-^* 2n7P*Ycd Ƴz/>@8cUՓQgWi>wqǬƑs)?qy_~2pnnS]tbFD%r!g49 }E5#oU1,S\wqZepN_&_XxOH(%a"&2՗g XN;yxTbBb\$M6bz[ifaRdM©3Ho~ELIxbXdi)W)G%dxsϿο3-u7Zzq$`Λo_NZ~U]wH/eV0$(+Iro|<-? & _P\Hl`zc{-9#%HH/|/F]!kҖ@VUAVZyw+0B\.QBpֹ}ߊS07cu>L #v.1fPP(Ԓ~R{W gcd%C}Q%GP~T$b?☈ ksF"_>DgT\jŴF%XS`־z & 9ib{Ad8Iz$9Zm;O<#xWJfiS eseŽN9J-Bd22%\rnj$FF{zՖ_/S6W<+ogL\ r@b#iJf҉|;_ dF%3c'@jUdHSn{aC܊@g?={מ;/5c8"%|<PYmwλǞy U*`"c2!`"V$&(11h/㕗Yi*(&k% E26G<_"02̺? /w ::63IkZ ORY3 4,wο:Qe@h\qhRSI"a)AsfM6mUVʲhjP J]Z* cq<{iӊ6jދAc;JI캁JIY&PfcTQj*תb*Cd*VlI.Ȑ#zh8Δz$i>,XK.9ۂȒQ 7X̺>_bQ'q+vbUչNɎNeKɔRޱ{?Ԏj?Hvx{ j(tlO;ƄFb/*bG 1,#T͂/9,TpQG1Hgouڗ\[~s{@X,Cg -A&ԓkaS,ށI{URkm^2TvYk~{\;Jzhn+МOOoAXΘL3RedoM1~{ɇ6,i"Q DM?.;[ԔҠ&ڑ &gsNpeW_%ŘrM-:M'=NZ6H@d 2ծBU榴.ܡ Rg)E`aa U % `xP!e>G 1UU TRXQ_tU .ַr MƓ1̡1lWfMuEm,h;mLt:oi\`dL0ZU,e ; %3 CUUshSW0IF &G2)/W :U|YH,KʫcJ3[t$U,}X T! !&(ۻ:kxݮ2pEqz߯<3zД΀!߯bl 6f  ٽ}(x$|\B1?v駭 [=&-|Iu̞>䎿Ip(JT"v{1'0s*lM,D!EMF*RzI#Ft&2),!Jdi`fV?H@<4VCucŎRk&WH^WBVn1Ѳ z#P- (8)c} x}8W6*L1jba|@speU!a_\{sF\ gU8TknǢhT xHĉJa9P#bY+O.p`{镋7yN"l*Q ! [j*GI9crPKL*w("5 k#!\ƓuchPMAEX2$E88ԑpZѠX(L$ Tqhca9 u 05@IN D: z{rWg,T %cp!AOy2T" D@rY::=A7I‚$`HIZ%Ymʈ$B8#6EfIj)hΆV7|{m 80RDl?kJGOi )C9"6[u,5_$&$s!ے'3ՈѫGKpTٕePƀՖvE~xվy U@$׿zRn:C.eOS8[~a;bv.!_}!e&. zOP/GE5.찃9vs%3B`rT*lm>9/g(W)D3g|i?md oa]O<1l q96BctO2m⥣SSȠjut)VBJꦄ EA HˉD]fHLM(? xE j };hlRI_$8)) ı틢t)8{V́PuI:{6ݶshc*R{3?u.q^4( _01-4^+ ̓%/|~ ].T#nUY\Ǟj`͚k[q9E8…p@B!C9 =JgњYQ|(sѲD9m;u}S:&M%T(Q[|5_bsw(I7߻+W?ؿs-?gY!2 =vjOqw=T(puV[hm2iuu-^r1{\MBE6,:E"ףO8᭷gQrL5L/`gm͒VȌ)Ji`%ʤ^j]v㏛ZzZí2|,_/gqs/b;&ŠDa-lRGbGGLDL=>D"8% wY9ɇ9$IgRZ3IFxrK(*QbY ւ+}g]i4LpɁ^@]bLH?{oS.v%A8Թ$d 3_~-c"%ň؄[m1[3y^J/ӷ gxBʩBd)Yq?ΊU5yXNm_' .@+>|âd)QQ#YX\vSLRWD3@x UF6 p{:YYً.&`fkV듊%KNȇc.17{I%˜VaVJ\گ^7nz'DTesqL 52L( V1)"j (Vbuw}7j%묘 pP%XO>ntwG06՘agxpF:>2sowұnj{Ut{,sNX[.\wSShN"oΜre8 j@9/~Hʰ|hZ?aUڃUgC2.=kAQv﮴†XBԨx;뷷͊?f~f+~& /=z"Z_CtN A9;l,)YɲԼhc!Ah Ym$IE 2%$4Ԟ qI"}{*ᬟr=ez\54`rDF@*b(,o ;-XCHwNK "3V|Ӱ45~ j¥FH$e|B qBQDՉ"b  JdT'g_z .~'?z)y,\cu0>ٗ.s0s,GshOf?k`ABAQBdM>o/bs]& đcEI D#Ur&\YՊXAV\f9`=8\5TS r5Ɣ?y9^#vNjDNcM0Κ a1L81ad6[VAyI|^TGWYkd1h0qa^9hICE4mR4{<NJ?,D'e[mK-?822;P U$vDlY~ƂDSj`XIlnh:gI삠FX!{_űx\Kܕ+Ig(g=$"PE08QU6ppq@iDD. $Vj4yARUc7sNk&UMUةadHߘ8UgK{W|gŧsZzNp}}}Ɩ [^ZΘ8BshWl=Wۍi^Mt/V|vXg*]`V]md5i+0 \̀IK/G?o>y8{_\~`UW5rr?,`ʱGd' P,ۚ*idVgM M  l'}3t?IM,kVtwŀk1l뺏LWfC<ߋ BQ/K+v#k,BB)UV#lfQC֫FZLj:?*%QdHһ2ӯo H҃8m6_rp1}FB,tR0 .8R&tPNssEz?߆N%ĥ͉8H.$%y׈LzP@s[ LUN^yS@ ھn)^|,urM'_VmF1fmDs#N?LSqkV'7 PS0qVRߕ:@ !%'DMou"@"'Xci7~ү]|2OQ| SR*FQe)&8]#bUu.x砅/.K&5ȉ0#꛳ݧ?qW|ԁ{t߈ʜӝgQj c¹_z]v_O&Qܒ%ۘ#sg`½)W?9(AҠ(ҵڧ yOo ҈|Ch Q$"vw]t(}H<9-3(.4\Dsh3hw=  xϾӿtޓ~.)K%`>sm?; {LPgZ{ V*H#ִF'ߜsx[VXR6qբTT L}~+&E I6pɨZ4)mpv~fpW@Iq9Jn!ő: հ0Z |v5HՆ >\^OPߔ@-h;m(`)J-nů+R3gGn v)#ȟyJ[j[2^qZI JPInSxwu. 2R `o%7"b;{q?qO_0FJM/~6XwmMUB˖! YJNQ֨JDBtYDL!*8*r:qFőTʛ}t~꓎9lNX ̨iLfCM7Q$_>i:$Z0U[_Cߝq#J1% u&xJD-[Di.1$֑$qN\'?_pTO, 0d2VB"p@/vu#|ګ6Z@nal+zmKHRþP `<ˇu\ `T z92&@pbj0v3V`lA=AGyW=[^ռ+>no<M*͙i4)Z !QQ"iʂ pչ 8%Q9.D(V@QW5r՟b` CTofãO~i_&-2 hc@!>3c6 @b8୙3O;\=S( $Iښoz͓6XxvX80h٘Ck!p"+vٗ]UƎ=×G9wz`IZ%LC㸚QX&:͏0 }TlNe_PaKNR%{vvRuπ Iygt7Ȇ D?$hRHޑ[J0V*6sQe *WrE\ZHU4*d _K,6d>pQC]_teMWHڰڭp#mşTkXٍ8qoq?2qJ bG=cMI5Dp҅J ~iB[ kab(q$?we_yu֙+-*P*,7QG3$'j焁s()RndPFG;Qw^6pUsHlȉ^x>u喢sWa(>W_[߹oJm(ʀA2"J9.$$H&$hZT)7?x^5^\V&B\{s*_ Ġ`Gx?ɏnwgvL_aQEe”k6M6c4Sl .j =fw(7Jӝي‚ W]woֻ&mċ x?/qM/-uA9QVJWoJ.i8ԪdaՏ'o bjpsz9ufSQi2olB$+_4rA\AP+!/&gKϺ?ēy曬' iF٤U|3N>#cgOԱ|\T( I"찃E)`;;V^m<( N;P_zw(ꏅQ49݋kLx%06!(qК5쟱Dzh>6&4&"BnD2)Nn 7gTdX @{x>26R1?דBǬcꌂRhG΍?D覨e?cs;kxo駟9|xU]r!";xYc,cE}qmeŶ `K*DJĹ'u: liSO;}uJL2<)~'_/Fiʴl l{Mq}Bc n~'v.Nҽq &Ș.]We+MƔ/ȄE]Y} BFcE$=cR_Pd@U)BRP b9`Y iyNBǿ'?9`B` њr2XN)Q76߿_8)Wbb6fFr\9C⟖>=€QaR%X P;LQ7ġ%mQcb]m“Ґ*P@D  WmM"7ڛRF`r?{.虛PA$ۑ k.U)IաJAHs=Oo8eJ+ɕx J߆5Wuʡ.69q+h?*W"[<#;^]E6Ɩ窺.~z٨?jETװϝCJ׷6F9vo ot7n)&,2 9_)¾BD.(06Q<l߾vKN} гO9^|B$G`VZ3֧ yb`Ϝ;({~)MNm &Fm#IOrdfc?x\i0p L~Okyd2'(O.~Ӿ_{3'qLM4_JȒ6Ln?U{dy'#L""P 5JߔI}o~}ϭ7 y,0|Ongw_ %q "Dѫe4ηo@ u)*oQFNЀ|?F9E9*!xwm=w;樣]|Q`^JՀ EzֽM?0(h&uB !j+s;nRAHRvj<^xOQBw,X÷ڨ S9QFKhghc~q!&dAxvً_UJR%C~" fa")uOwE:" HW@}V^z+}b  45$ʊ$IFaDJ?1KLZQ)Z}nu~sJQHZi%1f((W~]r/5,P#v.nP? "㙅 <@Ye 2A8 u.ݰWDZimF+X&:@m 0X;OVDI>ugrpoVGrkR  $I,H*^s?+.`*5Rf28 =:_|(8b(\`xBԌ0E&#"˱F m6C_\>Ո6i2h3 <#(%@L`~x :V8#F3_0e;=~[m>j[P{󭕗[~Wƌ#Օ'oTfYwyFBU#UA`pN?V:%f6Bt;A_- s\O&rV0FIFU<,m}w >? *␉|x ܏T@" ;m^xq׽;_zxєM=w޴^Pg0#$ q2{)'|h hJX]ފ;+vu' J&P J|u?9- ̘'î.̮\!fWLT{u\Xj{A7"ס!b86λws:ZoR9u4.ZN_ϫFTHD CRؒt*VHg19r"K^A 1 UGZj< êI =DyD+,}.jtl~5oDH"^w!sXh()T+𩛦,o!OA`KO<#=SN^b)NݼOs2N\{ /%RCtu,]`lI9ԭGrЮԴIKh7ޥ.IBU߹ʯ+a[Ff^0(W7kY5c~e"iVNnPtP\|ԉdxv: 杻'/@{1ѱPT[꣬w _??t3pځeVl Ȁ]NÎ>gM}ܣO>c ULlOUZsku7r-Ym5!*U'y%( _ms{0KU[fu5 yv .Ɇ.y|y P+1~)8'6ژh;m1 Y}LkٛbaapJDbMirw߹vHV @WN7>5؊d>| p;  }3^c./_p: 9wjl¤ɑP8I',&j>?+& 3RK m6m1O6ڨ?j$sT]\IX1&fH9rl®wޟ .>cKeNϽ 50d(@`uV_;Ӻ;l3%""u30Qp6Rm):i)5I8(vu^}ŁlHXZ['0kuII/{x-D1 D |ǜGH2T(϶ $qP1~41K^*&:Qc4 MM&Zmb6"f^͇#Ο<F@l`:n7?IYU:”nHTvq$.I7ގ\W#:AUK-Oڥ `Fj!b@5܁~ӟooƖ`Mk2rр O5%i!MLĨJm m Lj3ͼgXLlxȏ !,{dL2(9 bwLyYyr#{$M+ _zپTĉQLB K n|.wZ*$3{?cO{sV`#IFF$i=} F6 Pm |št~NJIBDJ. (J` EIDATB(PDAX`k#E~~c:sniF;X@vVYs=%.}{H!!vMXWXn=m?ŲSChӏqU} B?O;K/iK`CRo5ViW[aO#C .AUQc$3ꨌ!ue8LBOe}<1º^'JBI)`27Ѥ"ٴO(ΰwPTP%ʬZW_I-Ԑāl&BZ= RZ&@,$DDP( '#-ouc;Hm@BV{[,AkQ^'Zpj|I.4QCSmV _xa]w96-ț#=[j[~x~K/>tu L0DDڊ\l(k!Q%*@ʫn+/3R(5ufxSD`w艧B:QNyuћ"a0AoIJ4Clmf@l6ژh;m,H) Pq M31"W 3};>ipYxA]m%:b䩝 Iڮ_o~ynɏ}ьBc3 nmj+}7薟_,v(9:R'^wb?h*$00Hn}#:N|! 7r"}k @\ՃfFTMzѐW2-My@z htpwhm6 {)W:ܚ*B 4NhQea LY8JH5ARWw=O?ˎu-*qmȯX{Օ.M?ы.ǟBP%1 cp5 1*QKϘ~ȡnϬr@ՙ'ެgxx9ٲsLX0A! VBexf_ejɤ~m@:tHߖ/LPDH,?DPm.i!OkQbLjx_y/*ͽFb b/Z+jkqddWVh5w/hI 9LJYr#b, @jޞS<ԓtʄztbxVVM%0vf5V^3tw) 3[U݌ʀD4LjG![|}G>)Tsygb~fTH 8{ UH* Bp@PPaZkNm)z_ԁITUUTU(D%|{GjY_NC E7Dׇ#j<`Л?GoW>X AZ01&"0p$͜<"%a5 hqb9B 泝K6&>t*Du^Lulmj"VUQ$X`Qn4O\tS! >@Kg?.q[p&^ukvŕ__J4.c믾&Iœtt}a}yUWbb8f*Vc`Ýs/2lhPJa"JZCc5.ms#Qo⧭M1 $4F]^d(Cxm,h;m,1|@ZV [ %:u>;jj"!$iu5PF. ja|ƨ$3씠, 2R#.{7G<>q"bLí/r`%?slGǵެﻸtTf%*j'tVbŦBEad\evO܋/~1PBW\E0宮ɓ,7_G``!'0Ta cMUV*֏{αzͻ1jn*v6J6GfٹY Ǟ Rpw KR _i-&hJ5{QWWT;=R 5!.t;?c^t~w0` v(3N_aoߟzs[yD鋝3vq; "aęV{R_KA$";XgAAQ%_lŗ]l5ʸ_.oWP#[JUc<\==wX )m 6R{0JW~MP3xrdHXUD0[bbq$ &wuO[dZww7͙3P;<tt6ĎMCyAg D`A c8%Pk'{ &bM_z_ԁj"ð$"r?ůu6[z'AД^|X__}?8 04_ mf/[o]D ?0Bű!kE^7g}7_x呺#$J;Yሌ  ,R1cuV_y U!&@-=-a v#EǢr?€mP( a!H{gzR ØRG)v \Ƣ;:xU"J̃`KCE\(R6&:|uB2MUI%6f7:Sܭm6V\Q6֒ J,-|xjآ,ӧO/7ϼOctWXUeDA?sϿ=ƅ"CHغ^نj3rӽĢ\*h C%EO[do|`UWg~Pe3V5\$ҳBSF 7@D S ( eӰ%PAg`EC?SCfL4k.6@E}?q>kH!coaPLEȱh-^?,R&C #a,"܂Ȱ )ޜwW~*BlHIUME:(Qv/kku,xY#QiAbx"B@c.r#"0QO/T\sC}Ol ˨"$̌(q3MW:߯;~uW̖n[y?AF?g)-T*46hB3 q)_6x`,7x70([Oa@YeuN:|-7??5g66M٤[:P@>g<9RACT<5xJX IX>B?V0 !OF,LLDW㘠I.©$<*@ `_I5$ ,({-IHU;U{esMn) F Š )5.9h}ou}fe_MJ#:RXuq|+ҊW"rB3Ͼ#UL+m *v`}UKIB#넓ϼi T02$<ǷU_>G,ĚH)rM!0HB3;Iԁ :v+΂5' -ͧ6nWpxi D`Gu.FR/_x9g-RS9{|c?`V%'X= AGsJcn&w}G|ٗ^w{Ǿ7]-msx=۳{Cڠ \n.ҔXQ-IFV#nҴ)d fS^X( ~ V1;=ꐃ.eZP`[ww_G+UXJ&nlgj!Pmh_!F6h4 DwiId-S+`2Mt5W(>2oLttM,Em6D qP,Msd+8bG64uvOߛ3g?m`eفwP<]9߷>)_R_LG@ TFx*EfLSkr?:hrI'v K͘n:2iIW)֊ﯷΚ9# FhBRE vamm4DhcAD<ɨH| IŶ,T26ڲ} 8#MRzalkT:B.SeZ41 5 0 Xrzׅ瞽維 بsOUDQS4&HRMe&lv5F^Qo9qpAK%5?s.$Z #X7}Wsy})xFSIM qs u}r4IMQ8 [4OPcp@v0bq*Zm^k!{{8!?f7 QqqأkK (j˭ ZE WYtJ_l%2XU`M0N"7ȠUx?tkmL6ژw[SyT)p!H\+.<Lbx[miΎNJh4ȸY|DF6N"BP(Pe Q>r@<3( 7ޙA۝r)o%6ywvwGkb'~dJ;'Or7hYރ 5@g/;N2T #%4[ES WY.us(I HQ Q@Ge? 1a_FZT "*x 0ǥXWBAJϬc>`w\ 8h%Z0ʋ|ґ+b9ƌA1ͅևA &, a!( ]~ +!f&p&(I*TxkVcz9KfΩc ^̩P ~}][}zۛnQX,;E\ X;8ɓLV^C @oz{.6}jwg9za XXK{i/F.qWiP[W+[d\:+fmmbݢuPm|nKy,Xr_X1%`Ƚ~$S3HyFd:갃ޮ"s *t|Q$%u &"u[G )i&7[ipn6Bpۄ%2CA60DXpa&aȂԉR]ͻ-g烈羿xhǠ p9O=bqH5v*TMv5C%Q %fˁ96 EDQk}B8{nٚ+2s'_^;Mg#,Ȫ8 - G~2K/9@dMZm!@H 6'x,K3QvwӘEMQTi@ZFUmI 繹* ,4m6/6C0Ѹ2Mckkg+ͅ`osŌ[@0]RjT?Z >۽(ADa@r ٵ$02D G9mj:qh}/&RjBbΆQR1!(O#Uu;WRj,ZsA<`aިKQ YVC @2#$Yb  YTdu?t- M~ <"yd©}Y%K%ʼ Q@cym٤u|?=Xcdu;M>-b籟dҨŞJ-D}Ǭ ri(],@3$sԭ?Z% THrYlh iTO@SiYQQ8-u4̊`@a 6]u-xRc P n0+"t#ҧ?wO:>= uku&!N=ο+Gți EEb{=c㍶eB9`FefԛHUUb:䝅lgU 3U1kq)W:ê*wESJ-J9섉ﹷQ ; { %rK~ 8u MB;p߽XcbGfjzsIƖvqw;Oik9vfuyާƀa(Ie=ӱ SCH~\a҂+*-d?gC@e A A6X64TU%Y{nNIS˝3+HHE HR'׾H`%CʒCt!Om 9w5V%UQra^vgY !H kg忻#_,ͤqa!i01UY5ņ*L=^DD)@ѬB5$$Ȁ[^g伻f^}Բ6M}=NUE LTLI!tuݎ+1?_R l2s8WؼCs"C3?{޷ 59m Bu_ycD',CU#j||P6L c ߷koCa sxbR0۠E4JI#N|lycA92^l ڤΊhT<܃S*kՓT6*DĊ/^ذfiX_}͕je$O/'[ٱ{wwLvt@hI~ {[tҤIb1Tz;ȒH9.XS*ѡt=/M6Ahc ^=\ed@Tp4׍>C嬾7ȌP-fQ3$>K/h5(^˚ H%ۮ;F=Lخ{EsҔвwE¼'%縻Xǰ )*ΐ#"F5*>W4*+yY~K~9:/M"$ dF}pI7r^j,gV]sZy#Wl}Xr~bx FŠbzqbUOĐ %2ԩ )"}*X^ƚ{EF(Ln_Ǟ6C-͈XkR'( qELJ6Za@H(&efV%{R4 H@1, x㙕 !U OBW3FNJ<TgY0~F b7}뭳]s Dٺ励VZ0vjrl{_Vk31 !xhDH꽨m1dc"[ m m U>jPd𯜮ZhRcqG EJID[O^oc>9&==HTEIIb`+}OOfq@ D&E (53qӝˑHŃ " !knq;}2o4ى{ Wr4|)X`@Wg[ira6FK_lE[=-d7} kRT G5f;_ PZP!M7?#:̴8Xv⸝wڙCŰڳ/w7T]ə",3K/ Zf8uAM4cبNym 6;d֎x;"KEjwr RCFU5C7[ހY4:exKKSd:Q\u'_xFEɦ4)C7n_ط9Be@Q"R"@,͢&!wF*!Nâ,>EF3eb%4m#4~Tyj2>!0)ֿؒ(Y3E1'6<4 * 05Mh*W39׿) ,/ܾ]Y 3{G9Sb 45Rz8Eg,&mˮ5xK+"l% 0\؀R  HR[d>0~1Q4珀[ZҼ0,_FUߏ6ptdՒmM$'Xz_K0{Ad z1WhH$flAɮ\ŖwŗVUfP%UphKeqAj'2 "[W*y@6%F#BĨٜ7L WUCHTʤ+I7H \h'MZSLlik<*K2ԋTl{W|ܧwUX$MSUeÀ1zS֥^@ b2+EK*-NKkݺJh`^ 8}wPV`E qV0QJkκ7|7cVFRUA &>_.Q1F"HVVþGc͵ǭD Ԋy_nV_F b{ǵ[cٰ5x[7 9i᪺睢oUh^R霺8!RId(27TՈٲ9y7)u9۵KYROz!cԛH\[3?ϭu6`&*IJȘb8dB뭷*fKWUV\y֘h̫?(+!v}T `!"ҬC<|i2Tg페{} yZ~j+dh nd0@VꖃeN%x""_QEC\.I%28^[~ =IJ6 @^w?^uCy}~QM߷MmDlkFU; xwy`MSbM`(GZ0%JCTPHng|-r#͋ ߲ŏ:?폴189xx~WQ#uLLxj}j}ᇀ5V;_BiEA!Xki.3%b%cWzC*jIBnZ )e;~u!j҉@ۭWH}wu&΄1՜ۯ䃹5[5u3Z Ei.YTk2$9G?+SQxG9'^`xS_KQc]Q P5X8{ InI1g̘}dKGӔ ^}MXHhm0[^;g}駠C6sz"2baz sHx{>.+L,aL`(rנC(̍xxK_\nuU2K^K3D0sog5+O?F#E?hxZnNcSp$I$I!1D$#g k$;zY'vS œYHh3vF 6Ñ=Ak"N^s0{N u^4w^_}fq,Vz- ybDP끮.Ì%&RVO+bDR_!;gHu>ƯF\Jlgx/nTmVAgLI,j0 U0Dp*g[nFfBD.4:^>KHT)W&"ND֢K`!(mb^e*R"C`˭"Bɐ.z名q 3+3Cuճ@ԧ@ &2K *ճs(HyT&R'NuP`λ($->)~j?߿478}*S"P>-Ĕ5Mlf. 1Os`#- 1&t4F<ȘSB{^9R+EcK^"Bf۩7r30 dlς! =FK9@cÔ͚) 1ƕz(`As2yuQU$Ih1֪aH T$q 5'Q=\;&F "GQ57԰}ĆER yLZZaVD(w]aB,ɲg^9QC"T;0М(ͺ-MD-5r:!LNjHY*ă8&Jӊ8*{" az"*A!1eg{2!CyG>/d{r/'NDd!@&j=@/"Rka MLUneUǍE.^rj br~xVFSu *xx-٪ l֮PfeFDUFY.HUW\cI"$ LjP3*L'O\DILkիơ?՞~9~пE| Ua(s}GjAV?_3Fp~Hn e0@è**Fa٥Z?U@2fZN-W3yN @`Jyw=z{彙hlVJg3-KQ)x~]wܶͩ(cx&NGR'Sp0 R 7RͭE=;Ѣl>oJBjiFm1Z0ԑy)ڇ5*Yk{I\*'-iF B ;[ͥt*$lPfzciݷ:M3q0CcĊ|{i>]2ԡ+jt9[V,̊ˉT&l**1f-Ij)B,@J'kU"ՔaVR)Vj@. |#"0kΜ3fLZa>2k읪A@vgykֲ%]۠I@LI\zcܷ6Xq*BnR*ʤY)7dE8'&r540؋%F2B"8f* €4~h;q}wqsmsZNND 8*H7hr5-HH3-d*iٱg XS07iaB] ^թ3SZs{g'9(W`-)xC{ jMu-c~V\f-6=P 2U۴F6C@*>G}ǣ|ZAP[J²$RL ifb^GtzQU` xpwqoUHTUa{Mk\-orC@2EF:-yfzwgΞW] 'Xc}׳ϹWyւ^! [2V\3{ޙ>Cǯ*kfm%AU`$$Y.eqǿhn#Df<ԨhB(W"Uǐk*Z|Ua:6A-S|4W/GWՋpm?=̑3 <w^Ƽy0 0R8RUzYemoU,Z}od1kv_1Y {Fcm*S%̥Js֋pPԳ {˟o]~z?^>mvh`31+ؠRFsTCL76`U>J2PZ3+qsOo.묍6 m1U?֞|CÀTHTJ>^ !(htp/:wVIPJxHxI*66iS7`V+Tի pRà:p,=qkE\Lx{E͞ (C !?xQnYjؚ^zs;QEޒ,RGb0mƈ1ᝫIdoD~BUB>W|P9 y=U$>EAX^eJg?lm67- uq'2bas * 4p* Ww7XT/2& .>T N\4kTκ0eͼ(SQ:Z:{oVUT[Բ7`r;3`g5 5% 3+yAQ ALUFily{TJw֖((PATspΫJ\Gk6!;Km԰dK@6/ڟ~9@[S~fky~laz/`1^$RõJH )z5o(Mi+\7FQj򱜽A zwGg-F@t/7[R!"$GG([͠MǞ ֔pE{be;FK1644g]V8r8 FXn!@@] @9>?MgjoQHxYQӎ[_r\wg蜐1S9zF`(F +jo6nffH-zdZ5z*:M޽C1B 'βYuK$k9m=w;xwf [eb :ϻ~:'-hЂαٶjgB6hc6b2]{e}E{ZLDB#VP#>(ԑ_v[m Sl J 5Np$!}`W\{x8Ꞓ |Z B=8 h,HaT=#(G]rC`yǚ]c<1Xtb8xkrH=kckyqw ν `N‰v/='uiyetttF62lT5M@%Ol@.!0"Q/$JklGKE¤B03-(56Xza[W .~%G;\H|v4UA e%pok}^ 0OλT$X}>%D8iҳ/񑓿pÏ}Cm޴ ν%;}:젉Ea&c,zCFv֪yMb( +]+/| 5KN #(1FQ[CEޛbAAFZOy1N})cciijcxqO;҄1}U?\(Pg;\ŞKo間N!JDQ@ !*9 ~uM7q!) $MwNOOIf 8rv;/?}f;QԬ|0x(+%̘yC^ϻk'NؔIe[TXWf>WpӜ=>qP8 Iϣ2fNd=Īn2(w-~d( j^Vo +*3?C'Nh5ڝ(^ݵ&m~+x~v:OAh Ri1;wq=hǭZi)a __@!H z_y{ka7M,qS7,*D_ca7@hc)!UU@S{!b$`)2ƚg^}3<_o>}-oJ)L$9wޘ1kο=q|K^@GıN8"!@-l%]ϘW:gλ.x"BD7-7Xp=]v}Ν;C553Ԭ%}:Iq<,EIaXY8HCp%KO vʦKzC- sF& ∍1t7z[dx)S\}(6T\*/(;sΝ3sNڛr8 QGW#e(ՐԻ \xP!c~S?M7^N}2.:̪ H`4w/wbҘ" `ƨIUE–%~|"U x?:J5&\[)%nI?yB3*#pl&>(v1 (3D%^| 4nbJlaʉxY9}-:Ȝ5a LA,]I̚5S;{lHDB\`^`-ý!58?<0rʣ:]3 xajmFhcCKD-fDIP0ET,viwΘ)+izFE|WWsvutxfr`UvS2a`b׼rzUG<ⰏᕖDWQÇ"]׾zյ'5*w0Kŋ@n,u;HʲU#`$.?cN8W_a;BV9+?>]y8ɘrRb$[Wk5iM0]+wz:KIo38)Ȍ?!4N2vҨXs7̱+HTs}ocx|Fk(XdB E#(;P蘴Jkނr%QMҤhlY3]DVFFy[Cg07\,v=k?#GA z1+ TՒ2yg s?zQq)03-b푙Gת7E*fQW~gFV Q:wxC ve}-7lʄ1j}ZE5}7!"<{Ʒjl)ulcԌ@3m,5s:;&R2Px;) ENXP@֪nmQ){c$!ŧ56πmam~f_|IGu[u-;n˒=zH(,11C>@Y0AdTTU&~6[*da#{@Btli|OjoU Dz0| k$JqG?p}<ɏq>R*odb(>qq8ʫIQ0oc:6uGR[,pmGqGr}.Z$-OSMѲo;뼋? Sw"a9NN% 74JfP7NGdU R eoM&姤sAӸF^|AQ˾Qq~1YOjjCK"spuݧV켍4; @cAvdu rex`Ʊ@mQUԥ(PSUFUdOO;.RDj'{$6LwysޅzOoODkH1(:Jgj*d#V8L4c:ChOJifTc&ނN;{Q0qJ9I1h |`ZL"L0Q:jYi_^>!My)ֵ@m 7B+cQZ2 c02lA03  TGw~[l~.%88}$ƮO8n[Oq.x?g"ʱX/9,4VKsc&a^+ȗ3A &i%Y>;wyB'3 tG? c2J7ǖ %G:CN?_u ^QO/-![>%*f(̒3$0Na 6ay0PWj-N臶zW_"N;5v1&*rg I&.{ˉkmd0l0P%YW UT5u,.6^665/#@-,aGDJJdŲb(ȋ"!>{~?̝='/AD;r[lsZ"l%Q i (GH+cġ)x@C>HUĉs^UdJd1GBlTHS]+z|І[=2ٸ )Y`Z!iXyR/; y*lG,T(sX{ywýxNl㸌Be"bF%uPXbU-WKO$*\1XikU|8/$XO16vhcП?{TiaB 2͚\Bj* {4ĀOF]F3/v'OU~z-7ɉ VF!0`ڪsƕpy?ԳNŎ_ PS{UdmGϩ*!/4/J[H5eGDZN+TJtw{yVM0^TT-2+^|3:W^3c,[J %!6L 2̈",$Zdת"HU@]FKm"&N5V? `3)z`{ƄlB&i wvSx?}}~׿ke'*8‡wwK/7z7IRؑ,wBEDU^+&D0X(swʈ0(L`*t.)F7pvX~%^ k- nq7t|cO>4n{D2j4NdȊ!eQӐ?v |m,5Xm$҂|[R Q7K>7ΛPH<}xg^|cTP7*5&f2D&e"cBerdP.rIK6RŊo}!ؼ]4xfH"1ä}s$ƒ}ֻȐWw iqpyP(AFTRQG̚|ZhFl+ؖM}cv׿?9ߧ qZ1)}"w2M^6 x`Wp~~O>CELll4KhV!E ԐAg(0WiU} z(ʀ 2-×5 (S (B4M`Z;AMU."&MS%r ԕViۭN臷bØPf-?Ћ0T}zU1c֜~qYJ[Y85rׯYہ?Z{xyc8V%!l $0M^x*qweSUz(7H|I ̼)g@(@)tx׽=gfN錣:i-'3 !L.sϹT3]F٨`ۿ|t6l~Fg#sJPUQ蘫Tm  o.a "FkCJo漅\]5Ue̠DR(1BC&WP@h]˯~W~qZe1`^UۇM >s_w᥿믽`V8*P0xt--@5D$88wB`Cae*l!bX{Uekqe7o)ƛl>vwueB}։YHTrXԩJ1_~?zMGg))YOPX 0*?\Bᨪ* "*D&بtW,*α{k͆5GI 7r/j 2H򟊶TKzr "}rV2b/[G?q҉9SuPI]!-Kf(_sƙgƿ{{`"TT+9 !k% BT 2YVxT_0H 4%c vec8|uA̭w4 KV eǞN`qYϬօ QDY (GU)tyxU>c;hch;mi0qh[͔aT3+o4=j0H( klG 'j;Eq?:Ϟ-c[L{[no=巗ow= RDQ`E LɔD o#shttpVI37a|@֩6Zdu)J*"T8ʸenufN,c>>T?dqZ%3/: ϝM]]QC0D" Pq w-DSjuzL=7@c5&FӭV^*#I4UK%e|Jd*R j|?yzk/t{}t>8h ]x_ξ7ޝ "fÆ!<0iTy3wjw#(PUͅ򞊖{asMh21jq ˼/BpU /vOT&0#"Dq קce%P0`]d&f ji-0aWmTR[\M`VPHi 6;u# 1ED5Y&j1SLu ]*\a{'CCbd*n |hiVxY 8Z s{z/}?ɣ;xBP̣ j ;=-^/@+Lҧ>n;~zm3y8ne _Ukhf4DuU#CWьrU"pS N:$*F&$I5B<AI,@F%Vʪ1{kv>XUJk!Pz`xͷ;N[nAr*!*ŪؘT`?7[n#超  Hj&ƧW[ ԰Of@՝aO*L grp롿dVßv ~iޅG7I00 j^u%~+-a5~o,ݤXU,ZhcJtdtu!NA&A##;L! SZY=$3 uHN^x}ʯ};my &ZP&25/L_o ?y_>@)IB3d4W6fJO~$V k&iI"*R//joŘtR6 87?= }ѩK7 M!b2RC".Yg@5WKdŝfj%ƘIV0T3e0 T g 0^A"aTVq7kz?y哏8/~uJV> m58]v_sog-q[tk'UeHH )@I gQ:*fw,7@NjԖ*e#@*7Lfɇ)'BxH!2ӶL4E ׵`s:$>y,q*M7Ɇ 3)C%h(, gR>QQVmBkn6:6^racPX&Pg` U!b{o.x>t1GzPwlk] H +/{~E?͞Q!4֭^ɚ% `)6lC!ʵ ͔VI Nm L A96;޻Z/oLjb]p zoE9?k\buxy#g2A3P%#c.I"cg FN*D2}mRΕƈ1E<1֧@UˏU‡b"( BDՈ#w?Uti C|s¤BHT*IJsZ8 Ȉ w]񧮽O|wU@ Qa[0USs5V߾{q.w{2"Q\&RUY4MSuYEE 03 *@cH3& } IzdI94Ί+.sP@ET^wa#9d֍2c:"Gς_oog7 c \'dʜ@)-Smr3*ATe[FG͇¯,{/OC!cFm@cRRB# 1}ىj!^Wq{_qmąboqGJYdfʭ&!F RUN(R~**YDj#m_FÁ> EcF̒a(WCT01 E@` B F}rVgK/n< ^-}#nPkM׿_u͵ϽZѸ 4&%*i"Qֻ 6l-NOv$AY \R$ iRtH( Dϛ|C͖L[y2֡u @_uw&0C|7"@gj3tFC|D &BvC}k@c$Ks|ѯfF""*`V [Oj_dHcJS`aZ{aASR@R+O}="oG>p^*+ŦJ &s!ImW_zz猵x (bY]d@-|֊rRe\Ƙr7I@-WXp4_ڿ f!.8.afܹ%ctQ@,1ZbiFcv{\eo;҂| $b3+İe[q q~R;BQU lVﮐ*y/ѶpL-j9Să z#<iuĖ%G(TO 0sm7py8'8Կ1u7`M<ʥ:>%CKwEM{zQ;(-N>}!m#5)rFӧozQD *)m WsQ0E5dTF!F-+ *%Q/3̈u 3}tO "ʈc&X&+''LB:MIDATE(c6F֠x*RR~Y{-2QGWV'(Q1Vu:D_V`Q\P3g {R_$d{ydZmeٺJm}[seBp&Q R8aQ[^}ug_p{K^~7|{N WDZ4ЂǤ\ [ Ѭxͷxuy-Rn;c}N}#Sۂ)i>W76sԩXUJ R/"B"ْFJ"őOLD~J sAPb5%O (TU$T #IpH !)APD*Iς-7l=w?h_s8"U(x$Ba1*&c3D$e!0IPRE 6`lX$%&t'oGhaC(0VxC쒲@D$:iJ<&aX+Ҥlzɂc `j>a&Q:>ERJ+֫_kj1[y7̝W$Ncii;Cwo+O B]Sֽ:!,Q w^wޝD!2 5J"4Cь?s; QT.] N؇޿NV\Ӓ Lt5wuow俣3 ΗJpBWSަn?aeaz סZ Ą2AH%c"^&>!F@hcL6tB< *A ()Z(tvvw_K\vD!S$2!xJŻKf4Ia W hùҠ(0sR*#L[c]vf=`u'M֚ذ\N|'?;oM0n9Ѓ7p5WY6#ׇןj'+@'@Xm|;mO8M 5UI$$0*4e jMXmIStvu=k[o}>s<|LةIG'LFlƫ2' Qo/cP0@BYrҤB%-¸mx} qɖ7\m Df&FD-|zu H9F2olu՟Ad61DcX.P-[lF_wa EDA=EaGDjXk7;nrwuuXЫRETVYa.m1kR-1n uW"@+Nb+,lWG!Iq,}wy|_-5xߑ޹pd&ӧOꫮKˌ3?/hlT7!;Xm 4ߥ Q986,7||K'jLE(w}Yr"XWJѴ~PXZRxĆ7d㣎8|]w]q)]1Vl_ysr$*(cetقUSI֛<|6j-8=/u[nO'ؗz`r77\Z[3OҢ5R^{U>q qnSNmsU?FY|@oZab6<p3')0ldI-FGLR"waͽ1^aZ^~9 'M.9'Y^^ )Tm9T*û5V_'}4c0X_y, 'tEedg˝X60 lÓϾSNᦛm+Z~m-cjЧ?5p#c,!]92מ{ &m3|6m k`R󇫯Sȩe Űp:Phk"_{WbĩdXSL$0Rda {<')t W)T}r49]xzgBww*T`c(Pk4S~ۥXֺfMQwsl!%az,/>RI}ԩO},JtHa@8c@YacE(*= $Do̘wo.['Nwqp K蟔XiZ B`@~㟝f]\jd?iJRZzN= N G#1!۳?qί^ym.v(AA/$E*"F?hk4)H%A $Õ?{w4*:B\ċ&(R6xM{@Pj#}o}k{[.БvAgҏ֠eE% 3m=Q !FPM$+0y\k>o%fP&dKZiVwAW4' 1# 3{} ZYmo{iWCO{yՍ6xbwR sDBowbd#sQx'8tM.K iYuT0BP*o_qUrwtd&=pЂw4)M\vmo}A>(K(Qo+oʫ=?zxE,a.9M~*%dm{!}ł=2 ¨JZ{ߗp{wĶ$qc? `tw[o񊫬+ƛ`-B~P˲'S>/7#QDfHRd/bL}|^7&J#0?J qa3Pkl,R猉5z#aW#10a@9BQ%)]կF,LaBù;u _{uϜ9ːa-E'XbDrzkMEllP&V",Κ묷SO=֛o Uv\  t/}{ʮu ktl,OxO9̯뻷g[=ȿ{k%}Q,{ߜ9^zJwt&¨ozЭys|gT{ը5X餏vuuǬ-wN442,B`Y o|'?{dc9s^y퍿xӌV^e s_n#/"`Uq=5>[ma 4)EK;l|QXH`-4uΚ.C<2{2-t6(ygnfI%8y''Gypd'Mm7zX[eE^ ?-i;3Gzm`/v( q.-%EQi`RaȚʜO ׵ج ["6dàV]aU׼;,?ۨ 0H3³d(TQG%"f*ƏjN+ϻ?>CDDBL8Dëτ6K{~~[_QN8 i% y0/vƙgx=s:į_o'ZeV]~jm +.B[äY9&bguYW_{g{J}>@*p')3맠Š2>PZ뮹^(:h*pp@3185; /0bk|һfg㦭zA P9^D?/]PJ ƁWQh\~{k[}͵ 6d 3SJkM?_(l @փSyئ)P@! "q>]q)޿&o`7WxǴndsG~#uV[+DGiǐM(f7j O?z{8 :p6myX sg#Î|'nfS 00\~8ߏ~v#Oc;Kڶ*d, c$y7|ڙN:ii607cݘT7ɩc9ˮL@@EsV}Ú5"$0O2/W[3+:Þxi)SFEDLeeIi*w'~z/k4Y%Ϩ#>zrlIgP3whc)mMꉦxyP0\4IUÅ? 6#abYN7IEѵnMDTl.u@g>l5ޅauw$!Qa?X,N:5ìuNFk,H3w`5J@d(@S]g j1^E@ُ$K)>&b*Zk 6CZby~s/UUb,!} ~}_ogPQBvY{\tig "[ @`T*ϫzC{uŗ>C""x58u7.nI믿!yfTO֨j2yWRwl4sګf QkMo~rZ>}њ;Gf$6M V:Q%Ys{uaV~dVj2r!D@[lEQd"(jPU)TIr_QM1">! `> ˯ӥސGs%e5:vZTG3S@@{IQbb~3o9^vm ]Ls0+>꘣_wKq:;:_(P")Ldp { :|t6v,UW_e<~biӮn@$10P$?k,G]}SPPz/7z˯bl4$ "Վ6J"jO~[_O*YCw"cUWܟ]|uC=wvG6.M0uNUUyUm9Z%[BrZ({?zN2UCye a__SQEJ5Ը}nHydOcDQl/>C>F3ֺJb8㻕 .s?\Ɠ5dwju?w 1 Ib]ߜ3_]Kᲊ2'lv7?vMC4&0 MQJ7dA7$obXƄvu|\"%@UuP䓪vem,h;m@)0j#MRVhYVJk.;aUVc0 !-nxJi$;`f\f(:c[  ,}~VᡡO0]~^{%DP ?άVq^;OVZYA>9D(3Ͽ~_}u7ƅBJZ "2 bcQW7}{'N<+_ t>a TA8t. .;oSN[ߝ1&FdP4vbe$BpJC٧ ̪_}P +/Hyoᑇ|!¤ALw.АFJV}'E;L{B|=L7{"`6*~{ٟn=vߓg&{5/ ^} D&0 !q_SA ++EIeYu2:A4U1V[e{x1p!D U5W_}(ofDQb`uqϣaWQ@b$ $}WX1-Vv6 @c!< zTKE9<;'dX3j/PriD4yk_x 6_F` &LQX&_fCzfc1FQ !^Ρ\ԀLT liV\T ΉK|*>.H4 g_|/kq9x﬍r鋁LH-zrL,_s]~n=bgd0?A?'=YID8@L|QXρ|#zMbf-RuCf~;qȀJCʿQAT &?oqA|֚+[`11iِ$Q|` d9aCLl lN̤ Ht PkeL- %eXkT]5 4BD5vutL?~aA/;5hK4[=XUAA2hc)Ghc#WE|8UU2@ J?M Z0Բ+;Q4Qg\^O8gAhHwEjȎ+0D4~-1Qԉ d]dcW6kB * ^H}!2AP/~~}ڛhtMD,QQΥ .Io$X@+FJ({_WGF}b`6g  [n~%]~ޯ~қoCE9U޷ڶ1NU&^VQbJ+JR=$Q"cԋat N<}e Ԁ`=(}jY_8*5$tݏ~_};8ꀍPe2Ym[ny ;R-3hN%ﯼƿuءZeJCU`%GlwRvv*R" H X!p&hd<PVYɹe&NTd@joVXnز&rFE@լ$IVY;l[@W,N?&"Nș** Ru 8Kd8 ѵFhcLCUUHX>&YvRg)( aU&] r br!Z9/NO> (&Xjo3D\ۍC+dݶK q iCOf.tyŚ= u$GMz!&fxxOs/*\0{0>xna܀0Z{QG1q<\z/}; &TD-1w+`IOЮ;oYtΞm,A4s[!݁y!PdTnŕV<`NqRY!-G&J3M_{8*v!éAWI//JYWGľ7w//<>yGYm%6YN|FeҸ^2 맘LٵơqEߛFi'"FO0DԲˆ\C$B "h`m4l&r* 5Ss&PD1RIO*J qC4@>Fcm1Lhq (dTĪtre3T%xY`۬f58W5ʇ RIO-7)*i&+z* 9!z!ã^@;\Ql6Cm 5Vz:1-Nl`4%ޛ7'3/(SHT O*̣)Q'^|}7~k2Sle\+!0ybi)>D]I[5䬆uCԕz-Ӯ;p1Gtx2>ym_@!_}s.8_-'LMycr( 5L J`.9s<nw?{'UsoUwO(Q c`8`16?'p~yI"&DPZQզUU=ݳӳ=3=~[:T{ڿ_!Ʋy(>mKƣȔ́'5iN,Jd M5F-se 'd Qt<5F16#xR7{`Ұ~fe:xيxjd* Y?B*}ec!o}~E5=@%/Vx 3xi/~1Y.Q9u`>lWɸN֋SM6L10Au;w<ٸ˭靡PλL(/ȑ$*F&c hj)3Ք#uqوOڮ"֡ 8nKq=v.W7Xc0QUK|ڣO|goߵm]RUS"dLY%-CNbi󘣏ot,G;vJ ({g|}U\oc"8 mbP87Opm>9<1>,:BnBoK?gcA3E!&H?asL:DD,_pBizbI)\rE{o3>&%guM-ݞќ_ٯӀ z>9s21[tՋ7]``~z)ҍB)b4_sFpĤպ&wſs2=\r%?9G#Gg5,C믻믛w¾\ov{wGY1o!Ia^/ɩ$*~_Os6~&y^Ke'  7LW}i70$B !׿.~k^SP6˸oB@pp;O_=~:ޠ>6[%kPU;V,!Ywsnxh+kZ Qk_~ "`˚h2<$ Q8g_]x!`>. Ϸd8!!C ~{BG&MdžN.>V1NVI*Sol_~"sξ0dͧ\7o+7 `f$Nc<+iRxŘsϛ$G( Rr)Tb.<<"eosϝM5g!/>т;_]CO.sJWQ%D xC;2F } Ɠfy~AH֛n@K|TN] .ܼZkFˮxܣ+?'_ ;Yoei l8?;r\]JEiݟQ-YXpQ[mRH}dGe M?323=[3Q]ϻY V!Q ĵڵwxq!7JK/1R&RPI( !Pw[޳;_9krW\q܁7ȶMkB"K9Gz#vn~檫rCl$ʗzSs$1اNCKd&PB[UBi8*\cFAg&jcO0.ugU- L{w%BP"Gײ}gfRa?{/Țd _i‹r^'`s:R2U/kiY]'4D TS~w߷knPOPR%A#sCfQU/sSuitRkWr?<U\K_˟[r_?<q܎[Z5ڻ LTkOgKk31Hċx"kKw^ʃF0;\xŒ1+SmS@wyCORM/[ ,n%*Tب 2@3ek|oyx;.L Ͽ1>4qPYWD/yܔV'U_ͷekueaxyۯ^_sqo/}k`Cѡ  .ΆC'5 W_'O`. "B</M 3 !  ] 0Qgԝzq'uVΧ˼bHկE "rI#X@) I8KvLwu1KSR.MS~y]] q,|?'=admpwiR] HU+ҝ 9H)bD|յ7axLQ.M%M%M!= CT @VIQ"iK|We-n1D ,y{q;O ~%6);ff :+<$A֦Nō+lNqOnWxɩ~r0}ĥ&}G{`jz~{Zsu7zJBB=gwW 㒗>zBҗ/Z%J^??}]S!dX_|'RE+3myRU$I၏}ӟ◢ɩ2`%rؐa,`9_ַ_{OoxӽAMY\LxNw/#KjaҴBHpUExmN{7{zEPx \|8!<5׽aO #Idƨ6Oλ]G+ x{n6m;VLS1җOm^z|d$/~s&Lu="" 3_d13P+TX1FkPaC 7t߅xx R{zow߻?5:SKA}7-fSxd_ז3?H&b!yD&"眵y]7E'jn]'4 `R&e#L$UDc{̽7/_;_(-M ,cǖ?՟%&i9sX BhDLj6%i/=o{o/*eE%zz8ſ{9\My U|F!uzZ5!y#M$qǷ}ϟٟ/,,`Ï~Ϛ(?reb>H_Je3{! I6׿o \}m[ZP]F*7R<*l|T @ +_O}ț,v:HAOonM^R9K E [P,7_wi!.Pl(c%-,$ox>33c=D1Ͽt_P;b+7xxלO$KϹ_}џW^,]5Eu^OzC7_yǶ-m5AuD0 $~~f|kE4MP tV_/owso}j|:30%F͙(`j͠1Ϳ{ݿdz} @Ħ7mgTn[D{gW7xK}|A2XB ~Օ_umD\syud9'EH;XVT0{?fn/6i|j/K>Uw G Y(s(ػX+0\{TL99]ƣJ"P"r 96\q~pE)GNɫj~"p`D{;pYv۷ #ދK}(#`>ß׷]Ƥa6BMwA^ I~ '8>9D1#"2v3ֻ# )993yfLa`m'JjHW UM",AxE^}]'8E!DU;V7J3fQ0وꑏ|cu }<補KZ*}%~K_xƋܶdmlaaѾ0iV+k.0c._i62R JÓUP-Zi @YC l$K.ģyBg{$cU^ݪKyTӛ &qI'˿Bp޳hC yW[+G=頗">h",'p uZuW8o 'q .: ~걏?z۔)e 07>OTPM7S2BD ʌ~m_-NjHP{"Ϭ0!J<>!}+|'xXwWZwP83!ԟ}f|(K,,`'u5udH榩'rl ଳ/n.lj0Q&.3S=Z!!5l~S˿Oό&ʿDy٤o}%||+_ݻgXUp6h*Ե>Og?맟N8XA!bf69/_~թRܨ+Y,ċSsMh%8$L5\sGInCa Xԓ@J;ocǎcsc%*}V#L,+^oy;>3Qd@UfYQ^5vر㘚е?Z P)#UȰCO[Gn'>?ܧ<)'&j "쟝[/o{Sjkq--Z!ɿQT&V&2iW_N<8&ok-15q酅9;?8_]{'I( 7*ް֨w>m<ş=GmrĎECTQ'~.|o: f=9[P0>9k"%~4KXa8T",2*`9\Q)h%Ic68unlb?gS4miKg.$-D5;% 1lb1WJ\fKš5?qԎ{/Lmڴ##df᦯ cdj8kmUPX$I;k4^e4hͅ쁯} .JFUáuUضD<9޺y/sP aHLJ` 1 RMܓ~1?ş:͒@ m,D@e@DDϊ۪t XTϾou>SGqL4X00QG}Gnߑ;pѷ9Cb Ռ 3$I(zދ_iz{?66 o/YP8y#kS<߱cG\o-VzM~_'7ͷX4YH9d$M"\)V) /Qš׃e),,̇?D|Y>9kcxJ&#`24y-5 "²Rbp p(8>Cc{kvr-Vw=lqa$]E1Ns0vؼwBA(`6ݤ$HB8*`Nnپbc65u5x3g5)2ZYU;"L->""]˰)waf@l5QEk:HPo@E)kMo~6MOlpcj,֩#O8al-[DP(2ލT)i9b-koI#jj:7̲&knݓi H| 6 QH1c~箯{ M" @-,'FAϝR! [])o94(."D$j f3k5 UicSDO<8bYI>1TʪrIĬ04P"b0{[KXjX Mc&FJ9~3}QO68!Ԅ& -2̓(@&0ʕ5fqO C Jdg8Џ21NcU㉁EIiR`085\szc;'_T=U0fɗ]pooٟj&jZl`,x{wcS[]@lEc":ڶ-;wmT&+KƯR5B> ۊ.sE Ȁ"3ԂKV[j, h>>o&Q,x#J1x2)rBh-_ a(,OarLm{$flEi19%3BX3{^m kJ0(j 1`5E]@! b,.0 UoPatFFk(P DIpyJ ?Q*G))4Y% _) l(G BHԽ:55]w;{H!#5T#a}#~ɯw3]ev*࢝ox[㓩We0Ksw9$AS'픓 w`Q8 Y <G 0sd5DqPfV8bfeuSCY $5UIX iBIOCr>/9O!vFIy{oV47E{zWiQ=DT @ CBVIR!s%ЃW@bˡz s~%gdDd!!9fG{Jëc/0rhs=,..Hƭ=;om,&I;}>b@9Jꅈ{]/c..-g- HyG>򟩧-N ^) (O:l&%ba((#tm3@Z(Ԭ!.QQ\¥0Ў4"&e}ϔg$E .;?wA>!Ȟ؃SD>"FHD{6b WrvZa0T@_?n5hjs&D}qWּ[JhܘDUE=`U@d!EDq4HϾ2T+Tȳ{m'fwPPG g p` ȨT[:juM3/C{["tG>0 3-09ĦAMmG męfؘU5sr AK:\PPxCx31 @.Ĕdi]0yU 嗕_08G!A@k:XwmCfblH V-JzIbf.oʫ(Df m2[" 0<οo'?wMm^%I$;5u +{]Y U HP2_MlKY: QS! \  ʚPp@Tu6&(傘H3æsuzY/HXْFcfa.xs~ɩ'_3Ȋ˚ٺqi *13/|^wŏMLcc bfEâިy6UU_ƭTX7QmY9h/\ jWpxB$Ƒ7bPQ8B$0&q>SW|ˇ??KRU X[, b(cϾoWSl|sc,u !0MDxHu*TX{Pd^a%PUUU'fἪDQ1Dvy Cc]$1F@FUa#"D)U|x0EPiH SBH;69\rO޳yM=-j?8;R6mA['2I8" z*D%=+i2p(a2K큢 2vD%^T  B ,nm$ =96Ր0LFDC@!2+ ADz?˿\p{ꓟhDlZznLLQ\s CJ#YZtCb./VJVD#`Cy6j0j ] # C !=Ebo:,/=R>FUKL{8; )669;3/|/|ol޴%[_P2ou}Y11{*NUdqd lpd1DYt/]VidwWp8R*TX#*P9V׀1}e [ʝ&0 ux0jLm!}; ^W=YX wdmJe9 pH[ɪd((bDی7ž^K/>@iZvꡨ**}ya}b- _}^COY U)lM'ǧ'>ef4M#2DzZÐt{gSz0TuKT8Sh9{ĵ8v|셈Q7Ę}\rً~_cFdU=Dl[]3`HR-~%:3sJFEzPvQz3 }WQ, Șmا)8&V"%VPYi$l L7T`x/""2 93^Q8"'+M0S4 #Vz!!2! կ~O}g:}ϠrT?ƻK(a}Patyk6U!!1<  C@ߑϴDQ\Q_:jQ2,f~^3hzdWV +b<gCIl(H ˯5 _PKSӮ۹0XVW!YP%kY,/aId ` 64}aY*or9ݼx0~x9{OsƗ_gh (e.-ۼ rzݛ\y5fl"j+F9{*,jgYeMQTpB\0x<貾>͘FjdN4"BsG( c"k bP H[P?X9Aiԋ118S{ϽO~c@[h}y;+Mnnz l"f*7BB~~jˈZc24و>dV8PT@%P_[F{MUuH$k%XKp>d"JL&Z~~epi=o╯ko)0Csqpa]*_ڽmpcdiR,Q oo@Z詎LCB6UP 9cB(Lֻ|m\ճ3"{zc~r7%MNe߁~-[Mգ|_.ƔEH2suތjoha"Zo*A5+"6=-$}k@i82F* a_c^؆pDDܖW`Y@s )k&I\i&Kk(8C?LCE)4*SWoKBGp' AuZg(BxmA&.j$_}8`b(rmQ܇+*<n{(/T^jUX.?r*k?*O"VI* <.1n7ϧ/mFn #AKO@J' BH (h.|(t!>&SeޗEx*k]#JDjb*WS KR*TXv| ##PQRʢFQzZqC@Bɇ %xBR0O`UC5؏.DQБ'("B_ճPaQ)F6Ο΁f;OǷ45 e8HΥ޹CZaDwP^: fXce)9(3&-)[cBE2^ɕЃUQ aUff3*C@CD@YqZ-X]e}--siWJp(u6*  PaUW3yuQbzHAB$JPlN8 %STH==2ZW*J8XM伟뿼:J)٧k? +TR*TX{q\PPBNũ!P~l &dG% JXjzCl}aefG R*l HNxrmC3YତmF%7(:}UpP4;FIe(g]'%P0z*x(V} o+1+$T%fJ٫n/.qgQRy*uR$Tu EMY1 *^$  tQ.CSDzSO`H0,a]1j)$-ySfci/`+UؙXMEր4-I k" %?P&xmtT_<ɋvQUs.Je~4LfcJ)ZHS3)uDǵ3Z^ 4U"$6j_Ds@l&^:0 3XBs]Uo/ ԙݚd4YF0x 0 "AQE@Ĩ S[jf:3D%TSrVZu!t_L$Ɂ$ ӄ ^USD}G{32#}7U>ޡYZqPa1b[`HI!Y̰^dۮ 5PRUU5*kΫ fɧz"R$TRXq ig$NeDu4jZԨ``m kfe(kIf}#hX_O'1jp4Q)*-&xt .ё ‘CDEH!'ʆ|wW Xnt2: Fɝ5Ι#֫͡^H__IobDV裚*2H:k5iQX\Ϛx״pPf"Q !3v)G[awڴ#?6K|U "w ,aWZ5t|w+l$܉ݑ-Ȯ|ӆgU7pu1d34e1'<jo{1ra!J;VI)8fkB x{S-EXT}8yrhJPPgzlY 8'D ږ̲ҡ5l䊯{}3 R*6LTA5ZVb_.}21a/R]#0Y-0zx|&((2˓mͲ4>­= c~ Z>{ (j }j J1@4yWڒ@,:,GH|)}tA,.[g(dcx[ sYx @9QK9%Sǥ`5E%"<1_%RWCa O|P˺tJ&-FWnՠϖ[#Pf*{Pt߱a/q bTEC% py7݆ϒC BU|.^˃ @['._V1Q =Eh(OR*ah zňW8,SZF6j!Hx}@d1.=rRFףo U&ᇪfɰI()„HRӎi)iL\:Һ"&'(NS mkZkYޥ0C @QSėˆ%skyid){P(`̼3DFֶi~1Ir+Dى2jwNOZxQ%-*"?CUU;xLG.~2$-j-T{P2r΢!"|ǀԚf)> ByAl$>- @LwYc8u-kkT$- \ "%ᾇ]hY>V ?cs 0Z(M S)" s"42;}j:MZ) jq(@3q㑍u* jK<`Α` M@}f%s%D`&'ƾC8EfxD  D1Iql5\PIJ`B-WYK캦 l-w&zG#KDK(!tP9Mޘh/b$7 x)-o Xj (MH睠^gkдS=6 RHd+A^Ʃ*TMT @Ez5,N%Q'cQ=U^v,MYjK9my c”5l1x,,ed{@,rMRW&"QU[o4[)բPWYMd|dM| \v[U4-0lctbj)k>QY081A9¶f$O,sJ6brjw(HA807(r4:%~Q9Q2)1*]Vv\$ZF~õWyW*r5}6J,6^.B2jWecbn#k$Ր3g΍ZՊ5416jl;q]Nwߖ~[z\**:em&8ЎG?&GV4u3+,D $DlJdE6N:"q4yݶAVF>I|@z1앥\cijj& F`U8< y,HőD$~ U P_-1xXB :~|Ú{(Smlq<=7G; |*Ȩ 7wdW8!A-"A^o0DBY-{ 륏ԛ˜]Qt=Ǭޡ=O,g%wsY EKja买\ႢWыE# s޴nXn(PQxfe!" ,Ufw*BS$O3 cȽޗ噏RDDtmt־(D,{P% V l-` W-9fN DBf)LYVYȋԹ%ש2Y#T7sкʞ,P\cc_k)]| \*Iا<&J A0ӯA"&fCo4~An2]J*BJ! BNo iV>!:}>QpշnP;MVP)cBShuǑyJe"rDĹ4:R,PUW" Y^t @D%V/6㼇Ve޵K{HGX9Tzqg^ZuRmmNX:ZRz-jF1yJU~kʱ'Z,T¨Tu<;cw= قc!myR՜>e1:S!5\Z27"p;(RxNP`QxRFV'5&J |]1!xg3jѩ1{J"Kۖf2Qh. 18XU$k6dQSn̕sCE R28e J{IV3޲"VR7l5yUF bN5%ΖbkKd`fcL(8Ęka."f"b6kQj$xЦSM (ee0 a^,(e$"oP (*j-E87rmCTc·X;sk6Zfv)y"1Jey2e=] ϨOByOBOK-%!$ ax{${6R}W8!S8qdHʘ`w[cT52TJͧ`MUM4*"N"k̸DGEG=c֦eE 6*H#MݸN{O}+(=p@ge= ~+2V۶ب׷m d 8pG B`5_-.(#FH(҅[/ҴQ@[H.Hhn_ږ# vk_ɥ^4bhQyy~֔[40 M>d[xJZӷ]q)ySqS~{ɞ&ER>.IDAT/HD!5i.jq=1j (q5-"ls -;/o/ia%kgk6Ĉd\LnĹW_پePK>A'cNhsn kWiV_zMJaQ4 qkG\&Rfpˏ.ٶ%>6ud"ҥ;E'[m9ߢ1Y‹Y H܅ԹZq'*`3H _PZxtL,?i vߗw`4(p;0L7v2yl+?2㡕-J?_i͎<< !ޠD_ " N5ըfSˇbAg66H&e>TG pB$ C l }ub@ \&-zl_ b5ZTc C.#KΘ7oh\+(d)B>L+Z GƋS٬jn||٤)y:Z&,z$XfCԃbA%7DFD1.xxT 9%5P/}~`!VgE8Ea~ք6R/~UxsQMv yC)Ϫ0w3HX{,?:ts&Hh|2q5/p H[ B>{_Ȇz6 6Y؀FJZnUu׮]u⭍3s~}T3;]m9gXV]zɱPy*.Bʍw~ʎ%_6\>3%{S3C{@$2Ƒ!sEnf.E} Ld־)yɩhԬ,9oImZ*;3.'8uBR R /1Շiz~Wa>[1<&!O,yE0Ȣu/1}q.lycb]^oZQ^ִZfscI_TD}5>Ծυ}YWA8#b fጔFBfTU]XfAL30i F=1i0[t vmXep /*^ jN@^„` kݮ> \yRO|glV^*TX[TDVF7zc|J p;II$fa;Ȫh$vK*a U6V Z cjDQZnhznΫ'LΥl66f5FtH@bHyYʱ2P:;d^Ũ!"H$r>`Onq3jGUc^kn_B^?9"SV+!īPa9<F >/DVC, dǕdnknP;9iӖqPS|ӫ9v8Jgj̎-9m% ϊ0l0=`z(F?%۷o-sf:4$ (ͨx^:Mܻ'=lbVEe^ac峞W8\aG֊oTArz %^e-KS]nvfw~/ F%;\c;'&QdRuyPIɣo;{cJK f՜H֚,HKgbZD"*D +W]t^u_-Ĩu+wj_쁫t!  yVLG^R9/!!A @76w|^Z ʕ(+T|uя.&y;a$xROI$P$dՉ (;#8x^E◠ė{!*F/ByuB,^yăc,A33B˥KMlS3s3s4q<61^'&9/ZBY95: Cv V0k[ CXETr& g4EF`EI p}lozoE5nl +ˆM*r]9'7jcލ:8:I @Q `N"{@|(:QFD}~|3kf\)DɃz 4BPUvbr&fhj)Q8gg003YfnnɌ>$[Tc3t+޺ |Y ! Vm!>oLB%r}*C/v?,/C5pg>D:G~.a%Aw ;MEe/6PxRf'QxN +)Hōۚ#\rd4 F:Y`ɯt&`./}h̀Pt74GwQp]_!*zq. `)W>~Xc8'd'WMah׽7MLE wU[n⎶uUD Fq0]QD;YaC޷LdDh,Y\Hh{6ӽ޻ќCBS\FK.O}U/}s-T+* Pa$`i&.;w^ijD佔V f+ {421f@A3Fppp`_+D\ (8"e#h,~~'"^IZRmhll5ʿ7Wx=.kރ U G1^nmρs33b* 2 q*ιog%:&V7}SkyeEQ쨇О9v\++>6DUA趻J2"t6$RpoCAH>pvs(C DqJ;fVщ Q]HZ^|4uVZwkDfR0}JD&QD-ﬗА}LlM;/V)VAiuz7~4-PF֭Zbla "_wݳmdUP=ݞ 8RJMZֆn#D/Em u ֥ ̆!Fn0;7g%&zј Cq~g_0c Pf&loW,QT3[BU9}8G; ՚0{gOTM-yyR> 5L;gƴZ-"jzVV9+ۈN&ͫ!nχ*l*o5S'`0\>,Dr}&"@BUVEJ֫nG>JA~Wfd 61y}ź(dJ ,?3VE }x0"W>8/O?}b- ֆٻ*7QM U]Hoou2qd%)3V}]Eja6Qu ށ)Z.%QjYU!#bNTV 0?*x #JhET^?O|xԫ*AY8KK`܀*Zd6t1l#~q"gSLZVZ*zi "䝴ZYпk*ay^RyT87)9Wc]aaڝ}R bE? !čr!*HRTbFT:3 &yy 27L YT @5r01pv"9O'A1n=rV>N ZGzIYK {f6 `}WQ FUBpFTXKHeힽA\P&W}ZU*)0*6,G }p'[$Dl7 M9uNUC@fTς {`T:.7aXym]?"kJfC'"ϿcSku~ Vv:^qPgD*N]eoJ*ixo@ZWDl{ODh( `U%vB|װ磝1&-%y:/"2nՄ8wMOyȃFQW(k9eHVSXwh " fc SAHq[һ9o-xQ!fV 7Fc̫:F24UD%{f2e)DT, %"q3KK:O kL<޷o]9E 嵭d \2s.a"sf(4OW@EI+2a(OB݌4&w>i|GX3oii=%wIi#Ì5,۷} 3Q- ~я}2޺=#iF=>9YfyLP&bjQdku凌@Bd y˛AX `P2dX̉!]uV*(sQw|-l9׌ UA!4tk.AtUyc(7@1Yx <#;ٷ'8HW=JuIVb8 26!_gFjiZ\D@Bd5TAPw;wuPش\VQT @M=7oj<Lo4mS"λ4.HRG$kڎ E&!}w ÇBV<RjUl0)vȬ)(@(]7ܴ } Z)e5U"AK&8$пWb'W_^, b 7NAa@tR0j""f$-f zssk3̵ٰL4jXV]k'0CRiJ_|/n eT @5E"\O~pY)眭|#c38|{&'j 3sδ曳s wqg033;37{ӭ-7E G*-sB \*8p(f 0x:3̜( @TםEU *)iꈉ*0B V#2>Q(s;nM3 =P"%)Šd!$ިPan4M6MLLl2uqsэ͛l<>>u-S(lcc_Ʒ_W`TXk}̇Y* ¨R*xWt6^i T$"ljQcW'o7b2Mqyjٷ=]wҴjٻ޽{kw3q~7߼wf+]Xol#%2l,*P6@DDK`M҄%"I]̒$ g}VNw-GÇL b-ٟW ,(W0!Z>u#(˾"`<%IRDV]VnF2F bHe5o!aj윭՘!޵;Z 5FO<~r|lbbc>#wlzqm۾#-7o޲c"ϴAx̧/'?CqÚj_U&m.|[=xɋCRf}qL!a> mT @5Ŭ[q-GεZC3]Is|گs@(++` .\6=رm33uԓC$*={ݹ{;ng]fgRz-bŖƩ8fSkԉxZ,ޣ.e8i臲'@gC*K6#EEǴu,#٪JBM 1<-UXޫa -YCL$"Z1O]BkDq-`Glިr)۷mOd۶m\o'曭8 O4aS0 4(Qjec?W?ISNA"苽4W]я}oU-&IڕcVºR* X MZWidE/xړAzGzXSā;o}~0D*QfC̪jȂoڼ99!s }榧gooݻo;u-Z-q$Pؐafnq1Ji(D[<ʥA3zw|`o^tcJ=dY`]&.]a{^ $3GZߴ裏ٶm˦m[>iOۺm'?66Ά7OMlUT#yxUaHU0g&Fϒ-̥mSLB/_9338I<*MiBu^~g>!Nl*PR?~|ރ^gP6xYV™} vSu}+~l&7NL[}3>H`c ^{+^( N#%zn)_mJ$@FD,wLm ?q =0jf޽;ݟIj.sQ8Ȁɉ(S+iE12Km xDNJĝ뮓=ldRXF|16\z9wg&k[WM= ꨪw\"uCZ>SxLY*Yܶ2;x= Ex<&RUQvTXEIcq>A=7Qyc:򘣎~#qGر#qԑG98$IV3DDP\d|BGZ Ճe%>bbDo{0ъLn!$Dm{9\IDJ-WZnSi y>=VÕ*@UGd-ns{O 'f! tl^SNa]̪uNx{3~l+g׽ۻwΝ;o]?0={Ȋxb%9UF>H6f] ^eܮPr68z_ַ==w2U8UfDőK8@,xeqwG|G8:G6k05q NDcUg̞kc4Ox̣^ږ#ҡhjEYT}>gD0]aR* E@Ye庛#= ~lm}RsHZ}~H ¥׏SE䗞K$gޛn뺟x޳ڟ\w׮]{oݻ )STǵ+yx$#|2믮0µR ^l2qjrC50*DE! nWତ@DD:D+ᎃkTZbC4[ٖ"sI$[:|i}C[mڴi퓍H}^!ehŀ[nj?|θxQcR/ 31Ͽ>퉏hDlp 9*j\o2ˮ~i+GxQUP pܱ|#R@U 1qȈ8cP2cnēcx< NE߿ݻ뮾ګfϾW]s¾j u!>G42P){jm =9$Ob!*=0D{z%'~ccGy1;tN6uB^<#k92;olVW~_+^EBthǾ+__E D JJ 1#"j.ַjZ}NeÚȥW1R d-Ƕ1"ۧvlz?퉏m?{Ͻ̜y9{˯Ե<84%cLlsHM;SaLk63ax4%â* a9C'x X!}cvҼh2M$Mmdu64ϡʵ4O!>IŹ;ԓO9w‰CO8mۦ6$f?`& I{0}K&y_G=CS!qGmo5w},R@TX +87U߶Ȁ2^S A:}̥1&s.Iӷݵ ;k{EQcF^yaʷڹ=j&UG5Rۑ*dA>`;40\PkgM8$RGdo};Oǵ1O6i6rcl_?ؘk5 UpETX8K6>p` o|*s]HD n:b_ū;vx$A k fSdwl?~'ǟn/w^ywx۝ DQk@lx(Mj5"/Ǻ#Q^G!( Z +Z.TLOY"GWdv6I=ٶm#䓞'{̑8tGh<DΧEhčp}U%tbvXš>Ad'w?˿ؼovRc#"J,JO>g'q`9_aP)Vya|+YZVe}~Op-gk|"u D,T2C[-SO:a>/rpӵ?. uO3iB5jme?ŒYz B+G9 PIDkm6X"4Z=s$_\G=19O߱?S۷N !`G810"@ be7pMU^:c<-u&6~o|AuTN%Ϋٴcc{#$x!8@pӺrVu`>!}_rW5]q]NYEo|9GMwaqk_ 6+i-ލ8`Tx(>fۦ#> =~ ͅ楗_v9p]zKűbìckK,a^xQB̨lP:Ԗi,>D{e?~ĥD6km%PQDcM"eɂ-y/Y,#*p_ͩ{Y7V$F[{{ٯ{lap("PBqGm?夓O=ԇ?|#O>x dqVS.JwXRYlݒ)_{Pð F~gt)TeM;6kMVuS J}?3O_QcT+,˓|75 >ϠV÷>@pxYL+ J&u|ߥ@*.IW'kBD͈kȯEwn'Ir~ٻ,4K/M7tӍ7zm7&9ĥVY HHĢ*&j-pHu($޶ h@!O%d;7H עz PjA%Dl9q\7aylS'?鴇?SNzh41{LA11 nwb;!xٛYp5%BXE"`Q|K_}?ѧ096y+TPaPBl.>Oq޳1'w ?E/xAcm62wZ\b:ty/<'M{;/y?azzv~@P2D񪪀!!yHR(ya iK@\=7* j8%^cu 3ty[' 3Jsn&m5amcbSO|?=~2vĈw6Nrާ/}[߽l Ws}3aW\~??'MbHJL!mwT @Uy75R/PSH\>1sZNMM+_eSm;:p.e6̀u5DD>s1G??zͷWx5^Ygc2mEV*AdH* Dģ/XTsf1ڼ xH&k]kAZ H[Ix?Oz5@82\62w~S qɦɩ˿z0:R/`<$1flӟ3gA{ᝄZVgt>|\Dgk6 ZW{@ 4bV"=|h*H@Om~D0zzAXff9n<2vnzs/x"F +)J_:$*7">"{qǜp1~C+_y׮{:«nH0Yf( !@l2&G`3&,@l9 $u U|?{gwޝ{woҼ>3=N|Ou'S< q`őY'ߥ![/ QQ~t<@mׇ΋]@)O ."l}ewwQ$3Co@0 $pYYw'{ysG|ྵ [yo z D}?GMC`<+3ȓ_P73sm[>W  _G~<>]j [OOthr|w~&rގoE% ׿]S>[^͑t!PژL15VO򄇯7F ìdEU2QϳLư @Pb~{+p#n~{?Ͽ7 zĩ:% 'ePzyg.tDX(Jʦ֢yK38vo !uI_6 zB|i7ə'C9;d JR`ba"ꌔI>|=P#Q[jX.ٜ!s 9|qhbƦl Ġ+pMɼ~~Qx2sԴ!e㑽w_T]˜/s`|{Ճ^!$P΀|_78ǁ 1H}}wl S! 3O fG#:}8pUqםַoεfIJaXNn*lDHdhj ۭլe埃/2ͷ؎ UG"]i);6rz}N:8☣YV@*@8"C8{=KԧqeGbYh/6yǻ^r\q.Ieևa U Rd;`"Ȅ-^2XAĥ:OxKxs([c0A %P˥}\y#77|skֲLBR"@`ݹ!jNR0oh5g`׈^Tݦ`"lj(aG}W$ 4m?9׫Y_rǞxq{ґ |2$21;D\=@s>/^?a.,DI|?a}+!% [ 7-~ǻկ]_fp6XQ\<6 8Qe6v*qC9C=~~{|gj֙-QUΫި) 6R!"ނT5Ո*; !7F{v&1nzUߖԉ;3}ah.C @UMDh(o(G^%*V83EOڶRݸq?UO,Lr!C`!n6Gc7vۮjX|HkON}W\vI4gn{脫Z.7ZCa?O|o}s_/~,TQ+1VU;* ꡀpǟPיd\ĤpPze'X#R ֽC ('Kzѣb$9meV/tܞ:O; >{UV"V,ڸGC!QzW]/}kV* QKk@aJ?}??Vߣx@.>؞*Cl-׽kWFu4Յtcz/\5>oϱ<A/1` F+}:^ _wÛn=u>TI5`$j)CgcYƉF6&pt!J*jG'5;d#S:9 ),롑,^ᑥW\~q'w{IcjYٓhGy h _|Gv5LZă|c}?9tv*C I݉I{U_}? @A-%Y^ˮO3W5$.9!CJ?s.:笛oǿG?ɟk֯"36EsXQ\$zLYY!h~=8W-Ei\!s@gpųn8H)fkDu[i Qtڟ?bRTjcrB\E|؁urH-"0pq_Â&;"8Cy}c^ N{[F\/> x3ĶA_u}WT2sxHP?#BX@9+ȼCݯjfz8~fk'?'wD`au'XdM9hQ2VN?NO7]v"3Q"bLns\17,,D`"ȈY_xMK9La\lAU"#{m&VaOSx( T8x(٘uD뢆œvkSt'ᷘD$`0 YfT\i V6Za=!{sl F 3{(HV9O ^h5mR0aGRAZw{{k2J*ߦЧ>Ҁà _l?~b0zˑMlܵ6׾Ɉx&4"< Dce=_3"aq[}ߪe~%^|O|훟<1 ,ˈY<`vLO (@Tذq׺ *hO=v}:jY=Fy;|X<pC};?6va7"d?t5o{k_Z%rY ).X4 !rLY&Id^7[+cw >:o"i,qcj"\y=hesHaf^; -s f5ZyI?x£?/|+_On9%?ZigUpDy]b@nwe#"f6m Wċ'a&h**CDȰ(lކt]QjCaĻP6"GMzflұvygqSNɯ{[LC )Abͺu'=C,  8ǙcCF]}W %eXD#ͱMAY^W>ݗ%Q/:AƊK.AwO~/~kݽz+kJ&,D>fPI6ߣzLO yd& 9dN;)ޅ.%;fq"P[Ɇ_cE%P݆:N8G_vʑU^|^ Nf;<cG^q}s&e)@eoPn^a[~~쩏ddM;uu 1Ăahb 0S?6veT>kk~/9Mן3c "2  |5O;?>Cs͉FҬVTל <"%?<؀<,B+K/w]LĹXLƄAS^b3(rs,!"]>$֜\;/z݇?tӝw3+vJCbnC JRE^͛Q1fVTI[C?j, 9C׃?a~L "(|!=ɏܧ>||szʵ[I\^2ȅTUv~Bx`\|5 iv@ l$Z溵qDW>|xkz[>RȫK?jׅ*ꄇ>AwnjBcrT%e_QG z.nC,!@C$^hWknjf[37ͫ >1Wt ,; "8%J<^=/8?Ouu?/ *X!+BJOHB?`|mgXsJM aHݸl_l{pձGQMAbU GG?q[̂GY"3W?R {!&f:{?G( yfRr+s܆|(43^Gu^DS3@#)K[lXt[loh3b^`clm˹1 ~F Ɯ&3gXO{AJڗ|/C,x{X.x^{9mkdr2AD4;-C֬mKKSc,Cȹw1=Q}uO< DKCt g< K[[%!m @~s_J[!emyAgyuͯ~$y司~'gƿsl~b QR(D`+/<\7rQiNdB9}zV_++f2TUU">d t_DR'$RͱyC_W\Z`R # }x rQdbT1|]1hb-pT 1/d0/ oz+jcI}5*%ikzZ<0yQ "bVM>~e}cxǛ_w'攁dt~~wxC1+b ֭%]{_{O E~! (>!wnزDT_zOx{Tb0 x oU秵W浫ZmǗ4:~ cA{z<<}""6y{q"_R^vIwy{9Q0ٶ˔*Uwiv=a7F2:ι\ "1CӼ 2C%㲔-S{a.8>QgZLK)cru2D0;V=NL6fJ3o/ θO'QyLkYعW!}[*u5*dk$1ChOwΙW^`} :@oU]yˎ!hķ6nA2h ^J3m"V^ybd{ÚN>cx;y݃/}I 3ڮ!yw"kO|U*{-3[l9"%o|;X7)aL AJJ>wʃI}Ͽ}MlmvNm1;oqҌ Ks}H  ԝq{×$1CxfuN"ڱoq)'î7_JRyR,|3ঽljMT ,^HxЍWU!bUmU5A2tGaa&JlnMeS˖^>A8Gkq5(@JPϟ~b{bu}>ٌ>#CiO~TS5"_KZrO~Ygt^M` T hޛ![Çel=X˹N9/Yhk3JU 1&'k^>bC[0(Ix$N* D|9U`Vrn>O~cWG*J\v,z/(CB=C^9WtsdR<]~\.,LP€BE^3TAse\~wOCFP x l**s.\29#Bbaxÿ/;lTcU*W׿o}q)T1bŷ!L$đ%/~<,xđaFz?>a{ړzόK-- 0?jHbEgSMjy߷?_#ZZ[hb"k /;/YU}#becl&Ђ*D%fQ~z2<~;O}\QD@C34 |h]x!65A,=i WYjnhT-:ޝY&'&_m cUU_(*3*a{2`a P94|=3߯K#+W CN dvZů~^P礚D='rqEPbDpx?)Z*Y0'g#YPGU Bf,[:J/,s29fNgy؉0ÁJJo#gS_# `rX+"R)0{̇a;$nLM\䉏y#.l} DQr-`á롦hOt>r3N=Cwo|\TM(󂝍NEI(#-Q;W|]tQДEqkb'<}岘@@RԆanl猙}eK~xyI{R[0f 8SiAlA waSv"{ʓDFEl}xЯm\AY&X@&vNo~׼3)YbG[}B1``˾/}ٿlq\8Iv-s#pT%h`T0qwl)@M\Nq0BqiiO;z#R1lۈ* AFG$T`_J@Ba8ɩz;.2db̢Ͻg$Jf'ͼF^1+1Wz|uW'6VF+e>Uԗ"<:TmMlJE$L3et-GHA:.$I )|cjN}3/{=$6@38ݝWF> rFWλEf߳r-YӹW| pEXt@ ħ?CQ٪3c;5vzQ ##6XKRRJHunϭ)3P$e,|bԑJ0uޫn9CE,rYD&2bkXabI(FE*D1!g n o jkNe޶(Bh^9^/i]?ǖP\M,eg(9R@N5@d@&AηҘz'>3Cj#w[61"2;&^ D$p >f  ;xC*Ҷ̻h;+19̋h3Sx'Pэ(()yH~"?ib<*FQe|}+׫sɯB[ۣ`qzk~& BRXvE~C`$dt{5Mlz/kn @  <a}e-<'.a_ˌVa:FW r1D)!cb&e{_l"950$Li̜X6%j53,#."l^Kד0 |,=hC\}v/=S2#֨{( Pt4G1VуƤA95UO?>h%'CJMaa=93x Q P\%-*RAcZuڹV;k:r:yJ%s.ΉeED*0yBob!C)HBʞ;#bhmW rrNzs[Y%I-=ɟY= 3Ly^ )Jo~_p'03h Cb*Ct EKW*(XETp|oa8#/[k8{`.D8" M=F#"I<$.Txlˀ (M"*B҄L qrŘ1pjdI\n1EBypW*;F3 Ft 9k5Is|ǝtzGL25B$v,3E$Nd. BOt !2esϼsO:qf fx$7=GoB@r:8dv"sZ Wץ6v3si5k:k l$0PXXPD<8ˣjBG fQ=BB VV@@`$ EP13aHuq砓ޯo>ꑟ70ƲS!R 9z+A=Է֥!vL ! p]oyǻ02ꈡPLqs伛^ abTFu/xs{Pn &PQ 4ٶ"5RpTMjm¦UckjN1 1Eke59v=ZD) q-Tu_oo0:>&@Y㲶#T窍H9~a"Y#svE`25I"6qƩ/y9^ExO߉w"^ "@?&Z˺tt5N:¤Lрa'd*ijѩ&@(.OJuvqay1(Swhη[& II-R$љ\_t3Tr/Eu0CS??S?B.cl@ {*u{Xo>QSXQVڢAu XV\;eexM?ypƩX>p5H|Ӣ4"DYfX-%fz k Y%2uS/S`!6B[,sj &H,/c$BD0QNBPl؃*r;vdabZ~+;uYn'W rzL sMY t1C96;~s'=qX-|aJ;cqС8 A 4CӣiNT[-۞(rCk0:l SV+|/{ 9JFX7;`RBzZ@ Oɔ˦[?M/mf朁ȑEx>a +^!,r`R&@H<`ؘT G!] O,+y9N;k\k%9@o"Z41QK+E-+ AT)ă5wvVfC1VFq=E5BBH 0Z8 #* 79g;<=dY 踣;C^LJ敔:Q,O輮0 `QZ;5sAȝH37{뻎=X[AEM  zݝ徆{:`|ydi'|ry wP";drEj vS+@oæogzf{sNxeG<R3ؐe.MFƠ؈Q&~4kT)WlQ,k y(4PD=я<蠃^0%u!ͼ˲G7J~pNk*.u%]8(6n=K;pCjvRuø92&G?eel%Z]Ncy!K>`"Q% 3(,:~cRֺ5~賞p1`VC)G=۷VDXSۘ֩ɆFw SbF=pe/'Q&K0u7g(n)5Xf?Uf>jYMgR늇cjUŏz3Ef. p߳W|fC/c ?<1T`|>\Yn*/̐ ",Xz8^$w1pDŽ1!EB Hjgh=#R҉&$Xܒ8^2R_G˙F#0@%dz] @>kjHkqt!}nj60s!ɋ|x6t]isyg=cZ:@<6[>B E2#Zt Z4}t )LLJ 8S%8Vy f{u2>l#St1'w{/lmi?%1Tl}݆o~;:6bP’i3[-ow6nEAk"UW\jmڍٱP֜Gf,Y(pMxJBƄwDPZM7̤TA֌W#%IFc"KnuVM R$qeU|5+|;^%& `-JEV8ж* ji\6sW08d[Zm;ТH L6Y'?W^~Q[ N/o Ü@H40Nl]=vEd\ E<#c1  SzJ {:4;3ғg+ !w =%xjD/w"ft]B80)t=O@H9<\7v͘3)"Y lqOA 2  F ͕P ~ rHBJ9A 'fcs~蜔ko I7yhxJ Mo=S}I ֔pEF? =+[-[~#h:Rף~ K(?w=r>N&Qt*[ȴfr,IGn# T%H'a hb.{+/y ^pmw/j8Ծ_3qkuC5w"΅PYsӭf&2F2iUX&0)|mXs93W-]bTql]ź->a=- E -t^hNnuR6RѤCR!c َe9Oq =?-@? Tҿl1}͞g,&-O:sϾ _8^AI7}_̞C6G21=C^q߷q!B/ƖT)/D._vK/qwlN=c<˥U͑ SȚ" k3wtUu]9^[96^1BAL_>"̞Q5`Ϗ~yOx| 3\NۋU6sUFh4_g7َT}'cd@YsSN|/=@Z=0,ԯZKzPu’281pхG` 0ĦAS( '-7h}'P쇹ӯ|Ki$ͳy_/%9$  ]& dlR /yzHCp*@@V,ZAw>73cPG~< =hjIgzFaEXSSY'+뵽GGT X )MUq^.Ӷ€k?/җחdl33Ђ GbVhzUh^c$fT~?A5;V<ZCj"Jlil+^va%L己" sX[>#(D4.pnݘJnh:ēL X B,LҙzdD1fN:_K},=~sᦟ3G_R Xl5D˷Qկ}#?}Bgrry~|\1 N/:O#˼CQ^Mj Zx;q f/8hC=DdSj%m^["I cviiSB0[ab^C!U`n+ݜ!*"h5קmNw4㓭?%]5RmTja>DLDj` VV-Y%c2kmSge63Cd굷t!{~jZ^{T*Q5M mNӟX ]R@jl(e#q/yAU5Giπê})\JZFQT&%Zx͓7ډc XA!hOЏH@a%Y=ݹ'?e.#RvA9D=mT*}ODRh0/I?}眐jLj9c 6?;OAKA0k֮G_=!*,o~ӝ"K iOk ~?w趻ukOp4qT5"Xc,3mBiޕ^3~]u#t+ӯM Cܗl"c/nH-1l'f"gg~g ?IoӍyCRClqTZ}_,T;Fn??T;s_RWkns_ҍ 'D J. 0HE?O5+OP&6JJqSJݩ(@d+Gb*Rk5IՍwo}Z&"8*BlbƵvL 'N;KW\7 sg.+ s Ӎ=9h2]pP>5*5Cwlv >@ K`Ix?r(lGQ!\`ep-{R j;_wmqФLqEUTWabKd11` 1`lRVOLlY_7&vrs jի0byxe1T6a= }qI,*er-Eq)"Y]>>S9NHYTY!$@clMs CcIc"O~Ǘ_QVIn{%&5+_7+qKۡ`t~`B-8z$6cZ@9~Ah#Rb!Vk8qqogw7wLLmthp(\ک*o/S&c ?8= POsG-mT?%+V:"Q@ɄU G?O"ԡ|U&"iy@9 ,Z5oxY=eu;E"ܙ1'@hw 5=2^c(񕚏&ZdA$  k!IDATyyf; 72¤"R%n+W''@|0sWEњ[.}؃٥bVۼ6 qՁ1lC`) h7C6 3k\u'XUU  )ͯ6Pؒl_ͪŏ8TPVհQÙi O7a-٠TL'h|I^tA@ı9C;|7lDBy`haȊqD[#(*IN_᷽i#6/s -)i5I9 e8ֺѯWxܱ#ZR Q/D36 ""q=+d)Zf(šk56wѯ|K;Uc( Y# tȋv`uokjzIE G֘UHtʙԽ\oKʋ͢Figk+˛/9:I﷊:)K( [agUCJsYUζ ½g% [؈9W}a(DDx񅓷LjQ`/xʣBD܊pCj[;9-~ =#@$R<Q*řw*Ԫ%.eDUUEDD5*`]B:]Zǜ@F@acl?U5|gŧGǁ `qБkt:g@9^!$.ps =9ϹmI>Uњ{ $BL}׍31A?⧿בFEQVJЇ[X`RGc2ۍXgB'kP/Mk! ۘU($ ؚqYǀgQđBcARr[Ub+3kaӏ*@38JagPŋw q124yHPw !B &0eYMO"UCK?dp=3"21(I$֟+C.]~=F4"DRPQ=?mx ?6|IP sη>Y2di ^DZnV@ Y_1'&b4ٷi$Op lTʅ'xhJYmo_z~\E7yz `D5OԔ&wNNݠUkΧRc,>V%p("GVX(+ QXb ,qGV+-)6.+$i\R,AL\(&5#QU!xH( !mP*90 8hBbևXO^;@ n+<ĮMTTj[ P@0ԹO;Y WCzm)1nuzfǕx$ב8-m蜿VxL:O2iBc+/=Uِj8XUWx&2P D,QQlLbpYD@po鵲~p) lw4N(7LJ=+.צg0"i; ,NŁ.mf-/N#,U @ :V@P_~Bh,@djR"^5=^?`EeP@`3sxRO-'LNū~Ӟ;pI:̝߼fRd8SĜs'>!F?71=EX]JAkZY41˻Ĉ֨ *1  +4 O.3*1W8&djq̅ab#5 p.U >0:_z-);Q|.*{%4= 30h n3H4;P[$*I2!&4 %.Xv$sc }j#}j9{?j+ Yw{So{APa x=lΣr<yo݂Uk^yfؕw=X x4qvwz&,iQ'"Lt_nkLFIUS FF"W{ e 1CHMmUވcbP؄$$ˈ Xa5$y|K *kz7'҉D_q3ؼ أBNW@ၖGZY̵=eJ-{y<,V. &@kɀ0 8mU;[nwa!˗QWT[Z̬ !O{'=}䆸>.#;r0~ VMp `R@ZMsڳξˮ~;V|fA/!?olBM`1 ܰf/7l+#<ī8Dɹ9w}$!H 4= 02qYg/鑇0W̗Hf!i Kjl*K+ QؠGF, P*݃sv5+pyu;c ^+}Ӟ+\/eXv…@3Kۙv֙kh|[%DBh{q* Vk5dTNr #%2O~_Ħ=XD*c~/Ƶ\II"n39Wer0X9ug>b$igln`ˎ_ ~`DqWm&bB6%xqeiQ/HsDDHԪ1t[61]58j5'b #ްE0 toA*\Wc62 O9$ :B>@ ܮ)ǣ7pLTjl:ڍTm66'&I/Dy!@*L6v^(qοփY2hTb&no::jKF—|1Tj+rY ;W.[ݯ|ժ[~M_;gy_/H@(2zZ$+X@ Ζ]konm$F}TX"nydAAf U5 V&e01vbDC5c5 %.(I[K}[ijm*=,( 2c ]/bq0 Wx>'m<'t}kf =Z4u~*mIR'v;ua2"C&R ٫G(uՠֵ0m)᧜rK_48,[)l\oo׽$YE[;5 ~c7Wu`Y;f?'qIMmDb[`YbzY҄(#q~$ꕸEKkjD #6\S<FATׁy )g. @g*Tf1op 7;{F͝A(ɢ)"Z8 x?pj!ii T+];1zbc >sl,Q\2A9 CFkד|ȺF3&lɥԀK-gsNv^rB5Zg nYjLԞx GRE֩t7$y> m U2nook}bjB##,Uq E +,WXHFxt4TjK+TaT(7.ąYr2a Y]榠4X:C|uOHc-WM[1;NOn`KhJ ]iC@ |;:吉ηYfl9@ēu XQ"Tez!p3єA Ct>?߸7 E6KӭVk?/_t#"s]ÊCL1Tv-Zה_Mo}',u62X'cl촋/\vaq݌W=d] |!B0C`VDƬ\DT ɭHQ8[g*IdC> bMEhmk*P툁D+ DlhZEqK3`ÚlԔ'nbՖ QZKyYfR2 EqȌR?ٜ^o u *OwE)b6vŀ,K.^aKU.m(8w>,]cZ66#")$Kc6<^{#jQ "6m1:'j ۺ֟m' /ضQeYf"S!I""jHꖗVfQeT5 )1BFKlo-WnÀR|&ע͈HKN ..&?XCT5D&_HC* K 8Zh REKh:rh8\#K*xfO gJE&ݴF1~ a 6k+?3.nٵ&*4\EC@lv3Voη]{Z}gb9g^8~ t{0P\ 82W<_W, ,5)߳¤E,ԧf#u${8"""Ʊ8TA 1eYv6D=C 7ѸR!F61:VMbCՈDc@  t7oqrn(` !oA&('/Wa[-*L5ۯ}ū_MQM8ɲı8 Z -3'xH"hsb!ד?vjGuHg;wL\l&/kjHFV Gx,]6XlKhhlC`H@YU:Q )[Ҫ:,yn7lmeݽ_;ѕ-"W!$,Ud̥ZKN{tiK(%j \&i慣saS3#JPf0Bg'yk` 2*uEw鷿w_V`9囻/w~{mWvf2ВcoV(;fa-2F=#b0;/|;_W>y:h5zOuc|#)sG: B. im-11jzlk"&ČIN]r̽1cw,qоMiA|Y;-H2i4B0X ք7NL[v&l8Nl\jfIRfIэ[಑Va1vbBчm;`lӞVkgjU"u 82ӂ8(*˯~i<^XNݴ;m˻@$PO,&Y8VO޴qjBi#7RϘWQ6F!vHƐ%"j˒q PjK f;/BV`X{ @KgΊ xRou@YLm-h8I3V9NZ"MH˻Eq@ @AFl " !m>?z /]MeruDs*VSR"cϯ|jqBlqAaa$dŌX{u֊Cܨ1IaÆ! Fjd+DK֪R͠$@LH"Dlؒ Jh!JЂepNTTW%W6Uu&3v=jɌĕ I۰@`BĶNV=;l䨽WdZ+| ?W⠓ Nx< 窣+*x"KgvP-:Zsq!&2k&Tw nns_OLHI%OvXlt*4J c ~{foHdKyt?08f<ä Rh[rlhHBFpʎu8)eJ'u_׶6>2$}zULF c*JB>?'d^.s/De gS5FŨALL([JDD PeK#+6Zҍ  ZSU_.Yi lᎻ+@RoOF -u: LZmӬeۀ21+q4|R_:No_=p=G(LXJ/t(A]d٫^uko՗ )Ct9*^x+G")rih-N,Y 1;xnFei;<+BcA$n/Vw_6ʕ?p1FPBk7Tõ(ձsù s7B&$@ JF5yP-Z@KrvO5[mzPUlU/{.\nn&T}SN=e]bc.9ԻFeV  /t&Jn;7RSٞ(%+Xv\QmUZ3**ltґZz!wezÄAԒg02T~WЂ``&+N@ Q q{߶~ÚFjlC87eflNK\6Fz{c XBYbQɢr7яo~e+e"!( s!DzSwma9 w-RH#-tI Y?ukצQUl"`Y+8U!{bF#Ag( }ݣ vkק FU&<Jʝ:; _jϚ)Cny!^GdžTɴrtq0H #do~x㛒>sDssŚs} ,1=5̧> [gi,T@yW\^ eiF&=)|3^%Q 9uVvس^dRf3^,O:ik4Kjxo kϥ*)~!E0Qo[}0;бqʞ- 7޽捭 &"㊨!a녜PֶtJ"M].(֢vQ:n]@?gG㢮p|j=l)).E#t[2vzU-oxsVS K^ljz5: H*b,0 @qX,2(N}WjvCL~~{ɣ.M}bX)46dvj! n`A^.Ǭ+ӳtr]^8*zc=~ Rh ;U6Y[7Q{Ee evkGv@݀n &u׭^5IƖ`=Phjeh{-֊C͔^!66n8>|ݻw[Vs1Tή_~@]4 he @_v꜉"QS[n66:߻Xgf^C!=?۽Jq` bUXm$  k)Iz|-GҦyNSKqDZ xsT{:il$'994yYQ-u0؀" -.r$hD_9>jY-Cd ׬o4E8PW.(<;@ СZ$7ld֥3UqI&%cuˮ~ѱGޫbD* @n/o׍ƚEUq5F(=|Ւ;bIrXHo%B՝存} 5fș3_'fK\ҽq`T i"]e ΐ;=WP鿸nXJ< DykP#"ȼ1QLT Ah8[oMYkP֍acl;mGI|_{zQ,c Rh6Ŗ~p @W캶Џ}j0Yf.piqIvXz_26ęwT K>oAdVq”̥%U"H#l%i9AoqΛEj$t+_fz2cL^ ,XJR V/缋J|{( 5f]=m3]/)e1M dJDT"PU$77;\νL_~7bz\l59dȔʯ׮irw;`Wbgy J22=Oo TY_Vj\O374tVT\n+^g~ ,C_QP>E:fAб@o'}R1U"D.im\YK+3XDE22Y @r3utv7;\7俩c3~cJ*ȋժqDDDeuTڴ}G Z3:Ve?[VsVTe|B,W ff9V5ɫFvG*r!e-nm2R '/7sZpyt.Y%x=׊.8km@ ݜ ~zxao?{*NT Dg{FQ* ײ ۳Pr`A#h,@k|]}#0WZz(9+})Ǒ@]Eќr<ifr9͠ϢcH5rJv5֕x?cV_NLlRC2lGVTJYQ,E+q a5 ,tߴ{wγ-d3 ט+oŀ׹Uool11tᜍw޸>9|%,ƌ!‹2~-/|-㨢Uvcnr/zg߯-JQdh_66~y]6%waqLVwkyqΓx+cY=OO!Pi!w ;? [%ZKMKkQx:2Y;l ZSɪxQ&j0Ć(:W: [X(:4yhY^Vp^$JĪeyӏ:@^ܝHA]EiӬzb3(qcD@32C hq][ S{p}Q6͔Yuj Rq GL3%XɞlZ"Egl;M]y-qpb(QAbclHEAE$ VPM*kXjd@Ln5 vD*2KCgKXMNSXA/e+jw^5[K>ټe@U^FH {>{d (Cz<4jxxE؇? GPY+6I8p:Z:6n[1,ɪ^%q*{B+#k&^쥫.@dNtxG ).@* O})MSsyNgLem_,1EWd3*o\ן OsLHLV XfKA Ҙ956DoUY&˨D؛TP'B" 9yX a&(Σ)v|$%p4-ʌAP4+0[)aX+pT+<& Icu)Ld-lO撇\|NL}A`0+lC`WG1}Wc6$ĖԧjxXkSm|gźa\>l$ҧ]6E-04 p",`ϼvÔ;WieP X; c[lڙB8 Qxn7n=uQKEA[@REI#EkB#y6RKQس`쵃u\ ÀVs=bp#r~y XczTkcu߶aɉ?|[鄃۫[Ƌj^u3N>iO~ҍd̲ELL(୮\=^e 4kQزRHmԭS#&D\JPˍ?ʕ$.BK]ɩ Ӿf6thgXac`_`μkf>@b0 Ǔgks@gԣ=yI/wf DԋQD )2xn Klz6F"28T]RgeĄfx:qF<++iu9ijԛp*=+wTA[R>LPHLQ׽-sh5czClT6wX0qN3>#{l\6ՀIU1Yb̕{SPy VQ>bшbL3ed^gľvW5Uxb .L+t&gJNԩo[-کST$pJHۍ^Eɐաx넽FIÜvPJw:c)7IABs\p 0`$wG#h$2VZW# TEU52؉Yƥ審vj d@XanKg11֋5M90ݯVDjqT0{]rrng#H@6N)G2 yebӱ1*FkzĻՐ /j @`JIHAb  aesrgmX̋$(>{̧o~xJ*~E$IV?YO;+;<*q8sI}$N-3=̔EV^)7 ŝ3;6lj$Ɓ2+y%UZNifaD O(AQ!bB s|*" I"&{Oj{龷Ͻ>3wwuʵ+֋(*J SCWðvCd1"4*R4V3AJU }&G{,2x-@!Jq y!`(TTqbvdb;-@GXH J]ۺrQg""}뿋Z yP!y[A<>߿ ache.% DDz_ 6c⭈bs{O_DB>|M&/0aѬ^wEEyQƊ8QH)ȼ%@) (s ̨cJfXADc/|$Xs4Z 5 &T k&%ɨSBU+E A B%` >;ƬZ;Z֓d!C/@ec}A^3S{@ /"C'>,AFL9p]?+l <adk!(ϟ\FED""*D$aOLT}$p@ Ѐ+Z:QLǘHaP m QPpֵ`ʕzu"@X\UqZ]p#A5!0" zǿד7pY?n%$lipH0HzO_뮅΋hҲL+>Y42t\8f>k ~]G&9J & /l~+"d_CVNo{D"`XD1@~7}X\ cYNs'K-ZA]ZBi f9ynE3p@QՆ9DӼCUCW0|H8|bVd=N6ɺQtڵ $P=\ZTYLpb@4q6R=hՀVoC/:aP  8"h>s_m=dXGr tlgNV ʢ>8"$8SB r-\92yHM^`C|(3NӁR/%ȷJP?\%WJF`żj+fkVl(V**khiB|q!EU#ư3{&{5cN^__Ÿst0<RE|o/5".cgyaE9W;-O.g56 P WBu?L<h{?olP0)-pb23+D"ouXPiᱟvrR0Dh@VkxL `=gznnO{)K%c). ix~agXxh-SI4wqGa C vR)H(&\qKv#6;ZIaSE .&$TD@عd)5A}Ƿ[ef!s#TX[ko|v s3KV#*6Ҥ^W?18-GqB.}>}D5(ˈu?x m\`; <;B(I1s`M Q,T^Ѓ*Y2CHåh&&`πcxѴ"@dGFXB@!LK ;IP Úvd}Z$ *(y8vࠐ'9#B(,0fܯuIk $L/=܇S{VtsG??['0 on w+`vK%Z#Qe v2BjL+igvJRVX +J{M@JH<  MIǂZ^Y9PXPE^pN{FQ:9J (ӽjmm#M !1*Gc/@$àv1w]?"ǜ_6 d{[w=i_{NTX+4)v>1O/^/Y؈@驱Cgl2ۮȔv+%"!|9)PFna1(-HS*c@ #y٠D*PQYؼE]1z8AS.C5G-RG,^<m> U.ИDv29di4` 37xbk$I1A! آ(3ݎ4agVⰩizѷ}v~Z4 !F-8?KK?{u=˲L %HQ(a `V!*vnscRхA/Կi&K1,N1h6&IaӹPIDq T!ԴkQUNPCF)5$C'\ ; ĵtIo|D嚐9cni6N|} sCR ` i+CA!.]Ԑ5K4G{y~vPB$H-xi?eW<[en_{\bkFk&|fF-LsY Ym8zy\QiܢrINh}ǎ]С@]+TTX1?Г_]U rD9s2./\޿|g?NW^ s=L JAXRJ R3pn¹Uh㒶%*Pyg߼Yё}vjF4:7I0' %4QAh$P`{ޟ=/|j0 k bÿz{'9bZWt~GtU_GF&* sX!" {V>O|>KRZM?'t&W"(pC1i矺t d\ub581*0fF0'.^re7_gSmZ 8'RUH9 Wp3{1cCqaj#+1X1. cPh \,8LG-VDD`֚,q?đUA^CKhа.(|bqm^"!V5;W]vX [zD3딺7F~Zty1O=Vtշ-j:[9cX@ t @{ٽl0$. h~RĦ^!ȭI5w'GJ1RF5#D%BD?ʓJ]hFX}ы9`_x?o=Av#íL= :)yp46KiQ0hQŌyqA6 CZa&G_5M Yǭk[N9sEu sc6 c>:C9!#<`2n{t~k nK&@G ȍҿ~:0GBRu{&UzjL=E}p;ePyf<4Ro92*p)=ڃXEZQf&gU ,;ҩH@io['[;l_XHf '#^Z ^og}c{  al_הa.;[?X9 <l|S۷VCΆ##1Re`=XbtE gf%g̀(iũ;^ڿ_p(#yǗv>7e⇵##ΕGYy%8]Q{p6S]A(T24Fp@EPl]?/yeH@@Z@k2eu,`ZiJGA9j&+?!CRJ4D )c賅^Ơ&"DTs.n9w$!m"}_-x^1ٌX.u?NobV&MZ}[g*OH.c"~.6mV4pc?$5w F.Zf=V+_~}}w+ {Xp[`7t ٸìHE$,@%Oxlvߒ3hRJ[_ N^X +ZQ )FDzb[4 @pkg]y}cOC`l]s(Dx7/nS-8)}5a"CZ)Oy]x%\8^cr,Yn}sj%ǎ2Q8ֵ̼3۬<VtPHYB.O~3G? YADK;_s%TyxVth8U,/Cw~'r:0xA)YǛ5hPBZwg;wrFЊ  aUxq4 JTb50̭XɹaHi RFiEV#QYP aȈV9XczVn}tZ ̈$ʈ|1N .,o:7_8& `DD`ЊE|%yK^͝D_@ޑIadX .ow|O􋝨KY\Q+TAP@I+Ut,$a26oƿ? {Yt>ƴ5j217?giTDBAAAW?;ЧN5rOl>AFy+ {t9}49dP1X xS{,2&dId?&J٘Bpz&NPȃMnz.` if}ʔ}mLR8@jp+ʅD0yDW35!b }gƫb&`vU?~ g[D6NEY/g&^l|ثLYkQ)[hZňN繮Wafeb~W~ekQ嘨 ҫ=Ug*#ϼ`/Q"4&UC'3Xbq(TQDd`tO=6Ư~H0TvWλ,Um\ y.EDI٪)%kin6?MYN: "'o&;Z{ UF eExg;{'o폭E(S*}=аW^M/xw|')THP'=_/`l D"p}37}[]뛭+nM4imR.i'8qpH;K"#'sce7zC@H؆1 0%uJj6:8p`d7`vm2uy&K5_Jfp~fX c$XF5y܃>}4SqĮXh `f"n_#dEK5u"DVy Cy%4 &U!>֍5j=8Ұ^uRu^?#E#??pg>(sT.܉_^wS(3]iVtDʰη]G(1"o;y3vFi!ű026?t&3U#ȈyfC24O x8!ڦ7r #5\< <$`RH AIZ/h `PGn` Õ0_*VuTgKb=A_5Oz=FLgIci(oox泟G ގ$0 O)]0^hDJMo}oeiNA"ps)9TT>4cp}%ZNӫg56|VQc=co+^SYom4jc??k#OˢBYjfO6r:פRQjwg{E7蒡K^pq/{'I!g_oE}3~\2CT)Ǜ{71l 40(."5h TiZm2nl`R|:5Me(pG@f1sT %k'=1_q{T%0  ]/odM  \"_Uf@]@@'u{_sMgPi(|XF+f1I@qíaeS\$ҼM&۹3]l1vT[&-B3GNK2Ւ&5chl5>]G;_q쐩֛i0,q0›2xӻ0hA=m! F'{2<<쩗$C""йPm}L$? r0,/R-0g۩/QZ#Jژӻ~@XE3mR#{ÂTO;2'LfssfFi"ϗP6 fPDDcA*$h rQQa`5AcyK9y`LG)*gBr|&08"t_lrΒzMz֎=AHk")stӾ櫎otr4 ~^:iŭc9RY:2R>EEzLXz, #>S?;0le}"CC|F'vMoBܷ#V S9McabXטQPÈ5bO}ĆP~i ,f=.C}}R5DyO~ow'޻/Ujf + SoM_II2yw?#V K gw7,_,i8efETm꫌oDPdS) kW#@Εhgm<[!cؐ[5 kʘ(/w~EyaZl<mWϢ|r }E ? 챱u2!Tk 7?_aiL%6 o9&4hoG>3{{`>^~'`(@B N^t/'__(N:kA oZz3pN<Ԏ,="w]̭ksK܆_Q_@U1Og kVD5] ڭ?#?V8$eɵaՉy~K\WšX+H"m~?Ff~g$I0 &ZG+Z| Nэ7xכϥO+#D\4>_ъVtx므<_ջKc ^N_@dۻg>iW_~bSŚ3_{ۧϪQr]+Zъ!jOҚ[;;n¥>\! #581vҔju1cr_YŬhp+e8 ӫ^O})'D>_exgΜY[ i +ӹ}׿F Tsl9B:N$[h8=]׋9|Y1|IfJ)0@k2i5{{{̞i&+Zъ1nz 7W_~aY& Pw?ځgmbL+Z0 4ð$яRH)1hE+:„H&{p^+%+RzfthE<;)~;ߕLD]φ6!@Po*\Z݊4Mg^;<J^ <&T•MGuE+Z""!3$yetI=ԊnC~5M+ZѥDB ՟¼HD IhDP.>AB"mE+$H<LZ c4]즭ܩY;pXNl͉zނʬvw~Dt_v5W_~"YkgNH(XZeH0-L7g@Ytlk ֓,p'?sxFϤVx A2!gL7X)zq}+MB奡~M,ϣfy(Mg Mo8Z˲0| [2E|oo"Ü3Cˉg4ngk &ߘ%j˱x>ٴQZğsۃ-|a` hE$%νo6,/ sCn_[j%>\ъ<1@0yn++G>!zp ` 0 MSk 7[vHb0?< +ZъA("m9y>Z'a%_%J+pD`+.v$ «]$ڨ>{f5#p g/v.0 y? $"?+Zё {}x/4<9W>яEBxg;t{f/#y=Q*$@/)x[ъVtT(m@\}xr%P&H@<8ys& D;W] "@Hѭ_xhႄۿ !ճ}"R.ωr_%_g2n:(H?>;*ƊVtHop҂e];F Lo)&;  jҴo DbO=;%pE%$6[oϽ掾2]4<>PJh!ݱ"?˿6(,6y;Y?EV(WT}y&8aB@f# ľy#G<1۽^PHH6? 0PfD2N$,.(?Q 7y;;rC=ߺcN0*5l< j.l$|o |5be+^%r"??Y:s95hcc<^=柲UU$˵hʾSpy4)*=Mei2qhAtPfWw+p!ǼIP9s QG^w_]vaIY#C; U+a@PD/ঌÜb_}/_)"h:j# Tq1­gy}9ZhUsR%I k֙/|s:}tD\d @x 6$??#?ַ{u!h h(W28[ְHf1jًB?.4 .y_~ؘy߆)$@%EpT2(1bssIOxb  |[Cٟ_:NXPr-$߯}10&@ `X-ehꗵ̩҈Nϟ_Տ}9{! vJZy;^. nկ>y X@!WRvi)Gi$M ͺx Uo9d{ˌQev04|RgVR7 2z2<<$xlmȈ5Ul&@PYqRX|&.?/%9Vsaɼ۾ǐ"Frp0fW4EEC3-! '4yBwiM{KcϠ/q? 7`ӋͲ$嘟*x G0)aJz%e0eт))$µ3gTwJ—p%¼?jcbiԝ' w ojj<ڿs0VOdA.3v<q9~ jVl^$BٴOڰsVw#}3?)"l~xEϬ]V`2ce` H9n>7?>x}?9gY [Nof eS?Oլ=(㰨Ɋc5,o%j@5=cfmLU]B;0&ɮd 4%=h?3GRQ8`|ۄ7Cװ/ڤHGuc2iR!@`.o|s~g~,E @ oק>㙃4K3rJi98S4T_Y (}3x.Mͨsg%COXv]&FTiU;f \eLGgxsM/s&oFzYڀW + "FC&n j\W5OĔOys@S'Nٌ$֕eYwnN&]R_4WM0!e l7adץSAH܍~"o^N 5Bc㰼3qYn{'֓~<گQ) ^}GM6G" ??Sox[@kTrGIDAT^Klw?㫮'{W!`sdcLY\$ H(@$;i}ӷ<_nkǜvKEEq+6{[_>=ˏ%`9~7~/TYT (\ow}+w O=+_<ieVvsf=2835$ts$@D{bZ/_eyH9~bUJQX|Qw˓yc>xo-o{3Jb<"TwlѮaG20i*%Y]vϋ2>$+pt,zKJp.6cghsc2%'65XޒLip^_}J˖{G܈"p xV\䉇=A_w}p%€JQπW!X/F!@GCYpço<?NZHԥ㹁j.<}z2,_bϥ(`tq_ӡ 3v([ {v[v<.oٹ!r)՗cQs|'޼GV":A,VfΥw2K{10EJ+Dg= Ix(davQĮHkkk?wz;)IOO^w7Fy$2I,cd9plu4S@f$x}pIJ8ؿe լw4#@V3+/~'~RG # T MsB2 f$vk"['O\vBGV@Bjӳ}R*K84UZkA X$ &:u4"b%=/H"̞̀={ch^{\+p!h.9wYe5+p.]chyg, \T@Se틅A!FB??𲗽"Y̼4FS q9:R@ė7 & A1cLV=4?֠q48{A_W݉lP:N_on 8.VfΥwlmq@Yn~s76Of 9.)0s4'k&W]vk^dBX68.VfΥwlmqMrM7? Oc~=D^w_SUuO0ѫ<@}ɏ{nnϬql@e լw}h>CΖ p錝QÌs`h?K^y/o{OduED1Moa\ }4:E ÁXs#aD`NT ,EiDZL`gбl' k5jޣ  h9  8@+9_u_idfgffe4X!beEq[H`"(b=2估6T4`9{ "He% cYGQUoFweatJvAXҚ=דpya"/$/e7äа샻ox2"Ӟ]c1Ni(rN1 $ش?]|v٭tAr0Zr.1:$yWckInmeEQT ¶Z-H:2(:;=4Q= E`q+<_V;mɲA:r0\aWlεծՐXD|$eR>iN AT j@~UF ziER頇{$0 =K"ZJ/"Zs FQ4ADOx@3"x/Am{zH6{Jk䒭gϤ9 s‹fF`P:FP}:{i}:l L @5+p.@Zm j@DgR)5֞qS1w-@(XeG  (hDQP:½qCqg-֔08,XlZmYmл*[?ڃ ?~Doo ^Y0 OODJ1Fijw{{YQH"v;D*sY_w[ ˆ8#k8q '(C1!J5\X^aFسgv M8-ͰYH`DD {̇aJQs3q,"6G*^ø 8IfeFFڐTMJsƘ3G]11_.&nxv.!9n}9`5 'q2ck]/x\.p•6Ѕ [#}k-Q֗T/_ 2Mzh.OmQVjz-t񏙑ԘQP3;srܦj(R#-;ODF$dhcc#O3f Eb%SJ)ɳhs{ĉ"/ZZF EFGF@ ^I RMgP@d4!J0"RcS[4&6]&ypejKs1c-GoBD@NN}q1 qiSO$iggksTFن$@M(.OǞO~rIU{޼naү>k3ŵHAwKsDF;a; P?\wT?Wb(eYtR<vGQE(Dg wJshmn" G :NEQDQ$%½^cc[knm@Q @(왙#1Yq+ࡎG)") KludrD?Lu ,B+sweۼ@y0K…PLs`E"cȱO])BisH{DdpgzVkP*)2Z[,$I r7VfO͈I] (Q0f T 1 @R2J2Y5>^ht`FY|k^$f,Lg`?|w~\v;Y&Z5: he4}Ns>CIRWLsA:DAbFMD ̞q,51D Ar)RWz$@!NQ;i@k 'N4흵nى$6I( uF)[X0ifww[Q<iyMBFz,i:IϘ&ZMs{0­F_D%XHu/ vOM+0z4kthճi湁( RD,HXq\jR1{ZeYR*B@Dk]kkp1H5񼽵ӬjiBDZRZDQw-pgqZ/}&L0U9&w {k P \yz$ӤQ#^/C(, NA8qfYfY]c zQ!"jjgwLZ*Ie`nn F@ERh>H%i`ҕ=ZrŬD [2pjZ B@6+>o4+U.>X/{_lU|@A(em R9Cz{~q{!a ;<`KXTРEX!P" 1WJ+[&4*]I;fiH̾IeYF:xBj"BLiy uwkk}E:ֻ,NYbc@B`SH`6Q:s3m8àa+A {kQVt[!e2@U/F`ztf HSv;V]QYN'陵g$ɄI)Ľ~o{kK)CJIdD7=$m+oe,ĭ/ @ ew\ nJ@sI0o9*jZEav/2g]Omk-c<(=(bAAH+k]^AkeMszp.akR+jjxg_cdˌeI V(THQ%"LwZI[{W>LZ +r0bdnH@iֹؼ<~;{$G 8F?XхE5g>68!pjQ 珈$yAW:Ăc!R6&8,mw:8qZ "6:󍍍흓'7vwV޺vd1&Z[|"O9j%!LQWx"P=ѭ4s4+p`3/ P!fW ;883NtmK1l$[NEIywc^/DQ݉H)v{ZiP̨( E1)5HN'HHD^a$I"eJz`"d &4׽ix&/QVӥ|yJ[>q@i҈[=Z "2l-8;?qO|wohȭ( | iX_,~ xvNE ;m+2Qۈ=vX63FyE`mm-OVd>[-[][";Een;JkZBIm'mf㭷t:w ~g{gcs#I~cmYn9P8.֞8(۹~zO(:&"nۋZ+DX[Z~pgwyرO}FJqE&"XVJiOs:$;k[pVǑV:- ^) >7S=]X+p У yir/Ϸ^0DqlXhX۬ (Pw; ͍G<xCs ,P:&riU8lgX߹ 4%f(R;2HDi[ ]~5=ھoCte&3<  B:gM9kK|]C8j"k&I^/[Vb"\a E! q\dy󼥣n7[CtT"1iS;;{yeEQ$I(bgmu[MccbRFe&5?PѢ Z4jZ0_,in,V~R\FȈĄȈs/M`96cdޠA&u^$kݸp|-kmn TE={V+vbkC^,Ǟڝs;. [{{{ HH n{DV]E?yv++~aL4GPw \DL0@y4T)|' l|=˿⑭V zÍ>wJ{K[Ws]Fl德'I|?I7QOzf;2ZGiFU9o_zj}'yP:fI/jG/: 3[x@GFs*h#cٳ7QdmJi\fPE{ *TH@""VCD0+Z6,It;fiyw׺gN8q,qEQ  nŽȘ`qbQ&4O-@\UY; 3dyAIi;uW=EPzim1gD`M!5˒7sx U@d4rΜ'E;{Vz7 ֏myĉc{;4OI8.mnvoqe~֏9"ʽu$y[vZ woI)zϨϲjMDRJ'*4kEӐVVthN'|у}~3η{{ 0XI)D xQ"C!.xY]0 "!zV9;~~/ʨ8zsv- V6Ȧk -˜Y3<.3&`ckTlCN\fI4 v.qQ ֞UZ}Hs>O~}?t4f`Ac 6 X33넀fۢ "2{g"f0Vro{^]v; e h_\Gpp/ ^A"-.@5 eAw5K6'V GB*%ޟzwwH"̞jk$&:Q"my6Onw٩vzMp?蝝p*qemfY[mTQ!1( TfkD0(nZiΉ`!eY?6wI~qt܆`$6ˏg (ͳ8u&z[vDٳg;uvp e',@(l$;JHyTF;VRK M׺ki8cʵn6M4OCd,XZsm ~4ˢ( 'N4oEHH+G3?O]w}`X qGᕣnAГ|l}t@zjAӂXiۂ60 fiϲO^w7vϜvv^sYBRB);%" 44쩚䙍6h.Vcse;q:vıOZ(bqڭtz{9mT;NcgLI+s:fNl}}-8FAq,#"$ KF@6KonzHkE D[mk8ϦV>6!bEEQܶM ҫ."-rY4R LDTIJy^{{Fev;f0y}}ꬉBmmo9o򍭳iքٞgJ(*EO[V}8sC50BN /Ehk]DZ&78+Zсj6c @Ȁ; Dd66׏ѽF弳̰leY6<{F BG~@Z+"qX;佷9 "gB mAB.! z>GM1# "WH9W CZGHh<4TfΡn[OPF*kV(i⵵3ۖ֠i{gv*dDАI$ٳ6%^o'Z#Fy^X'egQdҬ{F7"1Il k@vDlwVhR$ kkD%ŝ&c I ' N+p"$&q;( -ZI+IL"b뭨ϼ{v(lε[qi4/@uqnmqm;N,ez"гI}dNO\Fs[t;EQ$>u"lno= i6UƋABiZ!(<㸰D&*lQip~v0_={DDP MQߪr;35|O8kY8xdBɇqO|N&@Kc. I)_ќj(B ވ(Ȉs΃VAuqlJB)S>TF8LpΙ O^T5+Mҍ*gV&@c.? аmK3Z-dh G2ZYO^Q-"VN"i+S)"$̲AEZctDsnj'̾lGBB:-ij"Tr]~٭v DĘq<DZ8Ha3E;1) ;N7NR 4QEF85)vN+Fx(DȂ0€[g%chg?? ;<ZR&ZyB`O t; I^(e!m4̃,Ա‰*ͲvEὋH:TٺWeEzMqbcc޻ Mbvi2<3)z`HRW{DAd5uQa)`)pi2 w,"F!Hw40D")sRdtaQJ{fFϾ:{<ۭ"aLDovXDԻ8$@[8jgx6jyiOcC0xY$`8sU%!e PpVG;et'y;Lls7x5WܡNa/n;H MvKD Vew>ғ'N" 8HAر^mm뭭EeYdpC[Ξs>8a6]^ NH$yӴ鐀N1@*mRʐB2Z[9*p-\cgfVsRY̥%!ii {D/v;llgecLh"rNH"ApmXQ3 [رGP$yOZ+ #.hgI@SA86Є< #8C:a)FC^: vIYY#?Upjt9( ,f&f$ΐbv;yJ6ٷmiFZ 8eZf8R@f ,4u0XNbA}/74xM)iEpK3M"? BX)AeFQlOibZPEϳVe㛑Ry>4mtJ: 趒vmZ};}QZw-f"ϒ8$I&b"ϼଋa[䃽QMD͵En}BX_Z[u!Cj+ƍlQ^SHIew}n.$ֺ *X6VUk=p,CbqejȔI LƘi-LC ԞR f!ֲQUw0H{]#ղ2B2coͨw LHL$U! !BB`DATu)vS%Sfm Os"1i j ([7q~|6vm !!50c@XtFH`|mHb!2"7Z "ʄUh >v1-+H϶Gd@|'=4Bְuƭba_-^~h/bHe_]7Z1~yGDxӗ $fF/;Rkn F1ܴ)w1 b`E Kdϯ Fbڢރ2hb0f D$0E0& =Lv! ] U{ղ (B@ZuGTDC L>>3wB@T1(S |CF) )2OiO3#0q9q0ӮNC%MS[fP 3K٧Fw7#*!hI{`428vj9^!N[3QB )B!i]WF4`2nX~qkqa=L̤b< ú.еf ERlVN.)1 !#D]JQT#BRHVցhRArr)Ԙow`?SETeԔ9mE|\w=W @Jx} UxByr_>=5?~{ 2!#km̹59ư̳ S"2YQO/1C[ʔ"b]@r Bؗ":검^V5{oʲƜٺu%8CRؿnzcο?O4A+uLl!'k5@Z yp@)gBZ#"J]JYnu R ,~ p:䜈ѴCtB)%ei².:w<$՞ 9bdboQu˕cnج. 3ZFrND8jCʑ1B~U Z[ DcJB( 0Q[1".cĮIu4M!D&][RjemcJeA l1@SO1҄+Hcڻ)tьI!YࠥYιD̴kl]pC޵w޷nfh`Ld][me]sΏ.Kqy1Oj&B3­gjQ>c-ZO"SbBhlHg !0䡬@-b $m= 12cλ6aփ4cBaL$*! K6DZC˺@o][ "dAԆU1|/|;[ާ? a Wמ<7 (l=O}̈́!GaTaYqmDig4nCok fDL`ۺi™n2.4lC @u]ruYbp3i+~"#OZ_,XH/5!p|X˒csFk(ˆ[t.w?rL)<S̭R1u[9d$ČzkH1՞bl2bTy'~H9־a3P!B10BUXjn@ZFFk ![9[1BКDrږf YB ~):Kޣ>%Z&QbDUF HX@1F2;?〭Q&0 G8&1Hb$p!1 H@ˑ/~^zֳ noE/|=^vy6pA`q80_~ m T;mK ak!$0j(< z+p{Df( Ro9H AYd*Sm],8hچ4dBL<ZLֻcDu]nNc @4VS 0 |:"ؐ!iuoW4B0Lak N"`S?>t3MʈY$ǰ+!ӐZ!'.eZ֛q8Cʁb}>~OKMӖH!}|ww eNa(d6ÔSYmkQZ~R c~?) ZJ5,s tr|뭵wRr1L9j]Ӑݞs,= àb!SJ)e9r sb1"@RVkp3q8Y0uvia$$B̆9ii"!bk]Pmpojw^U-bcIB DA$@R r Co}9c[w~? )@`[QkN8nUj)Z !Du !0b$kL6:/dwn5Tkˊ`v#bYmHy>j@̧)hjZow^[Y!B`hK݊d:8:RQ"vw3JjC8 2ץ y起Z(JX%EdL %I` l|c-kCLu^- .{ksAx+vA$̨ c 9B]a<08bS2CDAI+4M#rﵵ|:Z7ӘbS.3N@`Tn[d R3U<?nma ||zi‘9Qnvx9nQ"M9A׾{@D}p5h}2GBn2ʺOi7x0&HLJO{)K1!.HDCCH[%HLf"&kj Kz a[UJ PuHQNwwe9Z(Zj)&+uk-[4.CC PrLC 1'0E Rj9N0Z[)4Mc²jNiRS)nն.qIf{u$!"0yaq̂x:v7NHccĻ]9Dr T"J,CLzk|8! \Lk]NS03x!#3tmL]0h`)f c&LP[eD[A8fE8XCa.e!VS!H K2T[7jn˲b&LnoiYN0065FSz̴Z˺䔒ZE"sayHu6ZRV3ݺiN1DdS<!|3.y? N ٧ÁHmp_xuVN[8m՛4 d`㐧k0)FDfQ$PKk¤hrB10? )qș<236zd7 |3r; p4]KNQ P([SSJ"C cJu0ԗ5MI(eY+~_) Qv .+>ۺDDj<,M!6B"9" >H4DABW! C0a"0ԎfBnc pID[eT.ۚcȷHr/֒O_}? Gs._wavخYά\^m _߾z/O33)z.}~}Yz/=*siX>Dkm|~_|l?I sA(fIT B1&2B  i5vf1)%+ RJ̧DI.s ԴTAMնZ LAzt\i9,cAЧ a&!+fjܤtfN1 ?ڷUumm9wPC,0 0Ze1e ! QNqYaH9H)D&r)Ni ײF LjSTs΄dTKLcx:ovS-k-EA5 -K1 BQr:Z{?}Z˲ s[-sGemp!90˺(]IZ-ml@:%BTղ[R,|{s:m?g#4 4벴oo˼K .kAֆ͔|ژ3~zxu !80B+k](!`CZS2Lm$*z<;Rlrs3fñ},P4ݍ|<1~}f׮ۜBXDz!e]Yd#$S{m9DMs?/C9ͿR܌c[OKY?ބ l0y.0hețC!U˺uYzkkaxChhF3eq5aOGZ mm_ EAL;5 $26{lRSmu`aa!JD*ovSku]VSن :S9"penTN¦tx.Frvw{Èk]b0N0Y @S]fmL: i |8>1%8ni&yZsCh )uݍA§r<0퐱7hz H-9Z .Qz$_~R jrB% E$ӔB S Q(NA2s  Iv!z`/k{̈_ V9k+.]31޶$^aХ =޳8EdVE:>^w1UefF < - Z`VoT`fJv%i1J+5qԦr cAN˙U >n CneAO{l5S)!Aԭ&i{ (x:7w7GC*v^u!G$-.4 ZuM3Z (5Jt8KH4”x a`{CǮL{YfRjo kkL׺6fZFD w#ioew9&"FK,uL믻!Y-Z딢 hwwCSet8:\{+vR!dYȜbyZVkI@iShhr8u ?=fB1ƥq7bSNLj#DÐuKUݎFj2#)Ău벀x`qЕEx8D\הSkŬZsN!ȧMy 4شs=.~kOS`! )nls)"i>E(qR+(D"-e]f!b 77;2[_O7c8R2/?bnn<ٴi7uuI1law9ZZ)qr鶖2XzrBDו![ݐ<J)9E31u]wӸ.sRouKc)e& He>%6@U,F;B< kČ@uY"q@"3h}515>eb`K!# 9j f7BPh42B`L95"h,LJ9ga­V mRL!UVK,T_?Y@b"1}ΑYs]a$fBkʄ8ԲΧYz)1%VBHmpDV"AcJCl%R"SLl60cRq{te cW4էvi}YOtAK|;}FS/|{ ^/p=!*tUD{di { [B[M@,F9H`!0 cVM2^Kba_(r u?HIg}!"}[SN۔zeV!Ͽf4T H{AP.K T65KPM6ؑv;6P2<4 90C چQN98Qκ[!&u^[o眶[Ø2l->!$f[`1 lʲvևJnw[W2zY!D͖/?Cx<ݺi-͍v]v_?ߧ9C p܍2pR5 㐅#G5{8[k_c e "b@&cS?ݥ|>.HvH$2/)&BwDxN2JYNC/rxCz8>Vp|x@k}; 1"|:唴^Nfڇ۲XXUv90KϿzaÔR[#u )E e]@@9%PvcZו ֟Ʋ_~׵VKYu>BZt<q1r9F 뺨j2t<l֒ MLvFBSB"lZA3?{"mk10벬8eY1Ae7Mh6ƸL"a2V~_̧$lav[A$"Z1RƼ. מbƱk]f9bDZ֮=0)c_WFFM[ӐcZ&c!F"2c0#C'7X82h:>21Qt]כidaLwc[M-ǀBȁPN^4E(B# N9qȑ90 h3#m k1[#OD `dlnSD'_ٮrN$&_|)ыt*s'WbfW+]kJs-^Wǝ93 k8٫N-;Ukm"DA0u]۲ }q M7=|n<ݠk'ئcZ[ݧZK0vi)9n$1ZF˺tOwwLJDsnB(,9eYTݧZ뺮X{_e1"R8Z;0s-ḮkeHBJͧPn򨭁x9ݝZ;8sq>$i(pXN4AmaO `/Մq]k: bTj̴Z Q[<`]W`"$ +"AD {s>N8Z Hf}FST|h́zw""P1DD ;Xo= =8XD㥂DdOR޺)J`CUSJxn)9/vpKl#kSы4OAn_<P_f=Dr}j>i_2zwޜ͟\V=畲svi:q~R~\>ٮUDz [{g]4)ѫ|z6z>LCg >H DRǏl!&A$RȔߌ Xd[@vC%ZDاn<0)]Кe! d&Qו;Ҏab"$\OGQAγm.[-iyyVً).sO93tƼNH4TNO77hEp_֭z:e D[gO7##uق`jYCB$d ĵ%u!?it<)Q=:[-Wb$^{Na[ke?nwlN9[B&k?-Ou闛2/CJZi)>Nf `u>ð)AҘZ ƀ`[!1 ڶ41 RDFmmL:!w՜V\ ,1U_~)nͅ ۲~]t"V 1Fr[a!F$b, @:C/%0/s kYv(LtڂHY/뢽M-"y9<ܯ|w{c9zuǜ) uM1u}KYXcU 1AȈSinkښjm|󸞎֚nc̭qș˴ T)ǰNZV֔R+z6B2)HAx9ku[9-,H{-sd SʑK_݉)HDZ"YPZL1DDjjdF) &7~r|D@`8(BΖ'l-30llȶ("B>ƭErέ9ĜeR!b]$N~Qv& 3~_ZCDa.t:qK)%笪f=XT$D\%Ocv4*H)c_ ̬UǔRau!Zq|xx8-Oiqi]SJe]]d:5u9y?\kuYSJUBnU,8UmaYx=N0< ig"WJTJNZ˸e>e!UkYBN1ƸURضR[1~OiGDPJ3nMiqRf: ȹ j1FS3>"DDjB8N0lwCAc\EB yY1t3#@fP5fBV۶2i`ZQ[V=C~Ly^+Yl UcJ7U󳬔RKua05!ƸM#*jiӶI!S`kWAJm-Lc?~ 6=+L=]G-3Lpx z/zp=8_F]?X[8kMNu-G$m]v5tq[&"<6!Zo9Jij<OEBj뺒"jSv \jI))7ԺӫN/K1ӔF,D sY QO]6]tu]aV{ @r8H]sLKYKo <4uY 1FB #2˼.8Ťfk-,ik)I  Se.k/nnnpof7= 8j^kygH1q)49-i[ΉР 2eDjUmY[Wxl[[HX> n]PcЪaӑ(Bp8@뺮bҺʐs|ZZ Sׇ!qs)NtOn,31m]YT{k}Vp#yYnn˲nP3u] Z/D&`YN,"OhLMDj`RzX{Fd B "Br+Y׾uѩዮ޺jOwDL14D4í‚d6w~kb2U;_.?=/6pxi=?xpεND=A|-J~^vc_ѷst}};/?~M%E%QN+9[@ED&]}[f+l{y^ն?{C"1BkԶ<`jq] z*ț <,4߆shH8滟?>1ĿݴCDZ]!u] ĥ Ux]W RJ᭚vk0"lͬ$t8C(u1-ܞ hOl۟'bm'bw!01E[(B뇰mXH7{ D[G|5S*" vNf 3/tMS5H[Z@kclmfޚ5=.+11oUfj8 mIMo}k*l[[N챞v_ZeY)y?ޕg o3׾DF@ϔ./Rv2o|ǵh 疿l^x.mz.-sl0ӯy=eYRچ]nD}+]"q$xzk[FϥFom; ڍEffL/k4_UMu+l|`ˆڃR 9R„zGDaADՎH1D\[3-˻+Z)}+3LҖ%j&Z"1lmO_yLk2jye͞gHZ9^^ H6aCykmЧ ;{J ϭL\_#=~wр)ض{˧}g+N6.WϿ"z~ynKa~'|^y^u@9Wyl :̶h [j2ŅElOw2x@ hIDATsx{ZVg e=S ik0$ZKa-ef~iЮDz}J Bm߄#muH,Y^$m)N['2!hXl9!|vIoUӪmtoǵHˋ[ZBjm*8{$|~C--DA*j"_G|ה籹7UU}.Gҍ^_.a[1U>?ijGwp&¥s ܻyowr0~ČK0DeDEp:=1VWBV@իko"yiz}uCy|#1帜Nx9~,gx_?jx\̞&/kca᷒q}3¯3_Xϗio-=Mr/KD/W\ϟI*)|cEs|g2%^pιGv&ؙZk;?4m-JWm.]W ~A^m9•:osv K_6_{y?FkѼs=.}?Lx(czk]4j{ε׽DZ?k"0oUyRďkw9s]s9@s9w9s9s9>/8s9x9sιU&xoZzz~o{:=z} |K 8p{js9so 9s} ^p9ss9ss9@s9s9>"Шˠo7uow|o~^9__,oZ~x.ܶ9?Zqz=Km{cxOktp9sν!/8s9x9sι 9s} ^p9ss9ss9@.8/cqXų4쵶{=}\N|Uy|yץyq߯/Z}Oz]Ӆ|~:u9s}p^p9ss9ss9@s9s9>/8s9\<y׉q ćRkᅱ8/=k׵sWzWkxydk0彮eN\m;WO87Џzs9s9>/8s9\q s98덍q-9s} ^p9ss9s|o:l-Ϋ-oŃy _o=[?>zs/=5/z q^v^ W;KGMWy(\s9s^p9ss9ssWvouι/mx-9s} ^p9ss9sc!~x{AVsis|'m_wl z.!~\>ss񼿻åq/ bx>yv.*b.~^Zύu~?Kzg J2[Mop9ss9ss9@~y.wa>}jݷ{y?f]:£;O-9s} ^p9ss9s\'Ne_-y—ƋN\sqvg}o3\+.5Й]s8Z[\o/k#w]'wg܎Ǐ1/S[Ϸcoy~/]8sνϚ?/8sνwsιw^| uEGW8wCgCuIq/q?\p>יxvf~k]vϓp>_h~{[r!zflޥ_s.j6OYooq^y s9ss9@s9"n}9s#s9>/8s9x9sιsbuY?7z͗ΦEyt{xtΆٽN%q#oyc{}~^G/_W疧r,x|J~yicH.u^ϫ=o/|n<o޼sJ۽V:\m7?ﯿO x.zcos9s9>/8s9<E_llsϾ5s[sj9^pιs/8ܿsι͛VK˛Ǚ~b_d^˾Wk⋿^s}_6usqϹ4\r?+|؃p=C8}Й Ξ慸еkoky .ܟZ8s?҅\ms9sι 9s} ^p9syܛ[7vRۅ˟E__ާwp ދܵx s9ss9@s9cZqz=uط6u_!} (x+{muޮ-\>zѿey10Jkz?/-9s} ^p9ss9sc;p9E_7ts9>/8s9x9sιjc/_y zރ˽Wz[7{ό9,=kks9>/8s9x9sι9s}?9sι 9s} ^p9sx xo72G9m~>׊|Z\+v[|x^uo??i~g'Vh?9ך|>1o~?Ɯs9@s9s9>79?Qq3>ߗWx s9ss9@s9r1z_{+U?oZq?+-8o?G 8//_|_=_Wz~E_y}^y}пg/^yŸͫ{Ls9>/8s9x9sιpιvι?3qG9sι 9s} ^p9sJ7R⠿V׼Z/=qy(xKk=<1|}s>xt>~s}~יOmߏ/^o=σ8s9x9sι 9s} >sιUύ0_{ܧpΝ-9s} ^p9ss9stY"IENDB`xandikos-0.2.12/man/000077500000000000000000000000001470075263100141745ustar00rootroot00000000000000xandikos-0.2.12/man/xandikos.8000066400000000000000000000024161470075263100161100ustar00rootroot00000000000000.TH XANDIKOS "8" "March 2021" "xandikos 0.2.5" "System Administration Utilities" .SH NAME xandikos \- git-backed CalDAV/CardDAV server .SH DESCRIPTION usage: ./bin/xandikos \fB\-d\fR ROOT\-DIR [OPTIONS] .SS "optional arguments:" .TP \fB\-h\fR, \fB\-\-help\fR show this help message and exit .TP \fB\-\-version\fR show program's version number and exit .TP \fB\-d\fR DIRECTORY, \fB\-\-directory\fR DIRECTORY Directory to serve from. .TP \fB\-\-current\-user\-principal\fR CURRENT_USER_PRINCIPAL Path to current user principal. [/user/] .TP \fB\-\-autocreate\fR Automatically create necessary directories. .TP \fB\-\-defaults\fR Create initial calendar and address book. Implies \fB\-\-autocreate\fR. .TP \fB\-\-dump\-dav\-xml\fR Print DAV XML request/responses. .TP \fB\-\-avahi\fR Announce services with avahi. .TP \fB\-\-no\-strict\fR Enable workarounds for buggy CalDAV/CardDAV client implementations. .SS "Access Options:" .TP \fB\-l\fR LISTEN_ADDRESS, \fB\-\-listen\-address\fR LISTEN_ADDRESS Bind to this address. Pass in path for unix domain socket. [localhost] .TP \fB\-p\fR PORT, \fB\-\-port\fR PORT Port to listen on. [8080] .TP \fB\-\-route\-prefix\fR ROUTE_PREFIX Path to Xandikos. (useful when Xandikos is behind a reverse proxy) [/] .SH AUTHORS Jelmer Vernooij xandikos-0.2.12/notes/000077500000000000000000000000001470075263100145515ustar00rootroot00000000000000xandikos-0.2.12/notes/README.rst000066400000000000000000000001561470075263100162420ustar00rootroot00000000000000This directory contains rough design documentation for Xandikos. For user-targeted documentation, see docs/. xandikos-0.2.12/notes/api-stability.rst000066400000000000000000000007171470075263100200630ustar00rootroot00000000000000API Stability ============= There are currently no guarantees about Xandikos Python APIs staying the same across different versions, except the following APIs: xandikos.web.XandikosBackend(path) xandikos.web.XandikosBackend.create_principal(principal, create_defaults=False) xandikos.web.XandikosApp(backend, current_user_principal) xandikos.web.WellknownRedirector(app, path) If you care about stability of any other APIs, please file a bug against Xandikos. xandikos-0.2.12/notes/auth.rst000066400000000000000000000007651470075263100162540ustar00rootroot00000000000000Authentication ============== Ideally, Xandikos would stay out of the business of authenticating users. The trouble with this is that there are many flavours that need to be supported and configured. However, it is still necessary for Xandikos to handle authorization. An external system authenticates the user, and then sets the REMOTE_USER environment variable. Per http://wsgi.readthedocs.io/en/latest/specifications/simple_authentication.html, Xandikos should distinguish between 401 and 403. xandikos-0.2.12/notes/collection-config.rst000066400000000000000000000033231470075263100207020ustar00rootroot00000000000000Per-collection configuration ============================ Xandikos needs to store several piece of per-collection metadata. Goals ----- Find a place to store per-collection metadata. Some of these can be inferred from other sources. For starters, for each collection: - resource types: principal, calendar, addressbook At the moment, Xandikos is storing some of this information in git configuration. However, this means: * it is not versioned * there is a 1-1 relationship between collections and git repositories * some users object to mixing in this metadata in their git config Per resource type-specific properties ------------------------------------- Generic ~~~~~~~ - ACLs - owner? Principal ~~~~~~~~~ Per principal configuration settings: - calendar home sets - addressbook home sets - user address set - infit settings Calendar ~~~~~~~~ Need per calendar config: - color - description (can be inferred from .git/description) - inbox URL - outbox URL - max instances - max attendees per instance - calendar timezone - calendar schedule transparency Addressbook ~~~~~~~~~~~ Need per addressbook config: - max image size - max resource size - color - description (can be inferred from .git/description) Schedule Inbox ~~~~~~~~~~~~~~ - default-calendar-URL Proposed format --------------- Store a ini-style .xandikos file in the directory hosting the Collection (or Tree in case of a Git repository). All properties mentioned above are simple key/value pairs. For simplicity, it may make sense to use an ini-style format so that users can edit metadata using their editor. Example ------- # This is a standard Python configobj file, so it's mostly ini-style, and comments # can appear preceded by #. color = 030003 xandikos-0.2.12/notes/context.rst000066400000000000000000000013411470075263100167660ustar00rootroot00000000000000Contexts ======== Currently, property get_value/set_value receive three pieces of context: - HREF for the resource - resource object - Element object to update However, some properties need WebDAV server metadata: - supported-live-property-set needs list of properties - supported-report-set needs list of reports - supported-method-set needs list of methods Some operations need access to current user information: - current-user-principal - current-user-privilege-set - calendar-user-address-set PUT/DELETE/MKCOL need access to username (for author) and possibly things like user agent (for better commit message) .. code:: python class Context(object): def get_current_user(self): return (name, principal) xandikos-0.2.12/notes/dav-compliance.rst000066400000000000000000000173661470075263100202020ustar00rootroot00000000000000DAV Compliance ============== This document aims to document the compliance with various RFCs. rfc4918.txt (Core WebDAV) (obsoletes rfc2518) --------------------------------------------- Mostly supported. HTTP Methods ^^^^^^^^^^^^ - PROPFIND [supported] - PROPPATCH [supported] - MKCOL [supported] - DELETE [supported] - PUT [supported] - COPY [not implemented] - MOVE [not implemented] - LOCK [not implemented] - UNLOCK [not implemented] HTTP Headers ^^^^^^^^^^^^ - (9.1) Dav [supported] - (9.2) Depth ['0, '1' and 'infinity' are supported] - (9.3) Destination [only used with COPY/MOVE, which are not supported] - (9.4) If [not supported] - (9.5) Lock-Token [not supported] - (9.6) Overwrite [only used with COPY/MOVE, which are not supported] - (9.7) Status-URI [not supported] - (9.8) Timeout [not supported, only used for locks] DAV Properties ^^^^^^^^^^^^^^ - (15.1) creationdate [supported] - (15.2) displayname [supported] - (15.3) getcontentlanguage [supported] - (15.4) getcontentlength [supported] - (15.5) getcontenttype [supported] - (15.6) getetag [supported] - (15.7) getlastmodified [supported] - (15.8) lockdiscovery [supported] - (15.9) resourcetype [supported] - (15.10) supportedlock [supported] - (RFC2518 ONLY - 13.10) source [not supported] rfc3253.txt (Versioning Extensions) ----------------------------------- Broadly speaking, only features related to the REPORT method are supported. HTTP Methods ^^^^^^^^^^^^ - REPORT [supported] - CHECKOUT [not supported] - CHECKIN [not supported] - UNCHECKOUT [not supported] - MKWORKSPACE [not supported] - UPDATE [not supported] - LABEL [not supported] - MERGE [not supported] - VERSION-CONTROL [not supported] - BASELINE-CONTROL [not supported] - MKACTIVITY [not supported] DAV Properties ^^^^^^^^^^^^^^ - DAV:comment [supported] - DAV:creator-displayname [not supported] - DAV:supported-method-set [not supported] - DAV:supported-live-property-set [not supported] - DAV:supported-report-set [supported] - DAV:predecessor-set [not supported] - DAV:successor-set [not supported] - DAV:checkout-set [not supported] - DAV:version-name [not supported] - DAV:checked-out [not supported] - DAV:chcked-in [not supported] - DAV:auto-version [not supported] DAV Reports ^^^^^^^^^^^ - DAV:expand-property [supported] - DAV:version-tree [not supported] rfc5323.txt (WebDAV "SEARCH") ----------------------------- Not supported HTTP Methods ^^^^^^^^^^^^ - SEARCH [not supported] DAV Properties ^^^^^^^^^^^^^^ - DAV:datatype [not supported] - DAV:searchable [not supported] - DAV:selectable [not supported] - DAV:sortable [not supported] - DAV:caseless [not supported] - DAV:operators [not supported] rfc3744.txt (WebDAV access control) ----------------------------------- Not really supported DAV Properties ^^^^^^^^^^^^^^ - DAV:alternate-uri-set [not supported] - DAV:principal-URL [supported] - DAV:group-member-set [not supported] - DAV:group-membership [supported] - DAV:owner [supported] - DAV:group [not supported] - DAV:current-user-privilege-set [supported] - DAV:supported-privilege-set [not supported] - DAV:acl [not supported] - DAV:acl-restrictions [not supported] - DAV:inherited-acl-set [not supported] - DAV:principal-collection-set [not supported] DAV Reports ^^^^^^^^^^^ - DAV:acl-principal-prop-set [not supported] - DAV:principal-match [not supported] - DAV:principal-property-search [not supported] - DAV:principal-search-property-set [not supported] rfc4791.txt (CalDAV) -------------------- Fully supported. DAV Properties ^^^^^^^^^^^^^^ - CALDAV:calendar-description [supported] - CALDAV:calendar-home-set [supported] - CALDAV:calendar-timezone [supported] - CALDAV:supported-calendar-component-set [supported] - CALDAV:supported-calendar-data [supported] - CALDAV:max-resource-size [supported] - CALDAV:min-date-time [supported] - CALDAV:max-date-time [supported] - CALDAV:max-instances [supported] - CALDAV:max-attendees-per-instance [supported] HTTP Methods ^^^^^^^^^^^^ - MKCALENDAR [not supported] DAV Reports ^^^^^^^^^^^ - CALDAV:calendar-query [supported] - CALDAV:calendar-multiget [supported] - CALDAV:free-busy-query [supported] rfc6352.txt (CardDAV) --------------------- Fully supported. DAV Properties ^^^^^^^^^^^^^^ - CARDDAV:addressbook-description [supported] - CARDDAV:supported-address-data [supported] - CARDDAV:max-resource-size [supported] - CARDDAV:addressbook-home-set [supported] - CARDDAV:princial-address [supported] DAV Reports ^^^^^^^^^^^ - CARDDAV:addressbook-query [supported] - CARDDAV:addressbook-multiget [supported] rfc6638.txt (CalDAV scheduling extensions) ------------------------------------------ DAV Properties ^^^^^^^^^^^^^^ - CALDAV:schedule-outbox-URL [supported] - CALDAV:schedule-inbox-URL [supported] - CALDAV:calendar-user-address-set [supported] - CALDAV:calendar-user-type [supported] - CALDAV:schedule-calendar-transp [supported] - CALDAV:schedule-default-calendar-URL [supported] - CALDAV:schedule-tag [supported] rfc6764.txt (Locating groupware services) ----------------------------------------- Most of this is outside of the scope of xandikos, but it does support DAV:current-user-principal rfc7809.txt (CalDAV Time Zone Extensions) ----------------------------------------- Not supported DAV Properties ^^^^^^^^^^^^^^ - CALDAV:timezone-service-set [supported] - CALDAV:calendar-timezone-id [not supported] rfc5397.txt (WebDAV Current Principal Extension) ------------------------------------------------ DAV Properties ^^^^^^^^^^^^^^ - CALDAV:current-user-principal [supported] Proprietary extensions ---------------------- Custom properties used by various clients ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - CARDDAV:max-image-size [supported] https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-ctag.txt - DAV:getctag [supported] https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-proxy.txt - DAV:calendar-proxy-read-for [supported] - DAV:calendar-proxy-write-for [supported] Apple-specific Properties ^^^^^^^^^^^^^^^^^^^^^^^^^ - calendar-color [supported] - calendar-order [supported] - getctag [supported] - refreshrate [supported] - source XMPP Subscriptions ^^^^^^^^^^^^^^^^^^ - xmpp-server - xmpp-heartbeat - xmpp-uri inf-it properties ^^^^^^^^^^^^^^^^^ - headervalue [supported] - settings [supported] - addressbook-color [supported] AgendaV properties ^^^^^^^^^^^^^^^^^^ https://tools.ietf.org/id/draft-ietf-calext-caldav-attachments-03.html - CALDAV:max-attachments-per-resource [supported] - CALDAV:max-attachment-size [supported] - CALDAV:managed-attachments-server-URL [supported] rfc5995.txt (POST to create members) ------------------------------------ Fully supported. DAV Properties ^^^^^^^^^^^^^^ - DAV:add-member [supported] HTTP Methods ^^^^^^^^^^^^ - POST [supported] rfc5689 (Extended MKCOL) ------------------------ Fully supported HTTP Methods ^^^^^^^^^^^^ - MKCOL [supported] rfc7529.txt (WebDAV Quota) -------------------------- DAV properties ^^^^^^^^^^^^^^ - {DAV:}quote-available-bytes [supported] - {DAV:}quote-used-bytes [supported] rfc4709 (WebDAV Mount) ---------------------- This RFC documents a mechanism that allows clients to find the WebDAV mount associated with a specific page. It's unclear to the writer what the value of this is - an alternate resource in the HTML page would also do. As far as I can tell, there is only a single server side implementation and a single client side implementation of this RFC. I don't have access to the client implementation (Xythos Drive) and the server side implementation is in SabreDAV. Experimental support for WebDAV Mount is available in the 'mount' branch, but won't be merged without a good use case. Managed Attachments ------------------- Apple extension: https://datatracker.ietf.org/doc/html/draft-ietf-calext-caldav-attachments-04 Currently unsupported. xandikos-0.2.12/notes/debugging.rst000066400000000000000000000010501470075263100172320ustar00rootroot00000000000000Debugging Xandikos ================== When filing bugs, please include details on the Xandikos version you're running and the clients that you're using. It would be helpful if you can reproduce any issues with a clean Xandikos setup. That also makes it easier to e.g. share log files. 1. Verify the server side contents; you can do this by looking at the Git repository on the Xandikos side. 2. Run with ``xandikos --dump-dav-xml``; please note that these may contain personal information, so be careful before e.g. posting them on GitHub. xandikos-0.2.12/notes/file-format.rst000066400000000000000000000017671470075263100175230ustar00rootroot00000000000000File structure ============== Collections are represented as Git repositories on disk. A specific version is represented as a commit id. The 'ctag' for a calendar is taken from the tree id of the calendar root tree. The `entity tag`_ for an event is taken from the blob id of the Blob representing that EVENT. These kinds of entity tags are strong, since blobs are equivalent by octet equality. .. _entity tag: https://tools.ietf.org/html/rfc2616#section-3.11 The file name of calendar events shall be .ics / .vcf. Because of this, every file MUST only contain one UID and thus MUST contain exactly one VEVENT, VTODO, VJOURNAL or VFREEBUSY. All items in a collection *must* be well formed, so that they do not have to be validated when served. When new items are added, the collection should verify no existing items have the same UID. Open questions: - How to handle subtrees? Are they just subcollections? - Where should collection metadata (e.g. colors, description) be stored? .git/config? xandikos-0.2.12/notes/goals.rst000066400000000000000000000002541470075263100164110ustar00rootroot00000000000000Goals ===== - standards compliant - standards complete - backed by Git - easily hackable/editable with standard tools (e.g. Git/Vim) - version tracked - unit tested xandikos-0.2.12/notes/hacking.txt000066400000000000000000000000571470075263100167200ustar00rootroot00000000000000DAV in class names is spelled in all capitals. xandikos-0.2.12/notes/heroku.rst000066400000000000000000000022351470075263100166020ustar00rootroot00000000000000Running Xandikos on Heroku ========================== Heroku is an easy way to get a public instance of Xandikos running. A free heroku instance comes with 100Mb of local storage, which is enough for thousands of calendar items or contacts. Deployment ---------- All of these steps assume you already have a Heroku account and have installed the heroku command-line client. To run a Heroku instance with Xandikos: 1. Create a copy of Xandikos:: $ git clone git://jelmer.uk/xandikos xandikos $ cd xandikos 2. Make a copy of the example uwsgi configuration:: $ cp examples/uwsgi-heroku.ini uwsgi.ini 3. Edit *uwsgi.ini* as necessary, such as changing the credentials (the defaults are *user1*/*password1*). 4. Make heroku install and use uwsgi:: $ echo uwsgi > requirements.txt $ echo web: uwsgi uwsgi.ini > Procfile 5. Create the Heroku instance:: $ heroku create (this might ask you for your heroku credentials) 6. Deploy the app:: $ git push heroku master 7. Open the app with your browser:: $ heroku open (The URL opened is also the URL that you can provide to any CalDAV/CardDAV application that supports service discovery) xandikos-0.2.12/notes/indexes.rst000066400000000000000000000042761470075263100167530ustar00rootroot00000000000000Filter Performance ================== There are several API calls that would be good to speed up. In particular, querying an entire calendar with filters is quite slow because it involves scanning all the items. Common Filters ~~~~~~~~~~~~~~ There are a couple of common filters: Component filters that filter for only VTODO or VEVENT items Property filters that filter for a specific UID Property filters that filter for another property Property filters that do complex text searches, e.g. in DESCRIPTION Property filters that filter for some time range. But these are by no means the only possible filters, and there is no predicting what clients will scan for. Indexes are an implementation detail of the Store. This is necessary so that e.g. the Git stores can take advantage of the fact that they have a tree hash. One option would be to serialize the filter and then to keep a list of results per (tree_id, filter_hash). Unfortunately this by itself is not enough, since it doesn't help when we get repeated queries for different UIDs. Options considered: * Have some pre-set indexes. Perhaps components, and UID? * Cache but use the rightmost value as a key in a dict * Always just cache everything that was queried. This is probably actually fine. * Count how often a particular index is used Open Questions ~~~~~~~~~~~~~~ * How are indexes identified? Proposed API ~~~~~~~~~~~~ class Filter(object): def check_slow(self, name, resource): """Check whether this filter applies to a resources based on the actual resource. This is the naive, slow, fallback implementation. Args: resource: Resource to check """ raise NotImplementedError(self.check_slow) def check_index(self, values): """Check whether this filter applies to a resources based on index values. Args: values: Dictionary mapping indexes to index values """ raise NotImplementedError(self.check_index) def required_indexes(self): """Return a list of indexes that this Filter needs to function. Returns: List of ORed options, similar to a Depends line in Debian """ raise NotImplementedError(self.required_indexes) xandikos-0.2.12/notes/monitoring.rst000066400000000000000000000004401470075263100174660ustar00rootroot00000000000000Monitoring ========== Things to monitor: - number of uploaded items - number of accessed store items - number of lru cache hits - number of HTTP requests - number of reports - number of properties requested - number of unknown properties requested - number of unknown reports requested xandikos-0.2.12/notes/multi-user.rst000066400000000000000000000032741470075263100174170ustar00rootroot00000000000000Multi-User Support ================== Multi-user support could arguably also include sharing of calendars/collections/etc. This is beyond the scope of this document, which just focuses on allowing multiple users to use their own silo in a single instance of Xandikos. Siloed user support can be split up into three steps: * storage - mapping a user to a principal * authentication - letting a user log in * authorization - checking whether the user has access to a resource Authentication -------------- In the simplest form, a forwarding proxy provides the name of an authenticated user. E.g. Apache or uWSGI sets the REMOTE_USER environment variable. If REMOTE_USER is not present for an operation that requires authentication, a 401 error is returned. Authorization ------------- In the simplest form, users only have access to the resources under their own principal. As a second step, we could let users configure ACLs; one way of doing this would be to allow adding authentication in the collection configuration. I.e. something like:: [acl] read = jelmer, joe write = jelmer Storage ------- By default, the principal for a user is simply "/%(username)s". Roadmap ======= * Optional: Allow marking collections as principals [DONE] * Expose username (or None, if not logged in) everywhere [DONE] * Add function get_username_principal() for mapping username to principal path [DONE] * Support automatic creation of principal on first login of user * Add simple function check_path_access() for checking access ("is this user allowed to access this path?") * Use access checking function everywhere * Have current-user-principal setting depend on $REMOTE_USER and get_username_principal() [DONE] xandikos-0.2.12/notes/prometheus.rst000066400000000000000000000001761470075263100175020ustar00rootroot00000000000000Prometheus ========== Proposed metrics: * number of HTTP queries * number of DAV queries by category * DAV versions used xandikos-0.2.12/notes/release-process.rst000066400000000000000000000003411470075263100203750ustar00rootroot00000000000000Release Process =============== 1. Update version in setup.py 2. Update version in xandikos/__init__.py 3. git commit -a -m "Release $VERSION" 4. git tag -as -m "Release $VERSION" v$VERSION 5. ./setup.py sdist upload --sign xandikos-0.2.12/notes/scheduling-plan.rst000066400000000000000000000011621470075263100203600ustar00rootroot00000000000000CalDAV Scheduling ================= TODO: - When a new calendar object is uploaded to a calendar collection: * Check if the ATTENDEE property is present, and if so, process it - Support CALDAV:schedule-tag * When comparing with if-schedule-tag-match, simply retrieve the blob by schedule-tag and compare delta between newly uploaded and current * When determining schedule-tag, scroll back until last revision that didn't have attendee changes? + Perhaps include a hint in e.g. commit message? - Inbox "contains copies of incoming scheduling messages" - Outbox "at which busy time information requests are targeted." xandikos-0.2.12/notes/store.rst000066400000000000000000000011001470075263100164270ustar00rootroot00000000000000Dulwich Store ============= The main building blocks are vCard (.vcf) and iCalendar (.ics) files. Storage happens in Git repositories. Most items are identified by a UID and a filename, both of which are unique for the store. Items can have multiple versions, which are identified by an ETag. Each store maps to a single Git repository, and can not contain directories. In the future, a store could map to a subtree in a Git repository. Stores are responsible for making sure that: - their contents are validly formed calendars/contacts - UIDs are unique (where relevant) xandikos-0.2.12/notes/structure.rst000066400000000000000000000013161470075263100173440ustar00rootroot00000000000000Xandikos has a fairly clear distinction between different components. Modules ======= The core WebDAV implementation lives in xandikos.webdav. This just implements the WebDAV protocol, and provides abstract classes for WebDAV resources that can be implemented by other code. Several WebDAV extensions (access, CardDAV, CalDAV) live in their own Python file. They build on top of the WebDAV module, and provide extra reporter and property implementations as defined in those specifications. Store is a simple object-store implementation on top of a Git repository, which has several properties that make it useful as a WebDAV backend. The business logic lives in xandikos.web; it ties together the other modules, xandikos-0.2.12/notes/subcommands.rst000066400000000000000000000006701470075263100176210ustar00rootroot00000000000000Subcommands =========== At the moment, the Xandikos command just supports running a (debug) webserver. In various situations it would also be useful to have subcommands for adminstrative operations. Propose subcommands: * ``xandikos init [--defaults] [--autocreate] [-d DIRECTORY]`` - create a Xandikos database * ``xandikos stats`` - dump stats, similar to those exposed by prometheus * ``xandikos web`` - run a debug web server xandikos-0.2.12/notes/uwsgi.rst000066400000000000000000000017551470075263100164510ustar00rootroot00000000000000Running Xandikos from uWSGI =========================== In addition to running as a standalone service, Xandikos can also be run by any service that supports the wsgi interface. An example of such a service is uWSGI. One option is to setup uWSGI with a server like `Apache `_, `Nginx `_ or another web server that can authenticate users and forward authorized requests to Xandikos in uWSGI. See `examples/uwsgi.ini `_ for an example uWSGI configuration. Alternatively, you can run uWSGI standalone and have it authenticate and directly serve HTTP traffic. An example configuration for this can be found in `examples/uwsgi-standalone.ini `_. This will start a server on `localhost:8080 `_ with username *user1* and password *password1*. .. code:: shell mkdir -p $HOME/dav uwsgi examples/uwsgi-standalone.ini xandikos-0.2.12/notes/webdav.rst000066400000000000000000000024421470075263100165550ustar00rootroot00000000000000WebDAV implementation ===================== .. code:: python class DAVPropertyProvider(object): NAME property matchresource() # One or multiple properties? def proplist(self, resource, all=False): def getprop(self, resource, property): def propupdate(self, resource, updates): class DAVBackend(object): def get_resource(self, path): def create_collection(self, path): class DAVReporter(object): class DAVResource(object): def get_resource_types(self): def get_body(self): """Returns the body of the resource. Returns: bytes representing contents """ def set_body(self, body): """Set the body of the resource. Args: body: body (as bytes) """ def proplist(self): """Return list of properties. Returns: List of property names """ def propupdate(self, updates): """Update properties. Args: updates: Dictionary mapping names to new values """ def lock(self): def unlock(self): def members(self): """List members. Returns: List tuples of (name, DAVResource) """ # TODO(jelmer): COPY # TODO(jelmer): MOVE # TODO(jelmer): MKCOL # TODO(jelmer): LOCK/UNLOCK # TODO(jelmer): REPORT xandikos-0.2.12/pyproject.toml000066400000000000000000000036321470075263100163410ustar00rootroot00000000000000[build-system] requires = ["setuptools>=61.2"] build-backend = "setuptools.build_meta" [project] name = "xandikos" description = "Lightweight CalDAV/CardDAV server" readme = "README.rst" authors = [{name = "Jelmer Vernooij", email = "jelmer@jelmer.uk"}] license = {text = "GNU GPLv3 or later"} classifiers = [ "Development Status :: 4 - Beta", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Operating System :: POSIX", ] urls = {Homepage = "https://www.xandikos.org/"} requires-python = ">=3.9" dependencies = [ "aiohttp", "icalendar>=5.0.4", "dulwich>=0.21.6", "defusedxml", "jinja2", "multidict", "vobject", ] dynamic = ["version"] [project.optional-dependencies] prometheus = ["aiohttp_openmetrics"] systemd = ["systemd_python"] [project.scripts] xandikos = "xandikos.__main__:main" [tool.setuptools] include-package-data = false [tool.setuptools.packages] find = {namespaces = false} [tool.setuptools.package-data] xandikos = [ "templates/*.html", "py.typed", ] [tool.setuptools.dynamic] version = {attr = "xandikos.__version__"} [tool.mypy] ignore_missing_imports = true [tool.distutils.bdist_wheel] universal = 1 [tool.ruff] select = [ "ANN", "D", "E", "F", "UP", ] ignore = [ "ANN001", "ANN002", "ANN003", "ANN101", # missing-type-self "ANN102", "ANN201", "ANN202", "ANN204", "ANN206", "D100", "D101", "D102", "D103", "D104", "D105", "D107", "D403", "D417", "E501", ] target-version = "py37" [tool.ruff.pydocstyle] convention = "google" xandikos-0.2.12/requirements.txt000066400000000000000000000001061470075263100167020ustar00rootroot00000000000000icalendar dulwich defusedxml jinja2 aiohttp prometheus_client vobject xandikos-0.2.12/setup.py000077500000000000000000000000711470075263100151340ustar00rootroot00000000000000#!/usr/bin/python3 from setuptools import setup setup() xandikos-0.2.12/src/000077500000000000000000000000001470075263100142105ustar00rootroot00000000000000xandikos-0.2.12/src/lib.rs000066400000000000000000000000001470075263100153120ustar00rootroot00000000000000xandikos-0.2.12/tox.ini000066400000000000000000000002231470075263100147310ustar00rootroot00000000000000[tox] downloadcache = {toxworkdir}/cache/ envlist = py36, py37, py38 [testenv] commands = make check recreate = True whitelist_externals = make xandikos-0.2.12/xandikos/000077500000000000000000000000001470075263100152415ustar00rootroot00000000000000xandikos-0.2.12/xandikos/__init__.py000066400000000000000000000017371470075263100173620ustar00rootroot00000000000000# # Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """CalDAV/CardDAV server.""" import defusedxml.ElementTree # noqa: F401: This does some monkey-patching on-load __version__ = (0, 2, 12) version_string = ".".join(map(str, __version__)) xandikos-0.2.12/xandikos/__main__.py000066400000000000000000000020411470075263100173300ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2018 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Xandikos command-line handling.""" import asyncio def main(argv=None): # For now, just invoke xandikos.web from .web import main return asyncio.run(main(argv)) if __name__ == "__main__": import sys sys.exit(main(sys.argv[1:])) xandikos-0.2.12/xandikos/access.py000066400000000000000000000044411470075263100170570ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Access control. See http://www.webdav.org/specs/rfc3744.html """ from xandikos import webdav ET = webdav.ET # Feature to advertise access control support. FEATURE = "access-control" class CurrentUserPrivilegeSetProperty(webdav.Property): """current-user-privilege-set property. See http://www.webdav.org/specs/rfc3744.html, section 3.7 """ name = "{DAV:}current-user-privilege-set" in_allprops = False live = True async def get_value(self, href, resource, el, environ): privilege = ET.SubElement(el, "{DAV:}privilege") # TODO(jelmer): Use something other than all ET.SubElement(privilege, "{DAV:}all") class OwnerProperty(webdav.Property): """owner property. See http://www.webdav.org/specs/rfc3744.html, section 5.1 """ name = "{DAV:}owner" in_allprops = False live = True async def get_value(self, base_href, resource, el, environ): owner_href = resource.get_owner() if owner_href is not None: el.append(webdav.create_href(owner_href, base_href=base_href)) class GroupMembershipProperty(webdav.Property): """Group membership. See https://www.ietf.org/rfc/rfc3744.txt, section 4.4 """ name = "{DAV:}group-membership" in_allprops = False live = True resource_type = webdav.PRINCIPAL_RESOURCE_TYPE async def get_value(self, base_href, resource, el, environ): for href in resource.get_group_membership(): el.append(webdav.create_href(href, base_href=href)) xandikos-0.2.12/xandikos/apache.py000066400000000000000000000030011470075263100170260ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Apache.org mod_dav custom properties. See http://www.webdav.org/mod_dav/ """ from xandikos import webdav class ExecutableProperty(webdav.Property): """executable property. Equivalent of the 'x' bit on POSIX. """ name = "{http://apache.org/dav/props/}executable" resource_type = None live = False async def get_value(self, href, resource, el, environ): el.text = "T" if resource.get_is_executable() else "F" async def set_value(self, href, resource, el): if el.text == "T": resource.set_is_executable(True) elif el.text == "F": resource.set_is_executable(False) else: raise ValueError(f"invalid executable setting {el.text!r}") xandikos-0.2.12/xandikos/caldav.py000066400000000000000000001044131470075263100170500ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Simple CalDAV server. https://tools.ietf.org/html/rfc4791 """ import datetime import itertools from zoneinfo import ZoneInfo from icalendar.cal import Calendar as ICalendar from icalendar.cal import Component, FreeBusy, component_factory from icalendar.prop import vDDDTypes, vPeriod from . import davcommon, webdav from .icalendar import apply_time_range_vevent, as_tz_aware_ts, expand_calendar_rrule ET = webdav.ET PRODID = "-//Jelmer Vernooij//Xandikos//EN" WELLKNOWN_CALDAV_PATH = "/.well-known/caldav" EXTENDED_MKCOL_FEATURE = "extended-mkcol" NAMESPACE = "urn:ietf:params:xml:ns:caldav" # https://tools.ietf.org/html/rfc4791, section 4.2 CALENDAR_RESOURCE_TYPE = "{%s}calendar" % NAMESPACE SUBSCRIPTION_RESOURCE_TYPE = "{http://calendarserver.org/ns/}subscribed" # TODO(jelmer): These resource types belong in scheduling.py SCHEDULE_INBOX_RESOURCE_TYPE = "{%s}schedule-inbox" % NAMESPACE SCHEDULE_OUTBOX_RESOURCE_TYPE = "{%s}schedule-outbox" % NAMESPACE # Feature to advertise to indicate CalDAV support. FEATURE = "calendar-access" TRANSPARENCY_TRANSPARENT = "transparent" TRANSPARENCY_OPAQUE = "opaque" class Calendar(webdav.Collection): resource_types = webdav.Collection.resource_types + [CALENDAR_RESOURCE_TYPE] def get_calendar_description(self) -> str: """Return the calendar description.""" raise NotImplementedError(self.get_calendar_description) def get_calendar_color(self) -> str: """Return the calendar color.""" raise NotImplementedError(self.get_calendar_color) def set_calendar_color(self, color: str) -> None: """Set the calendar color.""" raise NotImplementedError(self.set_calendar_color) def get_calendar_order(self) -> str: """Return the calendar order.""" raise NotImplementedError(self.get_calendar_order) def set_calendar_order(self, order: str) -> None: """Set the calendar order.""" raise NotImplementedError(self.set_calendar_order) def get_calendar_timezone(self) -> str: """Return calendar timezone. This should be an iCalendar object with exactly one VTIMEZONE component. """ raise NotImplementedError(self.get_calendar_timezone) def set_calendar_timezone(self, content: str) -> None: """Set calendar timezone. This should be an iCalendar object with exactly one VTIMEZONE component. """ raise NotImplementedError(self.set_calendar_timezone) def get_supported_calendar_components(self) -> str: """Return set of supported calendar components in this calendar. Returns: iterable over component names """ raise NotImplementedError(self.get_supported_calendar_components) def get_supported_calendar_data_types(self) -> str: """Return supported calendar data types. Returns: iterable over (content_type, version) tuples """ raise NotImplementedError(self.get_supported_calendar_data_types) def get_min_date_time(self): """Return minimum datetime property.""" raise NotImplementedError(self.get_min_date_time) def get_max_date_time(self): """Return maximum datetime property.""" raise NotImplementedError(self.get_max_date_time) def get_max_instances(self): """Return maximum number of instances.""" raise NotImplementedError(self.get_max_instances) def get_max_attendees_per_instance(self): """Return maximum number of attendees per instance.""" raise NotImplementedError(self.get_max_attendees_per_instance) def get_max_resource_size(self): """Return max resource size.""" raise NotImplementedError(self.get_max_resource_size) def get_max_attachments_per_resource(self): """Return max attachments per resource.""" raise NotImplementedError(self.get_max_attachments_per_resource) def get_max_attachment_size(self): """Return max attachment size.""" raise NotImplementedError(self.get_max_attachment_size) def get_schedule_calendar_transparency(self): """Get calendar transparency. Possible values are TRANSPARENCY_TRANSPARENT and TRANSPARENCY_OPAQUE """ return TRANSPARENCY_OPAQUE def calendar_query(self, create_filter_fn): """Query for all the members of this calendar that match `filter`. This is a naive implementation; subclasses should ideally provide their own implementation that is faster. Args: create_filter_fn: Callback that constructs a filter; takes a filter building class. Returns: Iterator over name, resource objects """ raise NotImplementedError(self.calendar_query) def get_xmpp_server(self): raise NotImplementedError(self.get_xmpp_server) def get_xmpp_heartbeat(self): raise NotImplementedError(self.get_xmpp_heartbeat) def get_xmpp_uri(self): raise NotImplementedError(self.get_xmpp_uri) def get_created_by(self): raise NotImplementedError(self.get_created_by) def get_updated_by(self): raise NotImplementedError(self.get_updated_by) class Subscription: resource_types = webdav.Collection.resource_types + [SUBSCRIPTION_RESOURCE_TYPE] def get_source_url(self): """Get the source URL for this calendar.""" raise NotImplementedError(self.get_source_url) def set_source_url(self, url): """Set the source URL for this calendar.""" raise NotImplementedError(self.set_source_url) def get_calendar_description(self): """Return the calendar description.""" raise NotImplementedError(self.get_calendar_description) def get_calendar_color(self): """Return the calendar color.""" raise NotImplementedError(self.get_calendar_color) def set_calendar_color(self, color): """Set the calendar color.""" raise NotImplementedError(self.set_calendar_color) def get_supported_calendar_components(self): """Return set of supported calendar components in this calendar. Returns: iterable over component names """ raise NotImplementedError(self.get_supported_calendar_components) class CalendarHomeSet: def get_managed_attachments_server_url(self): """Return the attachments server URL.""" raise NotImplementedError(self.get_managed_attachments_server_url) class PrincipalExtensions: """CalDAV-specific extensions to DAVPrincipal.""" def get_calendar_home_set(self): """Get the calendar home set. Returns: a set of URLs """ raise NotImplementedError(self.get_calendar_home_set) def get_calendar_user_address_set(self): """Get the calendar user address set. Returns: a set of URLs (usually mailto:...) """ raise NotImplementedError(self.get_calendar_user_address_set) class CalendarHomeSetProperty(webdav.Property): """calendar-home-set property. See https://www.ietf.org/rfc/rfc4791.txt, section 6.2.1. """ name = "{%s}calendar-home-set" % NAMESPACE resource_type = "{DAV:}principal" in_allprops = False live = True async def get_value(self, base_href, resource, el, environ): for href in resource.get_calendar_home_set(): href = webdav.ensure_trailing_slash(href) el.append(webdav.create_href(href, base_href)) class CalendarDescriptionProperty(webdav.Property): """Provides calendar-description property. https://tools.ietf.org/html/rfc4791, section 5.2.1 """ name = "{%s}calendar-description" % NAMESPACE resource_type = (CALENDAR_RESOURCE_TYPE, SUBSCRIPTION_RESOURCE_TYPE) async def get_value(self, base_href, resource, el, environ): el.text = resource.get_calendar_description() # TODO(jelmer): allow modification of this property async def set_value(self, href, resource, el): raise NotImplementedError def _extract_from_component(incomp: Component, outcomp: Component, requested) -> None: """Extract specific properties from a calendar event. Args: incomp: Incoming component outcomp: Outcoming component requested: Which components should be included """ for tag in requested: if tag.tag == ("{%s}comp" % NAMESPACE): for insub in incomp.subcomponents: if insub.name == tag.get("name"): outsub = component_factory[insub.name]() outcomp.add_component(outsub) _extract_from_component(insub, outsub, tag) elif tag.tag == ("{%s}prop" % NAMESPACE): outcomp[tag.get("name")] = incomp[tag.get("name")] elif tag.tag == ("{%s}allprop" % NAMESPACE): for propname in incomp: outcomp[propname] = incomp[propname] elif tag.tag == ("{%s}allcomp" % NAMESPACE): for insub in incomp.subcomponents: outsub = component_factory[insub.name]() outcomp.add_component(outsub) _extract_from_component(insub, outsub, tag) else: raise AssertionError(f"invalid element {tag!r}") def extract_from_calendar(incal, requested): """Extract requested components/properties from calendar. Args: incal: Calendar to filter requested: element with requested components/properties """ for tag in requested: if tag.tag == ("{%s}comp" % NAMESPACE): if incal.name == tag.get("name"): c = ICalendar() _extract_from_component(incal, c, tag) incal = c elif tag.tag == ("{%s}expand" % NAMESPACE): (start, end) = _parse_time_range(tag) incal = expand_calendar_rrule(incal, start, end) elif tag.tag == ("{%s}limit-recurrence-set" % NAMESPACE): # TODO(jelmer): https://github.com/jelmer/xandikos/issues/103 raise NotImplementedError("limit-recurrence-set is not yet implemented") elif tag.tag == ("{%s}limit-freebusy-set" % NAMESPACE): # TODO(jelmer): https://github.com/jelmer/xandikos/issues/104 raise NotImplementedError("limit-freebusy-set is not yet implemented") else: raise AssertionError(f"invalid element {tag!r}") return incal class CalendarDataProperty(davcommon.SubbedProperty): """calendar-data property. See https://tools.ietf.org/html/rfc4791, section 5.2.4 Note that this is not technically a DAV property, and it is thus not registered in the regular webdav server. """ name = "{%s}calendar-data" % NAMESPACE def supported_on(self, resource): return resource.get_content_type() == "text/calendar" async def get_value_ext(self, base_href, resource, el, environ, requested): if len(requested) == 0: serialized_cal = b"".join(await resource.get_body()) else: calendar = await calendar_from_resource(resource) if calendar is None: raise KeyError c = extract_from_calendar(calendar, requested) serialized_cal = c.to_ical() # TODO(jelmer): Don't hardcode encoding # TODO(jelmer): Strip invalid characters or raise an exception el.text = serialized_cal.decode("utf-8") class CalendarOrderProperty(webdav.Property): """Provides calendar-order property.""" name = "{http://apple.com/ns/ical/}calendar-order" resource_type = CALENDAR_RESOURCE_TYPE async def get_value(self, base_href, resource, el, environ): el.text = resource.get_calendar_order() async def set_value(self, href, resource, el): resource.set_calendar_order(el.text) class CalendarMultiGetReporter(davcommon.MultiGetReporter): name = "{%s}calendar-multiget" % NAMESPACE resource_type = (CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE) data_property = CalendarDataProperty() def parse_prop_filter(el, cls): name = el.get("name") # From https://tools.ietf.org/html/rfc4791, 9.7.2: # A CALDAV:comp-filter is said to match if: prop_filter = cls(name=name) for subel in el: if subel.tag == "{urn:ietf:params:xml:ns:caldav}is-not-defined": prop_filter.is_not_defined = True elif subel.tag == "{urn:ietf:params:xml:ns:caldav}time-range": parse_time_range(subel, prop_filter.filter_time_range) elif subel.tag == "{urn:ietf:params:xml:ns:caldav}text-match": parse_text_match(subel, prop_filter.filter_text_match) elif subel.tag == "{urn:ietf:params:xml:ns:caldav}param-filter": parse_param_filter(subel, prop_filter.filter_parameter) elif subel.tag == "{urn:ietf:params:xml:ns:caldav}is-not-defined": pass else: raise AssertionError(f"unknown subelement {subel.tag!r}") return prop_filter def parse_text_match(el, cls): collation = el.get("collation", "i;ascii-casemap") negate_condition = el.get("negate-condition", "no") return cls( el.text, collation=collation, negate_condition=(negate_condition == "yes"), ) def parse_param_filter(el, cls): name = el.get("name") param_filter = cls(name=name) for subel in el: if subel.tag == "{urn:ietf:params:xml:ns:caldav}is-not-defined": param_filter.is_not_defined = True elif subel.tag == "{urn:ietf:params:xml:ns:caldav}text-match": parse_text_match(subel, param_filter.filter_time_range) else: raise AssertionError("unknown tag %r in param-filter", subel.tag) return param_filter def _parse_time_range(el): start = el.get("start") end = el.get("end") # Either start OR end OR both need to be specified. # https://tools.ietf.org/html/rfc4791, section 9.9 assert start is not None or end is not None if start is None: start = "00010101T000000Z" if end is None: end = "99991231T235959Z" start = vDDDTypes.from_ical(start) end = vDDDTypes.from_ical(end) assert end > start return (start, end) def parse_time_range(el, cls): (start, end) = _parse_time_range(el) return cls(start, end) def parse_comp_filter(el: ET.Element, cls): """Compile a comp-filter element into a Python function.""" name = el.get("name") # From https://tools.ietf.org/html/rfc4791, 9.7.1: # A CALDAV:comp-filter is said to match if: comp_filter = cls(name=name) # 3. The CALDAV:comp-filter XML element contains a CALDAV:time-range XML # element and at least one recurrence instance in the targeted calendar # component is scheduled to overlap the specified time range, and all # specified CALDAV:prop-filter and CALDAV:comp-filter child XML elements # also match the targeted calendar component; for subel in el: if subel.tag == "{urn:ietf:params:xml:ns:caldav}is-not-defined": comp_filter.is_not_defined = True if subel.tag == "{urn:ietf:params:xml:ns:caldav}comp-filter": parse_comp_filter(subel, comp_filter.filter_subcomponent) elif subel.tag == "{urn:ietf:params:xml:ns:caldav}prop-filter": parse_prop_filter(subel, comp_filter.filter_property) elif subel.tag == "{urn:ietf:params:xml:ns:caldav}time-range": parse_time_range(subel, comp_filter.filter_time_range) else: raise AssertionError(f"unknown filter tag {subel.tag!r}") return comp_filter def parse_filter(filter_el: ET.Element, cls): for subel in filter_el: if subel.tag == "{urn:ietf:params:xml:ns:caldav}comp-filter": parse_comp_filter(subel, cls.filter_subcomponent) else: raise AssertionError(f"unknown filter tag {subel.tag!r}") return cls async def calendar_from_resource(resource): try: if resource.get_content_type() != "text/calendar": return None except KeyError: return None file = await resource.get_file() return file.calendar def extract_tzid(cal): return cal.subcomponents[0]["TZID"] def get_timezone_from_text(tztext): tzid = extract_tzid(ICalendar.from_ical(tztext)) return ZoneInfo(tzid) def get_calendar_timezone(resource: Calendar): try: tztext = resource.get_calendar_timezone() except KeyError: now = datetime.datetime.now() local_now = now.astimezone() return local_now.tzinfo else: return get_timezone_from_text(tztext) class CalendarQueryReporter(webdav.Reporter): name = "{%s}calendar-query" % NAMESPACE resource_type = (CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE) data_property = CalendarDataProperty() @webdav.multistatus async def report( self, environ, body, resources_by_hrefs, properties, base_href, base_resource, depth, strict, ): # TODO(jelmer): Verify that resource is a calendar requested = None filter_el = None tztext = None for el in body: if el.tag in ("{DAV:}prop", "{DAV:}propname", "{DAV:}allprop"): requested = el elif el.tag == "{urn:ietf:params:xml:ns:caldav}filter": filter_el = el elif el.tag == "{urn:ietf:params:xml:ns:caldav}timezone": tztext = el.text else: webdav.nonfatal_bad_request( f"Unknown tag {el.tag} in report {self.name}", strict ) if requested is None: # The CalDAV RFC says that behaviour mimicks that of PROPFIND, # and the WebDAV RFC says that no body implies {DAV}allprop # This isn't exactly an empty body, but close enough. requested = ET.Element("{DAV:}allprop") if tztext is not None: tz = get_timezone_from_text(tztext) else: tz = get_calendar_timezone(base_resource) def filter_fn(cls): return parse_filter(filter_el, cls(tz)) def members(collection): return itertools.chain( collection.calendar_query(filter_fn), collection.subcollections(), ) async for href, resource in webdav.traverse_resource( base_resource, base_href, depth, members=members ): # Ideally traverse_resource would only return the right things. if getattr(resource, "content_type", None) == "text/calendar": propstat = davcommon.get_properties_with_data( self.data_property, href, resource, properties, environ, requested, ) yield webdav.Status( href, "200 OK", propstat=[s async for s in propstat] ) class CalendarColorProperty(webdav.Property): """calendar-color property. This contains a HTML #RRGGBB color code, as CDATA. """ name = "{http://apple.com/ns/ical/}calendar-color" resource_type = (CALENDAR_RESOURCE_TYPE, SUBSCRIPTION_RESOURCE_TYPE) async def get_value(self, href, resource, el, environ): el.text = resource.get_calendar_color() async def set_value(self, href, resource, el): resource.set_calendar_color(el.text) class CreatedByProperty(webdav.Property): """created-by property. """ name = "{http://calendarserver.org/ns/}created-by" resource_type = ( CALENDAR_RESOURCE_TYPE) async def get_value(self, href, resource, el, environ): el.text = resource.get_created_by() class UpdatedByProperty(webdav.Property): """updated-by property. """ name = "{http://calendarserver.org/ns/}updated-by" resource_type = ( CALENDAR_RESOURCE_TYPE) async def get_value(self, href, resource, el, environ): el.text = resource.get_updated_by() class SupportedCalendarComponentSetProperty(webdav.Property): """supported-calendar-component-set property. Set of supported calendar components by this calendar. See https://www.ietf.org/rfc/rfc4791.txt, section 5.2.3 """ name = "{%s}supported-calendar-component-set" % NAMESPACE resource_type = ( CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE, SCHEDULE_OUTBOX_RESOURCE_TYPE, SUBSCRIPTION_RESOURCE_TYPE, ) in_allprops = False live = True async def get_value(self, href, resource, el, environ): for component in resource.get_supported_calendar_components(): subel = ET.SubElement(el, "{urn:ietf:params:xml:ns:caldav}comp") subel.set("name", component) class SupportedCalendarDataProperty(webdav.Property): """supported-calendar-data property. See https://tools.ietf.org/html/rfc4791, section 5.2.4 """ name = "{urn:ietf:params:xml:ns:caldav}supported-calendar-data" resource_type = ( CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE, SCHEDULE_OUTBOX_RESOURCE_TYPE, ) in_allprops = False async def get_value(self, href, resource, el, environ): for ( content_type, version, ) in resource.get_supported_calendar_data_types(): subel = ET.SubElement(el, "{urn:ietf:params:xml:ns:caldav}calendar-data") subel.set("content-type", content_type) subel.set("version", version) class CalendarTimezoneProperty(webdav.Property): """calendar-timezone property. See https://tools.ietf.org/html/rfc4791, section 5.2.2 """ name = "{urn:ietf:params:xml:ns:caldav}calendar-timezone" resource_type = (CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE) in_allprops = False async def get_value(self, href, resource, el, environ): el.text = resource.get_calendar_timezone() async def set_value(self, href, resource, el): if el is not None: resource.set_calendar_timezone(el.text) else: resource.set_calendar_timezone(None) class MinDateTimeProperty(webdav.Property): """min-date-time property. See https://tools.ietf.org/html/rfc4791, section 5.2.6 """ name = "{urn:ietf:params:xml:ns:caldav}min-date-time" resource_type = ( CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE, SCHEDULE_OUTBOX_RESOURCE_TYPE, ) in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = resource.get_min_date_time() class MaxDateTimeProperty(webdav.Property): """max-date-time property. See https://tools.ietf.org/html/rfc4791, section 5.2.7 """ name = "{urn:ietf:params:xml:ns:caldav}max-date-time" resource_type = ( CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE, SCHEDULE_OUTBOX_RESOURCE_TYPE, ) in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = resource.get_max_date_time() class MaxInstancesProperty(webdav.Property): """max-instances property. See https://tools.ietf.org/html/rfc4791, section 5.2.8 """ name = "{%s}max-instances" % NAMESPACE resource_type = (CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE) in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_instances()) class MaxAttendeesPerInstanceProperty(webdav.Property): """max-instances property. See https://tools.ietf.org/html/rfc4791, section 5.2.9 """ name = "{%s}max-attendees-per-instance" % NAMESPACE resource_type = ( CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE, SCHEDULE_OUTBOX_RESOURCE_TYPE, ) in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_attendees_per_instance()) class MaxResourceSizeProperty(webdav.Property): """max-resource-size property. See https://tools.ietf.org/html/rfc4791, section 5.2.5 """ name = "{%s}max-resource-size" % NAMESPACE resource_type = ( CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE, SCHEDULE_OUTBOX_RESOURCE_TYPE, ) in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_resource_size()) class MaxAttachmentsPerResourceProperty(webdav.Property): """max-attachments-per-resource property. https://tools.ietf.org/id/draft-ietf-calext-caldav-attachments-03.html#rfc.section.6.3 """ name = "{%s}max-attachments-per-resource" % NAMESPACE resource_type = CALENDAR_RESOURCE_TYPE in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_attachments_per_resource()) class MaxAttachmentSizeProperty(webdav.Property): """max-attachment-size property. https://tools.ietf.org/id/draft-ietf-calext-caldav-attachments-03.html#rfc.section.6.2 """ name = "{%s}max-attachment-size" % NAMESPACE resource_type = CALENDAR_RESOURCE_TYPE in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_attachment_size()) class ManagedAttachmentsServerURLProperty(webdav.Property): """managed-attachments-server-URL property. https://tools.ietf.org/id/draft-ietf-calext-caldav-attachments-03.html#rfc.section.6.1 """ name = "{%s}managed-attachments-server-URL" % NAMESPACE in_allprops = False async def get_value(self, base_href, resource, el, environ): # The RFC specifies that this property can be set on a calendar home # collection. # However, there is no matching resource type and we don't want to # force all resources to implement it. So we just check whether the # attribute is present. fn = getattr(resource, "get_managed_attachments_server_url", None) if fn is None: raise KeyError href = fn() if href is not None: el.append(webdav.create_href(href, base_href)) class SourceProperty(webdav.Property): """source property.""" name = "{http://calendarserver.org/ns/}source" resource_type = SUBSCRIPTION_RESOURCE_TYPE in_allprops = True live = False async def get_value(self, base_href, resource, el, environ): el.append(webdav.create_href(resource.get_source_url(), base_href)) async def set_value(self, href, resource, el): raise NotImplementedError(self.set_value) class CalendarProxyReadForProperty(webdav.Property): """calendar-proxy-read-for property. See https://github.com/apple/ccs-calendarserver/blob/master/\ doc/Extensions/caldav-proxy.txt, section 5.3.1. """ name = "{http://calendarserver.org/ns/}calendar-proxy-read-for" resource_type = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = False live = True async def get_value(self, base_href, resource, el, environ): for href in resource.get_calendar_proxy_read_for(): el.append(webdav.create_href(href, base_href)) class CalendarProxyWriteForProperty(webdav.Property): """calendar-proxy-write-for property. See https://github.com/apple/ccs-calendarserver/blob/master/\ doc/Extensions/caldav-proxy.txt, section 5.3.2. """ name = "{http://calendarserver.org/ns/}calendar-proxy-write-for" resource_type = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = False live = True async def get_value(self, base_href, resource, el, environ): for href in resource.get_calendar_proxy_write_for(): el.append(webdav.create_href(href, base_href)) class ScheduleCalendarTransparencyProperty(webdav.Property): """schedule-calendar-transp property. See https://tools.ietf.org/html/rfc6638#section-9.1 """ name = "{%s}schedule-calendar-transp" % NAMESPACE in_allprops = False live = False resource_type = CALENDAR_RESOURCE_TYPE async def get_value(self, base_href, resource, el, environ): transp = resource.get_schedule_calendar_transparency() if transp == TRANSPARENCY_TRANSPARENT: ET.SubElement(el, "{%s}transparent" % NAMESPACE) elif transp == TRANSPARENCY_OPAQUE: ET.SubElement(el, "{%s}opaque" % NAMESPACE) else: raise ValueError(f"Invalid transparency {transp}") def map_freebusy(comp): transp = comp.get("TRANSP", "OPAQUE") if transp == "TRANSPARENT": return "FREE" assert transp == "OPAQUE", f"unknown transp {transp!r}" status = comp.get("STATUS", "CONFIRMED") if status == "CONFIRMED": return "BUSY" elif status == "CANCELLED": return "FREE" elif status == "TENTATIVE": return "BUSY-TENTATIVE" elif status.startswith("X-"): return status else: raise AssertionError(f"unknown status {status!r}") def extract_freebusy(comp, tzify): kind = map_freebusy(comp) if kind == "FREE": return None if "DTEND" in comp: ret = vPeriod((tzify(comp["DTSTART"].dt), tzify(comp["DTEND"].dt))) if "DURATION" in comp: ret = vPeriod((tzify(comp["DTSTART"].dt), comp["DURATION"].dt)) if kind != "BUSY": ret.params["FBTYPE"] = kind return ret async def iter_freebusy(resources, start, end, tzify): async for href, resource in resources: c = await calendar_from_resource(resource) if c is None: continue if c.name != "VCALENDAR": continue for comp in c.subcomponents: if comp.name == "VEVENT": if apply_time_range_vevent(start, end, comp, tzify): vp = extract_freebusy(comp, tzify) if vp is not None: yield vp class FreeBusyQueryReporter(webdav.Reporter): """free-busy-query reporter. See https://tools.ietf.org/html/rfc4791, section 7.10 """ name = "{urn:ietf:params:xml:ns:caldav}free-busy-query" resource_type = CALENDAR_RESOURCE_TYPE async def report( self, environ, body, resources_by_hrefs, properties, base_href, base_resource, depth, strict, ): requested = None for el in body: if el.tag == "{urn:ietf:params:xml:ns:caldav}time-range": requested = el else: webdav.nonfatal_bad_request("unexpected XML element", strict) continue tz = get_calendar_timezone(base_resource) def tzify(dt): return as_tz_aware_ts(dt, tz).astimezone(ZoneInfo('UTC')) (start, end) = _parse_time_range(requested) ret = ICalendar() ret["VERSION"] = "2.0" ret["PRODID"] = PRODID fb = FreeBusy() fb["DTSTAMP"] = vDDDTypes(tzify(datetime.datetime.now())) fb["DTSTART"] = vDDDTypes(start) fb["DTEND"] = vDDDTypes(end) fb["FREEBUSY"] = [ item async for item in iter_freebusy( webdav.traverse_resource(base_resource, base_href, depth), start, end, tzify, ) ] ret.add_component(fb) return webdav.Response(status="200 OK", body=[ret.to_ical()]) class MkcalendarMethod(webdav.Method): async def handle(self, request, environ, app): content_type = request.content_type base_content_type, params = webdav.parse_type(content_type) if base_content_type not in ( "text/xml", "application/xml", None, "text/plain", "application/octet-stream", ): raise webdav.UnsupportedMediaType(content_type) href, path, resource = app._get_resource_from_environ(request, environ) if resource is not None: return webdav._send_simple_dav_error( request, "403 Forbidden", error=ET.Element("{DAV:}resource-must-be-null"), description=f"Something already exists at {path!r}", ) try: resource = app.backend.create_collection(path) except FileNotFoundError: return webdav.Response(status="409 Conflict") el = ET.Element("{DAV:}resourcetype") await app.properties["{DAV:}resourcetype"].get_value( href, resource, el, environ ) ET.SubElement(el, "{urn:ietf:params:xml:ns:caldav}calendar") await app.properties["{DAV:}resourcetype"].set_value(href, resource, el) if base_content_type in ("text/xml", "application/xml"): et = await webdav._readXmlBody( request, "{urn:ietf:params:xml:ns:caldav}mkcalendar", strict=app.strict, ) propstat = [] for el in et: if el.tag != "{DAV:}set": webdav.nonfatal_bad_request( f"Unknown tag {el.tag} in mkcalendar", app.strict ) continue propstat.extend( [ ps async for ps in webdav.apply_modify_prop( el, href, resource, app.properties ) ] ) ret = ET.Element("{urn:ietf:params:xml:ns:carldav:}mkcalendar-response") for propstat_el in webdav.propstat_as_xml(propstat): ret.append(propstat_el) return webdav._send_xml_response( "201 Created", ret, webdav.DEFAULT_ENCODING ) else: return webdav.Response(status="201 Created") xandikos-0.2.12/xandikos/carddav.py000066400000000000000000000271671470075263100172340ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """CardDAV support. https://tools.ietf.org/html/rfc6352 """ from . import collation as _mod_collation from . import davcommon, webdav ET = webdav.ET WELLKNOWN_CARDDAV_PATH = "/.well-known/carddav" NAMESPACE = "urn:ietf:params:xml:ns:carddav" ADDRESSBOOK_RESOURCE_TYPE = "{%s}addressbook" % NAMESPACE # Feature to advertise presence of CardDAV support FEATURE = "addressbook" class AddressbookHomeSetProperty(webdav.Property): """addressbook-home-set property. See https://tools.ietf.org/html/rfc6352, section 7.1.1 """ name = "{%s}addressbook-home-set" % NAMESPACE resource_type = "{DAV:}principal" in_allprops = False live = True async def get_value(self, base_href, resource, el, environ): for href in resource.get_addressbook_home_set(): href = webdav.ensure_trailing_slash(href) el.append(webdav.create_href(href, base_href)) class AddressDataProperty(davcommon.SubbedProperty): """address-data property. See https://tools.ietf.org/html/rfc6352, section 10.4 Note that this is not technically a DAV property, and it is thus not registered in the regular webdav server. """ name = "{%s}address-data" % NAMESPACE def supported_on(self, resource): return resource.get_content_type() == "text/vcard" async def get_value_ext(self, href, resource, el, environ, requested): # TODO(jelmer): Support subproperties # TODO(jelmer): Don't hardcode encoding el.text = b"".join(await resource.get_body()).decode("utf-8") class AddressbookDescriptionProperty(webdav.Property): """Provides calendar-description property. https://tools.ietf.org/html/rfc6352, section 6.2.1 """ name = "{%s}addressbook-description" % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE async def get_value(self, href, resource, el, environ): el.text = resource.get_addressbook_description() async def set_value(self, href, resource, el): resource.set_addressbook_description(el.text) class AddressbookMultiGetReporter(davcommon.MultiGetReporter): name = "{%s}addressbook-multiget" % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE data_property = AddressDataProperty() class Addressbook(webdav.Collection): resource_types = webdav.Collection.resource_types + [ADDRESSBOOK_RESOURCE_TYPE] def get_addressbook_description(self) -> str: raise NotImplementedError(self.get_addressbook_description) def set_addressbook_description(self, description: str) -> None: raise NotImplementedError(self.set_addressbook_description) def get_addressbook_color(self) -> str: raise NotImplementedError(self.get_addressbook_color) def set_addressbook_color(self, color: str) -> None: raise NotImplementedError(self.set_addressbook_color) def get_supported_address_data_types(self): """Get list of supported data types. Returns: List of tuples with content type and version """ raise NotImplementedError(self.get_supported_address_data_types) def get_max_resource_size(self) -> int: """Get maximum object size this address book will store (in bytes). Absence indicates no maximum. """ raise NotImplementedError(self.get_max_resource_size) def get_max_image_size(self) -> int: """Get maximum image size this address book will store (in bytes). Absence indicates no maximum. """ raise NotImplementedError(self.get_max_image_size) class PrincipalExtensions: """Extensions to webdav.Principal.""" def get_addressbook_home_set(self) -> set[str]: """Return set of addressbook home URLs. Returns: set of URLs """ raise NotImplementedError(self.get_addressbook_home_set) def get_principal_address(self) -> str: """Return URL to principal address vCard.""" raise NotImplementedError(self.get_principal_address) class PrincipalAddressProperty(webdav.Property): """Provides the principal-address property. https://tools.ietf.org/html/rfc6352, section 7.1.2 """ name = "{%s}principal-address" % NAMESPACE resource_type = "{DAV:}principal" in_allprops = False async def get_value(self, href, resource, el, environ): el.append(webdav.create_href(resource.get_principal_address(), href)) class SupportedAddressDataProperty(webdav.Property): """Provides the supported-address-data property. https://tools.ietf.org/html/rfc6352, section 6.2.2 """ name = "{%s}supported-address-data" % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE in_allprops = False live = True async def get_value(self, href, resource, el, environ): for ( content_type, version, ) in resource.get_supported_address_data_types(): subel = ET.SubElement(el, "{%s}content-type" % NAMESPACE) subel.set("content-type", content_type) subel.set("version", version) class MaxResourceSizeProperty(webdav.Property): """Provides the max-resource-size property. See https://tools.ietf.org/html/rfc6352, section 6.2.3. """ name = "{%s}max-resource-size" % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_resource_size()) class MaxImageSizeProperty(webdav.Property): """Provides the max-image-size property. This seems to be a carddav extension used by iOS and caldavzap. """ name = "{%s}max-image-size" % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_image_size()) async def addressbook_from_resource(resource): try: if resource.get_content_type() != "text/vcard": return None except KeyError: return None file = await resource.get_file() return file.addressbook.contents def apply_text_match(el: ET.Element, value: str) -> bool: collation = el.get("collation", "i;ascii-casemap") negate_condition = el.get("negate-condition", "no") match_type = el.get("match-type", "contains") matches = _mod_collation.collations[collation](value, el.text or "", match_type) if negate_condition == "yes": return not matches else: return matches def apply_param_filter(el, prop): name = el.get("name") if len(el) == 1 and el[0].tag == "{urn:ietf:params:xml:ns:carddav}is-not-defined": return name not in prop.params try: value = prop.params[name] except KeyError: return False for subel in el: if subel.tag == "{urn:ietf:params:xml:ns:carddav}text-match": if not apply_text_match(subel, value): return False else: raise AssertionError("unknown tag %r in param-filter", subel.tag) return True def apply_prop_filter(el, ab): name = el.get("name").lower() # From https://tools.ietf.org/html/rfc6352 # A CARDDAV:prop-filter is said to match if: # The CARDDAV:prop-filter XML element contains a CARDDAV:is-not-defined XML # element and no property of the type specified by the "name" attribute # exists in the enclosing calendar component; if len(el) == 1 and el[0].tag == "{urn:ietf:params:xml:ns:carddav}is-not-defined": return name not in ab try: prop = ab[name] except KeyError: return False for prop_el in prop: matched = True for subel in el: if subel.tag == "{urn:ietf:params:xml:ns:carddav}text-match": if not apply_text_match(subel, str(prop_el)): matched = False break elif subel.tag == "{urn:ietf:params:xml:ns:carddav}param-filter": if not apply_param_filter(subel, prop_el): matched = False break if matched: return True return False async def apply_filter(el, resource): """Compile a filter element into a Python function.""" if el is None or not list(el): # Empty filter, let's not bother parsing return lambda x: True ab = await addressbook_from_resource(resource) if ab is None: return False test_name = el.get("test", "anyof") test = {"allof": all, "anyof": any}[test_name] return test(apply_prop_filter(subel, ab) for subel in el) class AddressbookQueryReporter(webdav.Reporter): name = "{%s}addressbook-query" % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE data_property = AddressDataProperty() @webdav.multistatus async def report( self, environ, body, resources_by_hrefs, properties, base_href, base_resource, depth, strict, ): requested = None filter_el = None limit = None for el in body: if el.tag in ("{DAV:}prop", "{DAV:}allprop", "{DAV:}propname"): requested = el elif el.tag == ("{%s}filter" % NAMESPACE): filter_el = el elif el.tag == ("{%s}limit" % NAMESPACE): limit = el else: webdav.nonfatal_bad_request( f"Unknown tag {el.tag} in report {self.name}", strict ) if requested is None: # The CardDAV RFC says that behaviour mimicks that of PROPFIND, # and the WebDAV RFC says that no body implies {DAV}allprop # This isn't exactly an empty body, but close enough. requested = ET.Element("{DAV:}allprop") if limit is not None: try: [nresults_el] = list(limit) except ValueError: webdav.nonfatal_bad_request( "Invalid number of subelements in limit", strict ) nresults = None else: try: nresults = int(nresults_el.text) except ValueError: webdav.nonfatal_bad_request("nresults not a number", strict) nresults = None else: nresults = None i = 0 async for href, resource in webdav.traverse_resource( base_resource, base_href, depth ): if not await apply_filter(filter_el, resource): continue if nresults is not None and i >= nresults: break propstat = davcommon.get_properties_with_data( self.data_property, href, resource, properties, environ, requested, ) yield webdav.Status(href, "200 OK", propstat=[s async for s in propstat]) i += 1 xandikos-0.2.12/xandikos/collation.py000066400000000000000000000041471470075263100176050ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Collations.""" from typing import Callable class UnknownCollation(Exception): def __init__(self, collation: str) -> None: super().__init__(f"Collation {collation!r} is not supported") self.collation = collation def _match(a, b, k): if k == "equals": return a == b elif k == "contains": return b in a elif k == "starts-with": return a.startswith(b) elif k == "ends-with": return b.endswith(b) else: raise NotImplementedError collations: dict[str, Callable[[str, str, str], bool]] = { "i;ascii-casemap": lambda a, b, k: _match( a.encode("ascii").upper(), b.encode("ascii").upper(), k ), "i;octet": lambda a, b, k: _match(a, b, k), # TODO(jelmer): Follow all rules as specified in # https://datatracker.ietf.org/doc/html/rfc5051 "i;unicode-casemap": lambda a, b, k: _match( a.encode("utf-8", "surrogateescape").upper(), b.encode("utf-8", "surrogateescape").upper(), k, ), } def get_collation(name: str) -> Callable[[str, str, str], bool]: """Get a collation by name. Args: name: Collation name Raises: UnknownCollation: If the collation is not supported """ try: return collations[name] except KeyError as exc: raise UnknownCollation(name) from exc xandikos-0.2.12/xandikos/davcommon.py000066400000000000000000000067671470075263100176160ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Common functions for DAV implementations.""" from xandikos import webdav ET = webdav.ET class SubbedProperty(webdav.Property): """Property with sub-components that can be queried.""" async def get_value_ext(self, href, resource, el, environ, requested): """Get the value of a data property. Args: href: Resource href resource: Resource to get value for el: Element to fill in environ: WSGI environ dict requested: Requested property (including subelements) """ raise NotImplementedError(self.get_value_ext) async def get_properties_with_data( data_property, href, resource, properties, environ, requested ): properties = dict(properties) properties[data_property.name] = data_property async for ps in webdav.get_properties( href, resource, properties, environ, requested ): yield ps class MultiGetReporter(webdav.Reporter): """Abstract base class for multi-get reporters.""" name: str # A SubbedProperty subclass data_property: SubbedProperty @webdav.multistatus async def report( self, environ, body, resources_by_hrefs, properties, base_href, resource, depth, strict, ): # TODO(jelmer): Verify that depth == "0" # TODO(jelmer): Verify that resource is an the right resource type requested = None hrefs = [] for el in body: if el.tag in ("{DAV:}prop", "{DAV:}allprop", "{DAV:}propname"): requested = el elif el.tag == "{DAV:}href": hrefs.append(webdav.read_href_element(el)) else: webdav.nonfatal_bad_request( f"Unknown tag {el.tag} in report {self.name}", strict ) if requested is None: # The CalDAV RFC says that behaviour mimicks that of PROPFIND, # and the WebDAV RFC says that no body implies {DAV}allprop # This isn't exactly an empty body, but close enough. requested = ET.Element("{DAV:}allprop") for href, resource in resources_by_hrefs(hrefs): if resource is None: yield webdav.Status(href, "404 Not Found", propstat=[]) else: propstat = get_properties_with_data( self.data_property, href, resource, properties, environ, requested, ) yield webdav.Status( href, "200 OK", propstat=[s async for s in propstat] ) # see https://tools.ietf.org/html/rfc4790 xandikos-0.2.12/xandikos/icalendar.py000066400000000000000000001025341470075263100175420ustar00rootroot00000000000000# Xandikos # Copyright (C) 2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """ICalendar file handling.""" import logging from collections.abc import Iterable from datetime import datetime, time, timedelta, timezone from typing import Callable, Optional, Union from zoneinfo import ZoneInfo import dateutil.rrule from icalendar.cal import Calendar, Component, component_factory from icalendar.prop import TypesFactory, vCategory, vDatetime, vDDDTypes, vText from xandikos.store import File, Filter, InvalidFileContents from . import collation as _mod_collation from .store.index import IndexDict, IndexKey, IndexValue, IndexValueIterator TYPES_FACTORY = TypesFactory() PropTypes = Union[vText] TzifyFunction = Callable[[datetime], datetime] # TODO(jelmer): Populate this further based on # https://tools.ietf.org/html/rfc5545#3.3.11 _INVALID_CONTROL_CHARACTERS = ["\x0c", "\x01"] class MissingProperty(Exception): def __init__(self, property_name) -> None: super().__init__(f"Property {property_name!r} missing") self.property_name = property_name def validate_calendar(cal, strict=False): """Validate a calendar object. Args: cal: Calendar object Returns: iterator over error messages """ yield from validate_component(cal, strict=strict) # SubIndexDict is like IndexDict, but None can also occur as a key SubIndexDict = dict[Optional[IndexKey], IndexValue] def create_subindexes( indexes: Union[SubIndexDict, IndexDict], base: str ) -> SubIndexDict: ret: SubIndexDict = {} for k, v in indexes.items(): if k is not None and k.startswith(base + "/"): ret[k[len(base) + 1 :]] = v elif k == base: ret[None] = v return ret def validate_component(comp, strict=False): """Validate a calendar component. Args: comp: Calendar component """ # Check text fields for invalid characters for name, value in comp.items(): if isinstance(value, vText): for c in _INVALID_CONTROL_CHARACTERS: if c in value: yield "Invalid character {} in field {}".format( c.encode("unicode_escape"), name, ) if strict: for required in comp.required: try: comp[required] except KeyError: yield f"Missing required field {required}" for subcomp in comp.subcomponents: yield from validate_component(subcomp, strict=strict) def calendar_component_delta(old_cal, new_cal): """Find the differences between components in two calendars. Args: old_cal: Old calendar (can be None) new_cal: New calendar (can be None) Returns: iterator over (old_component, new_component) tuples (either can be None) """ by_uid = {} by_content = {} by_idx = {} idx = 0 for component in getattr(old_cal, "subcomponents", []): try: by_uid[component["UID"]] = component except KeyError: by_content[component.to_ical()] = True by_idx[idx] = component idx += 1 idx = 0 for component in new_cal.subcomponents: try: old_component = by_uid.pop(component["UID"]) except KeyError: if not by_content.pop(component.to_ical(), None): # Not previously present yield ( by_idx.get(idx, component_factory[component.name]()), component, ) by_idx.pop(idx, None) else: yield (old_component, component) for old_component in by_idx.values(): yield (old_component, component_factory[old_component.name]()) def calendar_prop_delta(old_component, new_component): fields = set( [field for field in old_component or []] + [field for field in new_component or []] ) for field in fields: old_value = old_component.get(field) new_value = new_component.get(field) if ( getattr(old_value, "to_ical", None) is None or getattr(new_value, "to_ical", None) is None or old_value.to_ical() != new_value.to_ical() ): yield (field, old_value, new_value) def describe_component(component): if component.name == "VTODO": try: return f"task '{component['SUMMARY']}'" except KeyError: return "task" else: try: return component["SUMMARY"] except KeyError: return "calendar item" DELTA_IGNORE_FIELDS = { "LAST-MODIFIED", "SEQUENCE", "DTSTAMP", "PRODID", "CREATED", "COMPLETED", "X-MOZ-GENERATION", "X-LIC-ERROR", "UID", } def describe_calendar_delta(old_cal, new_cal): """Describe the differences between two calendars. Args: old_cal: Old calendar (can be None) new_cal: New calendar (can be None) Returns: Lines describing changes """ # TODO(jelmer): Extend for old_component, new_component in calendar_component_delta(old_cal, new_cal): if not new_component: yield f"Deleted {describe_component(old_component)}" continue description = describe_component(new_component) if not old_component: yield f"Added {describe_component(new_component)}" continue for field, old_value, new_value in calendar_prop_delta( old_component, new_component ): if field.upper() in DELTA_IGNORE_FIELDS: continue if old_component.name.upper() == "VTODO" and field.upper() == "STATUS": if new_value is None: yield f"status of {description} deleted" else: human_readable = { "NEEDS-ACTION": "needing action", "COMPLETED": "complete", "CANCELLED": "cancelled", } yield "{} marked as {}".format( description, human_readable.get(new_value.upper(), new_value), ) elif field.upper() == "DESCRIPTION": yield f"changed description of {description}" elif field.upper() == "SUMMARY": yield f"changed summary of {description}" elif field.upper() == "LOCATION": yield f"changed location of {description} to {new_value}" elif ( old_component.name.upper() == "VTODO" and field.upper() == "PERCENT-COMPLETE" and new_value is not None ): yield "%s marked as %d%% completed." % (description, new_value) elif field.upper() == "DUE": yield "changed due date for {} from {} to {}".format( description, old_value.dt if old_value else "none", new_value.dt if new_value else "none", ) elif field.upper() == "DTSTART": yield "changed start date/time of {} from {} to {}".format( description, old_value.dt if old_value else "none", new_value.dt if new_value else "none", ) elif field.upper() == "DTEND": yield "changed end date/time of {} from {} to {}".format( description, old_value.dt if old_value else "none", new_value.dt if new_value else "none", ) elif field.upper() == "CLASS": yield "changed class of {} from {} to {}".format( description, old_value.lower() if old_value else "none", new_value.lower() if new_value else "none", ) else: yield f"modified field {field} in {description}" logging.debug( "Changed %s/%s or %s/%s from %s to %s.", old_component.name, field, new_component.name, field, old_value, new_value, ) def apply_time_range_vevent(start, end, comp, tzify): dtstart = comp.get("DTSTART") if not dtstart: raise MissingProperty("DTSTART") if not (end > tzify(dtstart.dt)): return False dtend = comp.get("DTEND") if dtend: if tzify(dtend.dt) < tzify(dtstart.dt): logging.debug("Invalid DTEND < DTSTART") return start < tzify(dtend.dt) duration = comp.get("DURATION") if duration: return start < tzify(dtstart.dt) + duration.dt if getattr(dtstart.dt, "time", None) is not None: return start <= tzify(dtstart.dt) else: return start < (tzify(dtstart.dt) + timedelta(1)) def apply_time_range_vjournal(start, end, comp, tzify): dtstart = comp.get("DTSTART") if not dtstart: raise MissingProperty("DTSTART") if not (end > tzify(dtstart.dt)): return False if getattr(dtstart.dt, "time", None) is not None: return start <= tzify(dtstart.dt) else: return start < (tzify(dtstart.dt) + timedelta(1)) def apply_time_range_vtodo(start, end, comp, tzify): dtstart = comp.get("DTSTART") due = comp.get("DUE") # See RFC4719, section 9.9 if dtstart: duration = comp.get("DURATION") if duration and not due: return start <= tzify(dtstart.dt) + duration.dt and ( end > tzify(dtstart.dt) or end >= tzify(dtstart.dt) + duration.dt ) elif due and not duration: return (start <= tzify(dtstart.dt) or start < tzify(due.dt)) and ( end > tzify(dtstart.dt) or end < tzify(due.dt) ) else: return start <= tzify(dtstart.dt) and end > tzify(dtstart.dt) if due: return start < tzify(due.dt) and end >= tzify(due.dt) completed = comp.get("COMPLETED") created = comp.get("CREATED") if completed: if created: return (start <= tzify(created.dt) or start <= tzify(completed.dt)) and ( end >= tzify(created.dt) or end >= tzify(completed.dt) ) else: return start <= tzify(completed.dt) and end >= tzify(completed.dt) elif created: return end >= tzify(created.dt) else: return True def apply_time_range_vfreebusy(start, end, comp, tzify): dtstart = comp.get("DTSTART") dtend = comp.get("DTEND") if dtstart and dtend: return start <= tzify(dtend.dt) and end > tzify(dtstart.dt) for period in comp.get("FREEBUSY", []): if start < period.end and end > period.start: return True return False def apply_time_range_valarm(start, end, comp, tzify): raise NotImplementedError(apply_time_range_valarm) class PropertyTimeRangeMatcher: def __init__(self, start: datetime, end: datetime) -> None: self.start = start self.end = end def __repr__(self) -> str: return f"{self.__class__.__name__}({self.start!r}, {self.end!r})" def match(self, prop, tzify): dt = tzify(prop.dt) return dt >= self.start and dt <= self.end def match_indexes(self, prop: SubIndexDict, tzify: TzifyFunction): return any( self.match(vDDDTypes(vDDDTypes.from_ical(p.decode("utf-8"))), tzify) for p in prop[None] if not isinstance(p, bool) ) TimeRangeFilter = Callable[[datetime, datetime, Component, TzifyFunction], bool] class ComponentTimeRangeMatcher: all_props = [ "DTSTART", "DTEND", "DURATION", "CREATED", "COMPLETED", "DUE", "FREEBUSY", ] # According to https://tools.ietf.org/html/rfc4791, section 9.9 these # are the properties to check. component_handlers: dict[str, TimeRangeFilter] = { "VEVENT": apply_time_range_vevent, "VTODO": apply_time_range_vtodo, "VJOURNAL": apply_time_range_vjournal, "VFREEBUSY": apply_time_range_vfreebusy, "VALARM": apply_time_range_valarm, } def __init__(self, start, end, comp=None) -> None: self.start = start self.end = end self.comp = comp def __repr__(self) -> str: if self.comp is not None: return "{}({!r}, {!r}, comp={!r})".format( self.__class__.__name__, self.start, self.end, self.comp, ) else: return f"{self.__class__.__name__}({self.start!r}, {self.end!r})" def match(self, comp: Component, tzify: TzifyFunction): try: component_handler = self.component_handlers[comp.name] except KeyError: logging.warning("unknown component %r in time-range filter", comp.name) return False return component_handler(self.start, self.end, comp, tzify) def match_indexes(self, indexes: SubIndexDict, tzify: TzifyFunction): vs: dict[str, vDDDTypes] = {} for name, values in indexes.items(): if not name: continue field = name[2:] if field not in self.all_props: continue for value in values: if value and not isinstance(value, bool): vs.setdefault(field, []).append( vDDDTypes(vDDDTypes.from_ical(value.decode("utf-8"))) ) try: component_handler = self.component_handlers[self.comp] except KeyError: logging.warning("unknown component %r in time-range filter", self.comp) return False return component_handler( self.start, self.end, # TODO(jelmer): What to do if there is more than one value? {k: vs[0] for (k, vs) in vs.items()}, tzify, ) def index_keys(self) -> list[list[str]]: if self.comp == "VEVENT": props = ["DTSTART", "DTEND", "DURATION"] elif self.comp == "VTODO": props = ["DTSTART", "DUE", "DURATION", "CREATED", "COMPLETED"] elif self.comp == "VJOURNAL": props = ["DTSTART"] elif self.comp == "VFREEBUSY": props = ["DTSTART", "DTEND", "FREEBUSY"] elif self.comp == "VALARM": raise NotImplementedError else: props = self.all_props return [["P=" + prop] for prop in props] class TextMatcher: def __init__( self, name: str, text: str, collation: Optional[str] = None, negate_condition: bool = False, ) -> None: self.name = name self.type_fn = TYPES_FACTORY.for_property(name) assert isinstance(text, str) self.text = text if collation is None: collation = "i;ascii-casemap" self.collation = _mod_collation.get_collation(collation) self.negate_condition = negate_condition def __repr__(self) -> str: return "{}({!r}, {!r}, collation={!r}, negate_condition={!r})".format( self.__class__.__name__, self.name, self.text, self.collation, self.negate_condition, ) def match_indexes(self, indexes: SubIndexDict): return any( self.match(self.type_fn(self.type_fn.from_ical(k))) for k in indexes[None] ) def match(self, prop: Union[vText, vCategory, str]): if isinstance(prop, vText): matches = self.collation(self.text, str(prop), "equals") elif isinstance(prop, str): matches = self.collation(self.text, prop, "equals") elif isinstance(prop, vCategory): matches = any([self.match(cat) for cat in prop.cats]) else: logging.warning( "potentially unsupported value in text match search: " + repr(prop) ) return False if self.negate_condition: return not matches else: return matches class ComponentFilter: time_range: Optional[ComponentTimeRangeMatcher] def __init__( self, name: str, children=None, is_not_defined: bool = False, time_range=None ) -> None: self.name = name self.children = children self.is_not_defined = is_not_defined self.time_range = time_range self.children = children or [] def __repr__(self) -> str: return "{}({!r}, children={!r}, is_not_defined={!r}, time_range={!r})".format( self.__class__.__name__, self.name, self.children, self.is_not_defined, self.time_range, ) def filter_subcomponent( self, name: str, is_not_defined: bool = False, time_range: Optional[ComponentTimeRangeMatcher] = None, ): ret = ComponentFilter( name=name, is_not_defined=is_not_defined, time_range=time_range ) self.children.append(ret) return ret def filter_property( self, name: str, is_not_defined: bool = False, time_range: Optional[PropertyTimeRangeMatcher] = None, ): ret = PropertyFilter( name=name, is_not_defined=is_not_defined, time_range=time_range ) self.children.append(ret) return ret def filter_time_range(self, start: datetime, end: datetime): self.time_range = ComponentTimeRangeMatcher(start, end, comp=self.name) return self.time_range def match(self, comp: Component, tzify: TzifyFunction): # From https://tools.ietf.org/html/rfc4791, 9.7.1: # A CALDAV:comp-filter is said to match if: # 2. The CALDAV:comp-filter XML element contains a # CALDAV:is-not-defined XML element and the calendar object or calendar # component type specified by the "name" attribute does not exist in # the current scope; if self.is_not_defined: return comp.name != self.name # 1: The CALDAV:comp-filter XML element is empty and the calendar # object or calendar component type specified by the "name" attribute # exists in the current scope; if comp.name != self.name: return False # 3. The CALDAV:comp-filter XML element contains a CALDAV:time-range # XML element and at least one recurrence instance in the targeted # calendar component is scheduled to overlap the specified time range if self.time_range is not None and not self.time_range.match(comp, tzify): return False # ... and all specified CALDAV:prop-filter and CALDAV:comp-filter child # XML elements also match the targeted calendar component; for child in self.children: if isinstance(child, ComponentFilter): if not any(child.match(c, tzify) for c in comp.subcomponents): return False elif isinstance(child, PropertyFilter): if not child.match(comp, tzify): return False else: raise TypeError(child) return True def _implicitly_defined(self): return any( not getattr(child, "is_not_defined", False) for child in self.children ) def match_indexes(self, indexes: IndexDict, tzify: TzifyFunction): myindex = "C=" + self.name if self.is_not_defined: return not bool(indexes[myindex]) subindexes = create_subindexes(indexes, myindex) if self.time_range is not None and not self.time_range.match_indexes( subindexes, tzify ): return False for child in self.children: if not child.match_indexes(subindexes, tzify): return False if not self._implicitly_defined(): return bool(indexes[myindex]) return True def index_keys(self): mine = "C=" + self.name for child in self.children + ([self.time_range] if self.time_range else []): for tl in child.index_keys(): yield [(mine + "/" + child_index) for child_index in tl] if not self._implicitly_defined(): yield [mine] class PropertyFilter: def __init__( self, name: str, children=None, is_not_defined: bool = False, time_range: Optional[PropertyTimeRangeMatcher] = None, ) -> None: self.name = name self.is_not_defined = is_not_defined self.children = children or [] self.time_range = time_range def __repr__(self) -> str: return "{}({!r}, children={!r}, is_not_defined={!r}, time_range={!r})".format( self.__class__.__name__, self.name, self.children, self.is_not_defined, self.time_range, ) def filter_parameter( self, name: str, is_not_defined: bool = False ) -> "ParameterFilter": ret = ParameterFilter(name=name, is_not_defined=is_not_defined) self.children.append(ret) return ret def filter_time_range( self, start: datetime, end: datetime ) -> PropertyTimeRangeMatcher: self.time_range = PropertyTimeRangeMatcher(start, end) return self.time_range def filter_text_match( self, text: str, collation: Optional[str] = None, negate_condition: bool = False ) -> TextMatcher: ret = TextMatcher( self.name, text, collation=collation, negate_condition=negate_condition ) self.children.append(ret) return ret def match(self, comp: Component, tzify: TzifyFunction) -> bool: # From https://tools.ietf.org/html/rfc4791, 9.7.2: # A CALDAV:comp-filter is said to match if: # The CALDAV:prop-filter XML element contains a CALDAV:is-not-defined # XML element and no property of the type specified by the "name" # attribute exists in the enclosing calendar component; if self.is_not_defined: return self.name not in comp try: prop = comp[self.name] except KeyError: return False if self.time_range and not self.time_range.match(prop, tzify): return False for child in self.children: if not child.match(prop): return False return True def match_indexes(self, indexes: SubIndexDict, tzify: TzifyFunction) -> bool: myindex = "P=" + self.name if self.is_not_defined: return not bool(indexes[myindex]) subindexes: SubIndexDict = create_subindexes(indexes, myindex) if not self.children and not self.time_range: return bool(indexes[myindex]) if self.time_range is not None and not self.time_range.match_indexes( subindexes, tzify ): return False for child in self.children: if not child.match_indexes(subindexes): return False return True def index_keys(self): mine = "P=" + self.name for child in self.children: if not isinstance(child, ParameterFilter): continue for tl in child.index_keys(): yield [(mine + "/" + child_index) for child_index in tl] yield [mine] class ParameterFilter: children: list[TextMatcher] def __init__( self, name: str, children: Optional[list[TextMatcher]] = None, is_not_defined: bool = False, ) -> None: self.name = name self.is_not_defined = is_not_defined self.children = children or [] def filter_text_match( self, text: str, collation: Optional[str] = None, negate_condition: bool = False ) -> TextMatcher: ret = TextMatcher( self.name, text, collation=collation, negate_condition=negate_condition ) self.children.append(ret) return ret def match(self, prop: PropTypes) -> bool: if self.is_not_defined: return self.name not in prop.params try: value = prop.params[self.name] except KeyError: return False for child in self.children: if not child.match(value): return False return True def index_keys(self) -> Iterable[list[str]]: yield ["A=" + self.name] def match_indexes(self, indexes: IndexDict) -> bool: myindex = "A=" + self.name if self.is_not_defined: return not bool(indexes[myindex]) subindexes = create_subindexes(indexes, myindex) if not subindexes: return False for child in self.children: if not child.match_indexes(subindexes): return False return True class CalendarFilter(Filter): """A filter that works on ICalendar files.""" content_type = "text/calendar" def __init__(self, default_timezone: Union[str, timezone]) -> None: self.tzify = lambda dt: as_tz_aware_ts(dt, default_timezone) self.children: list[ComponentFilter] = [] def filter_subcomponent(self, name, is_not_defined=False, time_range=None): ret = ComponentFilter( name=name, is_not_defined=is_not_defined, time_range=time_range ) self.children.append(ret) return ret def check(self, name: str, file: File) -> bool: if not isinstance(file, ICalendarFile): return False c = file.calendar if c is None: return False for child_filter in self.children: try: if not child_filter.match(file.calendar, self.tzify): return False except MissingProperty as e: logging.warning( "calendar_query: Ignoring calendar object %s, due " "to missing property %s", name, e.property_name, ) return False return True def check_from_indexes(self, name: str, indexes: IndexDict) -> bool: for child_filter in self.children: try: if not child_filter.match_indexes(indexes, self.tzify): return False except MissingProperty as e: logging.warning( "calendar_query: Ignoring calendar object %s, due " "to missing property %s", name, e.property_name, ) return False return True def index_keys(self) -> list[str]: subindexes = [] for child in self.children: subindexes.extend(child.index_keys()) return subindexes def __repr__(self) -> str: return f"{self.__class__.__name__}({self.children!r})" class ICalendarFile(File): """Handle for ICalendar files.""" content_type = "text/calendar" def __init__(self, content, content_type) -> None: super().__init__(content, content_type) self._calendar = None def validate(self) -> None: """Verify that file contents are valid.""" cal = self.calendar # TODO(jelmer): return the list of errors to the caller if cal.errors: raise InvalidFileContents( self.content_type, self.content, "Broken calendar file: " + ", ".join(cal.errors) ) errors = list(validate_calendar(cal, strict=False)) if errors: raise InvalidFileContents( self.content_type, self.content, ", ".join(errors) ) def normalized(self): """Return a normalized version of the file.""" return [self.calendar.to_ical()] @property def calendar(self): if self._calendar is None: try: self._calendar = Calendar.from_ical(b"".join(self.content)) except ValueError as exc: raise InvalidFileContents( self.content_type, self.content, str(exc) ) from exc return self._calendar def describe_delta(self, name, previous): try: lines = list( describe_calendar_delta( previous.calendar if previous else None, self.calendar ) ) except NotImplementedError: lines = [] if not lines: lines = super().describe_delta(name, previous) return lines def describe(self, name): try: subcomponents = self.calendar.subcomponents except InvalidFileContents: pass else: for component in subcomponents: try: return component["SUMMARY"] except KeyError: pass return super().describe(name) def get_uid(self): """Extract the UID from a VCalendar file. Args: cal: Calendar, possibly serialized. Returns: UID """ for component in self.calendar.subcomponents: try: return component["UID"] except KeyError: pass raise KeyError def _get_index(self, key: IndexKey) -> IndexValueIterator: todo = [(self.calendar, key.split("/"))] rest = [] c: Component while todo: (c, segments) = todo.pop(0) if segments and segments[0].startswith("C="): if c.name == segments[0][2:]: if len(segments) > 1 and segments[1].startswith("C="): todo.extend((comp, segments[1:]) for comp in c.subcomponents) else: rest.append((c, segments[1:])) for c, segments in rest: if not segments: yield True elif segments[0].startswith("P="): assert len(segments) == 1 try: p = c[segments[0][2:]] except KeyError: pass else: if p is not None: yield p.to_ical() else: raise AssertionError(f"segments: {segments!r}") def as_tz_aware_ts(dt, default_timezone: Union[str, timezone]) -> datetime: if not getattr(dt, "time", None): dt = datetime.combine(dt, time()) if dt.tzinfo is None: dt = dt.replace(tzinfo=default_timezone) assert dt.tzinfo return dt def rruleset_from_comp(comp: Component) -> dateutil.rrule.rruleset: dtstart = comp["DTSTART"].dt rrulestr = comp["RRULE"].to_ical().decode("utf-8") rrule = dateutil.rrule.rrulestr(rrulestr, dtstart=dtstart) rs = dateutil.rrule.rruleset() rs.rrule(rrule) # type: ignore if "EXDATE" in comp: for exdate in comp["EXDATE"]: rs.exdate(exdate) if "RDATE" in comp: for rdate in comp["RDATE"]: rs.rdate(rdate) if "EXRULE" in comp: exrulestr = comp["EXRULE"].to_ical().decode("utf-8") exrule = dateutil.rrule.rrulestr(exrulestr, dtstart=dtstart) rs.exrule(exrule) return rs def _expand_rrule_component( incomp: Component, start: datetime, end: datetime, existing: dict[str, Component] ) -> Iterable[Component]: if "RRULE" not in incomp: return rs = rruleset_from_comp(incomp) for field in ["RRULE", "EXRULE", "UNTIL", "RDATE", "EXDATE"]: if field in incomp: del incomp[field] # Work our magic for ts in rs.between(start, end): utcts = asutc(ts) try: outcomp = existing.pop(utcts) outcomp["DTSTART"] = vDatetime(asutc(outcomp["DTSTART"].dt)) except KeyError: outcomp = incomp.copy() outcomp["DTSTART"] = vDatetime(utcts) outcomp["RECURRENCE-ID"] = vDatetime(utcts) yield outcomp def expand_calendar_rrule(incal: Calendar, start: datetime, end: datetime) -> Calendar: outcal = Calendar() if incal.name != "VCALENDAR": raise AssertionError(f"called on file with root component {incal.name}") for field in incal: outcal[field] = incal[field] known = {} for insub in incal.subcomponents: if "RECURRENCE-ID" in insub: ts = insub["RECURRENCE-ID"].dt utcts = asutc(ts) known[utcts] = insub for insub in incal.subcomponents: if insub.name == "VTIMEZONE": continue if "RECURRENCE-ID" in insub: continue if "RRULE" in insub: for outsub in _expand_rrule_component(insub, start, end, known): outcal.add_component(outsub) else: outcal.add_component(insub) return outcal def asutc(dt): return dt.astimezone(ZoneInfo('UTC')).replace(tzinfo=None) xandikos-0.2.12/xandikos/infit.py000066400000000000000000000044321470075263100167270ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Inf-It properties.""" from xandikos import carddav, webdav class SettingsProperty(webdav.Property): """settings propety. JSON settings. """ name = "{http://inf-it.com/ns/dav/}settings" resource_type = webdav.PRINCIPAL_RESOURCE_TYPE live = False async def get_value(self, href: str, resource, el, environ): el.text = resource.get_infit_settings() async def set_value(self, href: str, resource, el): resource.set_infit_settings(el.text) class AddressbookColorProperty(webdav.Property): """Provides the addressbook-color property. Contains a RRGGBB code, similar to calendar-color. """ name = "{http://inf-it.com/ns/ab/}addressbook-color" resource_type = carddav.ADDRESSBOOK_RESOURCE_TYPE in_allprops = False async def get_value(self, href, resource, el, environ): el.text = resource.get_addressbook_color() async def set_value(self, href, resource, el): resource.set_addressbook_color(el.text) class HeaderValueProperty(webdav.Property): """Provides the header-value property. This behaves similar to the hrefLabel setting in caldavzap/carddavmate. """ name = "{http://inf-it.com/ns/dav/}headervalue" resource_type = webdav.COLLECTION_RESOURCE_TYPE in_allprops = False live = False async def get_value(self, href, resource, el, environ): el.text = resource.get_headervalue() async def set_value(self, href, resource, el): # TODO raise NotImplementedError xandikos-0.2.12/xandikos/py.typed000066400000000000000000000000001470075263100167260ustar00rootroot00000000000000xandikos-0.2.12/xandikos/quota.py000066400000000000000000000027671470075263100167600ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Quota and Size properties. See https://tools.ietf.org/html/rfc4331 """ from xandikos import webdav FEATURE: str = "quota" class QuotaAvailableBytesProperty(webdav.Property): """quota-available-bytes.""" name = "{DAV:}quota-available-bytes" resource_type = None in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = resource.get_quota_available_bytes() class QuotaUsedBytesProperty(webdav.Property): """quota-used-bytes.""" name = "{DAV:}quota-used-bytes" resource_type = None in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = resource.get_quota_used_bytes() xandikos-0.2.12/xandikos/scheduling.py000066400000000000000000000170121470075263100177410ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Scheduling. See https://tools.ietf.org/html/rfc6638 """ from xandikos import caldav, webdav SCHEDULE_INBOX_RESOURCE_TYPE = "{%s}schedule-inbox" % caldav.NAMESPACE SCHEDULE_OUTBOX_RESOURCE_TYPE = "{%s}schedule-outbox" % caldav.NAMESPACE # Feature to advertise to indicate scheduling support. FEATURE = "calendar-auto-schedule" CALENDAR_USER_TYPE_INDIVIDUAL = "INDIVIDUAL" # An individual CALENDAR_USER_TYPE_GROUP = "GROUP" # A group of individuals CALENDAR_USER_TYPE_RESOURCE = "RESOURCE" # A physical resource CALENDAR_USER_TYPE_ROOM = "ROOM" # A room resource CALENDAR_USER_TYPE_UNKNOWN = "UNKNOWN" # Otherwise not known CALENDAR_USER_TYPES = ( CALENDAR_USER_TYPE_INDIVIDUAL, CALENDAR_USER_TYPE_GROUP, CALENDAR_USER_TYPE_RESOURCE, CALENDAR_USER_TYPE_ROOM, CALENDAR_USER_TYPE_UNKNOWN, ) class ScheduleInbox(webdav.Collection): resource_types = webdav.Collection.resource_types + [SCHEDULE_INBOX_RESOURCE_TYPE] def get_calendar_user_type(self): # Default, per section 2.4.2 return CALENDAR_USER_TYPE_INDIVIDUAL def get_calendar_timezone(self): """Return calendar timezone. This should be an iCalendar object with exactly one VTIMEZONE component. """ raise NotImplementedError(self.get_calendar_timezone) def set_calendar_timezone(self): """Set calendar timezone. This should be an iCalendar object with exactly one VTIMEZONE component. """ raise NotImplementedError(self.set_calendar_timezone) def get_supported_calendar_components(self): """Return set of supported calendar components in this calendar. Returns: iterable over component names """ raise NotImplementedError(self.get_supported_calendar_components) def get_supported_calendar_data_types(self): """Return supported calendar data types. Returns: iterable over (content_type, version) tuples """ raise NotImplementedError(self.get_supported_calendar_data_types) def get_min_date_time(self): """Return minimum datetime property.""" raise NotImplementedError(self.get_min_date_time) def get_max_date_time(self): """Return maximum datetime property.""" raise NotImplementedError(self.get_max_date_time) def get_max_instances(self): """Return maximum number of instances.""" raise NotImplementedError(self.get_max_instances) def get_max_attendees_per_instance(self): """Return maximum number of attendees per instance.""" raise NotImplementedError(self.get_max_attendees_per_instance) def get_max_resource_size(self): """Return max resource size.""" raise NotImplementedError(self.get_max_resource_size) def get_schedule_default_calendar_url(self): """Return default calendar URL. None indicates there is no default URL. """ return None class ScheduleOutbox(webdav.Collection): resource_types = webdav.Collection.resource_types + [SCHEDULE_OUTBOX_RESOURCE_TYPE] def get_supported_calendar_components(self): """Return set of supported calendar components in this calendar. Returns: iterable over component names """ raise NotImplementedError(self.get_supported_calendar_components) def get_supported_calendar_data_types(self): """Return supported calendar data types. Returns: iterable over (content_type, version) tuples """ raise NotImplementedError(self.get_supported_calendar_data_types) def get_max_resource_size(self): """Return max resource size.""" raise NotImplementedError(self.get_max_resource_size) def get_min_date_time(self): """Return minimum datetime property.""" raise NotImplementedError(self.get_min_date_time) def get_max_date_time(self): """Return maximum datetime property.""" raise NotImplementedError(self.get_max_date_time) def get_max_attendees_per_instance(self): """Return maximum number of attendees per instance.""" raise NotImplementedError(self.get_max_attendees_per_instance) class ScheduleInboxURLProperty(webdav.Property): """Schedule-inbox-URL property. See https://tools.ietf.org/html/rfc6638, section 2.2 """ name = "{%s}schedule-inbox-URL" % caldav.NAMESPACE resource_type = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = True async def get_value(self, href, resource, el, environ): el.append(webdav.create_href(resource.get_schedule_inbox_url(), href)) class ScheduleOutboxURLProperty(webdav.Property): """Schedule-outbox-URL property. See https://tools.ietf.org/html/rfc6638, section 2.1 """ name = "{%s}schedule-outbox-URL" % caldav.NAMESPACE resource_type = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = True async def get_value(self, href, resource, el, environ): el.append(webdav.create_href(resource.get_schedule_outbox_url(), href)) class CalendarUserAddressSetProperty(webdav.Property): """calendar-user-address-set property. See https://tools.ietf.org/html/rfc6638, section 2.4.1 """ name = "{%s}calendar-user-address-set" % caldav.NAMESPACE resource_type = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = False async def get_value(self, base_href, resource, el, environ): for href in resource.get_calendar_user_address_set(): el.append(webdav.create_href(href, base_href)) class ScheduleTagProperty(webdav.Property): """schedule-tag property. See https://tools.ietf.org/html/rfc6638, section 3.2.10 """ name = "{%s}schedule-tag" % caldav.NAMESPACE in_allprops = False def supported_on(self, resource): return resource.get_content_type() == "text/calendar" async def get_value(self, base_href, resource, el, environ): el.text = resource.get_schedule_tag() class CalendarUserTypeProperty(webdav.Property): """calendar-user-type property. See https://tools.ietf.org/html/rfc6638, section 2.4.2 """ name = "{%s}calendar-user-type" % caldav.NAMESPACE resource_type = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = False async def get_value(self, href, resource, el, environ): el.text = resource.get_calendar_user_type() class ScheduleDefaultCalendarURLProperty(webdav.Property): """schedule-default-calendar-URL property. See https://tools.ietf.org/html/rfc6638, section-9.2 """ name = "{%s}schedule-default-calendar-URL" % caldav.NAMESPACE resource_type = SCHEDULE_INBOX_RESOURCE_TYPE in_allprops = True async def get_value(self, href, resource, el, environ): url = resource.get_schedule_default_calendar_url() if url is not None: el.append(webdav.create_href(url, href)) xandikos-0.2.12/xandikos/server_info.py000066400000000000000000000044221470075263100201360ustar00rootroot00000000000000# Xandikos # Copyright (C) 2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Server info. See https://www.ietf.org/archive/id/draft-douglass-server-info-03.txt """ import hashlib from typing import List from xandikos import version_string, webdav ET = webdav.ET # Feature to advertise server-info support. FEATURE = "server-info" SERVER_INFO_MIME_TYPE = "application/server-info+xml" class ServerInfo: """Server info.""" def __init__(self) -> None: self._token = None self._features: List[str] = [] self._applications: List[str] = [] def add_feature(self, feature): self._features.append(feature) self._token = None @property def token(self): if self._token is None: h = hashlib.sha1() h.update(version_string.encode("utf-8")) for z in self._features + self._applications: h.update(z.encode("utf-8")) self._token = h.hexdigest() return self._token async def get_body(self): el = ET.Element("{DAV:}server-info") el.set("token", self.token) server_el = ET.SubElement(el, "server-instance-info") ET.SubElement(server_el, "name").text = "Xandikos" ET.SubElement(server_el, "version").text = version_string features_el = ET.SubElement(el, "features") for feature in self._features: features_el.append(feature) applications_el = ET.SubElement(el, "applications") for application in self.applications: applications_el.append(application) return el xandikos-0.2.12/xandikos/store/000077500000000000000000000000001470075263100163755ustar00rootroot00000000000000xandikos-0.2.12/xandikos/store/__init__.py000066400000000000000000000416721470075263100205200ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Stores and store sets. ETags (https://en.wikipedia.org/wiki/HTTP_ETag) used in this file are always strong, and should be returned without wrapping quotes. """ import logging import mimetypes from collections.abc import Iterable, Iterator from typing import Optional from .index import AutoIndexManager, IndexDict, IndexKey, IndexValueIterator STORE_TYPE_ADDRESSBOOK = "addressbook" STORE_TYPE_CALENDAR = "calendar" STORE_TYPE_PRINCIPAL = "principal" STORE_TYPE_SCHEDULE_INBOX = "schedule-inbox" STORE_TYPE_SCHEDULE_OUTBOX = "schedule-outbox" STORE_TYPE_SUBSCRIPTION = "subscription" STORE_TYPE_OTHER = "other" VALID_STORE_TYPES = ( STORE_TYPE_ADDRESSBOOK, STORE_TYPE_CALENDAR, STORE_TYPE_PRINCIPAL, STORE_TYPE_SCHEDULE_INBOX, STORE_TYPE_SCHEDULE_OUTBOX, STORE_TYPE_SUBSCRIPTION, STORE_TYPE_OTHER, ) MIMETYPES = mimetypes.MimeTypes() MIMETYPES.add_type("text/calendar", ".ics") # type: ignore MIMETYPES.add_type("text/vcard", ".vcf") # type: ignore DEFAULT_MIME_TYPE = "application/octet-stream" class InvalidCTag(Exception): """The request CTag can not be retrieved.""" def __init__(self, ctag) -> None: self.ctag = ctag class File: """A file type handler.""" content: Iterable[bytes] content_type: str def __init__(self, content: Iterable[bytes], content_type: str) -> None: self.content = content self.content_type = content_type def validate(self) -> None: """Verify that file contents are valid. :raise InvalidFileContents: Raised if a file is not valid """ def normalized(self) -> Iterable[bytes]: """Return a normalized version of the file.""" return self.content def describe(self, name: str) -> str: """Describe the contents of this file. Used in e.g. commit messages. """ return name def get_uid(self) -> str: """Return UID. :raise NotImplementedError: If UIDs aren't supported for this format :raise KeyError: If there is no UID set on this file :raise InvalidFileContents: If the file is misformatted Returns: UID """ raise NotImplementedError(self.get_uid) def describe_delta(self, name: str, previous: Optional["File"]) -> Iterator[str]: """Describe the important difference between this and previous one. Args: name: File name previous: Previous file to compare to. Raises: InvalidFileContents: If the file is misformatted Returns: List of strings describing change """ assert name is not None item_description = self.describe(name) assert item_description is not None if previous is None: yield "Added " + item_description else: yield "Modified " + item_description def _get_index(self, key: IndexKey) -> IndexValueIterator: """Obtain an index for this file. Args: key: Index key Returns: iterator over index values """ raise NotImplementedError(self._get_index) def get_indexes(self, keys: Iterable[IndexKey]) -> IndexDict: """Obtain indexes for this file. Args: keys: Iterable of index keys Returns: Dictionary mapping key names to values """ ret = {} for k in keys: ret[k] = list(self._get_index(k)) return ret class Filter: """A filter that can be used to query for certain resources. Filters are often resource-type specific. """ content_type: str def check(self, name: str, resource: File) -> bool: """Check if this filter applies to a resource. Args: name: Name of the resource resource: File object Returns: boolean """ raise NotImplementedError(self.check) def index_keys(self) -> list[IndexKey]: """Returns a list of indexes that could be used to apply this filter. Returns: AND-list of OR-options """ raise NotImplementedError(self.index_keys) def check_from_indexes(self, name: str, indexes: IndexDict) -> bool: """Check from a set of indexes whether a resource matches. Args: name: Name of the resource indexes: Dictionary mapping index names to values Returns: boolean """ raise NotImplementedError(self.check_from_indexes) def open_by_content_type( content: Iterable[bytes], content_type: str, extra_file_handlers ) -> File: """Open a file based on content type. Args: content: list of bytestrings with content content_type: MIME type Returns: File instance """ return extra_file_handlers.get(content_type.split(";")[0], File)( content, content_type ) def open_by_extension( content: Iterable[bytes], name: str, extra_file_handlers: dict[str, type[File]], ) -> File: """Open a file based on the filename extension. Args: content: list of bytestrings with content name: Name of file to open Returns: File instance """ (mime_type, _) = MIMETYPES.guess_type(name) if mime_type is None: mime_type = DEFAULT_MIME_TYPE return open_by_content_type( content, mime_type, extra_file_handlers=extra_file_handlers ) class DuplicateUidError(Exception): """UID already exists in store.""" def __init__(self, uid: str, existing_name: str, new_name: str) -> None: self.uid = uid self.existing_name = existing_name self.new_name = new_name class NoSuchItem(Exception): """No such item.""" def __init__(self, name: str) -> None: self.name = name class InvalidETag(Exception): """Unexpected value for etag.""" def __init__(self, name: str, expected_etag: str, got_etag: str) -> None: self.name = name self.expected_etag = expected_etag self.got_etag = got_etag class NotStoreError(Exception): """Not a store.""" def __init__(self, path: str) -> None: self.path = path class InvalidFileContents(Exception): """Invalid file contents.""" def __init__(self, content_type: str, data, error) -> None: self.content_type = content_type self.data = data self.error = error class OutOfSpaceError(Exception): """Out of disk space.""" def __init__(self) -> None: pass class LockedError(Exception): """File or store being accessed is locked.""" def __init__(self, path: str) -> None: self.path = path class Store: """A object store.""" extra_file_handlers: dict[str, type[File]] def __init__( self, index, *, double_check_indexes: bool = False, index_threshold: Optional[int] = None, ) -> None: self.extra_file_handlers = {} self.index = index self.index_manager = AutoIndexManager(self.index, threshold=index_threshold) self.double_check_indexes = double_check_indexes def load_extra_file_handler(self, file_handler: type[File]) -> None: self.extra_file_handlers[file_handler.content_type] = file_handler def iter_with_etag( self, ctag: Optional[str] = None ) -> Iterator[tuple[str, str, str]]: """Iterate over all items in the store with etag. Args: ctag: Possible ctag to iterate for Returns: iterator over (name, content_type, etag) tuples """ raise NotImplementedError(self.iter_with_etag) def iter_with_filter(self, filter: Filter) -> Iterator[tuple[str, File, str]]: """Iterate over all items in the store that match a particular filter. Args: filter: Filter to apply Returns: iterator over (name, file, etag) tuples """ if self.index_manager is not None: try: necessary_keys = filter.index_keys() except NotImplementedError: pass else: present_keys = self.index_manager.find_present_keys(necessary_keys) if present_keys is not None: return self._iter_with_filter_indexes(filter, present_keys) return self._iter_with_filter_naive(filter) def _iter_with_filter_naive( self, filter: Filter ) -> Iterator[tuple[str, File, str]]: for name, content_type, etag in self.iter_with_etag(): if not filter.content_type == content_type: continue file = self.get_file(name, content_type, etag) try: if filter.check(name, file): yield (name, file, etag) except InvalidFileContents: logging.warning("Unable to parse file %s, skipping.", name) def _iter_with_filter_indexes( self, filter: Filter, keys ) -> Iterator[tuple[str, File, str]]: for name, content_type, etag in self.iter_with_etag(): if not filter.content_type == content_type: continue try: file_values = self.index.get_values(name, etag, keys) except KeyError: # Index values not yet present for this file. file = self.get_file(name, content_type, etag) try: file_values = file.get_indexes(self.index.available_keys()) except InvalidFileContents: logging.warning( "Unable to parse file %s for indexing, skipping.", name ) file_values = {} self.index.add_values(name, etag, file_values) if filter.check_from_indexes(name, file_values): yield (name, file, etag) else: if file_values is None: continue file = self.get_file(name, content_type, etag) if self.double_check_indexes: if file_values != file.get_indexes(keys): raise AssertionError( f"{file_values!r} != {file.get_indexes(keys)!r}" ) if filter.check_from_indexes(name, file_values) != filter.check( name, file ): raise AssertionError( f"index based filter {filter} " f"(values: {file_values}) not matching " "real file filter" ) if filter.check_from_indexes(name, file_values): file = self.get_file(name, content_type, etag) yield (name, file, etag) def get_file( self, name: str, content_type: Optional[str] = None, etag: Optional[str] = None, ) -> File: """Get the contents of an object. Returns: A File object """ if content_type is None: return open_by_extension( self._get_raw(name, etag), name, extra_file_handlers=self.extra_file_handlers, ) else: return open_by_content_type( self._get_raw(name, etag), content_type, extra_file_handlers=self.extra_file_handlers, ) def _get_raw(self, name: str, etag: Optional[str] = None) -> Iterable[bytes]: """Get the raw contents of an object. Args: name: Filename etag: Optional etag to return Returns: raw contents """ raise NotImplementedError(self._get_raw) def get_ctag(self) -> str: """Return the ctag for this store.""" raise NotImplementedError(self.get_ctag) def import_one( self, name: str, content_type: str, data: Iterable[bytes], message: Optional[str] = None, author: Optional[str] = None, replace_etag: Optional[str] = None, ) -> tuple[str, str]: """Import a single object. Args: name: Name of the object content_type: Content type of the object data: serialized object as list of bytes message: Commit message author: Optional author replace_etag: Etag to replace Raise: NameExists: when the name already exists DuplicateUidError: when the uid already exists Returns: (name, etag) """ raise NotImplementedError(self.import_one) def delete_one( self, name: str, message: Optional[str] = None, author: Optional[str] = None, etag: Optional[str] = None, ) -> None: """Delete an item. Args: name: Filename to delete message: Commit message author: Optional author etag: Optional mandatory etag of object to remove Raises: NoSuchItem: when the item doesn't exist InvalidETag: If the specified ETag doesn't match the current """ raise NotImplementedError(self.delete_one) def set_type(self, store_type: str) -> None: """Set store type. Args: store_type: New store type (one of VALID_STORE_TYPES) """ raise NotImplementedError(self.set_type) def get_type(self) -> str: """Get type of this store. Returns: one of VALID_STORE_TYPES """ ret = STORE_TYPE_OTHER for name, content_type, etag in self.iter_with_etag(): if content_type == "text/calendar": ret = STORE_TYPE_CALENDAR elif content_type == "text/vcard": ret = STORE_TYPE_ADDRESSBOOK return ret def set_description(self, description: str) -> None: """Set the extended description of this store. Args: description: String with description """ raise NotImplementedError(self.set_description) def get_description(self) -> str: """Get the extended description of this store.""" raise NotImplementedError(self.get_description) def get_displayname(self) -> str: """Get the display name of this store.""" raise NotImplementedError(self.get_displayname) def set_displayname(self, displayname: str) -> None: """Set the display name of this store.""" raise NotImplementedError(self.set_displayname) def get_color(self) -> str: """Get the color code for this store.""" raise NotImplementedError(self.get_color) def set_color(self, color: str) -> None: """Set the color code for this store.""" raise NotImplementedError(self.set_color) def iter_changes( self, old_ctag: str, new_ctag: str ) -> Iterator[tuple[str, str, str, str]]: """Get changes between two versions of this store. Args: old_ctag: Old ctag (None for empty Store) new_ctag: New ctag Returns: Iterator over (name, content_type, old_etag, new_etag) """ raise NotImplementedError(self.iter_changes) def get_comment(self) -> str: """Retrieve store comment. Returns: Comment """ raise NotImplementedError(self.get_comment) def set_comment(self, comment: str) -> None: """Set comment. Args: comment: New comment to set """ raise NotImplementedError(self.set_comment) def destroy(self) -> None: """Destroy this store.""" raise NotImplementedError(self.destroy) def subdirectories(self) -> Iterator[str]: """Returns subdirectories to probe for other stores. Returns: List of names """ raise NotImplementedError(self.subdirectories) def get_source_url(self) -> str: """Return source URL, if this is a subscription.""" raise NotImplementedError(self.get_source_url) def set_source_url(self, url: str) -> None: """Set the source URL.""" raise NotImplementedError(self.set_source_url) def open_store(location: str) -> Store: """Open store from a location string. Args: location: Location string to open Returns: A `Store` """ # For now, just support opening git stores from .git import GitStore return GitStore.open_from_path(location) xandikos-0.2.12/xandikos/store/config.py000066400000000000000000000113231470075263100202140ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Collection configuration file.""" import configparser FILENAME = ".xandikos" class CollectionMetadata: """Metadata for a configuration.""" def get_color(self) -> str: """Get the color for this collection.""" raise NotImplementedError(self.get_color) def set_color(self, color: str) -> None: """Change the color of this collection.""" raise NotImplementedError(self.set_color) def get_source_url(self) -> str: """Get the source URL for this collection.""" raise NotImplementedError(self.get_source_url) def set_source_url(self, url: str) -> None: """Set the source URL for this collection.""" raise NotImplementedError(self.set_source_url) def get_comment(self) -> str: raise NotImplementedError(self.get_comment) def get_displayname(self) -> str: raise NotImplementedError(self.get_displayname) def get_description(self) -> str: raise NotImplementedError(self.get_description) def get_order(self) -> str: raise NotImplementedError(self.get_order) def set_order(self, order: str) -> None: raise NotImplementedError(self.set_order) class FileBasedCollectionMetadata(CollectionMetadata): """Metadata for a configuration.""" def __init__(self, cp=None, save=None) -> None: if cp is None: cp = configparser.ConfigParser() self._configparser = cp self._save_cb = save def _save(self, message): if self._save_cb is None: return self._save_cb(self._configparser, message) @classmethod def from_file(cls, f): cp = configparser.ConfigParser() cp.read_file(f) return cls(cp) def get_source_url(self): return self._configparser["DEFAULT"]["source"] def set_source_url(self, url): if url is not None: self._configparser["DEFAULT"]["source"] = url else: del self._configparser["DEFAULT"]["source"] self._save("Set source URL.") def get_color(self): return self._configparser["DEFAULT"]["color"] def get_comment(self): return self._configparser["DEFAULT"]["comment"] def get_displayname(self): return self._configparser["DEFAULT"]["displayname"] def get_description(self): return self._configparser["DEFAULT"]["description"] def set_color(self, color): if color is not None: self._configparser["DEFAULT"]["color"] = color else: del self._configparser["DEFAULT"]["color"] self._save("Set color.") def set_displayname(self, displayname): if displayname is not None: self._configparser["DEFAULT"]["displayname"] = displayname else: del self._configparser["DEFAULT"]["displayname"] self._save("Set display name.") def set_description(self, description): if description is not None: self._configparser["DEFAULT"]["description"] = description else: del self._configparser["DEFAULT"]["description"] self._save("Set description.") def set_comment(self, comment): if comment is not None: self._configparser["DEFAULT"]["comment"] = comment else: del self._configparser["DEFAULT"]["comment"] self._save("Set comment.") def set_type(self, store_type): self._configparser["DEFAULT"]["type"] = store_type self._save("Set collection type.") def get_type(self): return self._configparser["DEFAULT"]["type"] def get_order(self): return self._configparser["calendar"]["order"] def set_order(self, order): try: self._configparser.add_section("calendar") except configparser.DuplicateSectionError: pass if order is None: del self._configparser["calendar"]["order"] else: self._configparser["calendar"]["order"] = order xandikos-0.2.12/xandikos/store/git.py000066400000000000000000000631561470075263100175450ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Git store.""" import configparser import errno import logging import os import shutil import stat import uuid from io import BytesIO, StringIO from typing import Optional, Iterable import dulwich.repo from dulwich.file import FileLocked, GitFile from dulwich.index import Index, index_entry_from_stat, write_index_dict from dulwich.objects import Blob, Tree from dulwich.pack import SHA1Writer from . import ( DEFAULT_MIME_TYPE, MIMETYPES, VALID_STORE_TYPES, DuplicateUidError, InvalidCTag, InvalidETag, InvalidFileContents, LockedError, NoSuchItem, NotStoreError, OutOfSpaceError, Store, open_by_content_type, open_by_extension, ) from .config import FILENAME as CONFIG_FILENAME from .config import CollectionMetadata, FileBasedCollectionMetadata from .index import MemoryIndex DEFAULT_ENCODING = "utf-8" logger = logging.getLogger(__name__) class RepoCollectionMetadata(CollectionMetadata): def __init__(self, repo) -> None: self._repo = repo @classmethod def present(cls, repo): config = repo.get_config() return config.has_section((b"xandikos",)) def get_source_url(self): config = self._repo.get_config() url = config.get(b"xandikos", b"source") if not url: raise KeyError return url.decode(DEFAULT_ENCODING) def set_source_url(self, url): config = self._repo.get_config() if url is not None: config.set(b"xandikos", b"source", url.encode(DEFAULT_ENCODING)) else: # TODO(jelmer): Add and use config.remove() config.set(b"xandikos", b"source", b"") self._write_config(config) def get_color(self): config = self._repo.get_config() color = config.get(b"xandikos", b"color") if color == b"": raise KeyError return color.decode(DEFAULT_ENCODING) def set_color(self, color): config = self._repo.get_config() if color is not None: config.set(b"xandikos", b"color", color.encode(DEFAULT_ENCODING)) else: # TODO(jelmer): Add and use config.remove() config.set(b"xandikos", b"color", b"") self._write_config(config) def _write_config(self, config): f = BytesIO() config.write_to_file(f) self._repo._put_named_file("config", f.getvalue()) def get_displayname(self): config = self._repo.get_config() displayname = config.get(b"xandikos", b"displayname") if displayname == b"": raise KeyError return displayname.decode(DEFAULT_ENCODING) def set_displayname(self, displayname): config = self._repo.get_config() if displayname is not None: config.set( b"xandikos", b"displayname", displayname.encode(DEFAULT_ENCODING), ) else: config.set(b"xandikos", b"displayname", b"") self._write_config(config) def get_description(self): desc = self._repo.get_description() if desc in (None, b""): raise KeyError return desc.decode(DEFAULT_ENCODING) def set_description(self, description): if description is not None: self._repo.set_description(description.encode(DEFAULT_ENCODING)) else: self._repo.set_description(b"") def get_comment(self): config = self._repo.get_config() comment = config.get(b"xandikos", b"comment") if comment == b"": raise KeyError return comment.decode(DEFAULT_ENCODING) def set_comment(self, comment): config = self._repo.get_config() if comment is not None: config.set(b"xandikos", b"comment", comment.encode(DEFAULT_ENCODING)) else: # TODO(jelmer): Add and use config.remove() config.set(b"xandikos", b"comment", b"") self._write_config(config) def set_type(self, store_type): config = self._repo.get_config() config.set(b"xandikos", b"type", store_type.encode(DEFAULT_ENCODING)) self._write_config(config) def get_type(self): config = self._repo.get_config() store_type = config.get(b"xandikos", b"type") store_type = store_type.decode(DEFAULT_ENCODING) if store_type not in VALID_STORE_TYPES: logging.warning("Invalid store type %s set for %r.", store_type, self._repo) return store_type def get_order(self): config = self._repo.get_config() order = config.get(b"xandikos", b"calendar-order") if order == b"": raise KeyError return order.decode("utf-8") def set_order(self, order): config = self._repo.get_config() if order is None: order = "" config.set(b"xandikos", b"calendar-order", order.encode("utf-8")) self._write_config(config) class locked_index: def __init__(self, path) -> None: self._path = path def __enter__(self): self._file = GitFile(self._path, "wb") self._index = Index(self._path) return self._index def __exit__(self, exc_type, exc_value, traceback): if exc_type is not None: self._file.abort() return try: f = SHA1Writer(self._file) write_index_dict(f, self._index._byname) except BaseException: self._file.abort() else: f.close() class GitStore(Store): """A Store backed by a Git Repository.""" def __init__( self, repo, *, ref: bytes = b"HEAD", check_for_duplicate_uids=True, **kwargs, ) -> None: super().__init__(MemoryIndex(), **kwargs) self.ref = repo.refs.follow(ref)[0][-1] self.repo = repo # Maps uids to (sha, fname) self._uid_to_fname: dict[str, tuple[bytes, str]] = {} self._check_for_duplicate_uids = check_for_duplicate_uids # Set of blob ids that have already been scanned self._fname_to_uid: dict[str, tuple[str, str]] = {} def _get_etag(self, name: str) -> str: raise NotImplementedError(self._get_etag) def _import_one( self, name: str, data: Iterable[bytes], message: str, author: Optional[str] = None, ): raise NotImplementedError(self._import_one) @property def config(self): if RepoCollectionMetadata.present(self.repo): return RepoCollectionMetadata(self.repo) else: cp = configparser.ConfigParser() try: cf = self._get_raw(CONFIG_FILENAME) except KeyError: pass else: if cf is not None: cp.read_string(b"".join(cf).decode("utf-8")) def save_config(cp, message): f = StringIO() cp.write(f) self._import_one( CONFIG_FILENAME, [f.getvalue().encode("utf-8")], message ) return FileBasedCollectionMetadata(cp, save=save_config) def __repr__(self) -> str: return f"{type(self).__name__}({self.repo!r}, ref={self.ref!r})" @property def path(self): return self.repo.path def _check_duplicate(self, uid, name, replace_etag): if uid is not None and self._check_for_duplicate_uids: self._scan_uids() try: (existing_name, _) = self._uid_to_fname[uid] except KeyError: pass else: if existing_name != name: raise DuplicateUidError(uid, existing_name, name) try: etag = self._get_etag(name) except KeyError: etag = None if replace_etag is not None and etag != replace_etag: raise InvalidETag(name, etag, replace_etag) return etag def import_one( self, name: str, content_type: str, data: Iterable[bytes], message: Optional[str] = None, author: Optional[str] = None, replace_etag: Optional[str] = None, ) -> tuple[str, str]: """Import a single object. Args: name: name of the object content_type: Content type data: serialized object as list of bytes message: Commit message author: Optional author replace_etag: optional etag of object to replace Raises: InvalidETag: when the name already exists but with different etag DuplicateUidError: when the uid already exists Returns: etag """ if content_type is None: fi = open_by_extension(data, name, self.extra_file_handlers) else: fi = open_by_content_type(data, content_type, self.extra_file_handlers) if name is None: name = str(uuid.uuid4()) extension = MIMETYPES.guess_extension(content_type) if extension is not None: name += extension fi.validate() try: uid = fi.get_uid() except (KeyError, NotImplementedError): uid = None self._check_duplicate(uid, name, replace_etag) if message is None: try: old_fi = self.get_file(name, content_type, replace_etag) except KeyError: old_fi = None message = "\n".join(fi.describe_delta(name, old_fi)) etag = self._import_one(name, fi.normalized(), message, author=author) return (name, etag.decode("ascii")) def _get_raw(self, name, etag=None): """Get the raw contents of an object. Args: name: Name of the item etag: Optional etag Returns: raw contents as chunks """ if etag is None: etag = self._get_etag(name) blob = self.repo.object_store[etag.encode("ascii")] return blob.chunked def _scan_uids(self): removed = set(self._fname_to_uid.keys()) for name, mode, sha in self._iterblobs(): etag = sha.decode("ascii") if name in removed: removed.remove(name) if name in self._fname_to_uid and self._fname_to_uid[name][0] == etag: continue blob = self.repo.object_store[sha] fi = open_by_extension(blob.chunked, name, self.extra_file_handlers) try: uid = fi.get_uid() except KeyError: logger.warning("No UID found in file %s", name) uid = None except InvalidFileContents: logging.warning("Unable to parse file %s", name) uid = None except NotImplementedError: # This file type doesn't support UIDs uid = None self._fname_to_uid[name] = (etag, uid) if uid is not None: self._uid_to_fname[uid] = (name, etag) for name in removed: (unused_etag, uid) = self._fname_to_uid[name] if uid is not None: del self._uid_to_fname[uid] del self._fname_to_uid[name] def _iterblobs(self, ctag=None): raise NotImplementedError(self._iterblobs) def iter_with_etag(self, ctag=None): """Iterate over all items in the store with etag. Args: ctag: Ctag to iterate for Returns: iterator over (name, content_type, etag) tuples """ for name, mode, sha in self._iterblobs(ctag): (mime_type, _) = MIMETYPES.guess_type(name) if mime_type is None: mime_type = DEFAULT_MIME_TYPE yield (name, mime_type, sha.decode("ascii")) @classmethod def create(cls, path): """Create a new store backed by a Git repository on disk. Returns: A `GitStore` """ raise NotImplementedError(cls.create) @classmethod def open_from_path(cls, path, **kwargs): """Open a GitStore from a path. Args: path: Path Returns: A `GitStore` """ try: return cls.open(dulwich.repo.Repo(path), **kwargs) except dulwich.repo.NotGitRepository: raise NotStoreError(path) @classmethod def open(cls, repo, **kwargs): """Open a GitStore given a Repo object. Args: repo: A Dulwich `Repo` Returns: A `GitStore` """ if repo.has_index(): return TreeGitStore(repo, **kwargs) else: return BareGitStore(repo, **kwargs) def get_description(self): """Get extended description. Returns: repository description as string """ try: return self.config.get_description() except KeyError: return None def set_description(self, description): """Set extended description. Args: description: repository description as string """ self.config.set_description(description) def set_comment(self, comment): """Set comment. Args: comment: Comment """ self.config.set_comment(comment) def get_comment(self): """Get comment. Returns: Comment """ try: return self.config.get_comment() except KeyError: return None def get_color(self): """Get color. Returns: A Color code, or None """ try: return self.config.get_color() except KeyError: return None def set_color(self, color): """Set the color code for this store.""" self.config.set_color(color) def get_source_url(self): """Get source URL.""" try: return self.config.get_source_url() except KeyError: return None def set_source_url(self, url): """Set the source URL.""" self.config.set_source_url(url) def get_displayname(self): """Get display name. Returns: The display name, or None if not set """ try: return self.config.get_displayname() except KeyError: return None def set_displayname(self, displayname): """Set the display name. Args: displayname: New display name """ self.config.set_displayname(displayname) def set_type(self, store_type): """Set store type. Args: store_type: New store type (one of VALID_STORE_TYPES) """ self.config.set_type(store_type) def get_type(self): """Get store type. This looks in git config first, then falls back to guessing. """ try: return self.config.get_type() except KeyError: return super().get_type() def iter_changes(self, old_ctag, new_ctag): """Get changes between two versions of this store. Args: old_ctag: Old ctag (None for empty Store) new_ctag: New ctag Returns: Iterator over (name, content_type, old_etag, new_etag) """ if old_ctag is None: t = Tree() self.repo.object_store.add_object(t) old_ctag = t.id.decode("ascii") previous = { name: (content_type, etag) for (name, content_type, etag) in self.iter_with_etag(old_ctag) } for name, new_content_type, new_etag in self.iter_with_etag(new_ctag): try: (old_content_type, old_etag) = previous[name] except KeyError: old_etag = None else: assert old_content_type == new_content_type if old_etag != new_etag: yield (name, new_content_type, old_etag, new_etag) if old_etag is not None: del previous[name] for name, (old_content_type, old_etag) in previous.items(): yield (name, old_content_type, old_etag, None) def destroy(self): """Destroy this store.""" shutil.rmtree(self.path) class BareGitStore(GitStore): """A Store backed by a bare git repository.""" def _get_current_tree(self): try: ref_object = self.repo[self.ref] except KeyError: return Tree() if isinstance(ref_object, Tree): return ref_object else: return self.repo.object_store[ref_object.tree] def _get_etag(self, name): tree = self._get_current_tree() name = name.encode(DEFAULT_ENCODING) return tree[name][1].decode("ascii") def get_ctag(self): """Return the ctag for this store.""" return self._get_current_tree().id.decode("ascii") def _iterblobs(self, ctag=None): if ctag is None: tree = self._get_current_tree() else: try: tree = self.repo.object_store[ctag.encode("ascii")] except KeyError as exc: raise InvalidCTag(ctag) from exc for name, mode, sha in tree.iteritems(): name = name.decode(DEFAULT_ENCODING) if name == CONFIG_FILENAME: continue yield (name, mode, sha) @classmethod def create_memory(cls) -> "GitStore": """Create a new store backed by a memory repository. Returns: A `GitStore` """ return cls(dulwich.repo.MemoryRepo()) def _commit_tree(self, tree_id, message, author=None): return self.repo.do_commit( message=message, tree=tree_id, ref=self.ref, author=author ) def _import_one( self, name: str, data: Iterable[bytes], message: str, author: Optional[str] = None, ) -> bytes: """Import a single object. Args: name: Optional name of the object data: serialized object as bytes message: optional commit message author: optional author Returns: etag """ b = Blob() b.chunked = data tree = self._get_current_tree() old_tree_id = tree.id name_enc = name.encode(DEFAULT_ENCODING) tree[name_enc] = (0o644 | stat.S_IFREG, b.id) self.repo.object_store.add_objects([(tree, ""), (b, name_enc)]) if tree.id != old_tree_id: self._commit_tree(tree.id, message.encode(DEFAULT_ENCODING), author=author) return b.id def delete_one(self, name, message=None, author=None, etag=None): """Delete an item. Args: name: Filename to delete message; Commit message author: Optional author to store etag: Optional mandatory etag of object to remove Raises: NoSuchItem: when the item doesn't exist InvalidETag: If the specified ETag doesn't match the curren """ tree = self._get_current_tree() name_enc = name.encode(DEFAULT_ENCODING) try: current_sha = tree[name_enc][1] except KeyError as exc: raise NoSuchItem(name) from exc if etag is not None and current_sha != etag.encode("ascii"): raise InvalidETag(name, etag, current_sha.decode("ascii")) del tree[name_enc] self.repo.object_store.add_objects([(tree, "")]) if message is None: fi = open_by_extension( self.repo.object_store[current_sha].chunked, name, self.extra_file_handlers, ) message = "Delete " + fi.describe(name) self._commit_tree(tree.id, message.encode(DEFAULT_ENCODING), author=author) @classmethod def create(cls, path): """Create a new store backed by a Git repository on disk. Returns: A `GitStore` """ os.mkdir(path) return cls(dulwich.repo.Repo.init_bare(path)) def subdirectories(self): """Returns subdirectories to probe for other stores. Returns: List of names """ # Or perhaps just return all subdirectories but filter out # Git-owned ones? return [] class TreeGitStore(GitStore): """A Store that backs onto a treefull Git repository.""" @classmethod def create(cls, path, bare=True): """Create a new store backed by a Git repository on disk. Returns: A `GitStore` """ os.mkdir(path) return cls(dulwich.repo.Repo.init(path)) def _get_etag(self, name): index = self.repo.open_index() name = name.encode(DEFAULT_ENCODING) return index[name].sha.decode("ascii") def _commit_tree(self, index, message, author=None): tree = index.commit(self.repo.object_store) return self.repo.do_commit(message=message, author=author, tree=tree) def _import_one( self, name: str, data: Iterable[bytes], message: str, author: Optional[str] = None, ) -> bytes: """Import a single object. Args: name: name of the object data: serialized object as list of bytes message: Commit message author: Optional author Returns: etag """ try: with locked_index(self.repo.index_path()) as index: p = os.path.join(self.repo.path, name) with open(p, "wb") as f: f.writelines(data) st = os.lstat(p) blob = Blob.from_string(b"".join(data)) encoded_name = name.encode(DEFAULT_ENCODING) if encoded_name not in index or blob.id != index[encoded_name].sha: self.repo.object_store.add_object(blob) index[encoded_name] = index_entry_from_stat(st, blob.id) self._commit_tree( index, message.encode(DEFAULT_ENCODING), author=author ) return blob.id except FileLocked as exc: raise LockedError(name) from exc except OSError as exc: if exc.errno == errno.ENOSPC: raise OutOfSpaceError() from exc raise def delete_one(self, name, message=None, author=None, etag=None): """Delete an item. Args: name: Filename to delete message: Commit message author: Optional author etag: Optional mandatory etag of object to remove Raise: NoSuchItem: when the item doesn't exist InvalidETag: If the specified ETag doesn't match the curren """ p = os.path.join(self.repo.path, name) try: with open(p, "rb") as f: current_blob = Blob.from_string(f.read()) except FileNotFoundError as exc: raise NoSuchItem(name) from exc except IsADirectoryError as exc: raise NoSuchItem(name) from exc if message is None: fi = open_by_extension(current_blob.chunked, name, self.extra_file_handlers) message = "Delete " + fi.describe(name) if etag is not None: with open(p, "rb") as f: current_etag = current_blob.id if etag.encode("ascii") != current_etag: raise InvalidETag(name, etag, current_etag.decode("ascii")) try: with locked_index(self.repo.index_path()) as index: os.unlink(p) del index[name.encode(DEFAULT_ENCODING)] self._commit_tree( index, message.encode(DEFAULT_ENCODING), author=author ) except FileLocked: raise LockedError(name) def get_ctag(self): """Return the ctag for this store.""" index = self.repo.open_index() return index.commit(self.repo.object_store).decode("ascii") def _iterblobs(self, ctag=None): """Iterate over all items in the store with etag. :yield: (name, etag) tuples """ if ctag is not None: try: tree = self.repo.object_store[ctag.encode("ascii")] except KeyError as exc: raise InvalidCTag(ctag) from exc for name, mode, sha in tree.iteritems(): name = name.decode(DEFAULT_ENCODING) if name == CONFIG_FILENAME: continue yield (name, mode, sha) else: index = self.repo.open_index() for name, sha, mode in index.iterobjects(): name = name.decode(DEFAULT_ENCODING) if name == CONFIG_FILENAME: continue yield (name, mode, sha) def subdirectories(self): """Returns subdirectories to probe for other stores. Returns: List of names """ ret = [] for name in os.listdir(self.path): if name == dulwich.repo.CONTROLDIR: continue p = os.path.join(self.path, name) if os.path.isdir(p): ret.append(name) return ret xandikos-0.2.12/xandikos/store/index.py000066400000000000000000000100471470075263100200600ustar00rootroot00000000000000# Xandikos # Copyright (C) 2019 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Indexing.""" import collections import logging from collections.abc import Iterable, Iterator from typing import Optional, Union, Dict, Set IndexKey = str IndexValue = list[Union[bytes, bool]] IndexValueIterator = Iterator[Union[bytes, bool]] IndexDict = dict[IndexKey, IndexValue] DEFAULT_INDEXING_THRESHOLD = 5 class Index: """Index management.""" def available_keys(self) -> Iterable[IndexKey]: """Return list of available index keys.""" raise NotImplementedError(self.available_keys) def get_values(self, name: str, etag: str, keys: list[IndexKey]): """Get the values for specified keys for a name.""" raise NotImplementedError(self.get_values) def iter_etags(self) -> Iterator[str]: """Return all the etags covered by this index.""" raise NotImplementedError(self.iter_etags) class MemoryIndex(Index): def __init__(self) -> None: self._indexes: Dict[IndexKey, Dict[str, IndexValue]] = {} self._in_index: Set[str] = set() def available_keys(self): return self._indexes.keys() def get_values(self, name, etag, keys): if etag not in self._in_index: raise KeyError(etag) indexes = {} for k in keys: if k not in self._indexes: raise AssertionError try: indexes[k] = self._indexes[k][etag] except KeyError: indexes[k] = [] return indexes def iter_etags(self): return iter(self._in_index) def add_values(self, name, etag, values): for k, v in values.items(): if k not in self._indexes: raise AssertionError self._indexes[k][etag] = v self._in_index.add(etag) def reset(self, keys): self._in_index = set() self._indexes = {} for key in keys: self._indexes[key] = {} class AutoIndexManager: def __init__(self, index, threshold: Optional[int] = None) -> None: self.index = index self.desired: dict[IndexKey, int] = collections.defaultdict(lambda: 0) if threshold is None: threshold = DEFAULT_INDEXING_THRESHOLD self.indexing_threshold = threshold def find_present_keys( self, necessary_keys: Iterable[IndexKey] ) -> Optional[Iterable[IndexKey]]: available_keys = self.index.available_keys() needed_keys = [] missing_keys: list[IndexKey] = [] new_index_keys = set() for keys in necessary_keys: found = False for key in keys: if key in available_keys: needed_keys.append(key) found = True if not found: for key in keys: self.desired[key] += 1 if self.desired[key] > self.indexing_threshold: new_index_keys.add(key) missing_keys.extend(keys) if not missing_keys: return needed_keys if new_index_keys: logging.debug("Adding new index keys: %r", new_index_keys) self.index.reset(set(self.index.available_keys()) | new_index_keys) # TODO(jelmer): Maybe best to check if missing_keys are satisfiable # now? return None xandikos-0.2.12/xandikos/store/vdir.py000066400000000000000000000273051470075263100177220ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """vdir store. See https://github.com/pimutils/vdirsyncer/blob/master/docs/vdir.rst """ import configparser import hashlib import logging import os import shutil from typing import Dict import uuid from . import ( MIMETYPES, DuplicateUidError, InvalidETag, InvalidFileContents, NoSuchItem, Store, open_by_content_type, open_by_extension, ) from .config import FILENAME as CONFIG_FILENAME from .config import FileBasedCollectionMetadata from .index import MemoryIndex DEFAULT_ENCODING = "utf-8" logger = logging.getLogger(__name__) class VdirStore(Store): """A Store backed by a Vdir directory.""" def __init__(self, path, check_for_duplicate_uids=True) -> None: super().__init__(MemoryIndex()) self.path = path self._check_for_duplicate_uids = check_for_duplicate_uids # Set of blob ids that have already been scanned self._fname_to_uid: Dict[str, str] = {} # Maps uids to (sha, fname) self._uid_to_fname: Dict[str, str] = {} cp = configparser.ConfigParser() cp.read([os.path.join(self.path, CONFIG_FILENAME)]) def save_config(cp, message): with open(os.path.join(self.path, CONFIG_FILENAME), "w") as f: cp.write(f) self.config = FileBasedCollectionMetadata(cp, save=save_config) def __repr__(self) -> str: return f"{type(self).__name__}({self.path!r})" def _get_etag(self, name): path = os.path.join(self.path, name) md5 = hashlib.md5() try: with open(path, "rb") as f: for chunk in f: md5.update(chunk) except FileNotFoundError as exc: raise KeyError(name) from exc except IsADirectoryError as exc: raise KeyError(name) from exc return md5.hexdigest() def _get_raw(self, name, etag=None): """Get the raw contents of an object. Args: name: Name of the item etag: Optional etag (ignored) Returns: raw contents as chunks """ path = os.path.join(self.path, name) try: with open(path, "rb") as f: return [f.read()] except FileNotFoundError as exc: raise KeyError(name) from exc except IsADirectoryError as exc: raise KeyError(name) from exc def _scan_uids(self): removed = set(self._fname_to_uid.keys()) for name, content_type, etag in self.iter_with_etag(): if name in removed: removed.remove(name) if name in self._fname_to_uid and self._fname_to_uid[name][0] == etag: continue fi = open_by_extension( self._get_raw(name, etag), name, self.extra_file_handlers ) try: uid = fi.get_uid() except KeyError: logger.warning("No UID found in file %s", name) uid = None except InvalidFileContents: logging.warning("Unable to parse file %s", name) uid = None except NotImplementedError: # This file type doesn't support UIDs uid = None self._fname_to_uid[name] = (etag, uid) if uid is not None: self._uid_to_fname[uid] = (name, etag) for name in removed: (unused_etag, uid) = self._fname_to_uid[name] if uid is not None: del self._uid_to_fname[uid] del self._fname_to_uid[name] def _check_duplicate(self, uid, name, replace_etag): if uid is not None and self._check_for_duplicate_uids: self._scan_uids() try: (existing_name, _) = self._uid_to_fname[uid] except KeyError: pass else: if existing_name != name: raise DuplicateUidError(uid, existing_name, name) try: etag = self._get_etag(name) except KeyError: etag = None if replace_etag is not None and etag != replace_etag: raise InvalidETag(name, etag, replace_etag) return etag def import_one( self, name, content_type, data, message=None, author=None, replace_etag=None, ): """Import a single object. Args: name: name of the object content_type: Content type data: serialized object as list of bytes message: Commit message author: Optional author replace_etag: optional etag of object to replace Raises: InvalidETag: when the name already exists but with different etag DuplicateUidError: when the uid already exists Returns: etag """ if content_type is None: fi = open_by_extension(data, name, self.extra_file_handlers) else: fi = open_by_content_type(data, content_type, self.extra_file_handlers) if name is None: name = str(uuid.uuid4()) extension = MIMETYPES.guess_extension(content_type) if extension is not None: name += extension fi.validate() try: uid = fi.get_uid() except (KeyError, NotImplementedError): uid = None self._check_duplicate(uid, name, replace_etag) # TODO(jelmer): Check that extensions match content type: # if this is a vCard, the extension should be .vcf # if this is a iCalendar, the extension should be .ics # TODO(jelmer): check that a UID is present and that all UIDs are the # same path = os.path.join(self.path, name) tmppath = os.path.join(self.path, name + ".tmp") with open(tmppath, "wb") as f: for chunk in fi.normalized(): f.write(chunk) os.replace(tmppath, path) return (name, self._get_etag(name)) def iter_with_etag(self, ctag=None): """Iterate over all items in the store with etag. Args: ctag: Ctag to iterate for Returns: iterator over (name, content_type, etag) tuples """ for name in os.listdir(self.path): if name.endswith(".tmp"): continue if name == CONFIG_FILENAME: continue if name.endswith(".ics"): content_type = "text/calendar" elif name.endswith(".vcf"): content_type = "text/vcard" else: continue yield (name, content_type, self._get_etag(name)) @classmethod def create(cls, path: str) -> "VdirStore": """Create a new store backed by a Vdir on disk. Returns: A `VdirStore` """ os.mkdir(path) return cls(path) @classmethod def open_from_path(cls, path: str) -> "VdirStore": """Open a VdirStore from a path. Args: path: Path Returns: A `VdirStore` """ return cls(path) def get_description(self): """Get extended description. Returns: repository description as string """ return self.config.get_description() def set_description(self, description): """Set extended description. Args: description: repository description as string """ self.config.set_description(description) def set_comment(self, comment): """Set comment. Args: comment: Comment """ raise NotImplementedError(self.set_comment) def get_comment(self): """Get comment. Returns: Comment """ raise NotImplementedError(self.get_comment) def _read_metadata(self, name): try: with open(os.path.join(self.path, name)) as f: return f.read().strip() except FileNotFoundError: return None except IsADirectoryError: return None def _write_metadata(self, name, data): path = os.path.join(self.path, name) if data is not None: with open(path, "w") as f: f.write(data) else: os.unlink(path) def get_color(self): """Get color. Returns: A Color code, or None """ color = self._read_metadata("color") if color is not None: assert color.startswith("#") return color def set_color(self, color): """Set the color code for this store.""" assert color.startswith("#") self._write_metadata("color", color) def get_source_url(self): """Get source URL.""" return self._read_metadata("source") def set_source_url(self, url): """Set source URL.""" self._write_metadata("source", url) def get_displayname(self): """Get display name. Returns: The display name, or None if not set """ return self._read_metadata("displayname") def set_displayname(self, displayname): """Set the display name. Args: displayname: New display name """ self._write_metadata("displayname", displayname) def iter_changes(self, old_ctag, new_ctag): """Get changes between two versions of this store. Args: old_ctag: Old ctag (None for empty Store) new_ctag: New ctag Returns: Iterator over (name, content_type, old_etag, new_etag) """ raise NotImplementedError(self.iter_changes) def destroy(self): """Destroy this store.""" shutil.rmtree(self.path) def delete_one(self, name, message=None, author=None, etag=None): """Delete an item. Args: name: Filename to delete message: Commit message author: Optional author etag: Optional mandatory etag of object to remove Raises: NoSuchItem: when the item doesn't exist InvalidETag: If the specified ETag doesn't match the curren """ path = os.path.join(self.path, name) if etag is not None: try: current_etag = self._get_etag(name) except KeyError: raise NoSuchItem(name) if etag != current_etag: raise InvalidETag(name, etag, current_etag) try: os.unlink(path) except FileNotFoundError as exc: raise NoSuchItem(path) from exc except IsADirectoryError as exc: raise NoSuchItem(path) from exc def get_ctag(self): """Return the ctag for this store.""" raise NotImplementedError(self.get_ctag) def subdirectories(self): """Returns subdirectories to probe for other stores. Returns: List of names """ ret = [] for name in os.listdir(self.path): p = os.path.join(self.path, name) if os.path.isdir(p): ret.append(name) return ret xandikos-0.2.12/xandikos/sync.py000066400000000000000000000121061470075263100165670ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Calendar synchronisation. See https://tools.ietf.org/html/rfc6578 """ import itertools import urllib.parse from xandikos import webdav ET = webdav.ET FEATURE = "sync-collection" class SyncToken: """A sync token wrapper.""" def __init__(self, token) -> None: self.token = token def aselement(self): ret = ET.Element("{DAV:}sync-token") ret.text = self.token return ret class InvalidToken(Exception): """Requested token is invalid.""" def __init__(self, token) -> None: self.token = token class SyncCollectionReporter(webdav.Reporter): """sync-collection reporter implementation. See https://tools.ietf.org/html/rfc6578, section 3.2. """ name = "{DAV:}sync-collection" @webdav.multistatus # noqa: C901 async def report( # noqa: C901 self, environ, request_body, resources_by_hrefs, properties, href, resource, depth, strict, ): old_token = None sync_level = None limit = None requested = None for el in request_body: if el.tag == "{DAV:}sync-token": old_token = el.text elif el.tag == "{DAV:}sync-level": sync_level = el.text elif el.tag == "{DAV:}limit": limit = el.text elif el.tag == "{DAV:}prop": requested = list(el) else: webdav.nonfatal_bad_request(f"unknown tag {el.tag}", strict) # TODO(jelmer): Implement sync_level infinite if sync_level not in ("1",): raise webdav.BadRequestError(f"sync level {sync_level!r} unsupported") new_token = resource.get_sync_token() try: try: diff_iter = resource.iter_differences_since(old_token, new_token) except NotImplementedError: yield webdav.Status( href, "403 Forbidden", error=ET.Element("{DAV:}sync-traversal-supported"), ) return if limit is not None: try: [nresults_el] = list(limit) except ValueError: webdav.nonfatal_bad_request( "Invalid number of subelements in limit", strict ) else: try: nresults = int(nresults_el.text) except ValueError: webdav.nonfatal_bad_request("nresults not a number", strict) else: diff_iter = itertools.islice(diff_iter, nresults) for name, old_resource, new_resource in diff_iter: subhref = urllib.parse.urljoin(webdav.ensure_trailing_slash(href), name) if new_resource is None: yield webdav.Status(subhref, status="404 Not Found") else: propstat = [] for prop in requested: if old_resource is not None: old_propstat = await webdav.get_property_from_element( href, old_resource, properties, environ, prop ) else: old_propstat = None new_propstat = await webdav.get_property_from_element( href, new_resource, properties, environ, prop ) if old_propstat != new_propstat: propstat.append(new_propstat) yield webdav.Status(subhref, propstat=propstat) except InvalidToken as exc: raise webdav.PreconditionFailure( "{DAV:}valid-sync-token", f"Requested sync token {exc.token} is invalid" ) from exc yield SyncToken(new_token) class SyncTokenProperty(webdav.Property): """sync-token property. See https://tools.ietf.org/html/rfc6578, section 4 """ name = "{DAV:}sync-token" resource_type = webdav.COLLECTION_RESOURCE_TYPE in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = resource.get_sync_token() xandikos-0.2.12/xandikos/templates/000077500000000000000000000000001470075263100172375ustar00rootroot00000000000000xandikos-0.2.12/xandikos/templates/collection.html000066400000000000000000000011731470075263100222620ustar00rootroot00000000000000 WebDAV Collection - {{ collection.get_displayname() }}

{{ collection.get_displayname() }}

This is a collection.

Subcollections

For more information about Xandikos, see https://www.xandikos.org/ or https://github.com/jelmer/xandikos.

xandikos-0.2.12/xandikos/templates/principal.html000066400000000000000000000013431470075263100221070ustar00rootroot00000000000000 WebDAV Principal - {{ principal.get_displayname() }}

{{ principal.get_displayname() }}

This is a user principal. CalDAV/CardDAV clients that support autodiscovery can use the URL for this page for discovery.

Subcollections

    {% for name, resource in principal.subcollections() %}
  • {{ name }}
  • {% endfor %}

For more information about Xandikos, see https://www.xandikos.org/ or https://github.com/jelmer/xandikos.

xandikos-0.2.12/xandikos/templates/root.html000066400000000000000000000011151470075263100211060ustar00rootroot00000000000000 Xandikos WebDAV server

This is a Xandikos WebDAV server.

Principals on this server:

    {% for path in principals %}
  • {{ path }}
  • {% endfor %}

For more information about Xandikos, see https://www.xandikos.org/ or https://github.com/jelmer/xandikos.

xandikos-0.2.12/xandikos/tests/000077500000000000000000000000001470075263100164035ustar00rootroot00000000000000xandikos-0.2.12/xandikos/tests/__init__.py000066400000000000000000000022411470075263100205130ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import unittest def test_suite(): names = [ "api", "caldav", "carddav", "config", "icalendar", "store", "vcard", "webdav", "web", "wsgi", ] module_names = ["xandikos.tests.test_" + name for name in names] loader = unittest.TestLoader() return loader.loadTestsFromNames(module_names) xandikos-0.2.12/xandikos/tests/test_api.py000066400000000000000000000024551470075263100205730ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import shutil import tempfile import unittest from ..web import XandikosApp, XandikosBackend class WebTests(unittest.TestCase): # When changing this API, please update notes/api-stability.rst and inform # vdirsyncer, who rely on this API. def test_backend(self): path = tempfile.mkdtemp() try: backend = XandikosBackend(path) backend.create_principal("foo", create_defaults=True) XandikosApp(backend, "foo") finally: shutil.rmtree(path) xandikos-0.2.12/xandikos/tests/test_caldav.py000066400000000000000000000173271470075263100212600ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import unittest from wsgiref.util import setup_testing_defaults from icalendar.cal import Calendar as ICalendar from xandikos import caldav from xandikos.tests import test_webdav from ..webdav import ET, Property, WebDAVApp class WebTests(test_webdav.WebTestCase): def makeApp(self, backend): app = WebDAVApp(backend) app.register_methods([caldav.MkcalendarMethod()]) return app def mkcalendar(self, app, path): environ = { "PATH_INFO": path, "REQUEST_METHOD": "MKCALENDAR", "SCRIPT_NAME": "", } setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) contents = b"".join(app(environ, start_response)) return _code[0], _headers, contents def test_mkcalendar_ok(self): class Backend: def create_collection(self, relpath): pass def get_resource(self, relpath): return None class ResourceTypeProperty(Property): name = "{DAV:}resourcetype" async def get_value(unused_self, href, resource, ret, environ): ET.SubElement(ret, "{DAV:}collection") async def set_value(unused_self, href, resource, ret): self.assertEqual( [ "{DAV:}collection", "{urn:ietf:params:xml:ns:caldav}calendar", ], [x.tag for x in ret], ) app = self.makeApp(Backend()) app.register_properties([ResourceTypeProperty()]) code, headers, contents = self.mkcalendar(app, "/resource/bla") self.assertEqual("201 Created", code) self.assertEqual(b"", contents) class ExtractfromCalendarTests(unittest.TestCase): def setUp(self): super().setUp() self.requested = ET.Element("{%s}calendar-data" % caldav.NAMESPACE) def extractEqual(self, incal_str, outcal_str): incal = ICalendar.from_ical(incal_str) expected_outcal = ICalendar.from_ical(outcal_str) outcal = ICalendar() outcal = caldav.extract_from_calendar(incal, self.requested) self.maxDiff = None self.assertMultiLineEqual( expected_outcal.to_ical().decode(), outcal.to_ical().decode(), ET.tostring(self.requested), ) def test_comp(self): comp = ET.SubElement(self.requested, "{%s}comp" % caldav.NAMESPACE) comp.set("name", "VCALENDAR") self.extractEqual( """\ BEGIN:VCALENDAR BEGIN:VTODO CLASS:PUBLIC COMPLETED:20100829T234417Z CREATED:20090606T042958Z END:VTODO END:VCALENDAR """, """\ BEGIN:VCALENDAR END:VCALENDAR """, ) def test_comp_nested(self): vcal_comp = ET.SubElement(self.requested, "{%s}comp" % caldav.NAMESPACE) vcal_comp.set("name", "VCALENDAR") vtodo_comp = ET.SubElement(vcal_comp, "{%s}comp" % caldav.NAMESPACE) vtodo_comp.set("name", "VTODO") self.extractEqual( """\ BEGIN:VCALENDAR BEGIN:VTODO COMPLETED:20100829T234417Z CREATED:20090606T042958Z END:VTODO END:VCALENDAR """, """\ BEGIN:VCALENDAR BEGIN:VTODO END:VTODO END:VCALENDAR """, ) self.extractEqual( """\ BEGIN:VCALENDAR BEGIN:VEVENT COMPLETED:20100829T234417Z CREATED:20090606T042958Z END:VEVENT END:VCALENDAR """, """\ BEGIN:VCALENDAR END:VCALENDAR """, ) def test_prop(self): vcal_comp = ET.SubElement(self.requested, "{%s}comp" % caldav.NAMESPACE) vcal_comp.set("name", "VCALENDAR") vtodo_comp = ET.SubElement(vcal_comp, "{%s}comp" % caldav.NAMESPACE) vtodo_comp.set("name", "VTODO") completed_prop = ET.SubElement(vtodo_comp, "{%s}prop" % caldav.NAMESPACE) completed_prop.set("name", "COMPLETED") self.extractEqual( """\ BEGIN:VCALENDAR BEGIN:VTODO COMPLETED:20100829T234417Z CREATED:20090606T042958Z END:VTODO END:VCALENDAR """, """\ BEGIN:VCALENDAR BEGIN:VTODO COMPLETED:20100829T234417Z END:VTODO END:VCALENDAR """, ) self.extractEqual( """\ BEGIN:VCALENDAR BEGIN:VEVENT CREATED:20090606T042958Z END:VEVENT END:VCALENDAR """, """\ BEGIN:VCALENDAR END:VCALENDAR """, ) def test_allprop(self): vcal_comp = ET.SubElement(self.requested, "{%s}comp" % caldav.NAMESPACE) vcal_comp.set("name", "VCALENDAR") vtodo_comp = ET.SubElement(vcal_comp, "{%s}comp" % caldav.NAMESPACE) vtodo_comp.set("name", "VTODO") ET.SubElement(vtodo_comp, "{%s}allprop" % caldav.NAMESPACE) self.extractEqual( """\ BEGIN:VCALENDAR BEGIN:VTODO COMPLETED:20100829T234417Z CREATED:20090606T042958Z END:VTODO END:VCALENDAR """, """\ BEGIN:VCALENDAR BEGIN:VTODO COMPLETED:20100829T234417Z CREATED:20090606T042958Z END:VTODO END:VCALENDAR """, ) def test_allcomp(self): vcal_comp = ET.SubElement(self.requested, "{%s}comp" % caldav.NAMESPACE) vcal_comp.set("name", "VCALENDAR") ET.SubElement(vcal_comp, "{%s}allcomp" % caldav.NAMESPACE) self.extractEqual( """\ BEGIN:VCALENDAR BEGIN:VTODO COMPLETED:20100829T234417Z CREATED:20090606T042958Z END:VTODO END:VCALENDAR """, """\ BEGIN:VCALENDAR BEGIN:VTODO END:VTODO END:VCALENDAR """, ) def test_expand(self): expand = ET.SubElement(self.requested, "{%s}expand" % caldav.NAMESPACE) expand.set("start", "20060103T000000Z") expand.set("end", "20060105T000000Z") self.extractEqual( """\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTIMEZONE LAST-MODIFIED:20040110T032845Z TZID:US/Eastern BEGIN:DAYLIGHT DTSTART:20000404T020000 RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 TZNAME:EDT TZOFFSETFROM:-0500 TZOFFSETTO:-0400 END:DAYLIGHT BEGIN:STANDARD DTSTART:20001026T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 TZNAME:EST TZOFFSETFROM:-0400 TZOFFSETTO:-0500 END:STANDARD END:VTIMEZONE BEGIN:VEVENT DTSTAMP:20060206T001121Z DTSTART;TZID=US/Eastern:20060102T120000 DURATION:PT1H RRULE:FREQ=DAILY;COUNT=5 SUMMARY:Event #2 UID:00959BC664CA650E933C892C@example.com END:VEVENT BEGIN:VEVENT DTSTAMP:20060206T001121Z DTSTART;TZID=US/Eastern:20060104T140000 DURATION:PT1H RECURRENCE-ID;TZID=US/Eastern:20060104T120000 SUMMARY:Event #2 bis UID:00959BC664CA650E933C892C@example.com END:VEVENT END:VCALENDAR """, """\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VEVENT DTSTAMP:20060206T001121Z DTSTART:20060103T170000 DURATION:PT1H RECURRENCE-ID:20060103T170000 SUMMARY:Event #2 UID:00959BC664CA650E933C892C@example.com END:VEVENT BEGIN:VEVENT DTSTAMP:20060206T001121Z DTSTART:20060104T190000 DURATION:PT1H RECURRENCE-ID:20060104T170000 SUMMARY:Event #2 bis UID:00959BC664CA650E933C892C@example.com END:VEVENT END:VCALENDAR """, ) xandikos-0.2.12/xandikos/tests/test_carddav.py000066400000000000000000000031711470075263100214220ustar00rootroot00000000000000# Xandikos # Copyright (C) 2022 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import asyncio import unittest from ..carddav import NAMESPACE, apply_filter from ..vcard import VCardFile from ..webdav import ET from .test_vcard import EXAMPLE_VCARD1 class TestApplyFilter(unittest.TestCase): async def get_file(self): return VCardFile([EXAMPLE_VCARD1], "text/vcard") def get_content_type(self): return "text/vcard" def test_apply_filter(self): el = ET.Element("{%s}filter" % NAMESPACE) el.set("test", "anyof") pf = ET.SubElement(el, "{%s}prop-filter" % NAMESPACE) pf.set("name", "FN") tm = ET.SubElement(pf, "{%s}text-match" % NAMESPACE) tm.set("collation", "i;unicode-casemap") tm.set("match-type", "contains") tm.text = "Jeffrey" loop = asyncio.get_event_loop() self.assertTrue(loop.run_until_complete(apply_filter(el, self))) xandikos-0.2.12/xandikos/tests/test_config.py000066400000000000000000000111321470075263100212570ustar00rootroot00000000000000# Xandikos # Copyright (C) 2018 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Tests for xandikos.store.config.""" from io import StringIO from unittest import TestCase import dulwich.repo from ..store.config import FileBasedCollectionMetadata from ..store.git import RepoCollectionMetadata class FileBasedCollectionMetadataTests(TestCase): def test_get_color(self): f = StringIO( """\ [DEFAULT] color = #ffffff """ ) cc = FileBasedCollectionMetadata.from_file(f) self.assertEqual("#ffffff", cc.get_color()) def test_get_color_missing(self): f = StringIO("") cc = FileBasedCollectionMetadata.from_file(f) self.assertRaises(KeyError, cc.get_color) def test_get_comment(self): f = StringIO( """\ [DEFAULT] comment = this is a comment """ ) cc = FileBasedCollectionMetadata.from_file(f) self.assertEqual("this is a comment", cc.get_comment()) def test_get_comment_missing(self): f = StringIO("") cc = FileBasedCollectionMetadata.from_file(f) self.assertRaises(KeyError, cc.get_comment) def test_get_description(self): f = StringIO( """\ [DEFAULT] description = this is a description """ ) cc = FileBasedCollectionMetadata.from_file(f) self.assertEqual("this is a description", cc.get_description()) def test_get_description_missing(self): f = StringIO("") cc = FileBasedCollectionMetadata.from_file(f) self.assertRaises(KeyError, cc.get_description) def test_get_displayname(self): f = StringIO( """\ [DEFAULT] displayname = DISPLAY-NAME """ ) cc = FileBasedCollectionMetadata.from_file(f) self.assertEqual("DISPLAY-NAME", cc.get_displayname()) def test_get_displayname_missing(self): f = StringIO("") cc = FileBasedCollectionMetadata.from_file(f) self.assertRaises(KeyError, cc.get_displayname) class MetadataTests: def test_color(self): self.assertRaises(KeyError, self._config.get_color) self._config.set_color("#ffffff") self.assertEqual("#ffffff", self._config.get_color()) self._config.set_color(None) self.assertRaises(KeyError, self._config.get_color) def test_comment(self): self.assertRaises(KeyError, self._config.get_comment) self._config.set_comment("this is a comment") self.assertEqual("this is a comment", self._config.get_comment()) self._config.set_comment(None) self.assertRaises(KeyError, self._config.get_comment) def test_displayname(self): self.assertRaises(KeyError, self._config.get_displayname) self._config.set_displayname("DiSpLaYName") self.assertEqual("DiSpLaYName", self._config.get_displayname()) self._config.set_displayname(None) self.assertRaises(KeyError, self._config.get_displayname) def test_description(self): self.assertRaises(KeyError, self._config.get_description) self._config.set_description("this is a description") self.assertEqual("this is a description", self._config.get_description()) self._config.set_description(None) self.assertRaises(KeyError, self._config.get_description) def test_order(self): self.assertRaises(KeyError, self._config.get_order) self._config.set_order("this is a order") self.assertEqual("this is a order", self._config.get_order()) self._config.set_order(None) self.assertRaises(KeyError, self._config.get_order) class FileMetadataTests(TestCase, MetadataTests): def setUp(self): super().setUp() self._config = FileBasedCollectionMetadata() class RepoMetadataTests(TestCase, MetadataTests): def setUp(self): super().setUp() self._repo = dulwich.repo.MemoryRepo() self._config = RepoCollectionMetadata(self._repo) xandikos-0.2.12/xandikos/tests/test_icalendar.py000066400000000000000000000436321470075263100217460ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Tests for xandikos.icalendar.""" import unittest from datetime import datetime from zoneinfo import ZoneInfo from icalendar.cal import Event from icalendar.prop import vCategory, vText from xandikos import collation as _mod_collation from xandikos.store import InvalidFileContents from ..icalendar import ( CalendarFilter, ICalendarFile, MissingProperty, TextMatcher, apply_time_range_vevent, as_tz_aware_ts, validate_calendar, ) EXAMPLE_VCALENDAR1 = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED:20150314T223512Z DTSTAMP:20150527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something CATEGORIES:home UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff END:VTODO END:VCALENDAR """ EXAMPLE_VCALENDAR_WITH_PARAM = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED;TZID=America/Denver:20150314T223512Z DTSTAMP:20150527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff END:VTODO END:VCALENDAR """ EXAMPLE_VCALENDAR_NO_UID = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED:20120314T223512Z DTSTAMP:20130527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something without uid END:VTODO END:VCALENDAR """ EXAMPLE_VCALENDAR_INVALID_CHAR = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED:20150314T223512Z DTSTAMP:20150527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do somethi ng ID:bdc22720-b9e1-42c9-89c2-a85405d8fbff END:VTODO END:VCALENDAR """ class ExtractCalendarUIDTests(unittest.TestCase): def test_extract_str(self): fi = ICalendarFile([EXAMPLE_VCALENDAR1], "text/calendar") self.assertEqual("bdc22720-b9e1-42c9-89c2-a85405d8fbff", fi.get_uid()) fi.validate() def test_extract_no_uid(self): fi = ICalendarFile([EXAMPLE_VCALENDAR_NO_UID], "text/calendar") fi.validate() self.assertEqual( ["Missing required field UID"], list(validate_calendar(fi.calendar, strict=True)), ) self.assertEqual([], list(validate_calendar(fi.calendar, strict=False))) self.assertRaises(KeyError, fi.get_uid) def test_invalid_character(self): fi = ICalendarFile([EXAMPLE_VCALENDAR_INVALID_CHAR], "text/calendar") self.assertRaises(InvalidFileContents, fi.validate) self.assertEqual( ["Invalid character b'\\\\x0c' in field SUMMARY"], list(validate_calendar(fi.calendar, strict=False)), ) class CalendarFilterTests(unittest.TestCase): def setUp(self): self.cal = ICalendarFile([EXAMPLE_VCALENDAR1], "text/calendar") def test_simple_comp_filter(self): filter = CalendarFilter(None) filter.filter_subcomponent("VCALENDAR").filter_subcomponent("VEVENT") self.assertEqual(filter.index_keys(), [["C=VCALENDAR/C=VEVENT"]]) self.assertEqual( self.cal.get_indexes(["C=VCALENDAR/C=VEVENT", "C=VCALENDAR/C=VTODO"]), {"C=VCALENDAR/C=VEVENT": [], "C=VCALENDAR/C=VTODO": [True]}, ) self.assertFalse( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VEVENT": [], "C=VCALENDAR/C=VTODO": [True]}, ) ) self.assertFalse(filter.check("file", self.cal)) filter = CalendarFilter(None) filter.filter_subcomponent("VCALENDAR").filter_subcomponent("VTODO") self.assertTrue(filter.check("file", self.cal)) self.assertTrue( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VEVENT": [], "C=VCALENDAR/C=VTODO": [True]}, ) ) def test_simple_comp_missing_filter(self): filter = CalendarFilter(None) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO", is_not_defined=True ) self.assertEqual( filter.index_keys(), [["C=VCALENDAR/C=VTODO"], ["C=VCALENDAR"]] ) self.assertFalse( filter.check_from_indexes( "file", { "C=VCALENDAR": [True], "C=VCALENDAR/C=VEVENT": [], "C=VCALENDAR/C=VTODO": [True], }, ) ) self.assertFalse(filter.check("file", self.cal)) filter = CalendarFilter(None) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VEVENT", is_not_defined=True ) self.assertTrue(filter.check("file", self.cal)) self.assertTrue( filter.check_from_indexes( "file", { "C=VCALENDAR": [True], "C=VCALENDAR/C=VEVENT": [], "C=VCALENDAR/C=VTODO": [True], }, ) ) def test_prop_presence_filter(self): filter = CalendarFilter(None) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_property("X-SUMMARY") self.assertEqual(filter.index_keys(), [["C=VCALENDAR/C=VTODO/P=X-SUMMARY"]]) self.assertFalse( filter.check_from_indexes("file", {"C=VCALENDAR/C=VTODO/P=X-SUMMARY": []}) ) self.assertFalse(filter.check("file", self.cal)) filter = CalendarFilter(None) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_property("SUMMARY") self.assertTrue( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VTODO/P=SUMMARY": [b"do something"]} ) ) self.assertTrue(filter.check("file", self.cal)) def test_prop_explicitly_missing_filter(self): filter = CalendarFilter(None) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VEVENT" ).filter_property("X-SUMMARY", is_not_defined=True) self.assertEqual( filter.index_keys(), [["C=VCALENDAR/C=VEVENT/P=X-SUMMARY"], ["C=VCALENDAR/C=VEVENT"]], ) self.assertFalse( filter.check_from_indexes( "file", { "C=VCALENDAR/C=VEVENT/P=X-SUMMARY": [], "C=VCALENDAR/C=VEVENT": [], }, ) ) self.assertFalse(filter.check("file", self.cal)) filter = CalendarFilter(None) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_property("X-SUMMARY", is_not_defined=True) self.assertTrue( filter.check_from_indexes( "file", { "C=VCALENDAR/C=VTODO/P=X-SUMMARY": [], "C=VCALENDAR/C=VTODO": [True], }, ) ) self.assertTrue(filter.check("file", self.cal)) def test_prop_text_match(self): filter = CalendarFilter(None) f = filter.filter_subcomponent("VCALENDAR") f = f.filter_subcomponent("VTODO") f = f.filter_property("SUMMARY") f.filter_text_match("do something different") self.assertEqual(filter.index_keys(), [["C=VCALENDAR/C=VTODO/P=SUMMARY"]]) self.assertFalse( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VTODO/P=SUMMARY": [b"do something"]} ) ) self.assertFalse(filter.check("file", self.cal)) filter = CalendarFilter(None) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_property("SUMMARY").filter_text_match("do something") self.assertTrue( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VTODO/P=SUMMARY": [b"do something"]} ) ) self.assertTrue(filter.check("file", self.cal)) def test_prop_text_match_category(self): filter = CalendarFilter(None) f = filter.filter_subcomponent("VCALENDAR") f = f.filter_subcomponent("VTODO") f = f.filter_property("CATEGORIES") f.filter_text_match("work") self.assertEqual( self.cal.get_indexes(["C=VCALENDAR/C=VTODO/P=CATEGORIES"]), {"C=VCALENDAR/C=VTODO/P=CATEGORIES": [b"home"]}, ) self.assertEqual(filter.index_keys(), [["C=VCALENDAR/C=VTODO/P=CATEGORIES"]]) self.assertFalse( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VTODO/P=CATEGORIES": [b"home"]} ) ) self.assertFalse(filter.check("file", self.cal)) filter = CalendarFilter(None) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_property("CATEGORIES").filter_text_match("home") self.assertTrue( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VTODO/P=CATEGORIES": [b"home"]} ) ) self.assertTrue(filter.check("file", self.cal)) def test_param_text_match(self): self.cal = ICalendarFile([EXAMPLE_VCALENDAR_WITH_PARAM], "text/calendar") filter = CalendarFilter(None) f = filter.filter_subcomponent("VCALENDAR") f = f.filter_subcomponent("VTODO") f = f.filter_property("CREATED") f = f.filter_parameter("TZID") f.filter_text_match("America/Blah") self.assertEqual( filter.index_keys(), [ ["C=VCALENDAR/C=VTODO/P=CREATED/A=TZID"], ["C=VCALENDAR/C=VTODO/P=CREATED"], ], ) self.assertFalse( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VTODO/P=CREATED/A=TZID": [b"America/Denver"]}, ) ) self.assertFalse(filter.check("file", self.cal)) filter = CalendarFilter(None) f = filter.filter_subcomponent("VCALENDAR") f = f.filter_subcomponent("VTODO") f = f.filter_property("CREATED") f = f.filter_parameter("TZID") f.filter_text_match("America/Denver") self.assertTrue( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VTODO/P=CREATED/A=TZID": [b"America/Denver"]}, ) ) self.assertTrue(filter.check("file", self.cal)) def _tzify(self, dt): return as_tz_aware_ts(dt, ZoneInfo('UTC')) def test_prop_apply_time_range(self): filter = CalendarFilter(ZoneInfo('UTC')) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_property("CREATED").filter_time_range( self._tzify(datetime(2019, 3, 10, 22, 35, 12)), self._tzify(datetime(2019, 3, 18, 22, 35, 12)), ) self.assertEqual(filter.index_keys(), [["C=VCALENDAR/C=VTODO/P=CREATED"]]) self.assertFalse( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314T223512Z"]} ) ) self.assertFalse( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314"]} ) ) self.assertFalse(filter.check("file", self.cal)) filter = CalendarFilter(self._tzify) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_property("CREATED").filter_time_range( self._tzify(datetime(2015, 3, 10, 22, 35, 12)), self._tzify(datetime(2015, 3, 18, 22, 35, 12)), ) self.assertTrue( filter.check_from_indexes( "file", {"C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314T223512Z"]} ) ) self.assertTrue(filter.check("file", self.cal)) def test_comp_apply_time_range(self): self.assertEqual( self.cal.get_indexes(["C=VCALENDAR/C=VTODO/P=CREATED"]), {"C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314T223512Z"]}, ) filter = CalendarFilter(ZoneInfo('UTC')) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_time_range( self._tzify(datetime(2015, 3, 3, 22, 35, 12)), self._tzify(datetime(2015, 3, 10, 22, 35, 12)), ) self.assertEqual( filter.index_keys(), [ ["C=VCALENDAR/C=VTODO/P=DTSTART"], ["C=VCALENDAR/C=VTODO/P=DUE"], ["C=VCALENDAR/C=VTODO/P=DURATION"], ["C=VCALENDAR/C=VTODO/P=CREATED"], ["C=VCALENDAR/C=VTODO/P=COMPLETED"], ["C=VCALENDAR/C=VTODO"], ], ) self.assertFalse( filter.check_from_indexes( "file", { "C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314T223512Z"], "C=VCALENDAR/C=VTODO": [True], "C=VCALENDAR/C=VTODO/P=DUE": [], "C=VCALENDAR/C=VTODO/P=DURATION": [], "C=VCALENDAR/C=VTODO/P=COMPLETED": [], "C=VCALENDAR/C=VTODO/P=DTSTART": [], }, ) ) self.assertFalse( filter.check_from_indexes( "file", { "C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314"], "C=VCALENDAR/C=VTODO": [True], "C=VCALENDAR/C=VTODO/P=DUE": [], "C=VCALENDAR/C=VTODO/P=DURATION": [], "C=VCALENDAR/C=VTODO/P=COMPLETED": [], "C=VCALENDAR/C=VTODO/P=DTSTART": [], }, ) ) self.assertFalse(filter.check("file", self.cal)) filter = CalendarFilter(ZoneInfo('UTC')) filter.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_time_range( self._tzify(datetime(2015, 3, 10, 22, 35, 12)), self._tzify(datetime(2015, 3, 18, 22, 35, 12)), ) self.assertTrue( filter.check_from_indexes( "file", { "C=VCALENDAR/C=VTODO/P=CREATED": [b"20150314T223512Z"], "C=VCALENDAR/C=VTODO": [True], "C=VCALENDAR/C=VTODO/P=DUE": [], "C=VCALENDAR/C=VTODO/P=DURATION": [], "C=VCALENDAR/C=VTODO/P=COMPLETED": [], "C=VCALENDAR/C=VTODO/P=DTSTART": [], }, ) ) self.assertTrue(filter.check("file", self.cal)) class TextMatchTest(unittest.TestCase): def test_default_collation(self): tm = TextMatcher("summary", "foobar") self.assertTrue(tm.match(vText("FOOBAR"))) self.assertTrue(tm.match(vText("foobar"))) self.assertFalse(tm.match(vText("fobar"))) self.assertTrue(tm.match_indexes({None: [b"foobar"]})) self.assertTrue(tm.match_indexes({None: [b"FOOBAR"]})) self.assertFalse(tm.match_indexes({None: [b"fobar"]})) def test_casecmp_collation(self): tm = TextMatcher("summary", "foobar", collation="i;ascii-casemap") self.assertTrue(tm.match(vText("FOOBAR"))) self.assertTrue(tm.match(vText("foobar"))) self.assertFalse(tm.match(vText("fobar"))) self.assertTrue(tm.match_indexes({None: [b"foobar"]})) self.assertTrue(tm.match_indexes({None: [b"FOOBAR"]})) self.assertFalse(tm.match_indexes({None: [b"fobar"]})) def test_cmp_collation(self): tm = TextMatcher("summary", "foobar", collation="i;octet") self.assertFalse(tm.match(vText("FOOBAR"))) self.assertTrue(tm.match(vText("foobar"))) self.assertFalse(tm.match(vText("fobar"))) self.assertFalse(tm.match_indexes({None: [b"FOOBAR"]})) self.assertTrue(tm.match_indexes({None: [b"foobar"]})) self.assertFalse(tm.match_indexes({None: [b"fobar"]})) def test_category(self): tm = TextMatcher("categories", "foobar") self.assertTrue(tm.match(vCategory(["FOOBAR", "blah"]))) self.assertTrue(tm.match(vCategory(["foobar"]))) self.assertFalse(tm.match(vCategory(["fobar"]))) self.assertTrue(tm.match_indexes({None: [b"foobar,blah"]})) self.assertFalse(tm.match_indexes({None: [b"foobarblah"]})) def test_unknown_type(self): tm = TextMatcher("dontknow", "foobar") self.assertFalse(tm.match(object())) self.assertFalse(tm.match_indexes({None: [b"foobarblah"]})) def test_unknown_collation(self): self.assertRaises( _mod_collation.UnknownCollation, TextMatcher, "summary", "foobar", collation="i;blah", ) class ApplyTimeRangeVeventTests(unittest.TestCase): def _tzify(self, dt): return as_tz_aware_ts(dt, "UTC") def test_missing_dtstart(self): ev = Event() self.assertRaises( MissingProperty, apply_time_range_vevent, datetime.utcnow(), datetime.utcnow(), ev, self._tzify, ) xandikos-0.2.12/xandikos/tests/test_store.py000066400000000000000000000371761470075263100211660ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import os import shutil import stat import tempfile import unittest from dulwich.objects import Blob, Commit, Tree from dulwich.repo import Repo from xandikos.store import ( DuplicateUidError, File, Filter, InvalidETag, NoSuchItem, Store, ) from ..icalendar import ICalendarFile from ..store.git import BareGitStore, GitStore, TreeGitStore from ..store.vdir import VdirStore EXAMPLE_VCALENDAR1 = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED:20150314T223512Z DTSTAMP:20150527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff END:VTODO END:VCALENDAR """ EXAMPLE_VCALENDAR1_NORMALIZED = b"""\ BEGIN:VCALENDAR\r VERSION:2.0\r PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN\r BEGIN:VTODO\r CREATED:20150314T223512Z\r DTSTAMP:20150527T221952Z\r LAST-MODIFIED:20150314T223512Z\r STATUS:NEEDS-ACTION\r SUMMARY:do something\r UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff\r END:VTODO\r END:VCALENDAR\r """ EXAMPLE_VCALENDAR2 = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED:20120314T223512Z DTSTAMP:20130527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something else UID:bdc22764-b9e1-42c9-89c2-a85405d8fbff END:VTODO END:VCALENDAR """ EXAMPLE_VCALENDAR2_NORMALIZED = b"""\ BEGIN:VCALENDAR\r VERSION:2.0\r PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN\r BEGIN:VTODO\r CREATED:20120314T223512Z\r DTSTAMP:20130527T221952Z\r LAST-MODIFIED:20150314T223512Z\r STATUS:NEEDS-ACTION\r SUMMARY:do something else\r UID:bdc22764-b9e1-42c9-89c2-a85405d8fbff\r END:VTODO\r END:VCALENDAR\r """ EXAMPLE_VCALENDAR_NO_UID = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED:20120314T223512Z DTSTAMP:20130527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something without uid END:VTODO END:VCALENDAR """ class BaseStoreTest: def test_import_one(self): gc = self.create_store() (name, etag) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.assertIsInstance(etag, str) self.assertEqual( [("foo.ics", "text/calendar", etag)], list(gc.iter_with_etag()) ) def test_with_filter(self): gc = self.create_store() (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) (name2, etag2) = gc.import_one("bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) class DummyFilter(Filter): content_type = "text/calendar" def __init__(self, text) -> None: self.text = text def check(self, name, resource): return self.text in b"".join(resource.content) self.assertEqual( 2, len(list(gc.iter_with_filter(filter=DummyFilter(b"do something")))) ) [(ret_name, ret_file, ret_etag)] = list( gc.iter_with_filter(filter=DummyFilter(b"do something else")) ) self.assertEqual(ret_name, name2) self.assertEqual(ret_etag, etag2) self.assertEqual(ret_file.content_type, "text/calendar") self.assertEqual( b"".join(ret_file.content), EXAMPLE_VCALENDAR2.replace(b"\n", b"\r\n"), ) def test_get_by_index(self): gc = self.create_store() (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) (name2, etag2) = gc.import_one("bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) (name3, etag3) = gc.import_one( "bar.txt", "text/plain", [b"Not a calendar file."] ) self.assertEqual({}, dict(gc.index_manager.desired)) filtertext = "C=VCALENDAR/C=VTODO/P=SUMMARY" class DummyFilter(Filter): content_type = "text/calendar" def __init__(self, text) -> None: self.text = text def index_keys(self): return [[filtertext]] def check_from_indexes(self, name, index_values): return any(self.text in v for v in index_values[filtertext]) def check(self, name, resource): return self.text in b"".join(resource.content) self.assertEqual( 2, len(list(gc.iter_with_filter(filter=DummyFilter(b"do something")))) ) [(ret_name, ret_file, ret_etag)] = list( gc.iter_with_filter(filter=DummyFilter(b"do something else")) ) self.assertEqual({filtertext: 2}, dict(gc.index_manager.desired)) # Force index gc.index.reset([filtertext]) [(ret_name, ret_file, ret_etag)] = list( gc.iter_with_filter(filter=DummyFilter(b"do something else")) ) self.assertEqual({filtertext: 2}, dict(gc.index_manager.desired)) self.assertEqual(ret_name, name2) self.assertEqual(ret_etag, etag2) self.assertEqual(ret_file.content_type, "text/calendar") self.assertEqual( b"".join(ret_file.content), EXAMPLE_VCALENDAR2.replace(b"\n", b"\r\n"), ) def test_import_one_duplicate_uid(self): gc = self.create_store() (name, etag) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.assertRaises( DuplicateUidError, gc.import_one, "bar.ics", "text/calendar", [EXAMPLE_VCALENDAR1], ) def test_import_one_duplicate_name(self): gc = self.create_store() (name, etag) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) (name, etag) = gc.import_one( "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR2], replace_etag=etag ) (name, etag) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.assertRaises( InvalidETag, gc.import_one, "foo.ics", "text/calendar", [EXAMPLE_VCALENDAR2], replace_etag="invalidetag", ) def test_get_raw(self): gc = self.create_store() (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) (name2, etag2) = gc.import_one("bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) self.assertEqual( EXAMPLE_VCALENDAR1_NORMALIZED, b"".join(gc._get_raw("foo.ics", etag1)), ) self.assertEqual( EXAMPLE_VCALENDAR2_NORMALIZED, b"".join(gc._get_raw("bar.ics", etag2)), ) self.assertRaises(KeyError, gc._get_raw, "missing.ics", "01" * 20) def test_get_file(self): gc = self.create_store() (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) (name1, etag2) = gc.import_one("bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) f1 = gc.get_file("foo.ics", "text/calendar", etag1) self.assertEqual(EXAMPLE_VCALENDAR1_NORMALIZED, b"".join(f1.content)) self.assertEqual("text/calendar", f1.content_type) f2 = gc.get_file("bar.ics", "text/calendar", etag2) self.assertEqual(EXAMPLE_VCALENDAR2_NORMALIZED, b"".join(f2.content)) self.assertEqual("text/calendar", f2.content_type) self.assertRaises(KeyError, gc._get_raw, "missing.ics", "01" * 20) def test_delete_one(self): gc = self.create_store() self.assertEqual([], list(gc.iter_with_etag())) (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.assertEqual( [("foo.ics", "text/calendar", etag1)], list(gc.iter_with_etag()) ) gc.delete_one("foo.ics") self.assertEqual([], list(gc.iter_with_etag())) def test_delete_one_with_etag(self): gc = self.create_store() self.assertEqual([], list(gc.iter_with_etag())) (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.assertEqual( [("foo.ics", "text/calendar", etag1)], list(gc.iter_with_etag()) ) gc.delete_one("foo.ics", etag=etag1) self.assertEqual([], list(gc.iter_with_etag())) def test_delete_one_nonexistant(self): gc = self.create_store() self.assertRaises(NoSuchItem, gc.delete_one, "foo.ics") def test_delete_one_invalid_etag(self): gc = self.create_store() self.assertEqual([], list(gc.iter_with_etag())) (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) (name2, etag2) = gc.import_one("bar.ics", "text/calendar", [EXAMPLE_VCALENDAR2]) self.assertEqual( { ("foo.ics", "text/calendar", etag1), ("bar.ics", "text/calendar", etag2), }, set(gc.iter_with_etag()), ) self.assertRaises(InvalidETag, gc.delete_one, "foo.ics", etag=etag2) self.assertEqual( { ("foo.ics", "text/calendar", etag1), ("bar.ics", "text/calendar", etag2), }, set(gc.iter_with_etag()), ) class VdirStoreTest(BaseStoreTest, unittest.TestCase): kls = VdirStore def create_store(self): d = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, d) store = self.kls.create(os.path.join(d, "store")) store.load_extra_file_handler(ICalendarFile) return store class BaseGitStoreTest(BaseStoreTest): kls: type[Store] def create_store(self): raise NotImplementedError(self.create_store) def add_blob(self, gc, name, contents): raise NotImplementedError(self.add_blob) def test_create(self): d = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, d) gc = self.kls.create(os.path.join(d, "store")) self.assertIsInstance(gc, GitStore) self.assertEqual(gc.repo.path, os.path.join(d, "store")) def test_iter_with_etag_missing_uid(self): logging.getLogger("").setLevel(logging.ERROR) gc = self.create_store() bid = self.add_blob(gc, "foo.ics", EXAMPLE_VCALENDAR_NO_UID) self.assertEqual([("foo.ics", "text/calendar", bid)], list(gc.iter_with_etag())) gc._scan_uids() logging.getLogger("").setLevel(logging.NOTSET) def test_iter_with_etag(self): gc = self.create_store() bid = self.add_blob(gc, "foo.ics", EXAMPLE_VCALENDAR1) self.assertEqual([("foo.ics", "text/calendar", bid)], list(gc.iter_with_etag())) def test_get_description_from_git_config(self): gc = self.create_store() config = gc.repo.get_config() config.set(b"xandikos", b"test", b"test") if getattr(config, "path", None): config.write_to_path() gc.repo.set_description(b"a repo description") self.assertEqual(gc.get_description(), "a repo description") def test_displayname(self): gc = self.create_store() self.assertIs(None, gc.get_color()) c = gc.repo.get_config() c.set(b"xandikos", b"displayname", b"a name") if getattr(c, "path", None): c.write_to_path() self.assertEqual("a name", gc.get_displayname()) def test_get_color(self): gc = self.create_store() self.assertIs(None, gc.get_color()) c = gc.repo.get_config() c.set(b"xandikos", b"color", b"334433") if getattr(c, "path", None): c.write_to_path() self.assertEqual("334433", gc.get_color()) def test_get_source_url(self): gc = self.create_store() self.assertIs(None, gc.get_source_url()) c = gc.repo.get_config() c.set(b"xandikos", b"source", b"www.google.com") if getattr(c, "path", None): c.write_to_path() self.assertEqual("www.google.com", gc.get_source_url()) def test_default_no_subdirectories(self): gc = self.create_store() self.assertEqual([], gc.subdirectories()) def test_import_only_once(self): gc = self.create_store() (name1, etag1) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) (name2, etag2) = gc.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.assertEqual(name1, name2) self.assertEqual(etag1, etag2) walker = gc.repo.get_walker(include=[gc.repo.refs[gc.ref]]) self.assertEqual(1, len([w.commit for w in walker])) class GitStoreTest(unittest.TestCase): def test_open_from_path_bare(self): d = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, d) Repo.init_bare(d) gc = GitStore.open_from_path(d) self.assertIsInstance(gc, BareGitStore) self.assertEqual(gc.repo.path, d) def test_open_from_path_tree(self): d = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, d) Repo.init(d) gc = GitStore.open_from_path(d) self.assertIsInstance(gc, TreeGitStore) self.assertEqual(gc.repo.path, d) class BareGitStoreTest(BaseGitStoreTest, unittest.TestCase): kls = BareGitStore def create_store(self): store = BareGitStore.create_memory() store.load_extra_file_handler(ICalendarFile) return store def test_create_memory(self): gc = BareGitStore.create_memory() self.assertIsInstance(gc, GitStore) def add_blob(self, gc, name, contents): b = Blob.from_string(contents) t = Tree() t.add(name.encode("utf-8"), 0o644 | stat.S_IFREG, b.id) c = Commit() c.tree = t.id c.committer = c.author = b"Somebody " c.commit_time = c.author_time = 800000 c.commit_timezone = c.author_timezone = 0 c.message = b"do something" gc.repo.object_store.add_objects([(b, None), (t, None), (c, None)]) gc.repo[gc.ref] = c.id return b.id.decode("ascii") def test_get_ctag(self): gc = self.create_store() self.assertEqual(Tree().id.decode("ascii"), gc.get_ctag()) self.add_blob(gc, "foo.ics", EXAMPLE_VCALENDAR1) self.assertEqual(gc._get_current_tree().id.decode("ascii"), gc.get_ctag()) class TreeGitStoreTest(BaseGitStoreTest, unittest.TestCase): kls = TreeGitStore def create_store(self): d = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, d) store = self.kls.create(os.path.join(d, "store")) store.load_extra_file_handler(ICalendarFile) return store def add_blob(self, gc, name, contents): with open(os.path.join(gc.repo.path, name), "wb") as f: f.write(contents) gc.repo.stage(name.encode("utf-8")) return Blob.from_string(contents).id.decode("ascii") class ExtractRegularUIDTests(unittest.TestCase): def test_extract_no_uid(self): fi = File([EXAMPLE_VCALENDAR_NO_UID], "text/bla") self.assertRaises(NotImplementedError, fi.get_uid) xandikos-0.2.12/xandikos/tests/test_vcard.py000066400000000000000000000023431470075263100211150ustar00rootroot00000000000000# Xandikos # Copyright (C) 2022 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Tests for xandikos.vcard.""" import unittest from ..vcard import VCardFile EXAMPLE_VCARD1 = b"""\ BEGIN:VCARD VERSION:3.0 EMAIL;TYPE=INTERNET:jeffrey@osafoundation.org EMAIL;TYPE=INTERNET:jeffery@example.org ORG:Open Source Applications Foundation FN:Jeffrey Harris N:Harris;Jeffrey;;; END:VCARD """ class ParseVcardTests(unittest.TestCase): def test_validate(self): fi = VCardFile([EXAMPLE_VCARD1], "text/vcard") fi.validate() xandikos-0.2.12/xandikos/tests/test_web.py000066400000000000000000000145411470075263100205760ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Tests for xandikos.web.""" import os import shutil import tempfile import unittest from .. import caldav from ..icalendar import ICalendarFile from ..store.git import TreeGitStore from ..web import CalendarCollection, XandikosBackend EXAMPLE_VCALENDAR1 = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED:20150314T223512Z DTSTAMP:20150527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff END:VTODO END:VCALENDAR """ class CalendarCollectionTests(unittest.TestCase): def setUp(self): super().setUp() self.tempdir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.tempdir) self.store = TreeGitStore.create(os.path.join(self.tempdir, "c")) self.store.load_extra_file_handler(ICalendarFile) self.backend = XandikosBackend(self.tempdir) self.cal = CalendarCollection(self.backend, "c", self.store) def test_description(self): self.store.set_description("foo") self.assertEqual("foo", self.cal.get_calendar_description()) def test_color(self): self.assertRaises(KeyError, self.cal.get_calendar_color) self.cal.set_calendar_color("#aabbcc") self.assertEqual("#aabbcc", self.cal.get_calendar_color()) def test_get_supported_calendar_components(self): self.assertEqual( ["VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY"], self.cal.get_supported_calendar_components(), ) def test_calendar_query_vtodos(self): def create_fn(cls): f = cls(None) f.filter_subcomponent("VCALENDAR").filter_subcomponent("VTODO") return f self.assertEqual([], list(self.cal.calendar_query(create_fn))) self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) result = list(self.cal.calendar_query(create_fn)) self.assertEqual(1, len(result)) self.assertEqual("foo.ics", result[0][0]) self.assertIs(self.store, result[0][1].store) self.assertEqual("foo.ics", result[0][1].name) self.assertEqual("text/calendar", result[0][1].content_type) def test_calendar_query_vtodo_by_uid(self): def create_fn(cls): f = cls(None) f.filter_subcomponent("VCALENDAR").filter_subcomponent( "VTODO" ).filter_property("UID").filter_text_match( "bdc22720-b9e1-42c9-89c2-a85405d8fbff" ) return f self.assertEqual([], list(self.cal.calendar_query(create_fn))) self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) result = list(self.cal.calendar_query(create_fn)) self.assertEqual(1, len(result)) self.assertEqual("foo.ics", result[0][0]) self.assertIs(self.store, result[0][1].store) self.assertEqual("foo.ics", result[0][1].name) self.assertEqual("text/calendar", result[0][1].content_type) def test_get_supported_calendar_data_types(self): self.assertEqual( [("text/calendar", "1.0"), ("text/calendar", "2.0")], self.cal.get_supported_calendar_data_types(), ) def test_get_max_date_time(self): self.assertEqual("99991231T235959Z", self.cal.get_max_date_time()) def test_get_min_date_time(self): self.assertEqual("00010101T000000Z", self.cal.get_min_date_time()) def test_members(self): self.assertEqual([], list(self.cal.members())) self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) result = list(self.cal.members()) self.assertEqual(1, len(result)) self.assertEqual("foo.ics", result[0][0]) self.assertIs(self.store, result[0][1].store) self.assertEqual("foo.ics", result[0][1].name) self.assertEqual("text/calendar", result[0][1].content_type) def test_get_member(self): self.assertRaises(KeyError, self.cal.get_member, "foo.ics") self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) result = self.cal.get_member("foo.ics") self.assertIs(self.store, result.store) self.assertEqual("foo.ics", result.name) self.assertEqual("text/calendar", result.content_type) def test_delete_member(self): self.assertRaises(KeyError, self.cal.get_member, "foo.ics") self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) self.cal.get_member("foo.ics") self.cal.delete_member("foo.ics") self.assertRaises(KeyError, self.cal.get_member, "foo.ics") def test_get_schedule_calendar_transparency(self): self.assertEqual( caldav.TRANSPARENCY_OPAQUE, self.cal.get_schedule_calendar_transparency(), ) def test_git_refs(self): from ..web import XandikosApp from wsgiref.util import setup_testing_defaults self.store.import_one("foo.ics", "text/calendar", [EXAMPLE_VCALENDAR1]) app = XandikosApp(self.backend, "user") default_branch = self.store.repo.refs.follow(b"HEAD")[0][-1] commit_hash = self.store.repo.refs[default_branch] environ = {"PATH_INFO": "/c/.git/info/refs", "REQUEST_METHOD": "GET", "QUERY_STRING": ""} setup_testing_defaults(environ) codes = [] def start_response(code, _headers): codes.append(code) body = b"".join(app(environ, start_response)) self.assertEqual(["200 OK"], codes) self.assertEqual(b"".join([commit_hash, b"\t", default_branch, b"\n"]), body) xandikos-0.2.12/xandikos/tests/test_webdav.py000066400000000000000000000436211470075263100212720ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import unittest from io import BytesIO from wsgiref.util import setup_testing_defaults from xandikos import webdav from ..webdav import ET, Collection, Property, Resource, WebDAVApp, href_to_path class WebTestCase(unittest.TestCase): def setUp(self): super().setUp() logging.disable(logging.WARNING) self.addCleanup(logging.disable, logging.NOTSET) def makeApp(self, resources, properties): class Backend: get_resource = resources.get app = WebDAVApp(Backend()) app.register_properties(properties) return app class WebTests(WebTestCase): def _method(self, app, method, path): environ = {"PATH_INFO": path, "REQUEST_METHOD": method} setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) contents = b"".join(app(environ, start_response)) return _code[0], _headers, contents def lock(self, app, path): return self._method(app, "LOCK", path) def mkcol(self, app, path): environ = { "PATH_INFO": path, "REQUEST_METHOD": "MKCOL", } setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) contents = b"".join(app(environ, start_response)) return _code[0], _headers, contents def delete(self, app, path): environ = {"PATH_INFO": path, "REQUEST_METHOD": "DELETE"} setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) contents = b"".join(app(environ, start_response)) return _code[0], _headers, contents def get(self, app, path): environ = {"PATH_INFO": path, "REQUEST_METHOD": "GET"} setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) contents = b"".join(app(environ, start_response)) return _code[0], _headers, contents def put(self, app, path, contents): environ = { "PATH_INFO": path, "REQUEST_METHOD": "PUT", "wsgi.input": BytesIO(contents), } setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) list(app(environ, start_response)) return _code[0], _headers def propfind(self, app, path, body): environ = { "PATH_INFO": path, "REQUEST_METHOD": "PROPFIND", "CONTENT_TYPE": "text/xml", "wsgi.input": BytesIO(body), } setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) contents = b"".join(app(environ, start_response)) return _code[0], _headers, contents def test_not_found(self): app = self.makeApp({}, []) code, headers, contents = self.get(app, "/.well-known/carddav") self.assertEqual("404 Not Found", code) def test_get_body(self): class TestResource(Resource): async def get_body(self): return [b"this is content"] def get_last_modified(self): raise KeyError def get_content_language(self): raise KeyError async def get_etag(self): return "myetag" def get_content_type(self): return "text/plain" app = self.makeApp({"/.well-known/carddav": TestResource()}, []) code, headers, contents = self.get(app, "/.well-known/carddav") self.assertEqual("200 OK", code) self.assertEqual(b"this is content", contents) def test_set_body(self): new_body = [] class TestResource(Resource): async def set_body(self, body, replace_etag=None): new_body.extend(body) async def get_etag(self): return '"blala"' app = self.makeApp({"/.well-known/carddav": TestResource()}, []) code, headers = self.put(app, "/.well-known/carddav", b"New contents") self.assertEqual("204 No Content", code) self.assertEqual([b"New contents"], new_body) def test_lock_not_allowed(self): app = self.makeApp({}, []) code, headers, contents = self.lock(app, "/resource") self.assertEqual("405 Method Not Allowed", code) self.assertIn( ( "Allow", ( "DELETE, GET, HEAD, MKCOL, OPTIONS, " "POST, PROPFIND, PROPPATCH, PUT, REPORT" ), ), headers, ) self.assertEqual(b"", contents) def test_mkcol_ok(self): class Backend: def create_collection(self, relpath): pass def get_resource(self, relpath): return None app = WebDAVApp(Backend()) code, headers, contents = self.mkcol(app, "/resource/bla") self.assertEqual("201 Created", code) self.assertEqual(b"", contents) def test_mkcol_exists(self): app = self.makeApp({"/resource": Resource(), "/resource/bla": Resource()}, []) code, headers, contents = self.mkcol(app, "/resource/bla") self.assertEqual("405 Method Not Allowed", code) self.assertEqual(b"", contents) def test_delete(self): class TestResource(Collection): async def get_etag(self): return '"foo"' def delete_member(unused_self, name, etag=None): self.assertEqual(name, "resource") app = self.makeApp({"/": TestResource(), "/resource": TestResource()}, []) code, headers, contents = self.delete(app, "/resource") self.assertEqual("204 No Content", code) self.assertEqual(b"", contents) def test_delete_not_found(self): class TestResource(Collection): pass app = self.makeApp({"/resource": TestResource()}, []) code, headers, contents = self.delete(app, "/resource") self.assertEqual("404 Not Found", code) self.assertTrue(contents.endswith(b"/resource not found.")) def test_propfind_prop_does_not_exist(self): app = self.makeApp({"/resource": Resource()}, []) code, headers, contents = self.propfind( app, "/resource", b"""\ """, ) self.assertMultiLineEqual( contents.decode("utf-8"), '' "/resource" "HTTP/1.1 404 Not Found" "" "", ) self.assertEqual(code, "207 Multi-Status") def test_propfind_prop_not_present(self): class TestProperty(Property): name = "{DAV:}current-user-principal" async def get_value(self, href, resource, ret, environ): raise KeyError app = self.makeApp({"/resource": Resource()}, [TestProperty()]) code, headers, contents = self.propfind( app, "/resource", b"""\ """, ) self.assertMultiLineEqual( contents.decode("utf-8"), '' "/resource" "HTTP/1.1 404 Not Found" "" "", ) self.assertEqual(code, "207 Multi-Status") def test_propfind_found(self): class TestProperty(Property): name = "{DAV:}current-user-principal" async def get_value(self, href, resource, ret, environ): ET.SubElement(ret, "{DAV:}href").text = "/user/" app = self.makeApp({"/resource": Resource()}, [TestProperty()]) code, headers, contents = self.propfind( app, "/resource", b"""\ \ """, ) self.assertMultiLineEqual( contents.decode("utf-8"), '' "/resource" "HTTP/1.1 200 OK" "/user/" "" "", ) self.assertEqual(code, "207 Multi-Status") def test_propfind_found_multi(self): class TestProperty1(Property): name = "{DAV:}current-user-principal" async def get_value(self, href, resource, el, environ): ET.SubElement(el, "{DAV:}href").text = "/user/" class TestProperty2(Property): name = "{DAV:}somethingelse" async def get_value(self, href, resource, el, environ): pass app = self.makeApp( {"/resource": Resource()}, [TestProperty1(), TestProperty2()] ) code, headers, contents = self.propfind( app, "/resource", b"""\ \ """, ) self.maxDiff = None self.assertMultiLineEqual( contents.decode("utf-8"), '' "/resource" "HTTP/1.1 200 OK" "/user/" "" "", ) self.assertEqual(code, "207 Multi-Status") def test_propfind_found_multi_status(self): class TestProperty(Property): name = "{DAV:}current-user-principal" async def get_value(self, href, resource, ret, environ): ET.SubElement(ret, "{DAV:}href").text = "/user/" app = self.makeApp({"/resource": Resource()}, [TestProperty()]) code, headers, contents = self.propfind( app, "/resource", b"""\ \ """, ) self.maxDiff = None self.assertEqual(code, "207 Multi-Status") self.assertMultiLineEqual( contents.decode("utf-8"), """\ /resource\ HTTP/1.1 200 OK\ /user/\ \ HTTP/1.1 404 Not Found\ \ \ """, ) class PickContentTypesTests(unittest.TestCase): def test_not_acceptable(self): self.assertRaises( webdav.NotAcceptableError, webdav.pick_content_types, [("text/plain", {})], ["text/html"], ) self.assertRaises( webdav.NotAcceptableError, webdav.pick_content_types, [("text/plain", {}), ("text/html", {"q": "0"})], ["text/html"], ) def test_highest_q(self): self.assertEqual( ["text/plain"], webdav.pick_content_types( [("text/html", {"q": "0.3"}), ("text/plain", {"q": "0.4"})], ["text/plain", "text/html"], ), ) self.assertEqual( ["text/html", "text/plain"], webdav.pick_content_types( [("text/html", {}), ("text/plain", {"q": "1"})], ["text/plain", "text/html"], ), ) def test_no_q(self): self.assertEqual( ["text/html", "text/plain"], webdav.pick_content_types( [("text/html", {}), ("text/plain", {})], ["text/plain", "text/html"], ), ) def test_wildcard(self): self.assertEqual( ["text/plain"], webdav.pick_content_types( [("text/*", {"q": "0.3"}), ("text/plain", {"q": "0.4"})], ["text/plain", "text/html"], ), ) self.assertEqual( {"text/plain", "text/html"}, set( webdav.pick_content_types( [("text/*", {"q": "0.4"}), ("text/plain", {"q": "0.3"})], ["text/plain", "text/html"], ) ), ) self.assertEqual( ["application/html"], webdav.pick_content_types( [ ("application/*", {"q": "0.4"}), ("text/plain", {"q": "0.3"}), ], ["text/plain", "application/html"], ), ) class ParseAcceptHeaderTests(unittest.TestCase): def test_parse(self): self.assertEqual([], webdav.parse_accept_header("")) self.assertEqual( [("text/plain", {"q": "0.1"})], webdav.parse_accept_header("text/plain; q=0.1"), ) self.assertEqual( [("text/plain", {"q": "0.1"}), ("text/plain", {})], webdav.parse_accept_header("text/plain; q=0.1, text/plain"), ) class ETagMatchesTests(unittest.TestCase): def test_matches(self): self.assertTrue(webdav.etag_matches("etag1, etag2", "etag1")) self.assertFalse(webdav.etag_matches("etag3, etag2", "etag1")) self.assertFalse(webdav.etag_matches("etag1 etag2", "etag1")) self.assertFalse(webdav.etag_matches("etag1, etag2", None)) self.assertTrue(webdav.etag_matches("*, etag2", "etag1")) self.assertTrue(webdav.etag_matches("*", "etag1")) self.assertFalse(webdav.etag_matches("*", None)) class PropstatByStatusTests(unittest.TestCase): def test_none(self): self.assertEqual({}, webdav.propstat_by_status([])) def test_one(self): self.assertEqual( {("200 OK", None): ["foo"]}, webdav.propstat_by_status([webdav.PropStatus("200 OK", None, "foo")]), ) def test_multiple(self): self.assertEqual( { ("200 OK", None): ["foo"], ("404 Not Found", "Cannot find"): ["bar"], }, webdav.propstat_by_status( [ webdav.PropStatus("200 OK", None, "foo"), webdav.PropStatus("404 Not Found", "Cannot find", "bar"), ] ), ) class PropstatAsXmlTests(unittest.TestCase): def test_none(self): self.assertEqual([], list(webdav.propstat_as_xml([]))) def test_one(self): self.assertEqual( [ b'HTTP/1.1 200 ' b"OK" ], [ ET.tostring(x) for x in webdav.propstat_as_xml( [webdav.PropStatus("200 OK", None, ET.Element("foo"))] ) ], ) class PathFromEnvironTests(unittest.TestCase): def test_ascii(self): self.assertEqual( "/bla", webdav.path_from_environ({"PATH_INFO": "/bla"}, "PATH_INFO"), ) def test_recode(self): self.assertEqual( "/blü", webdav.path_from_environ({"PATH_INFO": "/bl\xc3\xbc"}, "PATH_INFO"), ) class HrefToPathTests(unittest.TestCase): def test_outside(self): self.assertIs(None, href_to_path({'SCRIPT_NAME': '/dav'}, '/bar')) def test_root(self): self.assertEqual('/', href_to_path({'SCRIPT_NAME': '/dav'}, '/dav')) self.assertEqual('/', href_to_path({'SCRIPT_NAME': '/dav/'}, '/dav')) self.assertEqual('/', href_to_path({'SCRIPT_NAME': '/dav/'}, '/dav/')) self.assertEqual('/', href_to_path({'SCRIPT_NAME': '/dav'}, '/dav/')) def test_relpath(self): self.assertEqual('/foo', href_to_path({'SCRIPT_NAME': '/dav'}, '/dav/foo')) self.assertEqual('/foo', href_to_path({'SCRIPT_NAME': '/dav/'}, '/dav/foo')) self.assertEqual('/foo/', href_to_path({'SCRIPT_NAME': '/dav/'}, '/dav/foo/')) xandikos-0.2.12/xandikos/tests/test_wsgi.py000066400000000000000000000020231470075263100207620ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import unittest from ..wsgi_helpers import WellknownRedirector class WebTests(unittest.TestCase): def test_wellknownredirector(self): def app(environ, start_response): pass WellknownRedirector(app, "/path") xandikos-0.2.12/xandikos/timezones.py000066400000000000000000000032011470075263100176240ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Timezone handling. See http://www.webdav.org/specs/rfc7809.html """ from xandikos import webdav class TimezoneServiceSetProperty(webdav.Property): """timezone-service-set property. See http://www.webdav.org/specs/rfc7809.html, section 5.1 """ name = "{DAV:}timezone-service-set" # Should be set on CalDAV calendar home collection resources, # but Xandikos doesn't have a separate resource type for those. resource_type = webdav.COLLECTION_RESOURCE_TYPE in_allprops = False live = True def __init__(self, timezone_services) -> None: super().__init__() self._timezone_services = timezone_services async def get_value(self, base_href, resource, el, environ): for timezone_service_href in self._timezone_services: el.append(webdav.create_href(timezone_service_href, base_href)) xandikos-0.2.12/xandikos/vcard.py000066400000000000000000000042141470075263100167130ustar00rootroot00000000000000# Xandikos # Copyright (C) 2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """VCard file handling.""" from .store import File, InvalidFileContents class VCardFile(File): content_type = "text/vcard" def __init__(self, content, content_type) -> None: super().__init__(content, content_type) self._addressbook = None def validate(self): c = b"".join(self.content).strip() # TODO(jelmer): Do more extensive checking of VCards if not c.startswith((b"BEGIN:VCARD\r\n", b"BEGIN:VCARD\n")) or not c.endswith( b"\nEND:VCARD" ): raise InvalidFileContents( self.content_type, self.content, "Missing header and trailer lines", ) if not self.addressbook.validate(): # TODO(jelmer): Get data about what is invalid raise InvalidFileContents( self.content_type, self.content, "Invalid VCard file" ) @property def addressbook(self): if self._addressbook is None: import vobject text = b"".join(self.content).decode("utf-8", "surrogateescape") try: self._addressbook = vobject.readOne(text) except vobject.base.ParseError as exc: raise InvalidFileContents( self.content_type, self.content, str(exc) ) from exc return self._addressbook xandikos-0.2.12/xandikos/web.py000066400000000000000000001435121470075263100163760ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Web server implementation.. This is the concrete web server implementation. It provides the high level application logic that combines the WebDAV server, the carddav support, the caldav support and the DAV store. """ import asyncio import functools import hashlib import logging import os import posixpath import shutil import socket import urllib.parse from collections.abc import Iterable, Iterator from email.utils import parseaddr from typing import Optional from dulwich.web import make_wsgi_chain from dulwich.server import DictBackend from itertools import takewhile import jinja2 try: import systemd.daemon except ImportError: systemd_imported = False def get_systemd_listen_sockets() -> list[socket.socket]: raise NotImplementedError else: systemd_imported = True def get_systemd_listen_sockets() -> list[socket.socket]: socks = [] for fd in systemd.daemon.listen_fds(): for family in ( socket.AF_UNIX, # type: ignore socket.AF_INET, socket.AF_INET6, ): if systemd.daemon.is_socket( fd, family=family, type=socket.SOCK_STREAM, listening=True ): sock = socket.fromfd(fd, family, socket.SOCK_STREAM) socks.append(sock) break else: raise RuntimeError( "socket family must be AF_INET, AF_INET6, or AF_UNIX; " "socket type must be SOCK_STREAM; and it must be listening" ) return socks from xandikos import __version__ as xandikos_version from xandikos import ( access, apache, caldav, carddav, infit, quota, scheduling, sync, timezones, webdav, xmpp, ) from xandikos.store import ( STORE_TYPE_ADDRESSBOOK, STORE_TYPE_CALENDAR, STORE_TYPE_OTHER, STORE_TYPE_PRINCIPAL, STORE_TYPE_SCHEDULE_INBOX, STORE_TYPE_SCHEDULE_OUTBOX, STORE_TYPE_SUBSCRIPTION, DuplicateUidError, File, InvalidCTag, InvalidFileContents, LockedError, NoSuchItem, NotStoreError, OutOfSpaceError, Store, ) from .icalendar import CalendarFilter, ICalendarFile from .store.git import GitStore, TreeGitStore from .vcard import VCardFile try: from asyncio import to_thread # type: ignore except ImportError: # python < 3.8 import contextvars from asyncio import events async def to_thread(func, *args, **kwargs): # type: ignore loop = events.get_running_loop() ctx = contextvars.copy_context() func_call = functools.partial(ctx.run, func, *args, **kwargs) return await loop.run_in_executor(None, func_call) WELLKNOWN_DAV_PATHS = { caldav.WELLKNOWN_CALDAV_PATH, carddav.WELLKNOWN_CARDDAV_PATH, } STORE_CACHE_SIZE = 128 # TODO(jelmer): Make these configurable/dynamic CALENDAR_HOME_SET = ["calendars"] ADDRESSBOOK_HOME_SET = ["contacts"] GIT_PATH = ".git" TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") jinja_env = jinja2.Environment( loader=jinja2.FileSystemLoader(TEMPLATES_DIR), enable_async=True ) async def render_jinja_page( name: str, accepted_content_languages: list[str], **kwargs ) -> tuple[Iterable[bytes], int, Optional[str], str, list[str]]: """Render a HTML page from jinja template. Args: name: Name of the page accepted_content_languages: List of accepted content languages Returns: Tuple of (body, content_length, etag, content_type, languages) """ # TODO(jelmer): Support rendering other languages encoding = "utf-8" template = jinja_env.get_template(name) body = await template.render_async( version=xandikos_version, urljoin=urllib.parse.urljoin, **kwargs ) body_encoded = body.encode(encoding) return ( [body_encoded], len(body_encoded), None, f"text/html; encoding={encoding}", ["en-UK"], ) def create_strong_etag(etag: str) -> str: """Create strong etags. Args: etag: basic etag Returns: A strong etag """ return '"' + etag + '"' def extract_strong_etag(etag: Optional[str]) -> Optional[str]: """Extract a strong etag from a string.""" if etag is None: return etag return etag.strip('"') class ObjectResource(webdav.Resource): """Object resource.""" def __init__( self, store: Store, name: str, content_type: str, etag: str, file: Optional[File] = None, ) -> None: self.store = store self.name = name self.etag = etag self.content_type = content_type self._file = file def __repr__(self) -> str: return "{}({!r}, {!r}, {!r}, {!r})".format( type(self).__name__, self.store, self.name, self.etag, self.get_content_type(), ) async def get_file(self) -> File: if self._file is None: self._file = await to_thread( self.store.get_file, self.name, self.content_type, self.etag ) assert self._file is not None return self._file async def get_body(self) -> Iterable[bytes]: file = await self.get_file() return file.content async def set_body(self, data, replace_etag=None): try: (name, etag) = await to_thread( self.store.import_one, self.name, self.content_type, data, replace_etag=extract_strong_etag(replace_etag), ) except InvalidFileContents as exc: # TODO(jelmer): Not every invalid file is a calendar file.. raise webdav.PreconditionFailure( "{%s}valid-calendar-data" % caldav.NAMESPACE, f"Not a valid calendar file: {exc.error}", ) from exc except DuplicateUidError as exc: raise webdav.PreconditionFailure( "{%s}no-uid-conflict" % caldav.NAMESPACE, "UID already in use." ) from exc except LockedError as exc: raise webdav.ResourceLocked() from exc return create_strong_etag(etag) def get_content_language(self) -> str: raise KeyError def get_content_type(self) -> str: return self.content_type async def get_content_length(self) -> int: return sum(map(len, await self.get_body())) async def get_etag(self) -> str: return create_strong_etag(self.etag) def get_supported_locks(self): return [] def get_active_locks(self): return [] def get_owner(self): return None def get_comment(self): raise KeyError def set_comment(self, comment): raise NotImplementedError(self.set_comment) def get_creationdate(self): # TODO(jelmer): Find creation date using store function raise KeyError def get_last_modified(self): # TODO(jelmer): Find last modified time using store function raise KeyError def get_is_executable(self): # TODO(jelmer): Retrieve POSIX mode and check for executability. return False def get_quota_used_bytes(self): # TODO(jelmer): Ask the store? raise KeyError def get_quota_available_bytes(self): # TODO(jelmer): Ask the store? raise KeyError def get_schedule_tag(self): # TODO(jelmer): Ask the store? raise KeyError class StoreBasedCollection: def __init__(self, backend, relpath, store) -> None: self.backend = backend self.relpath = relpath self.store = store def __repr__(self) -> str: return f"{type(self).__name__}({self.store!r})" def set_resource_types(self, resource_types): # TODO(jelmer): Allow more than just this set; allow combining # addressbook/calendar. resource_types = set(resource_types) if resource_types == { caldav.CALENDAR_RESOURCE_TYPE, webdav.COLLECTION_RESOURCE_TYPE, }: self.store.set_type(STORE_TYPE_CALENDAR) elif resource_types == { carddav.ADDRESSBOOK_RESOURCE_TYPE, webdav.COLLECTION_RESOURCE_TYPE, }: self.store.set_type(STORE_TYPE_ADDRESSBOOK) elif resource_types == {webdav.PRINCIPAL_RESOURCE_TYPE}: self.store.set_type(STORE_TYPE_PRINCIPAL) elif resource_types == { caldav.SCHEDULE_INBOX_RESOURCE_TYPE, webdav.COLLECTION_RESOURCE_TYPE, }: self.store.set_type(STORE_TYPE_SCHEDULE_INBOX) elif resource_types == { caldav.SCHEDULE_OUTBOX_RESOURCE_TYPE, webdav.COLLECTION_RESOURCE_TYPE, }: self.store.set_type(STORE_TYPE_SCHEDULE_OUTBOX) elif resource_types == {webdav.COLLECTION_RESOURCE_TYPE}: self.store.set_type(STORE_TYPE_OTHER) elif resource_types == { webdav.COLLECTION_RESOURCE_TYPE, caldav.SUBSCRIPTION_RESOURCE_TYPE, }: self.store.set_type(STORE_TYPE_SUBSCRIPTION) else: raise NotImplementedError(self.set_resource_types) def _get_resource( self, name: str, content_type: str, etag: str, file: Optional[File] = None, ) -> webdav.Resource: return ObjectResource(self.store, name, content_type, etag, file=file) def _get_subcollection(self, name: str) -> webdav.Collection: return self.backend.get_resource(posixpath.join(self.relpath, name)) def get_displayname(self) -> str: displayname = self.store.get_displayname() if displayname is None: return os.path.basename(self.store.repo.path) return displayname def set_displayname(self, displayname: str) -> None: self.store.set_displayname(displayname) def get_sync_token(self) -> str: return self.store.get_ctag() def get_ctag(self) -> str: return self.store.get_ctag() async def get_etag(self) -> str: return create_strong_etag(self.store.get_ctag()) def members(self) -> Iterator[tuple[str, webdav.Resource]]: for name, content_type, etag in self.store.iter_with_etag(): resource = self._get_resource(name, content_type, etag) yield (name, resource) for name, resource in self.subcollections(): yield (name, resource) def subcollections(self): for name in self.store.subdirectories(): yield (name, self._get_subcollection(name)) def get_member(self, name): assert name != "" for fname, content_type, fetag in self.store.iter_with_etag(): if name == fname: return self._get_resource(name, content_type, fetag) if name in self.store.subdirectories(): return self._get_subcollection(name) raise KeyError(name) def delete_member(self, name, etag=None): assert name != "" try: self.store.delete_one(name, etag=extract_strong_etag(etag)) except NoSuchItem: # TODO: Properly allow removing subcollections # self.get_subcollection(name).destroy() shutil.rmtree(os.path.join(self.store.path, name)) async def create_member( self, name: str, contents: Iterable[bytes], content_type: str ) -> tuple[str, str]: try: (name, etag) = self.store.import_one(name, content_type, contents) except InvalidFileContents as exc: # TODO(jelmer): Not every invalid file is a calendar file.. raise webdav.PreconditionFailure( "{%s}valid-calendar-data" % caldav.NAMESPACE, f"Not a valid calendar file: {exc.error}", ) from exc except DuplicateUidError as exc: raise webdav.PreconditionFailure( "{%s}no-uid-conflict" % caldav.NAMESPACE, "UID already in use." ) from exc except OutOfSpaceError as exc: raise webdav.InsufficientStorage() from exc except LockedError as exc: raise webdav.ResourceLocked() from exc return (name, create_strong_etag(etag)) def iter_differences_since( self, old_token: str, new_token: str ) -> Iterator[tuple[str, Optional[webdav.Resource], Optional[webdav.Resource]]]: old_resource: Optional[webdav.Resource] new_resource: Optional[webdav.Resource] try: for ( name, content_type, old_etag, new_etag, ) in self.store.iter_changes(old_token, new_token): if old_etag is not None: old_resource = self._get_resource(name, content_type, old_etag) else: old_resource = None if new_etag is not None: new_resource = self._get_resource(name, content_type, new_etag) else: new_resource = None yield (name, old_resource, new_resource) except InvalidCTag as exc: raise sync.InvalidToken(exc.ctag) from exc def get_owner(self): return None def get_supported_locks(self): return [] def get_active_locks(self): return [] def get_headervalue(self): raise KeyError def get_comment(self): return self.store.get_comment() def set_comment(self, comment): self.store.set_comment(comment) def get_creationdate(self): # TODO(jelmer): Find creation date using store function raise KeyError def get_last_modified(self): # TODO(jelmer): Find last modified time using store function raise KeyError def get_content_type(self): return "httpd/unix-directory" def get_content_language(self): raise KeyError async def get_content_length(self): raise KeyError def destroy(self) -> None: # RFC2518, section 8.6.2 says this should recursively delete. self.store.destroy() async def get_body(self): raise NotImplementedError(self.get_body) async def render( self, self_url, accepted_content_types, accepted_content_languages ): content_types = webdav.pick_content_types(accepted_content_types, ["text/html"]) assert content_types == ["text/html"] return await render_jinja_page( "collection.html", accepted_content_languages, collection=self, self_url=self_url, ) def get_is_executable(self) -> bool: return False def get_quota_used_bytes(self): # TODO(jelmer): Ask the store? raise KeyError def get_quota_available_bytes(self): # TODO(jelmer): Ask the store? raise KeyError def get_refreshrate(self): # TODO(jelmer): Support setting refreshrate raise KeyError def set_refreshrate(self, value): # TODO(jelmer): Store refreshrate raise NotImplementedError(self.set_refreshrate) class Collection(StoreBasedCollection, webdav.Collection): """A generic WebDAV collection.""" class ScheduleInbox(StoreBasedCollection, scheduling.ScheduleInbox): """A schedling inbox collection.""" class ScheduleOutbox(StoreBasedCollection, scheduling.ScheduleOutbox): """A schedling outbox collection.""" class SubscriptionCollection(StoreBasedCollection, caldav.Subscription): def get_source_url(self): source_url = self.store.get_source_url() if source_url is None: raise KeyError return source_url def set_source_url(self, url): self.store.set_source_url(url) def get_calendar_description(self): return self.store.get_description() def get_calendar_color(self): color = self.store.get_color() if not color: raise KeyError if color and color[0] != "#": color = "#" + color return color def set_calendar_color(self, color): self.store.set_color(color) def get_supported_calendar_components(self): return ["VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY"] class CalendarCollection(StoreBasedCollection, caldav.Calendar): def get_calendar_description(self): return self.store.get_description() def get_calendar_color(self): color = self.store.get_color() if not color: raise KeyError if color and color[0] != "#": color = "#" + color return color def set_calendar_color(self, color): self.store.set_color(color) def get_calendar_order(self): order = self.store.config.get_order() if not order: raise KeyError return order def set_calendar_order(self, order): self.store.config.set_order(order) def get_calendar_timezone(self): # TODO(jelmer): Read from config raise KeyError def set_calendar_timezone(self, content): raise NotImplementedError(self.set_calendar_timezone) def get_supported_calendar_components(self): return ["VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY"] def get_supported_calendar_data_types(self): return [("text/calendar", "1.0"), ("text/calendar", "2.0")] def get_max_date_time(self): return "99991231T235959Z" def get_min_date_time(self): return "00010101T000000Z" def get_max_instances(self): raise KeyError def get_max_attendees_per_instance(self): raise KeyError def get_max_resource_size(self): # No resource limit raise KeyError def get_max_attachments_per_resource(self): # No resource limit raise KeyError def get_max_attachment_size(self): # No resource limit raise KeyError def get_schedule_calendar_transparency(self): # TODO(jelmer): Allow configuration in config return caldav.TRANSPARENCY_OPAQUE def get_managed_attachments_server_url(self): # TODO(jelmer) raise KeyError def calendar_query(self, create_filter_fn): filter = create_filter_fn(CalendarFilter) for name, file, etag in self.store.iter_with_filter(filter=filter): resource = self._get_resource(name, file.content_type, etag, file=file) yield (name, resource) def get_xmpp_heartbeat(self): # TODO raise KeyError def get_xmpp_server(self): # TODO raise KeyError def get_xmpp_uri(self): # TODO raise KeyError class AddressbookCollection(StoreBasedCollection, carddav.Addressbook): def get_addressbook_description(self): return self.store.get_description() def set_addressbook_description(self, description): self.store.set_description(description) def get_supported_address_data_types(self): return [("text/vcard", "3.0")] def get_max_resource_size(self): # No resource limit raise KeyError def get_max_image_size(self): # No resource limit raise KeyError def set_addressbook_color(self, color): self.store.set_color(color) def get_addressbook_color(self): color = self.store.get_color() if not color: raise KeyError if color and color[0] != "#": color = "#" + color return color class CollectionSetResource(webdav.Collection): """Resource for calendar sets.""" def __init__(self, backend, relpath) -> None: self.backend = backend self.relpath = relpath @classmethod def create(cls, backend, relpath): path = backend._map_to_file_path(relpath) if not os.path.isdir(path): os.makedirs(path) logging.info("Creating %s", path) return cls(backend, relpath) def get_displayname(self): return posixpath.basename(self.relpath) def get_sync_token(self): raise KeyError async def get_etag(self): raise KeyError def get_ctag(self): raise KeyError def get_supported_locks(self): return [] def get_active_locks(self): return [] def get_owner(self): return None def members(self): p = self.backend._map_to_file_path(self.relpath) for name in os.listdir(p): if name.startswith("."): continue resource = self.get_member(name) yield (name, resource) def get_member(self, name): assert name != "" relpath = posixpath.join(self.relpath, name) p = self.backend._map_to_file_path(relpath) if not os.path.isdir(p): raise KeyError(name) return self.backend.get_resource(relpath) def get_headervalue(self): raise KeyError def get_comment(self): raise KeyError def set_comment(self, comment): raise NotImplementedError(self.set_comment) def get_content_type(self): return "httpd/unix-directory" def get_content_language(self): raise KeyError async def get_content_length(self): raise KeyError def get_last_modified(self): # TODO(jelmer): Find last modified time using store function raise KeyError def delete_member(self, name, etag=None): # This doesn't have any non-collection members. self.get_member(name).destroy() def destroy(self): p = self.backend._map_to_file_path(self.relpath) # RFC2518, section 8.6.2 says this should recursively delete. shutil.rmtree(p) async def render( self, self_url, accepted_content_types, accepted_content_languages ): content_types = webdav.pick_content_types(accepted_content_types, ["text/html"]) assert content_types == ["text/html"] return await render_jinja_page( "root.html", accepted_content_languages, self_url=self_url ) def get_is_executable(self): return False def get_quota_used_bytes(self): # TODO(jelmer): Ask the store? raise KeyError def get_quota_available_bytes(self): # TODO(jelmer): Ask the store? raise KeyError def get_creationdate(self): # TODO(jelmer): Find creation date using store function raise KeyError class RootPage(webdav.Resource): """A non-DAV resource.""" resource_types: list[str] = [] def __init__(self, backend) -> None: self.backend = backend def render(self, self_url, accepted_content_types, accepted_content_languages): content_types = webdav.pick_content_types(accepted_content_types, ["text/html"]) assert content_types == ["text/html"] return render_jinja_page( "root.html", accepted_content_languages, principals=self.backend.find_principals(), self_url=self_url, ) async def get_body(self): raise KeyError async def get_content_length(self): raise KeyError def get_content_type(self): return "text/html" def get_supported_locks(self): return [] def get_active_locks(self): return [] async def get_etag(self): h = hashlib.md5() for c in await self.get_body(): h.update(c) return h.hexdigest() def get_last_modified(self): raise KeyError def get_content_language(self): return ["en-UK"] def get_member(self, name): return self.backend.get_resource("/" + name) def delete_member(self, name, etag=None): # This doesn't have any non-collection members. self.get_member("/" + name).destroy() def get_is_executable(self): return False def get_quota_used_bytes(self): # TODO(jelmer): Ask the store? raise KeyError def get_quota_available_bytes(self): # TODO(jelmer): Ask the store? raise KeyError class Principal(webdav.Principal): def get_principal_url(self): return "." def get_principal_address(self): raise KeyError def get_calendar_home_set(self): return CALENDAR_HOME_SET def get_addressbook_home_set(self): return ADDRESSBOOK_HOME_SET def get_calendar_user_address_set(self): # TODO(jelmer): Make this configurable ret = [] try: (fullname, email) = parseaddr(os.environ["EMAIL"]) except KeyError: pass else: ret.append("mailto:" + email) return ret def set_infit_settings(self, settings): relpath = posixpath.join(self.relpath, ".infit") p = self.backend._map_to_file_path(relpath) with open(p, "w") as f: f.write(settings) def get_infit_settings(self): relpath = posixpath.join(self.relpath, ".infit") p = self.backend._map_to_file_path(relpath) if not os.path.exists(p): raise KeyError with open(p) as f: return f.read() def get_group_membership(self): """Get group membership URLs.""" return [] def get_calendar_user_type(self): # TODO(jelmer) return scheduling.CALENDAR_USER_TYPE_INDIVIDUAL def get_calendar_proxy_read_for(self): # TODO(jelmer) return [] def get_calendar_proxy_write_for(self): # TODO(jelmer) return [] def get_owner(self): return None def get_schedule_outbox_url(self): raise KeyError def get_schedule_inbox_url(self): # TODO(jelmer): make this configurable return "inbox" def get_creationdate(self): raise KeyError class PrincipalBare(CollectionSetResource, Principal): """Principal user resource.""" resource_types = [webdav.PRINCIPAL_RESOURCE_TYPE] @classmethod def create(cls, backend, relpath): p = super().create(backend, relpath) to_create = set() to_create.update(p.get_addressbook_home_set()) to_create.update(p.get_calendar_home_set()) for n in to_create: try: backend.create_collection(posixpath.join(relpath, n)) except FileExistsError: pass return p async def render( self, self_url, accepted_content_types, accepted_content_languages ): content_types = webdav.pick_content_types(accepted_content_types, ["text/html"]) assert content_types == ["text/html"] return await render_jinja_page( "principal.html", accepted_content_languages, principal=self, self_url=self_url, ) def subcollections(self): # TODO(jelmer): Return members return [] class PrincipalCollection(Collection, Principal): """Principal user resource.""" resource_types = webdav.Collection.resource_types + [webdav.PRINCIPAL_RESOURCE_TYPE] @classmethod def create(cls, backend, relpath): p = super().create(backend, relpath) p.store.set_type(STORE_TYPE_PRINCIPAL) to_create = set() to_create.update(p.get_addressbook_home_set()) to_create.update(p.get_calendar_home_set()) for n in to_create: try: backend.create_collection(posixpath.join(relpath, n)) except FileExistsError: pass return p @functools.lru_cache(maxsize=STORE_CACHE_SIZE) def open_store_from_path(path: str, **kwargs): store = GitStore.open_from_path(path, **kwargs) store.load_extra_file_handler(ICalendarFile) store.load_extra_file_handler(VCardFile) return store class XandikosBackend(webdav.Backend): def __init__( self, path, *, paranoid: bool = False, index_threshold: Optional[int] = None ) -> None: self.path = path self._user_principals: set[str] = set() self.paranoid = paranoid self.index_threshold = index_threshold def _map_to_file_path(self, relpath): return os.path.join(self.path, relpath.lstrip("/")) def _mark_as_principal(self, path): self._user_principals.add(posixpath.normpath(path)) def create_collection(self, relpath): p = self._map_to_file_path(relpath) return Collection(self, relpath, TreeGitStore.create(p)) def create_principal(self, relpath, create_defaults=False): principal = PrincipalBare.create(self, relpath) self._mark_as_principal(relpath) if create_defaults: create_principal_defaults(self, principal) def find_principals(self): """List all of the principals on this server.""" return self._user_principals def get_resource(self, relpath): relpath = posixpath.normpath(relpath) if not relpath.startswith("/"): raise ValueError("relpath %r should start with /") if relpath == "/": return RootPage(self) p = self._map_to_file_path(relpath) if p is None: return None if os.path.isdir(p): try: store = open_store_from_path( p, double_check_indexes=self.paranoid, index_threshold=self.index_threshold, ) except NotStoreError: if relpath in self._user_principals: return PrincipalBare(self, relpath) return CollectionSetResource(self, relpath) else: return { STORE_TYPE_CALENDAR: CalendarCollection, STORE_TYPE_ADDRESSBOOK: AddressbookCollection, STORE_TYPE_PRINCIPAL: PrincipalCollection, STORE_TYPE_SCHEDULE_INBOX: ScheduleInbox, STORE_TYPE_SCHEDULE_OUTBOX: ScheduleOutbox, STORE_TYPE_SUBSCRIPTION: SubscriptionCollection, STORE_TYPE_OTHER: Collection, }[store.get_type()](self, relpath, store) else: (basepath, name) = os.path.split(relpath) assert name != "", f"path is {relpath!r}" store = self.get_resource(basepath) if store is None: return None if webdav.COLLECTION_RESOURCE_TYPE not in store.resource_types: return None try: return store.get_member(name) except KeyError: return None class XandikosApp(webdav.WebDAVApp): """A wsgi App that provides a Xandikos web server.""" def __init__(self, backend, current_user_principal, strict=True) -> None: super().__init__(backend, strict=strict) def get_current_user_principal(env): try: return current_user_principal % env except KeyError: return None self.register_properties( [ webdav.ResourceTypeProperty(), webdav.CurrentUserPrincipalProperty(get_current_user_principal), webdav.PrincipalURLProperty(), webdav.DisplayNameProperty(), webdav.GetETagProperty(), webdav.GetContentTypeProperty(), webdav.GetContentLengthProperty(), webdav.GetContentLanguageProperty(), caldav.SourceProperty(), caldav.CalendarHomeSetProperty(), carddav.AddressbookHomeSetProperty(), caldav.CalendarDescriptionProperty(), caldav.CalendarColorProperty(), caldav.CalendarOrderProperty(), caldav.CreatedByProperty(), caldav.UpdatedByProperty(), caldav.SupportedCalendarComponentSetProperty(), carddav.AddressbookDescriptionProperty(), carddav.PrincipalAddressProperty(), webdav.AppleGetCTagProperty(), webdav.DAVGetCTagProperty(), carddav.SupportedAddressDataProperty(), webdav.SupportedReportSetProperty(self.reporters), sync.SyncTokenProperty(), caldav.SupportedCalendarDataProperty(), caldav.CalendarTimezoneProperty(), caldav.MinDateTimeProperty(), caldav.MaxDateTimeProperty(), caldav.MaxResourceSizeProperty(), carddav.MaxResourceSizeProperty(), carddav.MaxImageSizeProperty(), access.CurrentUserPrivilegeSetProperty(), access.OwnerProperty(), webdav.CreationDateProperty(), webdav.SupportedLockProperty(), webdav.LockDiscoveryProperty(), infit.AddressbookColorProperty(), infit.SettingsProperty(), infit.HeaderValueProperty(), webdav.CommentProperty(), scheduling.CalendarUserAddressSetProperty(), scheduling.ScheduleInboxURLProperty(), scheduling.ScheduleOutboxURLProperty(), scheduling.CalendarUserTypeProperty(), scheduling.ScheduleTagProperty(), webdav.GetLastModifiedProperty(), timezones.TimezoneServiceSetProperty([]), webdav.AddMemberProperty(), caldav.ScheduleCalendarTransparencyProperty(), scheduling.ScheduleDefaultCalendarURLProperty(), caldav.MaxInstancesProperty(), caldav.MaxAttendeesPerInstanceProperty(), access.GroupMembershipProperty(), apache.ExecutableProperty(), caldav.CalendarProxyReadForProperty(), caldav.CalendarProxyWriteForProperty(), caldav.MaxAttachmentSizeProperty(), caldav.MaxAttachmentsPerResourceProperty(), caldav.ManagedAttachmentsServerURLProperty(), quota.QuotaAvailableBytesProperty(), quota.QuotaUsedBytesProperty(), webdav.RefreshRateProperty(), xmpp.XmppUriProperty(), xmpp.XmppServerProperty(), xmpp.XmppHeartbeatProperty(), ] ) self.register_reporters( [ caldav.CalendarMultiGetReporter(), caldav.CalendarQueryReporter(), carddav.AddressbookMultiGetReporter(), carddav.AddressbookQueryReporter(), webdav.ExpandPropertyReporter(), sync.SyncCollectionReporter(), caldav.FreeBusyQueryReporter(), ] ) self.register_methods( [ caldav.MkcalendarMethod(), ] ) async def _handle_request(self, request, environ, start_response=None): if start_response and GIT_PATH in request.path.split(posixpath.sep): return self._handle_git_request(request, environ["ORIGINAL_ENVIRON"], takewhile(lambda x: x != GIT_PATH, request.path.split(posixpath.sep)), start_response) else: return await super()._handle_request(request, environ) def _handle_git_request(self, request, environ, path, start_response): resource_path = posixpath.join("/", *path) resource = self.backend.get_resource(resource_path) if not isinstance(resource, StoreBasedCollection) or not isinstance(resource.store, GitStore): return webdav._send_not_found(request) prefix = posixpath.join(resource_path, GIT_PATH) chain = make_wsgi_chain(DictBackend({prefix: resource.store.repo}), dumb=True) return chain(environ, start_response) def create_principal_defaults(backend, principal): """Create default calendar and addressbook for a principal. Args: backend: Backend in which the principal exists. principal: Principal object """ calendar_path = posixpath.join( principal.relpath, principal.get_calendar_home_set()[0], "calendar" ) try: resource = backend.create_collection(calendar_path) except FileExistsError: pass else: resource.store.set_type(STORE_TYPE_CALENDAR) logging.info("Create calendar in %s.", resource.store.path) addressbook_path = posixpath.join( principal.relpath, principal.get_addressbook_home_set()[0], "addressbook", ) try: resource = backend.create_collection(addressbook_path) except FileExistsError: pass else: resource.store.set_type(STORE_TYPE_ADDRESSBOOK) logging.info("Create addressbook in %s.", resource.store.path) calendar_path = posixpath.join( principal.relpath, principal.get_schedule_inbox_url() ) try: resource = backend.create_collection(calendar_path) except FileExistsError: pass else: resource.store.set_type(STORE_TYPE_SCHEDULE_INBOX) logging.info("Create inbox in %s.", resource.store.path) class RedirectDavHandler: def __init__(self, dav_root: str) -> None: self._dav_root = dav_root async def __call__(self, request): from aiohttp import web return web.HTTPFound(self._dav_root) MDNS_NAME = "Xandikos CalDAV/CardDAV service" def avahi_register(port: int, path: str): import avahi import dbus bus = dbus.SystemBus() server = dbus.Interface( bus.get_object(avahi.DBUS_NAME, avahi.DBUS_PATH_SERVER), avahi.DBUS_INTERFACE_SERVER, ) group = dbus.Interface( bus.get_object(avahi.DBUS_NAME, server.EntryGroupNew()), avahi.DBUS_INTERFACE_ENTRY_GROUP, ) for service in ["_carddav._tcp", "_caldav._tcp"]: try: group.AddService( avahi.IF_UNSPEC, avahi.PROTO_INET, 0, MDNS_NAME, service, "", "", port, avahi.string_array_to_txt_array([f"path={path}"]), ) except dbus.DBusException as e: logging.error("Error registering %s: %s", service, e) group.Commit() def run_simple_server( directory: str, current_user_principal: str, autocreate: bool = False, defaults: bool = False, strict: bool = True, route_prefix: str = "/", listen_address: Optional[str] = "::", port: Optional[int] = 8080, socket_path: Optional[str] = None, ) -> None: """Simple function to run a Xandikos server. This function is meant to be used by external code. We'll try our best not to break API compatibility. Args: directory: Directory to store data in ("/tmp/blah") current_user_principal: Name of current user principal ("/user") autocreate: Whether to create missing principals and collections defaults: Whether to create default calendar and addressbook collections strict: Whether to be strict in *DAV implementation. Set to False for buggy clients route_prefix: Route prefix under which to server ("/") listen_address: IP address to listen on (None to disable) port: TCP Port to listen on (None to disable) socket_path: Unix domain socket path to listen on (None to disable) """ backend = XandikosBackend(directory) backend._mark_as_principal(current_user_principal) if autocreate or defaults: if not os.path.isdir(directory): os.makedirs(directory) backend.create_principal(current_user_principal, create_defaults=defaults) if not os.path.isdir(directory): logging.warning( "%r does not exist. Run xandikos with --autocreate?", directory, ) if not backend.get_resource(current_user_principal): logging.warning( "default user principal %s does not exist. " "Run xandikos with --autocreate?", current_user_principal, ) main_app = XandikosApp( backend, current_user_principal=current_user_principal, strict=strict, ) async def xandikos_handler(request): return await main_app.aiohttp_handler(request, route_prefix) if socket_path: logging.info("Listening on unix domain socket %s", socket_path) if listen_address and port: logging.info("Listening on %s:%s", listen_address, port) from aiohttp import web app = web.Application() for path in WELLKNOWN_DAV_PATHS: app.router.add_route("*", path, RedirectDavHandler(route_prefix).__call__) if route_prefix.strip("/"): xandikos_app = web.Application() xandikos_app.router.add_route("*", "/{path_info:.*}", xandikos_handler) async def redirect_to_subprefix(request): return web.HTTPFound(route_prefix) app.router.add_route("*", "/", redirect_to_subprefix) app.add_subapp(route_prefix, xandikos_app) else: app.router.add_route("*", "/{path_info:.*}", xandikos_handler) web.run_app(app, port=port, host=listen_address, path=socket_path) async def main(argv=None): # noqa: C901 import argparse import sys from xandikos import __version__ parser = argparse.ArgumentParser(usage="%(prog)s -d ROOT-DIR [OPTIONS]") parser.add_argument( "--version", action="version", version="%(prog)s " + ".".join(map(str, __version__)), ) access_group = parser.add_argument_group(title="Access Options") access_group.add_argument( "--no-detect-systemd", action="store_false", dest="detect_systemd", help="Disable systemd detection and socket activation.", default=systemd_imported, ) access_group.add_argument( "-l", "--listen-address", dest="listen_address", default="localhost", help=( "Bind to this address. " "Pass in path for unix domain socket. [%(default)s]" ), ) access_group.add_argument( "-p", "--port", dest="port", type=int, default=8080, help="Port to listen on. [%(default)s]", ) access_group.add_argument( "--metrics-port", dest="metrics_port", default=None, help="Port to listen on for metrics. [%(default)s]", ) access_group.add_argument( "--route-prefix", default="/", help=( "Path to Xandikos. " "(useful when Xandikos is behind a reverse proxy) " "[%(default)s]" ), ) parser.add_argument( "-d", "--directory", dest="directory", default=None, help="Directory to serve from.", ) parser.add_argument( "--current-user-principal", default="/user/", help="Path to current user principal. [%(default)s]", ) parser.add_argument( "--autocreate", action="store_true", dest="autocreate", help="Automatically create necessary directories.", ) parser.add_argument( "--defaults", action="store_true", dest="defaults", help=("Create initial calendar and address book. " "Implies --autocreate."), ) parser.add_argument( "--dump-dav-xml", action="store_true", dest="dump_dav_xml", help="Print DAV XML request/responses.", ) parser.add_argument( "--avahi", action="store_true", help="Announce services with avahi." ) parser.add_argument( "--no-strict", action="store_false", dest="strict", help=("Enable workarounds for buggy CalDAV/CardDAV client " "implementations."), default=True, ) parser.add_argument("--debug", action="store_true", help="Print debug messages") # Hidden arguments. These may change without notice in between releases, # and are generally just meant for developers. parser.add_argument("--paranoid", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--index-threshold", type=int, help=argparse.SUPPRESS) options = parser.parse_args(argv) if options.directory is None: parser.print_usage() sys.exit(1) if options.dump_dav_xml: # TODO(jelmer): Find a way to propagate this without abusing # os.environ. os.environ["XANDIKOS_DUMP_DAV_XML"] = "1" if not options.route_prefix.endswith("/"): options.route_prefix += "/" if options.debug: loglevel = logging.DEBUG else: loglevel = logging.INFO logging.basicConfig(level=loglevel, format="%(message)s") backend = XandikosBackend( os.path.abspath(options.directory), paranoid=options.paranoid, index_threshold=options.index_threshold, ) backend._mark_as_principal(options.current_user_principal) if options.autocreate or options.defaults: if not os.path.isdir(options.directory): os.makedirs(options.directory) backend.create_principal( options.current_user_principal, create_defaults=options.defaults ) if not os.path.isdir(options.directory): logging.warning( "%r does not exist. Run xandikos with --autocreate?", options.directory, ) if not backend.get_resource(options.current_user_principal): logging.warning( "default user principal %s does not exist. " "Run xandikos with --autocreate?", options.current_user_principal, ) logging.info("Xandikos %s", ".".join(map(str, xandikos_version))) main_app = XandikosApp( backend, current_user_principal=options.current_user_principal, strict=options.strict, ) async def xandikos_handler(request): return await main_app.aiohttp_handler(request, options.route_prefix) if options.detect_systemd and not systemd_imported: parser.error("systemd detection requested, but unable to find systemd_python") if options.detect_systemd and systemd.daemon.booted(): listen_socks = get_systemd_listen_sockets() socket_path = None listen_address = None listen_port = None logging.info("Receiving file descriptors from systemd socket activation") elif "/" in options.listen_address: socket_path = options.listen_address listen_address = None listen_port = None # otherwise aiohttp also listens on default host listen_socks = [] logging.info("Listening on unix domain socket %s", socket_path) else: listen_address = options.listen_address listen_port = options.port socket_path = None listen_socks = [] logging.info("Listening on %s:%s", listen_address, options.port) from aiohttp import web if options.metrics_port == options.port: parser.error("Metrics port cannot be the same as the main port") app = web.Application() if options.metrics_port is not None: metrics_app = web.Application() try: from aiohttp_openmetrics import metrics, metrics_middleware except ModuleNotFoundError: logging.warning( "aiohttp-openmetrics not found; " "/metrics will not be available." ) else: app.middlewares.insert(0, metrics_middleware) metrics_app.router.add_get("/metrics", metrics, name="metrics") # For now, just always claim everything is okay. metrics_app.router.add_get("/health", lambda r: web.Response(text="ok")) else: metrics_app = None for path in WELLKNOWN_DAV_PATHS: app.router.add_route( "*", path, RedirectDavHandler(options.route_prefix).__call__ ) if options.route_prefix.strip("/"): xandikos_app = web.Application() xandikos_app.router.add_route("*", "/{path_info:.*}", xandikos_handler) async def redirect_to_subprefix(request): return web.HTTPFound(options.route_prefix) app.router.add_route("*", "/", redirect_to_subprefix) app.add_subapp(options.route_prefix, xandikos_app) else: app.router.add_route("*", "/{path_info:.*}", xandikos_handler) if options.avahi: try: import avahi # noqa: F401 import dbus # noqa: F401 except ImportError: logging.error( "Please install python-avahi and python-dbus for " "avahi support." ) else: avahi_register(options.port, options.route_prefix) runner = web.AppRunner(app) await runner.setup() sites = [] if metrics_app: metrics_runner = web.AppRunner(metrics_app) await metrics_runner.setup() # TODO(jelmer): Allow different metrics listen addres? sites.append(web.TCPSite(metrics_runner, listen_address, options.metrics_port)) # Use systemd sockets first and only if not present use the socket path or # address from --listen-address. if listen_socks: sites.extend([web.SockSite(runner, sock) for sock in listen_socks]) elif socket_path: sites.append(web.UnixSite(runner, socket_path)) else: sites.append(web.TCPSite(runner, listen_address, listen_port)) import signal # Set SIGINT to default handler; this appears to be necessary # when running under coverage. signal.signal(signal.SIGINT, signal.SIG_DFL) for site in sites: await site.start() while True: await asyncio.sleep(3600) if __name__ == "__main__": import sys sys.exit(asyncio.run(main(sys.argv[1:]))) xandikos-0.2.12/xandikos/webdav.py000066400000000000000000002104011470075263100170610ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Abstract WebDAV server implementation.. This module contains an abstract WebDAV server. All caldav/carddav specific functionality should live in xandikos.caldav/xandikos.carddav respectively. """ # TODO(jelmer): Add authorization support import asyncio import collections import fnmatch import functools import logging import os import posixpath import urllib.parse from collections.abc import AsyncIterable, Iterable, Iterator, Sequence from datetime import datetime from typing import Callable, Optional, Union, Dict, Type from wsgiref.util import request_uri # Hmm, defusedxml doesn't have XML generation functions? :( from xml.etree import ElementTree as ET from defusedxml.ElementTree import fromstring as xmlparse DEFAULT_ENCODING = "utf-8" COLLECTION_RESOURCE_TYPE = "{DAV:}collection" PRINCIPAL_RESOURCE_TYPE = "{DAV:}principal" PropStatus = collections.namedtuple( "PropStatus", ["statuscode", "responsedescription", "prop"] ) class BadRequestError(Exception): """Base class for bad request errors.""" def __init__(self, message) -> None: super().__init__(message) self.message = message def nonfatal_bad_request(message, strict=False): if strict: raise BadRequestError(message) logging.debug("Bad request: %s", message) class NotAcceptableError(Exception): """Base class for not acceptable errors.""" def __init__(self, available_content_types, acceptable_content_types) -> None: super().__init__( f"Unable to convert from content types {available_content_types!r} to one of {acceptable_content_types!r}" ) self.available_content_types = available_content_types self.acceptable_content_types = acceptable_content_types class UnsupportedMediaType(Exception): """Base class for unsupported media type errors.""" def __init__(self, content_type) -> None: super().__init__(f"Unsupported media type: {content_type!r}") self.content_type = content_type class UnauthorizedError(Exception): """Base class for unauthorized errors.""" def __init__(self) -> None: super().__init__("Request unauthorized") class Response: """Generic wrapper for HTTP-style responses.""" def __init__(self, status=200, reason="OK", body=None, headers=None) -> None: if isinstance(status, str): self.status = int(status.split(" ", 1)[0]) self.reason = status.split(" ", 1)[1] else: self.status = status self.reason = reason self.body = body or [] if isinstance(headers, dict): self.headers = list(headers.items()) elif isinstance(headers, list): self.headers = list(headers) elif not headers: self.headers = [] else: raise TypeError(headers) def for_wsgi(self, start_response): start_response("%d %s" % (self.status, self.reason), self.headers) return self.body def for_aiohttp(self): from aiohttp import web if isinstance(self.body, list): body = b"".join(self.body) else: body = self.body return web.Response( status=self.status, reason=self.reason, headers=self.headers, body=body, ) def pick_content_types(accepted_content_types, available_content_types): """Pick best content types for a client. Args: accepted_content_types: Accept variable (as name, params tuples) Raises: NotAcceptableError: If there are no overlapping content types """ available_content_types = set(available_content_types) acceptable_by_q = {} for ct, params in accepted_content_types: acceptable_by_q.setdefault(float(params.get("q", "1")), []).append(ct) if 0 in acceptable_by_q: # Items with q=0 are not acceptable for pat in acceptable_by_q[0]: available_content_types -= set(fnmatch.filter(available_content_types, pat)) del acceptable_by_q[0] for q, pats in sorted(acceptable_by_q.items(), reverse=True): ret = [] for pat in pats: ret.extend(fnmatch.filter(available_content_types, pat)) if ret: return ret raise NotAcceptableError(available_content_types, accepted_content_types) def parse_type(content_type): """Parse a content-type style header. Args: content_type: type to parse Returns: Tuple with base name and dict with params """ params = {} try: (ct, rest) = content_type.split(";", 1) except ValueError: ct = content_type else: for param in rest.split(";"): (key, val) = param.split("=") params[key.strip()] = val.strip() return (ct, params) def parse_accept_header(accept): """Parse a HTTP Accept or Accept-Language header. Args: accept: Accept header contents Returns: List of (content_type, params) tuples """ ret = [] for part in accept.split(","): part = part.strip() if not part: continue ret.append(parse_type(part)) return ret class PreconditionFailure(Exception): """A precondition failed.""" def __init__(self, precondition, description) -> None: self.precondition = precondition self.description = description class InsufficientStorage(Exception): """Insufficient storage.""" class ResourceLocked(Exception): """Resource locked.""" def etag_matches(condition, actual_etag): """Check if an etag matches an If-Matches condition. Args: condition: Condition (e.g. '*', '"foo"' or '"foo", "bar"' actual_etag: ETag to compare to. None nonexistant Returns: bool indicating whether condition matches """ if actual_etag is None and condition: return False for etag in condition.split(","): if etag.strip(" ") == "*": return True if etag.strip(" ") == actual_etag: return True return False class NeedsMultiStatus(Exception): """Raised when a response needs multi-status (e.g. for propstat).""" def propstat_by_status(propstat): """Sort a list of propstatus objects by HTTP status. Args: propstat: List of PropStatus objects: Returns: dictionary mapping HTTP status code to list of PropStatus objects """ bystatus = {} for propstat in propstat: ( bystatus.setdefault( (propstat.statuscode, propstat.responsedescription), [] ).append(propstat.prop) ) return bystatus def propstat_as_xml(propstat): """Format a list of propstats as XML elements. Args: propstat: List of PropStatus objects Returns: Iterator over {DAV:}propstat elements """ bystatus = propstat_by_status(propstat) for (status, rd), props in sorted(bystatus.items()): propstat = ET.Element("{DAV:}propstat") ET.SubElement(propstat, "{DAV:}status").text = "HTTP/1.1 " + status if rd: ET.SubElement(propstat, "{DAV:}responsedescription").text = rd propresp = ET.SubElement(propstat, "{DAV:}prop") for prop in props: propresp.append(prop) yield propstat def path_from_environ(environ, name): """Return a path from an environ dict. Will re-decode using a different encoding as necessary. """ # Re-decode using DEFAULT_ENCODING. PEP-3333 says that # everything will be decoded using iso-8859-1. # See also https://bugs.python.org/issue16679 path = environ[name].encode("iso-8859-1").decode(DEFAULT_ENCODING) return posixpath.normpath(path) class Status: """A DAV response that can be used in multi-status.""" def __init__( self, href, status=None, error=None, responsedescription=None, propstat=None, ) -> None: self.href = str(href) self.status = status self.error = error self.propstat = propstat self.responsedescription = responsedescription def __repr__(self) -> str: return "<{}({!r}, {!r}, {!r})>".format( type(self).__name__, self.href, self.status, self.responsedescription, ) def get_single_body(self, encoding): if self.propstat and len(propstat_by_status(self.propstat)) > 1: raise NeedsMultiStatus() if self.error is not None: raise NeedsMultiStatus() if self.propstat: [ret] = list(propstat_as_xml(self.propstat)) body = ET.tostringlist(ret, encoding) return body, (f'text/xml; encoding="{encoding}"') else: body = ( [self.responsedescription.encode(encoding)] if self.responsedescription else [] ) return body, (f'text/plain; encoding="{encoding}"') def aselement(self): ret = ET.Element("{DAV:}response") ret.append(create_href(self.href)) if self.propstat: for ps in propstat_as_xml(self.propstat): ret.append(ps) elif self.status: ET.SubElement(ret, "{DAV:}status").text = "HTTP/1.1 " + self.status # Note the check for "is not None" here. Elements without children # evaluate to False. if self.error is not None: ET.SubElement(ret, "{DAV:}error").append(self.error) if self.responsedescription: ET.SubElement( ret, "{DAV:}responsedescription" ).text = self.responsedescription return ret def multistatus(req_fn): async def wrapper(self, environ, *args, **kwargs): responses = [] async for resp in req_fn(self, environ, *args, **kwargs): responses.append(resp) return _send_dav_responses(responses, DEFAULT_ENCODING) return wrapper class Resource: """A WebDAV resource.""" # A list of resource type names (e.g. '{DAV:}collection') resource_types: list[str] = [] # TODO(jelmer): Be consistent in using get/set functions vs properties. def set_resource_types(self, resource_types: list[str]) -> None: """Set the resource types.""" raise NotImplementedError(self.set_resource_types) def get_displayname(self) -> str: """Get the resource display name.""" raise KeyError def set_displayname(self, displayname: str) -> None: """Set the resource display name.""" raise NotImplementedError(self.set_displayname) def get_creationdate(self) -> datetime: """Get the resource creation date. Returns: A datetime object """ raise NotImplementedError(self.get_creationdate) def get_supported_locks(self) -> list[tuple[str, str]]: """Get the list of supported locks. This should return a list of (lockscope, locktype) tuples. Known lockscopes are LOCK_SCOPE_EXCLUSIVE, LOCK_SCOPE_SHARED Known locktypes are LOCK_TYPE_WRITE """ raise NotImplementedError(self.get_supported_locks) def get_active_locks(self) -> list["ActiveLock"]: """Return the list of active locks. Returns: A list of ActiveLock tuples """ raise NotImplementedError(self.get_active_locks) def get_content_type(self) -> str: """Get the content type for the resource. This is a mime type like text/plain """ raise NotImplementedError(self.get_content_type) def get_owner(self) -> str: """Get an href identifying the owner of the resource. Can be None if owner information is not known. """ raise NotImplementedError(self.get_owner) async def get_etag(self) -> str: """Get the etag for this resource. Contains the ETag header value (from Section 14.19 of [RFC2616]) as it would be returned by a GET without accept headers. """ raise NotImplementedError(self.get_etag) async def get_body(self) -> Iterable[bytes]: """Get resource contents. Returns: Iterable over bytestrings. """ raise NotImplementedError(self.get_body) async def render( self, self_url: str, accepted_content_types: list[str], accepted_languages: list[str], ) -> tuple[Iterable[bytes], int, str, str, Optional[str]]: """'Render' this resource in the specified content type. The default implementation just checks that the resource' content type is acceptable and if so returns (get_body(), get_content_type(), get_content_language()). Args: accepted_content_types: List of accepted content types accepted_languages: List of accepted languages Raises: NotAcceptableError: if there is no acceptable content type Returns: Tuple with (content_body, content_length, etag, content_type, content_language) """ # TODO(jelmer): Check content_language content_types = pick_content_types( accepted_content_types, [self.get_content_type()] ) assert content_types == [self.get_content_type()] body = await self.get_body() try: content_language = self.get_content_language() except KeyError: content_language = None return ( body, sum(map(len, body)), await self.get_etag(), self.get_content_type(), content_language, ) async def get_content_length(self) -> int: """Get content length. Returns: Length of this objects content. """ return sum(map(len, await self.get_body())) def get_content_language(self) -> str: """Get content language. Returns: Language, as used in HTTP Accept-Language """ raise NotImplementedError(self.get_content_language) async def set_body( self, body: Iterable[bytes], replace_etag: Optional[str] = None ) -> str: """Set resource contents. Args: body: Iterable over bytestrings Returns: New ETag """ raise NotImplementedError(self.set_body) def set_comment(self, comment: str) -> None: """Set resource comment. Args: comment: New comment """ raise NotImplementedError(self.set_comment) def get_comment(self) -> str: """Get resource comment. Returns: comment """ raise NotImplementedError(self.get_comment) def get_last_modified(self) -> datetime: """Get last modified time. Returns: Last modified time """ raise NotImplementedError(self.get_last_modified) def get_is_executable(self) -> bool: """Get executable bit. Returns: Boolean indicating executability """ raise NotImplementedError(self.get_is_executable) def set_is_executable(self, executable: bool) -> None: """Set executable bit. Args: executable: Boolean indicating executability """ raise NotImplementedError(self.set_is_executable) def get_quota_used_bytes(self) -> int: """Return bytes consumed by this resource. If unknown, this can raise KeyError. Returns: an integer """ raise NotImplementedError(self.get_quota_used_bytes) def get_quota_available_bytes(self) -> int: """Return quota available as bytes. This can raise KeyError if there is infinite quota available. """ raise NotImplementedError(self.get_quota_available_bytes) class Property: """Handler for listing, retrieving and updating DAV Properties.""" # Property name (e.g. '{DAV:}resourcetype') name: str # Whether to include this property in 'allprop' PROPFIND requests. # https://tools.ietf.org/html/rfc4918, section 14.2 in_allprops: bool = True # Resource type this property belongs to. If None, get_value() # will always be called. resource_type: Optional[Sequence[str]] = None # Whether this property is live (i.e set by the server) live: bool def supported_on(self, resource: Resource) -> bool: if self.resource_type is None: return True if isinstance(self.resource_type, tuple): return any(rs in resource.resource_types for rs in self.resource_type) if self.resource_type in resource.resource_types: return True return False async def is_set( self, href: str, resource: Resource, environ: dict[str, str] ) -> bool: """Check if this property is set on a resource.""" if not self.supported_on(resource): return False try: await self.get_value("/", resource, ET.Element(self.name), environ) except KeyError: return False else: return True async def get_value( self, href: str, resource: Resource, el: ET.Element, environ: dict[str, str], ) -> None: """Get property with specified name. Args: href: Resource href resource: Resource for which to retrieve the property el: Element to populate environ: WSGI environment dict Raises: KeyError: if this property is not present """ raise KeyError(self.name) async def set_value(self, href: str, resource: Resource, el: ET.Element) -> None: """Set property. Args: href: Resource href resource: Resource to modify el: Element to get new value from (None to remove property) Raises: NotImplementedError: to indicate this property can not be set (i.e. is protected) """ raise NotImplementedError(self.set_value) class ResourceTypeProperty(Property): """Provides {DAV:}resourcetype.""" name = "{DAV:}resourcetype" resource_type = None live = True async def get_value(self, href, resource, el, environ): for rt in resource.resource_types: ET.SubElement(el, rt) async def set_value(self, href, resource, el): resource.set_resource_types([e.tag for e in el]) class DisplayNameProperty(Property): """Provides {DAV:}displayname. https://tools.ietf.org/html/rfc4918, section 5.2 """ name = "{DAV:}displayname" resource_type = None async def get_value(self, href, resource, el, environ): el.text = resource.get_displayname() async def set_value(self, href, resource, el): resource.set_displayname(el.text) class GetETagProperty(Property): """Provides {DAV:}getetag. https://tools.ietf.org/html/rfc4918, section 15.6 """ name = "{DAV:}getetag" resource_type = None live = True async def get_value(self, href, resource, el, environ): el.text = await resource.get_etag() ADD_MEMBER_FEATURE = "add-member" class AddMemberProperty(Property): """Provides {DAV:}add-member. https://tools.ietf.org/html/rfc5995, section 3.2.1 """ name = "{DAV:}add-member" resource_type = COLLECTION_RESOURCE_TYPE live = True async def get_value(self, href, resource, el, environ): # Support POST against collection URL el.append(create_href(".", href)) class GetLastModifiedProperty(Property): """Provides {DAV:}getlastmodified. https://tools.ietf.org/html/rfc4918, section 15.7 """ name = "{DAV:}getlastmodified" resource_type = None live = True in_allprops = True async def get_value(self, href, resource, el, environ): # Use rfc1123 date (section 3.3.1 of RFC2616) el.text = resource.get_last_modified().strftime("%a, %d %b %Y %H:%M:%S GMT") def format_datetime(dt: datetime) -> bytes: s = "%04d%02d%02dT%02d%02d%02dZ" % ( dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, ) return s.encode("utf-8") class CreationDateProperty(Property): """Provides {DAV:}creationdate. https://tools.ietf.org/html/rfc4918, section 23.2 """ name = "{DAV:}creationdate" resource_type = None live = True async def get_value(self, href, resource, el, environ): el.text = format_datetime(resource.get_creationdate()) class GetContentLanguageProperty(Property): """Provides {DAV:}getcontentlanguage. https://tools.ietf.org/html/rfc4918, section 15.3 """ name = "{DAV:}getcontentlanguage" resource_type = None async def get_value(self, href, resource, el, environ): el.text = ", ".join(resource.get_content_language()) class GetContentLengthProperty(Property): """Provides {DAV:}getcontentlength. https://tools.ietf.org/html/rfc4918, section 15.4 """ name = "{DAV:}getcontentlength" resource_type = None async def get_value(self, href, resource, el, environ): el.text = str(await resource.get_content_length()) class GetContentTypeProperty(Property): """Provides {DAV:}getcontenttype. https://tools.ietf.org/html/rfc4918, section 13.5 """ name = "{DAV:}getcontenttype" resource_type = None async def get_value(self, href, resource, el, environ): el.text = resource.get_content_type() class CurrentUserPrincipalProperty(Property): """Provides {DAV:}current-user-principal. See https://tools.ietf.org/html/rfc5397 """ name = "{DAV:}current-user-principal" resource_type = None in_allprops = False live = True def __init__(self, get_current_user_principal) -> None: super().__init__() self.get_current_user_principal = get_current_user_principal async def get_value(self, href, resource, el, environ): """Get property with specified name. Args: name: A property name. """ current_user_principal = self.get_current_user_principal(environ) if current_user_principal is None: ET.SubElement(el, "{DAV:}unauthenticated") else: current_user_principal = ensure_trailing_slash( current_user_principal.lstrip("/") ) el.append(create_href(current_user_principal, environ["SCRIPT_NAME"])) class PrincipalURLProperty(Property): name = "{DAV:}principal-URL" resource_type = "{DAV:}principal" in_allprops = True live = True async def get_value(self, href, resource, el, environ): """Get property with specified name. Args: name: A property name. """ el.append( create_href(ensure_trailing_slash(resource.get_principal_url()), href) ) class SupportedReportSetProperty(Property): name = "{DAV:}supported-report-set" resource_type = "{DAV:}collection" in_allprops = False live = True def __init__(self, reporters) -> None: self._reporters = reporters async def get_value(self, href, resource, el, environ): for name, reporter in self._reporters.items(): if reporter.supported_on(resource): bel = ET.SubElement(el, "{DAV:}supported-report") rel = ET.SubElement(bel, "{DAV:}report") ET.SubElement(rel, name) class GetCTagProperty(Property): """getctag property.""" name: str resource_type = COLLECTION_RESOURCE_TYPE in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = resource.get_ctag() class DAVGetCTagProperty(GetCTagProperty): """getctag property.""" name = "{DAV:}getctag" class AppleGetCTagProperty(GetCTagProperty): """getctag property.""" name = "{http://calendarserver.org/ns/}getctag" class RefreshRateProperty(Property): """refreshrate property. (no public documentation, but contains an ical-style frequency indicator) """ name = "{http://calendarserver.org/ns/}refreshrate" resource_type = COLLECTION_RESOURCE_TYPE in_allprops = False async def get_value(self, href, resource, el, environ): el.text = resource.get_refreshrate() async def set_value(self, href, resource, el): resource.set_refreshrate(el.text) LOCK_SCOPE_EXCLUSIVE = "{DAV:}exclusive" LOCK_SCOPE_SHARED = "{DAV:}shared" LOCK_TYPE_WRITE = "{DAV:}write" ActiveLock = collections.namedtuple( "ActiveLock", [ "lockscope", "locktype", "depth", "owner", "timeout", "locktoken", "lockroot", ], ) class Collection(Resource): """Resource for a WebDAV Collection.""" resource_types = Resource.resource_types + [COLLECTION_RESOURCE_TYPE] def members(self) -> Iterable[tuple[str, Resource]]: """List all members. Returns: List of (name, Resource) tuples """ raise NotImplementedError(self.members) def get_member(self, name: str) -> Resource: """Retrieve a member by name. Args; name: Name of member to retrieve Returns: A Resource """ raise NotImplementedError(self.get_member) def delete_member(self, name: str, etag: Optional[str] = None) -> None: """Delete a member with a specific name. Args: name: Member name etag: Optional required etag Raises: KeyError: when the item doesn't exist """ raise NotImplementedError(self.delete_member) async def create_member( self, name: str, contents: Iterable[bytes], content_type: str ) -> tuple[str, str]: """Create a new member with specified name and contents. Args: name: Member name (can be None) contents: Chunked contents etag: Optional required etag Returns: (name, etag) for the new member """ raise NotImplementedError(self.create_member) def get_sync_token(self) -> str: """Get sync-token for the current state of this collection.""" raise NotImplementedError(self.get_sync_token) def iter_differences_since( self, old_token: str, new_token: str ) -> Iterator[tuple[str, Optional[Resource], Optional[Resource]]]: """Iterate over differences in this collection. Should return an iterator over (name, old resource, new resource) tuples. If one of the two didn't exist previously or now, they should be None. If old_token is None, this should return full contents of the collection. May raise NotImplementedError if iterating differences is not supported. """ raise NotImplementedError(self.iter_differences_since) def get_ctag(self) -> str: raise NotImplementedError(self.get_ctag) def get_headervalue(self) -> str: raise NotImplementedError(self.get_headervalue) def destroy(self) -> None: """Destroy this collection itself.""" raise NotImplementedError(self.destroy) def set_refreshrate(self, value: Optional[str]) -> None: """Set the recommended refresh rate for this collection. Args: value: Refresh rate (None to remove) """ raise NotImplementedError(self.set_refreshrate) def get_refreshrate(self) -> str: """Get the recommended refresh rate. Returns: Recommended refresh rate :raise KeyError: if there is no refresh rate set """ raise NotImplementedError(self.get_refreshrate) class Principal(Resource): """Resource for a DAV Principal.""" resource_Types = Resource.resource_types + [PRINCIPAL_RESOURCE_TYPE] def get_principal_url(self) -> str: """Return the principal URL for this principal. Returns: A URL identifying this principal. """ raise NotImplementedError(self.get_principal_url) def get_infit_settings(self) -> str: """Return inf-it settings string.""" raise NotImplementedError(self.get_infit_settings) def set_infit_settings(self, settings: Optional[str]) -> None: """Set inf-it settings string.""" raise NotImplementedError(self.get_infit_settings) def get_group_membership(self) -> list[str]: """Get group membership URLs.""" raise NotImplementedError(self.get_group_membership) def get_calendar_proxy_read_for(self) -> list[str]: """List principals for which this one is a read proxy. Returns: List of principal hrefs """ raise NotImplementedError(self.get_calendar_proxy_read_for) def get_calendar_proxy_write_for(self) -> list[str]: """List principals for which this one is a write proxy. Returns: List of principal hrefs """ raise NotImplementedError(self.get_calendar_proxy_write_for) def get_schedule_inbox_url(self) -> str: raise NotImplementedError(self.get_schedule_inbox_url) def get_schedule_outbox_url(self) -> str: raise NotImplementedError(self.get_schedule_outbox_url) async def get_property_from_name( href: str, resource: Resource, properties, name: str, environ ): """Get a single property on a resource. Args: href: Resource href resource: Resource object properties: Dictionary of properties environ: WSGI environ dict name: name of property to resolve Returns: PropStatus items """ return await get_property_from_element( href, resource, properties, environ, ET.Element(name) ) async def get_property_from_element( href: str, resource: Resource, properties: dict[str, Property], environ, requested: ET.Element, ) -> PropStatus: """Get a single property on a resource. Args: href: Resource href resource: Resource object properties: Dictionary of properties environ: WSGI environ dict requested: Requested element Returns: PropStatus items """ responsedescription = None ret = ET.Element(requested.tag) try: prop = properties[requested.tag] except KeyError: statuscode = "404 Not Found" logging.warning( "Client requested unknown property %s on %s (%r)", requested.tag, href, resource.resource_types, ) else: try: if not prop.supported_on(resource): raise KeyError if hasattr(prop, "get_value_ext"): await prop.get_value_ext( # type: ignore href, resource, ret, environ, requested ) else: await prop.get_value(href, resource, ret, environ) except KeyError: statuscode = "404 Not Found" except NotImplementedError: logging.exception( "Not implemented while getting %s for %r", requested.tag, resource, ) statuscode = "501 Not Implemented" else: statuscode = "200 OK" return PropStatus(statuscode, responsedescription, ret) async def get_properties( href: str, resource: Resource, properties: dict[str, Property], environ, requested: ET.Element, ) -> AsyncIterable[PropStatus]: """Get a set of properties. Args: href: Resource Href resource: Resource object properties: Dictionary of properties requested: XML {DAV:}prop element with properties to look up environ: WSGI environ dict Returns: Iterator over PropStatus items """ for propreq in list(requested): yield await get_property_from_element( href, resource, properties, environ, propreq ) async def get_property_names( href: str, resource: Resource, properties: dict[str, Property], environ, requested: ET.Element, ) -> AsyncIterable[PropStatus]: """Get a set of property names. Args: href: Resource Href resource: Resource object properties: Dictionary of properties environ: WSGI environ dict requested: XML {DAV:}prop element with properties to look up Returns: Iterator over PropStatus items """ for name, prop in properties.items(): if await prop.is_set(href, resource, environ): yield PropStatus("200 OK", None, ET.Element(name)) async def get_all_properties( href: str, resource: Resource, properties: dict[str, Property], environ ) -> AsyncIterable[PropStatus]: """Get all properties. Args: href: Resource Href resource: Resource object properties: Dictionary of properties requested: XML {DAV:}prop element with properties to look up environ: WSGI environ dict Returns: Iterator over PropStatus items """ for name in properties: ps = await get_property_from_name(href, resource, properties, name, environ) if ps.statuscode == "200 OK": yield ps def ensure_trailing_slash(href: str) -> str: """Ensure that a href has a trailing slash. Useful for collection hrefs, e.g. when used with urljoin. Args: href: href to possibly add slash to Returns: href with trailing slash """ if href.endswith("/"): return href return href + "/" async def traverse_resource( base_resource: Resource, base_href: str, depth: str, members: Optional[Callable[[Collection], Iterable[tuple[str, Resource]]]] = None, ) -> AsyncIterable[tuple[str, Resource]]: """Traverse a resource. Args: base_resource: Resource to traverse from base_href: href for base resource depth: Depth ("0", "1", "infinity") members: Function to use to get members of each collection. Returns: Iterator over (URL, Resource) tuples """ if members is None: def members_fn(c): return c.members() else: members_fn = members todo = collections.deque([(base_href, base_resource, depth)]) while todo: (href, resource, depth) = todo.popleft() if COLLECTION_RESOURCE_TYPE in resource.resource_types: # caldavzap/carddavmate require this # https://tools.ietf.org/html/rfc4918#section-5.2 # mentions that a trailing slash *SHOULD* be added for # collections. href = ensure_trailing_slash(href) yield (href, resource) if depth == "0": continue elif depth == "1": nextdepth = "0" elif depth == "infinity": nextdepth = "infinity" else: raise AssertionError(f"invalid depth {depth!r}") if COLLECTION_RESOURCE_TYPE in resource.resource_types: for child_name, child_resource in members_fn(resource): child_href = urllib.parse.urljoin(href, child_name) todo.append((child_href, child_resource, nextdepth)) class Reporter: """Implementation for DAV REPORT requests.""" name: str resource_type: Optional[Union[str, tuple]] = None def supported_on(self, resource: Resource) -> bool: """Check if this reporter is available for the specified resource. Args: resource: Resource to check for Returns: boolean indicating whether this reporter is available """ if self.resource_type is None: return True if isinstance(self.resource_type, tuple): return any(rs in resource.resource_types for rs in self.resource_type) return self.resource_type in resource.resource_types async def report( self, environ: dict[str, str], request_body: ET.Element, resources_by_hrefs: Callable[[Iterable[str]], Iterable[tuple[str, Resource]]], properties: dict[str, Property], href: str, resource: Resource, depth: str, strict: bool, ) -> Status: """Send a report. Args: environ: wsgi environ request_body: XML Element for request body resources_by_hrefs: Function for retrieving resource by HREF properties: Dictionary mapping names to DAVProperty instances href: Base resource href resource: Resource to start from depth: Depth ("0", "1", ...) strict: Returns: a response """ raise NotImplementedError(self.report) def create_href(href: str, base_href: Optional[str] = None) -> ET.Element: parsed_url = urllib.parse.urlparse(href) if "//" in parsed_url.path: logging.warning("invalidly formatted href: %s", href) et = ET.Element("{DAV:}href") if base_href is not None: href = urllib.parse.urljoin(ensure_trailing_slash(base_href), href) et.text = urllib.parse.quote(href) return et def read_href_element(et: ET.Element) -> Optional[str]: if et.text is None: return None el = urllib.parse.unquote(et.text) parsed_url = urllib.parse.urlsplit(el) # TODO(jelmer): Check that the hostname matches the local hostname? return parsed_url.path class ExpandPropertyReporter(Reporter): """A expand-property reporter. See https://tools.ietf.org/html/rfc3253, section 3.8 """ name = "{DAV:}expand-property" async def _populate( self, prop_list: ET.Element, resources_by_hrefs: Callable[[Iterable[str]], list[tuple[str, Resource]]], properties: dict[str, Property], href: str, resource: Resource, environ, strict, ) -> AsyncIterable[Status]: """Expand properties for a resource. Args: prop_list: DAV:property elements to retrieve and expand resources_by_hrefs: Resolve resource by HREF properties: Available properties href: href for current resource resource: current resource environ: WSGI environ dict Returns: Status object """ ret = [] for prop in prop_list: prop_name = prop.get("name") if prop_name is None: nonfatal_bad_request(f"Tag {prop.tag} without name attribute", strict) continue # FIXME: Resolve prop_name on resource propstat = await get_property_from_name( href, resource, properties, prop_name, environ ) new_prop = ET.Element(propstat.prop.tag) child_hrefs = filter( None, [ read_href_element(prop_child) for prop_child in propstat.prop if prop_child.tag == "{DAV:}href" ], ) child_resources = resources_by_hrefs(child_hrefs) for prop_child in propstat.prop: if prop_child.tag != "{DAV:}href": new_prop.append(prop_child) else: child_href = read_href_element(prop_child) if child_href is None: nonfatal_bad_request( f"Tag {prop_child.tag} without valid href", strict ) continue child_resource = dict(child_resources).get(child_href) if child_resource is None: # FIXME: What to do if the referenced href is invalid? # For now, let's just keep the unresolved href around new_prop.append(prop_child) else: async for response in self._populate( prop, resources_by_hrefs, properties, child_href, child_resource, environ, strict, ): new_prop.append(response.aselement()) propstat = PropStatus( propstat.statuscode, propstat.responsedescription, prop=new_prop, ) ret.append(propstat) yield Status(href, "200 OK", propstat=ret) @multistatus async def report( self, environ, request_body, resources_by_hrefs, properties, href, resource, depth, strict, ): async for resp in self._populate( request_body, resources_by_hrefs, properties, href, resource, environ, strict, ): yield resp class SupportedLockProperty(Property): """supportedlock property. See rfc4918, section 15.10. """ name = "{DAV:}supportedlock" resource_type = None live = True async def get_value(self, href, resource, el, environ): for lockscope, locktype in resource.get_supported_locks(): entry = ET.SubElement(el, "{DAV:}lockentry") scope_el = ET.SubElement(entry, "{DAV:}lockscope") ET.SubElement(scope_el, lockscope) type_el = ET.SubElement(entry, "{DAV:}locktype") ET.SubElement(type_el, locktype) class LockDiscoveryProperty(Property): """lockdiscovery property. See rfc4918, section 15.8 """ name = "{DAV:}lockdiscovery" resource_type = None live = True async def get_value(self, href, resource, el, environ): for activelock in resource.get_active_locks(): entry = ET.SubElement(el, "{DAV:}activelock") type_el = ET.SubElement(entry, "{DAV:}locktype") ET.SubElement(type_el, activelock.locktype) scope_el = ET.SubElement(entry, "{DAV:}lockscope") ET.SubElement(scope_el, activelock.lockscope) ET.SubElement(entry, "{DAV:}depth").text = str(activelock.depth) if activelock.owner: ET.SubElement(entry, "{DAV:}owner").text = activelock.owner if activelock.timeout: ET.SubElement(entry, "{DAV:}timeout").text = activelock.timeout if activelock.locktoken: locktoken_el = ET.SubElement(entry, "{DAV:}locktoken") locktoken_el.append(create_href(activelock.locktoken)) if activelock.lockroot: lockroot_el = ET.SubElement(entry, "{DAV:}lockroot") lockroot_el.append(create_href(activelock.lockroot)) class CommentProperty(Property): """comment property. See RFC3253, section 3.1.1 """ name = "{DAV:}comment" live = False in_allprops = False async def get_value(self, href, resource, el, environ): el.text = resource.get_comment() async def set_value(self, href, resource, el): resource.set_comment(el.text) class Backend: """WebDAV backend.""" def create_collection(self, relpath): """Create a collection with the specified relpath. Args: relpath: Collection path """ raise NotImplementedError(self.create_collection) def get_resource(self, relpath: str) -> Optional[Resource]: raise NotImplementedError(self.get_resource) def get_resources(self, relpaths) -> Iterator[tuple[str, Optional[Resource]]]: for relpath in relpaths: yield relpath, self.get_resource(relpath) def href_to_path(environ, href) -> Optional[str]: script_name = environ["SCRIPT_NAME"].rstrip('/') if not href or not href.startswith(script_name): return None else: path = href[len(script_name) :] if not path.startswith("/"): path = "/" + path return path def _get_resources_by_hrefs(backend, environ, hrefs) -> Iterator[tuple[str, Optional[Resource]]]: """Retrieve multiple resources by href. Args: backend: backend from which to retrieve resources environ: Environment dictionary hrefs: List of hrefs to resolve Returns: iterator over (href, resource) tuples """ paths: dict[str, str] = {} for href in hrefs: path = href_to_path(environ, href) if path is not None: paths[path] = href else: yield (href, None) for (relpath, resource) in backend.get_resources(paths): href = paths[relpath] yield (href, resource) def _send_xml_response(status, et, out_encoding): body_type = f'text/xml; charset="{out_encoding}"' if os.environ.get("XANDIKOS_DUMP_DAV_XML"): print("OUT: " + ET.tostring(et).decode("utf-8")) body = ET.tostringlist(et, encoding=out_encoding) return Response( status=status, body=body, headers={ "Content-Type": body_type, "Content-Length": str(sum(map(len, body))), }, ) def _send_dav_responses(responses, out_encoding): if isinstance(responses, Status): try: (body, body_type) = responses.get_single_body(out_encoding) except NeedsMultiStatus: responses = [responses] else: return Response( status=responses.status, headers={ "Content-Type": body_type, "Content-Length": str(sum(map(len, body))), }, body=body, ) ret = ET.Element("{DAV:}multistatus") for response in responses: ret.append(response.aselement()) return _send_xml_response("207 Multi-Status", ret, out_encoding) def _send_simple_dav_error(request, statuscode, error, description): status = Status( request.url, statuscode, error=error, responsedescription=description ) return _send_dav_responses(status, DEFAULT_ENCODING) def _send_not_found(request): body = [b"Path " + request.path.encode(DEFAULT_ENCODING) + b" not found."] return Response(body=body, status=404, reason="Not Found") def _send_method_not_allowed(allowed_methods): return Response( status=405, reason="Method Not Allowed", headers={"Allow": ", ".join(allowed_methods)}, ) async def apply_modify_prop(el, href, resource, properties): """Apply property set/remove operations. Returns: el: set element to apply. href: Resource href resource: Resource to apply property modifications on properties: Known properties Returns: PropStatus objects """ if el.tag not in ("{DAV:}set", "{DAV:}remove"): # callers should check tag raise AssertionError try: [requested] = el except IndexError as exc: raise BadRequestError( "Received more than one element in {DAV:}set element." ) from exc if requested.tag != "{DAV:}prop": raise BadRequestError("Expected prop tag, got " + requested.tag) for propel in requested: try: handler = properties[propel.tag] except KeyError: logging.warning( "client attempted to modify unknown property %r on %r", propel.tag, href, ) yield PropStatus("404 Not Found", None, ET.Element(propel.tag)) else: if el.tag == "{DAV:}remove": newval = None elif el.tag == "{DAV:}set": newval = propel else: raise AssertionError if not handler.supported_on(resource): statuscode = "404 Not Found" else: try: await handler.set_value(href, resource, newval) except NotImplementedError: # TODO(jelmer): Signal # {DAV:}cannot-modify-protected-property error statuscode = "409 Conflict" else: statuscode = "200 OK" yield PropStatus(statuscode, None, ET.Element(propel.tag)) async def _readBody(request): return [await request.content.read()] async def _readXmlBody( request, expected_tag: Optional[str] = None, strict: bool = True ): content_type = request.content_type base_content_type, params = parse_type(content_type) if strict and base_content_type not in ("text/xml", "application/xml"): raise UnsupportedMediaType(content_type) body = b"".join(await _readBody(request)) if os.environ.get("XANDIKOS_DUMP_DAV_XML"): print("IN: " + body.decode("utf-8")) try: et = xmlparse(body) except ET.ParseError as exc: raise BadRequestError("Unable to parse body.") from exc if expected_tag is not None and et.tag != expected_tag: raise BadRequestError(f"Expected {expected_tag} tag, got {et.tag}") return et class Method: @property def name(self): return type(self).__name__.upper()[:-6] async def handle(self, request, environ, app): raise NotImplementedError(self.handle) def allow(self, request): """Is this method allowed considering the specified request?""" return True class DeleteMethod(Method): async def handle(self, request, environ, app): unused_href, path, r = app._get_resource_from_environ(request, environ) if r is None: return _send_not_found(request) container_path, item_name = posixpath.split(path.rstrip("/")) pr = app.backend.get_resource(container_path) if pr is None: return _send_not_found(request) current_etag = await r.get_etag() if_match = request.headers.get("If-Match", None) if if_match is not None and not etag_matches(if_match, current_etag): return Response(status=412, reason="Precondition Failed") pr.delete_member(item_name, current_etag) return Response(status=204, reason="No Content") class PostMethod(Method): async def handle(self, request, environ, app): # see RFC5995 new_contents = await _readBody(request) unused_href, path, r = app._get_resource_from_environ(request, environ) if r is None: return _send_not_found(request) if COLLECTION_RESOURCE_TYPE not in r.resource_types: return _send_method_not_allowed(app._get_allowed_methods(request)) content_type, params = parse_type(request.content_type) try: (name, etag) = await r.create_member(None, new_contents, content_type) except PreconditionFailure as e: return _send_simple_dav_error( request, "412 Precondition Failed", error=ET.Element(e.precondition), description=e.description, ) except InsufficientStorage: return Response(status=507, reason="Insufficient Storage") except ResourceLocked: return Response(status=423, reason="Resource Locked") href = environ["SCRIPT_NAME"] + urllib.parse.urljoin( ensure_trailing_slash(path), urllib.parse.quote(name) ) return Response(headers={"Location": href}) class PutMethod(Method): async def handle(self, request, environ, app): new_contents = await _readBody(request) unused_href, path, r = app._get_resource_from_environ(request, environ) if r is not None: current_etag = await r.get_etag() else: current_etag = None if_match = request.headers.get("If-Match", None) if if_match is not None and not etag_matches(if_match, current_etag): return Response(status="412 Precondition Failed") if_none_match = request.headers.get("If-None-Match", None) if if_none_match and etag_matches(if_none_match, current_etag): return Response(status="412 Precondition Failed") if r is not None: # Item already exists; update it try: new_etag = await r.set_body(new_contents, current_etag) except ResourceLocked: return Response(status=423, reason="Resource Locked") except PreconditionFailure as e: return _send_simple_dav_error( request, "412 Precondition Failed", error=ET.Element(e.precondition), description=e.description, ) except NotImplementedError: return _send_method_not_allowed(app._get_allowed_methods(request)) else: return Response(status="204 No Content", headers=[("ETag", new_etag)]) content_type = request.content_type container_path, name = posixpath.split(path) r = app.backend.get_resource(container_path) if r is None: return _send_not_found(request) if COLLECTION_RESOURCE_TYPE not in r.resource_types: return _send_method_not_allowed(app._get_allowed_methods(request)) try: (new_name, new_etag) = await r.create_member( name, new_contents, content_type ) except PreconditionFailure as e: return _send_simple_dav_error( request, "412 Precondition Failed", error=ET.Element(e.precondition), description=e.description, ) except InsufficientStorage: return Response(status=507, reason="Insufficient Storage") except ResourceLocked: return Response(status=423, reason="Resource Locked") return Response(status=201, reason="Created", headers=[("ETag", new_etag)]) class ReportMethod(Method): async def handle(self, request, environ, app): # See https://tools.ietf.org/html/rfc3253, section 3.6 base_href, unused_path, r = app._get_resource_from_environ(request, environ) if r is None: return _send_not_found(request) depth = request.headers.get("Depth", "0") et = await _readXmlBody(request, None, strict=app.strict) try: reporter = app.reporters[et.tag] except KeyError: logging.warning("Client requested unknown REPORT %s", et.tag) return _send_simple_dav_error( request, "403 Forbidden", error=ET.Element("{DAV:}supported-report"), description=f"Unknown report {et.tag}.", ) if not reporter.supported_on(r): return _send_simple_dav_error( request, "403 Forbidden", error=ET.Element("{DAV:}supported-report"), description=f"Report {et.tag} not supported on resource.", ) try: return await reporter.report( environ, et, functools.partial(_get_resources_by_hrefs, app.backend, environ), app.properties, base_href, r, depth, app.strict, ) except PreconditionFailure as e: return _send_simple_dav_error( request, "412 Precondition Failed", error=ET.Element(e.precondition), description=e.description, ) class PropfindMethod(Method): @multistatus async def handle(self, request, environ, app): base_href, unused_path, base_resource = app._get_resource_from_environ( request, environ ) if base_resource is None: yield Status(request.url, "404 Not Found") return # Default depth is infinity, per RFC2518 depth = request.headers.get("Depth", "infinity") if not request.can_read_body: requested = None else: et = await _readXmlBody(request, "{DAV:}propfind", strict=app.strict) try: [requested] = et except ValueError as exc: raise BadRequestError( "Received more than one element in propfind." ) from exc async for href, resource in traverse_resource(base_resource, base_href, depth): propstat = [] if requested is None or requested.tag == "{DAV:}allprop": propstat = get_all_properties(href, resource, app.properties, environ) elif requested.tag == "{DAV:}prop": propstat = get_properties( href, resource, app.properties, environ, requested ) elif requested.tag == "{DAV:}propname": propstat = get_property_names( href, resource, app.properties, environ, requested ) else: nonfatal_bad_request( "Expected prop/allprop/propname tag, got " + requested.tag, app.strict, ) continue yield Status(href, "200 OK", propstat=[s async for s in propstat]) # By my reading of the WebDAV RFC, it should be legal to return # '200 OK' here if Depth=0, but the RFC is not super clear and # some clients don't seem to like it and prefer a 207 instead. class ProppatchMethod(Method): @multistatus async def handle(self, request, environ, app): href, unused_path, resource = app._get_resource_from_environ(request, environ) if resource is None: yield Status(request.url, "404 Not Found") return et = await _readXmlBody(request, "{DAV:}propertyupdate", strict=app.strict) propstat = [] for el in et: if el.tag not in ("{DAV:}set", "{DAV:}remove"): nonfatal_bad_request( f"Unknown tag {el.tag} in propertyupdate", app.strict ) continue propstat.extend( [ ps async for ps in apply_modify_prop( el, href, resource, app.properties ) ] ) yield Status(request.url, propstat=propstat) class MkcolMethod(Method): async def handle(self, request, environ, app): content_type = request.content_type base_content_type, params = parse_type(content_type) if base_content_type not in ( "text/plain", "text/xml", "application/xml", None, "application/octet-stream", ): raise UnsupportedMediaType(base_content_type) href, path, resource = app._get_resource_from_environ(request, environ) if resource is not None: return _send_method_not_allowed(app._get_allowed_methods(request)) try: resource = app.backend.create_collection(path) except FileNotFoundError: return Response(status=409, reason="Conflict") if base_content_type in ("text/xml", "application/xml"): # Extended MKCOL (RFC5689) et = await _readXmlBody(request, "{DAV:}mkcol", strict=app.strict) propstat = [] for el in et: if el.tag != "{DAV:}set": nonfatal_bad_request(f"Unknown tag {el.tag} in mkcol", app.strict) continue propstat.extend( [ ps async for ps in apply_modify_prop( el, href, resource, app.properties ) ] ) ret = ET.Element("{DAV:}mkcol-response") for propstat_el in propstat_as_xml(propstat): ret.append(propstat_el) return _send_xml_response("201 Created", ret, DEFAULT_ENCODING) else: return Response(status=201, reason="Created") class OptionsMethod(Method): async def handle(self, request, environ, app): headers = [] if request.raw_path != "*": unused_href, unused_path, r = app._get_resource_from_environ( request, environ ) if r is None: return _send_not_found(request) dav_features = app._get_dav_features(r) headers.append(("DAV", ", ".join(dav_features))) allowed_methods = app._get_allowed_methods(request) headers.append(("Allow", ", ".join(allowed_methods))) # RFC7231 requires that if there is no response body, # Content-Length: 0 must be sent. This implies that there is # content (albeit empty), and thus a 204 is not a valid reply. # Thunderbird also fails if a 204 is sent rather than a 200. return Response( status=200, reason="OK", headers=headers + [("Content-Length", "0")], ) class HeadMethod(Method): async def handle(self, request, environ, app): return await _do_get(request, environ, app, send_body=False) class GetMethod(Method): async def handle(self, request, environ, app): return await _do_get(request, environ, app, send_body=True) async def _do_get(request, environ, app, send_body): unused_href, unused_path, r = app._get_resource_from_environ(request, environ) if r is None: return _send_not_found(request) accept_content_types = parse_accept_header(request.headers.get("Accept", "*/*")) accept_content_languages = parse_accept_header( request.headers.get("Accept-Languages", "*") ) ( body, content_length, current_etag, content_type, content_languages, ) = await r.render(request.path, accept_content_types, accept_content_languages) if_none_match = request.headers.get("If-None-Match", None) if ( if_none_match and current_etag is not None and etag_matches(if_none_match, current_etag) ): return Response(status="304 Not Modified") headers = [ ("Content-Length", str(content_length)), ] if current_etag is not None: headers.append(("ETag", current_etag)) if content_type is not None: headers.append(("Content-Type", content_type)) try: last_modified = r.get_last_modified() except KeyError: pass else: headers.append(("Last-Modified", last_modified)) if content_languages is not None: headers.append(("Content-Language", ", ".join(content_languages))) if send_body: return Response(body=body, status=200, reason="OK", headers=headers) else: return Response(status=200, reason="OK", headers=headers) class WSGIRequest: """Request object for wsgi requests (with environ).""" def __init__(self, environ) -> None: self._environ = environ self.method = environ["REQUEST_METHOD"] self.raw_path = environ["SCRIPT_NAME"] + environ["PATH_INFO"] self.path = environ["SCRIPT_NAME"] + path_from_environ(environ, "PATH_INFO") self.content_type = environ.get("CONTENT_TYPE", "application/octet-stream") try: self.content_length: Optional[int] = int(environ["CONTENT_LENGTH"]) except (KeyError, ValueError): self.content_length = None from multidict import CIMultiDict self.headers = CIMultiDict( [(k[5:], v) for k, v in environ.items() if k.startswith("HTTP_")] ) self.url = request_uri(environ) class StreamWrapper: def __init__(self, stream) -> None: self._stream = stream async def read(self, size=None): if size is None: return self._stream.read() else: return self._stream.read(size) self.content = StreamWrapper(self._environ["wsgi.input"]) self.match_info = {"path_info": environ["PATH_INFO"]} @property def can_read_body(self): return ( "CONTENT_TYPE" in self._environ or self._environ.get("CONTENT_LENGTH") != "0" ) async def read(self): return self._environ["wsgi.input"].read() class WebDAVApp: """A wsgi App that provides a WebDAV server. A concrete implementation should provide an implementation of the lookup_resource function that can map a path to a Resource object (returning None for nonexistant objects). """ def __init__(self, backend, strict=True) -> None: self.backend = backend self.properties: Dict[str, Type[Property]] = {} self.reporters: Dict[str, Type[Reporter]] = {} self.methods: Dict[str, Type[Method]] = {} self.strict = strict self.register_methods( [ DeleteMethod(), PostMethod(), PutMethod(), ReportMethod(), PropfindMethod(), ProppatchMethod(), MkcolMethod(), OptionsMethod(), GetMethod(), HeadMethod(), ] ) def _get_resource_from_environ(self, request, environ): path_info = request.match_info["path_info"] if not path_info.startswith("/"): path_info = "/" + path_info r = self.backend.get_resource(path_info) return (request.path, path_info, r) def register_properties(self, properties): for p in properties: self.properties[p.name] = p def register_reporters(self, reporters): for r in reporters: self.reporters[r.name] = r def register_methods(self, methods): for m in methods: self.methods[m.name] = m def _get_dav_features(self, resource): # TODO(jelmer): Support access-control return [ "1", "2", "3", "calendar-access", "calendar-auto-scheduling", "addressbook", "extended-mkcol", "add-member", "sync-collection", "quota", ] def _get_allowed_methods(self, request): """List of supported methods on this endpoint.""" ret = [] for name in sorted(self.methods.keys()): if self.methods[name].allow(request): ret.append(name) return ret async def _handle_request(self, request, environ, start_response=None): try: do = self.methods[request.method] except KeyError: return _send_method_not_allowed(self._get_allowed_methods(request)) try: return await do.handle(request, environ, self) except BadRequestError as e: logging.debug("Bad request: %s", e.message) return Response( status="400 Bad Request", body=[e.message.encode(DEFAULT_ENCODING)], ) except NotAcceptableError as e: return Response( status="406 Not Acceptable", body=[str(e).encode(DEFAULT_ENCODING)], ) except UnsupportedMediaType as e: return Response( status="415 Unsupported Media Type", body=[ f"Unsupported media type {e.content_type!r}".encode( DEFAULT_ENCODING ) ], ) except UnauthorizedError: return Response( status="401 Unauthorized", body=[("Please login.".encode(DEFAULT_ENCODING))], ) def handle_wsgi_request(self, environ, start_response): if "SCRIPT_NAME" not in environ: logging.debug('SCRIPT_NAME not set; assuming "".') environ["SCRIPT_NAME"] = "" request = WSGIRequest(environ) environ = {"SCRIPT_NAME": environ["SCRIPT_NAME"], "ORIGINAL_ENVIRON": environ} try: loop = asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) response = loop.run_until_complete(self._handle_request(request, environ, start_response)) return response.for_wsgi(start_response) if isinstance(response, Response) else response async def aiohttp_handler(self, request, route_prefix="/"): environ = {"SCRIPT_NAME": route_prefix} response = await self._handle_request(request, environ) return response.for_aiohttp() # Backwards compatibility __call__ = handle_wsgi_request xandikos-0.2.12/xandikos/wsgi.py000066400000000000000000000042371470075263100165720ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """WSGI wrapper for xandikos.""" import logging import os from .web import XandikosApp, XandikosBackend create_defaults = False autocreate_str = os.getenv("AUTOCREATE") if autocreate_str == "defaults": logging.warning("Creating default collections.") create_defaults = True autocreate = True elif autocreate_str in ("empty", "yes"): autocreate = True elif autocreate_str in (None, "no"): autocreate = False else: logging.warning("Unknown value for AUTOCREATE: %r", autocreate_str) autocreate = False backend = XandikosBackend(path=os.environ["XANDIKOSPATH"]) if not os.path.isdir(backend.path): if autocreate: os.makedirs(os.environ["XANDIKOSPATH"]) else: logging.warning("%r does not exist.", backend.path) current_user_principal = os.environ.get("CURRENT_USER_PRINCIPAL", "/user/") if not backend.get_resource(current_user_principal): if autocreate: backend.create_principal( current_user_principal, create_defaults=create_defaults ) else: logging.warning( "default user principal '%s' does not exist. " "Create directory %s or set AUTOCREATE variable?", current_user_principal, backend._map_to_file_path(current_user_principal), ) backend._mark_as_principal(current_user_principal) app = XandikosApp(backend, current_user_principal) xandikos-0.2.12/xandikos/wsgi_helpers.py000066400000000000000000000027131470075263100203110ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2020 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """WSGI wrapper for xandikos.""" import posixpath from .web import WELLKNOWN_DAV_PATHS class WellknownRedirector: """Redirect paths under .well-known/ to the appropriate paths.""" def __init__(self, inner_app, dav_root) -> None: self._inner_app = inner_app self._dav_root = dav_root def __call__(self, environ, start_response): # See https://tools.ietf.org/html/rfc6764 path = posixpath.normpath(environ["SCRIPT_NAME"] + environ["PATH_INFO"]) if path in WELLKNOWN_DAV_PATHS: start_response("302 Found", [("Location", self._dav_root)]) return [] return self._inner_app(environ, start_response) xandikos-0.2.12/xandikos/xmpp.py000066400000000000000000000050321470075263100165770ustar00rootroot00000000000000# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """XMPP support. https://github.com/evert/calendarserver-extensions/blob/master/caldav-pubsubdiscovery.txt """ from . import webdav from .caldav import CALENDAR_RESOURCE_TYPE ET = webdav.ET class XmppUriProperty(webdav.Property): """xmpp-uri property.""" name = "{http://calendarserver.org/ns/}xmpp-uri" resource_type = CALENDAR_RESOURCE_TYPE in_allprops = True live = False async def get_value(self, base_href, resource, el, environ): el.text = resource.get_xmpp_uri() async def set_value(self, href, resource, el): raise NotImplementedError(self.set_value) class XmppHeartbeatProperty(webdav.Property): """xmpp-heartbeat property.""" name = "{http://calendarserver.org/ns/}xmpp-heartbeat" resource_type = CALENDAR_RESOURCE_TYPE in_allprops = True live = False async def get_value(self, base_href, resource, el, environ): (uri, minutes) = resource.get_xmpp_heartbeat() uri_el = ET.SubElement(el, "{http://calendarserver.org/ns/}xmpp-heartbeat-uri") uri_el.text = uri minutes_el = ET.SubElement( el, "{http://calendarserver.org/ns/}xmpp-heartbeat-minutes" ) minutes_el.text = str(minutes) async def set_value(self, href, resource, el): raise NotImplementedError(self.set_value) class XmppServerProperty(webdav.Property): """xmpp-server property.""" name = "{http://calendarserver.org/ns/}xmpp-server" resource_type = CALENDAR_RESOURCE_TYPE in_allprops = True live = False async def get_value(self, base_href, resource, el, environ): server = resource.get_xmpp_server() el.text = server async def set_value(self, href, resource, el): raise NotImplementedError(self.set_value)